@workos/oagen-emitters 0.15.2 → 0.16.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +48 -1
- package/dist/index.d.mts +51 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +852 -2
- package/dist/index.mjs.map +1 -0
- package/dist/{plugin-Xkr83G9A.mjs → plugin-CpO8rePT.mjs} +1219 -493
- package/dist/plugin-CpO8rePT.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/dotnet/naming.ts +1 -1
- package/src/go/naming.ts +1 -1
- package/src/index.ts +15 -0
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +264 -4
- package/src/node/live-surface.ts +309 -0
- package/src/node/models.ts +69 -3
- package/src/node/naming.ts +204 -23
- package/src/node/resources.ts +39 -3
- package/src/node/tests.ts +29 -3
- package/src/node/utils.ts +140 -22
- package/src/snippets/dotnet.ts +159 -0
- package/src/snippets/go.ts +148 -0
- package/src/snippets/index.ts +8 -0
- package/src/snippets/kotlin.ts +144 -0
- package/src/snippets/php.ts +149 -0
- package/src/snippets/plugin.ts +36 -0
- package/src/snippets/python.ts +135 -0
- package/src/snippets/ruby.ts +152 -0
- package/src/snippets/rust.ts +189 -0
- package/test/node/enums.test.ts +239 -2
- package/test/node/live-surface.test.ts +771 -1
- package/test/node/models.test.ts +738 -3
- package/test/node/naming.test.ts +159 -0
- package/test/node/resources.test.ts +464 -0
- package/test/node/utils.test.ts +157 -2
- package/test/snippets/_helpers.ts +67 -0
- package/test/snippets/dotnet.test.ts +49 -0
- package/test/snippets/go.test.ts +94 -0
- package/test/snippets/kotlin.test.ts +53 -0
- package/test/snippets/php.test.ts +48 -0
- package/test/snippets/python.test.ts +73 -0
- package/test/snippets/ruby.test.ts +339 -0
- package/test/snippets/rust.test.ts +76 -0
- package/dist/plugin-Xkr83G9A.mjs.map +0 -1
|
@@ -3,8 +3,17 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import type { ApiSpec, EmitterContext, Operation, Service } from '@workos/oagen';
|
|
7
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
6
8
|
|
|
7
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildLiveSurface,
|
|
11
|
+
emptyLiveSurface,
|
|
12
|
+
mergeGeneratedClassMethodsIntoExisting,
|
|
13
|
+
pathExists,
|
|
14
|
+
shouldSkipPath,
|
|
15
|
+
} from '../../src/node/live-surface.js';
|
|
16
|
+
import { nodeEmitter } from '../../src/node/index.js';
|
|
8
17
|
|
|
9
18
|
let tmpRoot: string;
|
|
10
19
|
|
|
@@ -238,3 +247,764 @@ describe('helpers', () => {
|
|
|
238
247
|
expect(pathExists(surface, 'anything')).toBe(false);
|
|
239
248
|
});
|
|
240
249
|
});
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Partial emission must never delete existing methods (workos-node api-keys
|
|
253
|
+
// regression): a NOT-owned, NOT-adopted service whose autogen-headed resource
|
|
254
|
+
// file already exists gets a "new operations only" emission from resources.ts.
|
|
255
|
+
// applyLiveSurface used to promote that partial file to a full overwrite,
|
|
256
|
+
// replacing a 4-method class with a 1-method class.
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
const baseSpec: ApiSpec = {
|
|
260
|
+
name: 'Test',
|
|
261
|
+
version: '1.0.0',
|
|
262
|
+
baseUrl: '',
|
|
263
|
+
services: [],
|
|
264
|
+
models: [],
|
|
265
|
+
enums: [],
|
|
266
|
+
sdk: defaultSdkBehavior(),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const baseCtx: EmitterContext = {
|
|
270
|
+
namespace: 'workos',
|
|
271
|
+
namespacePascal: 'WorkOS',
|
|
272
|
+
spec: baseSpec,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
function makeOp(name: string, httpMethod: Operation['httpMethod'], opPath: string): Operation {
|
|
276
|
+
return {
|
|
277
|
+
name,
|
|
278
|
+
httpMethod,
|
|
279
|
+
path: opPath,
|
|
280
|
+
pathParams: opPath.includes('{id}')
|
|
281
|
+
? [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }]
|
|
282
|
+
: [],
|
|
283
|
+
queryParams: [],
|
|
284
|
+
headerParams: [],
|
|
285
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
286
|
+
errors: [],
|
|
287
|
+
injectIdempotencyKey: false,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const EXISTING_API_KEYS_CLASS = [
|
|
292
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
293
|
+
'',
|
|
294
|
+
"import { WorkOS } from '../workos';",
|
|
295
|
+
'import {',
|
|
296
|
+
' ValidateApiKeyOptions,',
|
|
297
|
+
' ValidateApiKeyResponse,',
|
|
298
|
+
"} from './interfaces/validate-api-key.interface';",
|
|
299
|
+
'',
|
|
300
|
+
'export class ApiKeys {',
|
|
301
|
+
' constructor(private readonly workos: WorkOS) {}',
|
|
302
|
+
'',
|
|
303
|
+
' async createValidation(',
|
|
304
|
+
' payload: ValidateApiKeyOptions,',
|
|
305
|
+
' ): Promise<ValidateApiKeyResponse> {',
|
|
306
|
+
" const { data } = await this.workos.post('/api_keys/validations', payload);",
|
|
307
|
+
' return data;',
|
|
308
|
+
' }',
|
|
309
|
+
'',
|
|
310
|
+
' /**',
|
|
311
|
+
' * Delete an API key',
|
|
312
|
+
' */',
|
|
313
|
+
' async deleteApiKey(id: string): Promise<void> {',
|
|
314
|
+
' await this.workos.delete(`/api_keys/${id}`);',
|
|
315
|
+
' }',
|
|
316
|
+
'',
|
|
317
|
+
' async listOrganizationApiKeys(organizationId: string): Promise<unknown> {',
|
|
318
|
+
' return this.workos.get(`/organizations/${organizationId}/api_keys`);',
|
|
319
|
+
' }',
|
|
320
|
+
'',
|
|
321
|
+
' async createOrganizationApiKey(organizationId: string): Promise<unknown> {',
|
|
322
|
+
' return this.workos.post(`/organizations/${organizationId}/api_keys`, {});',
|
|
323
|
+
' }',
|
|
324
|
+
'}',
|
|
325
|
+
'',
|
|
326
|
+
].join('\n');
|
|
327
|
+
|
|
328
|
+
function createApiKeysSdkRoot(): string {
|
|
329
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'node-partial-overwrite-'));
|
|
330
|
+
fs.mkdirSync(path.join(root, 'src', 'api-keys'), { recursive: true });
|
|
331
|
+
fs.writeFileSync(path.join(root, 'src', 'workos.ts'), '// @oagen-ignore-file\nexport class WorkOS {}\n');
|
|
332
|
+
fs.writeFileSync(path.join(root, 'src', 'index.ts'), '// @oagen-ignore-file\n');
|
|
333
|
+
fs.writeFileSync(path.join(root, 'src', 'api-keys', 'api-keys.ts'), EXISTING_API_KEYS_CLASS);
|
|
334
|
+
execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' });
|
|
335
|
+
execFileSync('git', ['add', 'src'], { cwd: root, stdio: 'ignore' });
|
|
336
|
+
return root;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function apiKeysBaselineSurface(): unknown {
|
|
340
|
+
const method = (name: string) => [{ name, params: [], returnType: 'Promise<unknown>', async: true }];
|
|
341
|
+
return {
|
|
342
|
+
classes: {
|
|
343
|
+
ApiKeys: {
|
|
344
|
+
methods: {
|
|
345
|
+
createValidation: method('createValidation'),
|
|
346
|
+
deleteApiKey: method('deleteApiKey'),
|
|
347
|
+
listOrganizationApiKeys: method('listOrganizationApiKeys'),
|
|
348
|
+
createOrganizationApiKey: method('createOrganizationApiKey'),
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
describe('mergeGeneratedClassMethodsIntoExisting', () => {
|
|
356
|
+
const existingFile = [
|
|
357
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
358
|
+
'',
|
|
359
|
+
"import { WorkOS } from '../workos';",
|
|
360
|
+
'import {',
|
|
361
|
+
' ValidateApiKeyOptions,',
|
|
362
|
+
"} from './interfaces/validate-api-key.interface';",
|
|
363
|
+
'',
|
|
364
|
+
'export class ApiKeys {',
|
|
365
|
+
' constructor(private readonly workos: WorkOS) {}',
|
|
366
|
+
'',
|
|
367
|
+
' async createValidation(payload: ValidateApiKeyOptions): Promise<unknown> {',
|
|
368
|
+
" return this.workos.post('/api_keys/validations', payload);",
|
|
369
|
+
' }',
|
|
370
|
+
'',
|
|
371
|
+
' async deleteApiKey(id: string): Promise<void> {',
|
|
372
|
+
' await this.workos.delete(`/api_keys/${id}`);',
|
|
373
|
+
' }',
|
|
374
|
+
'}',
|
|
375
|
+
'',
|
|
376
|
+
].join('\n');
|
|
377
|
+
|
|
378
|
+
const partialEmission = [
|
|
379
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
380
|
+
'',
|
|
381
|
+
"import type { WorkOS } from '../workos';",
|
|
382
|
+
"import { deserializeApiKeyExpire } from './serializers/api-key-expire.serializer';",
|
|
383
|
+
'',
|
|
384
|
+
'export class ApiKeys {',
|
|
385
|
+
' constructor(private readonly workos: WorkOS) {}',
|
|
386
|
+
'',
|
|
387
|
+
' /** Expire an API key. */',
|
|
388
|
+
' async createApiKeyExpire(id: string): Promise<void> {',
|
|
389
|
+
' await this.workos.post(`/api_keys/${encodeURIComponent(id)}/expire`, {});',
|
|
390
|
+
' }',
|
|
391
|
+
'}',
|
|
392
|
+
'',
|
|
393
|
+
].join('\n');
|
|
394
|
+
|
|
395
|
+
it('returns null when the generated class covers every existing method', () => {
|
|
396
|
+
// Identical emission: nothing dropped → caller may overwrite.
|
|
397
|
+
expect(mergeGeneratedClassMethodsIntoExisting(existingFile, existingFile)).toBeNull();
|
|
398
|
+
// Superset emission (every existing method plus a new one): full
|
|
399
|
+
// regenerations must keep flowing through the overwrite path so spec
|
|
400
|
+
// renames and emitter improvements propagate.
|
|
401
|
+
const supersetEmission = existingFile.replace(
|
|
402
|
+
' async deleteApiKey(id: string): Promise<void> {',
|
|
403
|
+
[
|
|
404
|
+
' async createApiKeyExpire(id: string): Promise<void> {',
|
|
405
|
+
' return;',
|
|
406
|
+
' }',
|
|
407
|
+
'',
|
|
408
|
+
' async deleteApiKey(id: string): Promise<void> {',
|
|
409
|
+
].join('\n'),
|
|
410
|
+
);
|
|
411
|
+
expect(mergeGeneratedClassMethodsIntoExisting(existingFile, supersetEmission)).toBeNull();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('returns null for files without classes (models, serializers)', () => {
|
|
415
|
+
const iface = 'export interface ApiKey {\n id: string;\n}\n';
|
|
416
|
+
expect(mergeGeneratedClassMethodsIntoExisting(iface, 'export interface ApiKey {\n name: string;\n}\n')).toBeNull();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('appends generated-only methods and missing imports, preserving the rest verbatim', () => {
|
|
420
|
+
const merged = mergeGeneratedClassMethodsIntoExisting(existingFile, partialEmission);
|
|
421
|
+
expect(merged).not.toBeNull();
|
|
422
|
+
// Existing methods and their bodies survive byte-for-byte.
|
|
423
|
+
expect(merged).toContain('async createValidation(payload: ValidateApiKeyOptions): Promise<unknown> {');
|
|
424
|
+
expect(merged).toContain('await this.workos.delete(`/api_keys/${id}`);');
|
|
425
|
+
// The new method (with its doc comment) is appended inside the class.
|
|
426
|
+
expect(merged).toContain('/** Expire an API key. */');
|
|
427
|
+
expect(merged).toContain('async createApiKeyExpire(id: string): Promise<void> {');
|
|
428
|
+
expect(merged!.indexOf('createApiKeyExpire')).toBeGreaterThan(merged!.indexOf('deleteApiKey'));
|
|
429
|
+
// The class still closes exactly once after the appended method.
|
|
430
|
+
expect(merged!.trimEnd().endsWith('}')).toBe(true);
|
|
431
|
+
// Imports: the serializer import is added once; WorkOS is NOT re-imported
|
|
432
|
+
// even though the generated file used the `import type` form.
|
|
433
|
+
expect(merged!.match(/deserializeApiKeyExpire/g)).toHaveLength(1);
|
|
434
|
+
expect(merged!.match(/from '\.\.\/workos'/g)).toHaveLength(1);
|
|
435
|
+
// Multi-line existing import block is untouched.
|
|
436
|
+
expect(merged).toContain("} from './interfaces/validate-api-key.interface';");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('extracts a generated method whose body has an inner brace at method-close indent', () => {
|
|
440
|
+
// The `};` closing the inner object literal sits at exactly two-space
|
|
441
|
+
// indent — the old "first two-space-indented `}`" heuristic clipped the
|
|
442
|
+
// method there, dropping the trailing statement and the real closing brace
|
|
443
|
+
// and appending a truncated, unbalanced block. Brace-depth tracking keeps
|
|
444
|
+
// the whole method.
|
|
445
|
+
const partialWithInnerBrace = [
|
|
446
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
447
|
+
'',
|
|
448
|
+
"import type { WorkOS } from '../workos';",
|
|
449
|
+
'',
|
|
450
|
+
'export class ApiKeys {',
|
|
451
|
+
' constructor(private readonly workos: WorkOS) {}',
|
|
452
|
+
'',
|
|
453
|
+
' async createWithMetadata(id: string): Promise<void> {',
|
|
454
|
+
' const payload = {',
|
|
455
|
+
' nested: { a: 1 },',
|
|
456
|
+
' };',
|
|
457
|
+
' await this.workos.post(`/api_keys/${id}/meta`, payload);',
|
|
458
|
+
' }',
|
|
459
|
+
'}',
|
|
460
|
+
'',
|
|
461
|
+
].join('\n');
|
|
462
|
+
|
|
463
|
+
const merged = mergeGeneratedClassMethodsIntoExisting(existingFile, partialWithInnerBrace);
|
|
464
|
+
expect(merged).not.toBeNull();
|
|
465
|
+
// The full method body survives — including the statement AFTER the inner
|
|
466
|
+
// `};` that the old heuristic would have truncated.
|
|
467
|
+
expect(merged).toContain('nested: { a: 1 },');
|
|
468
|
+
expect(merged).toContain('await this.workos.post(`/api_keys/${id}/meta`, payload);');
|
|
469
|
+
// Existing methods are still intact and the class closes exactly once
|
|
470
|
+
// (a standalone `}` at column 0 — not the multi-line import's `} from …`).
|
|
471
|
+
expect(merged).toContain('async deleteApiKey(id: string): Promise<void> {');
|
|
472
|
+
expect(merged!.match(/^}$/gm)).toHaveLength(1);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('extracts a generated method whose comments and strings contain stray braces', () => {
|
|
476
|
+
// A `}` inside a line comment, a single-quoted string, and a template
|
|
477
|
+
// literal must NOT close the method block. Raw character counting (counting
|
|
478
|
+
// every `}` regardless of context) would clip the method at the comment's
|
|
479
|
+
// `}`, dropping the rest of the body and the real closing brace; the
|
|
480
|
+
// string/template/comment-aware scan keeps the whole method.
|
|
481
|
+
const partialWithStrayBraces = [
|
|
482
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
483
|
+
'',
|
|
484
|
+
"import type { WorkOS } from '../workos';",
|
|
485
|
+
'',
|
|
486
|
+
'export class ApiKeys {',
|
|
487
|
+
' constructor(private readonly workos: WorkOS) {}',
|
|
488
|
+
'',
|
|
489
|
+
' async danger(id: string): Promise<void> {',
|
|
490
|
+
' // early return path closes here }',
|
|
491
|
+
" const marker = 'closing brace: }';",
|
|
492
|
+
' await this.workos.post(`/api_keys/${id}`, { note: marker });',
|
|
493
|
+
' }',
|
|
494
|
+
'}',
|
|
495
|
+
'',
|
|
496
|
+
].join('\n');
|
|
497
|
+
|
|
498
|
+
const merged = mergeGeneratedClassMethodsIntoExisting(existingFile, partialWithStrayBraces);
|
|
499
|
+
expect(merged).not.toBeNull();
|
|
500
|
+
// Every line of the new method body survives the stray braces.
|
|
501
|
+
expect(merged).toContain('// early return path closes here }');
|
|
502
|
+
expect(merged).toContain("const marker = 'closing brace: }';");
|
|
503
|
+
expect(merged).toContain('await this.workos.post(`/api_keys/${id}`, { note: marker });');
|
|
504
|
+
// Existing methods are intact and the class still closes exactly once.
|
|
505
|
+
expect(merged).toContain('async deleteApiKey(id: string): Promise<void> {');
|
|
506
|
+
expect(merged!.match(/^}$/gm)).toHaveLength(1);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// Alias-form re-exports must not trigger structural renames on regeneration.
|
|
512
|
+
//
|
|
513
|
+
// When `export type ApiKeyOwner = OrganizationApiKeyWithValueOwner;` is on
|
|
514
|
+
// disk, the engine's api-surface puts ApiKeyOwner under typeAliases (no
|
|
515
|
+
// fields), so its structural matcher cannot claim the exact name and its
|
|
516
|
+
// Jaccard fallback "renames" the IR model to whatever interface looks
|
|
517
|
+
// similar (here: UserApiKeyOwner). The emitter must treat an exactly-declared
|
|
518
|
+
// name — interface OR alias — as preempting that structural rename, so the
|
|
519
|
+
// emission is identical whether the file on disk holds the alias form or the
|
|
520
|
+
// materialized form.
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
const OWNER_FIELDS = [
|
|
524
|
+
{ name: 'user', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
525
|
+
{ name: 'service_account', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
526
|
+
// New spec field: forces re-emission against a baseline that lacks it.
|
|
527
|
+
{ name: 'expires_at', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
528
|
+
] as const;
|
|
529
|
+
|
|
530
|
+
function ownerModels() {
|
|
531
|
+
// Canonical first: pass-1 fingerprint dedup maps the later twin onto it.
|
|
532
|
+
return [
|
|
533
|
+
{ name: 'OrganizationApiKeyWithValueOwner', fields: [...OWNER_FIELDS] },
|
|
534
|
+
{ name: 'ApiKeyOwner', fields: [...OWNER_FIELDS] },
|
|
535
|
+
];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function ownerSpec(): ApiSpec {
|
|
539
|
+
return {
|
|
540
|
+
...baseSpec,
|
|
541
|
+
models: ownerModels() as ApiSpec['models'],
|
|
542
|
+
services: [
|
|
543
|
+
{
|
|
544
|
+
name: 'ApiKeys',
|
|
545
|
+
operations: [
|
|
546
|
+
{
|
|
547
|
+
...makeOp('getApiKeyOwner', 'get', '/api_keys/owner'),
|
|
548
|
+
response: { kind: 'model', name: 'ApiKeyOwner' },
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
...makeOp('getOrganizationApiKeyOwner', 'get', '/organizations/{id}/api_keys/owner'),
|
|
552
|
+
response: { kind: 'model', name: 'OrganizationApiKeyWithValueOwner' },
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const CANONICAL_OWNER_FILE = [
|
|
561
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
562
|
+
'',
|
|
563
|
+
'export interface OrganizationApiKeyWithValueOwner {',
|
|
564
|
+
' user: string;',
|
|
565
|
+
' serviceAccount: string;',
|
|
566
|
+
'}',
|
|
567
|
+
'',
|
|
568
|
+
'export interface OrganizationApiKeyWithValueOwnerResponse {',
|
|
569
|
+
' user: string;',
|
|
570
|
+
' service_account: string;',
|
|
571
|
+
'}',
|
|
572
|
+
'',
|
|
573
|
+
].join('\n');
|
|
574
|
+
|
|
575
|
+
const ALIAS_OWNER_FILE = [
|
|
576
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
577
|
+
'',
|
|
578
|
+
'import type {',
|
|
579
|
+
' OrganizationApiKeyWithValueOwner,',
|
|
580
|
+
' OrganizationApiKeyWithValueOwnerResponse,',
|
|
581
|
+
"} from './organization-api-key-with-value-owner.interface';",
|
|
582
|
+
'',
|
|
583
|
+
'export type ApiKeyOwner = OrganizationApiKeyWithValueOwner;',
|
|
584
|
+
'export type ApiKeyOwnerResponse = OrganizationApiKeyWithValueOwnerResponse;',
|
|
585
|
+
'',
|
|
586
|
+
].join('\n');
|
|
587
|
+
|
|
588
|
+
const MATERIALIZED_OWNER_FILE = [
|
|
589
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
590
|
+
'',
|
|
591
|
+
'export interface ApiKeyOwner {',
|
|
592
|
+
' user: string;',
|
|
593
|
+
' serviceAccount: string;',
|
|
594
|
+
'}',
|
|
595
|
+
'',
|
|
596
|
+
'export interface ApiKeyOwnerResponse {',
|
|
597
|
+
' user: string;',
|
|
598
|
+
' service_account: string;',
|
|
599
|
+
'}',
|
|
600
|
+
'',
|
|
601
|
+
].join('\n');
|
|
602
|
+
|
|
603
|
+
function createOwnerSdkRoot(form: 'alias' | 'materialized'): string {
|
|
604
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `node-alias-${form}-`));
|
|
605
|
+
const ifaceDir = path.join(root, 'src', 'api-keys', 'interfaces');
|
|
606
|
+
const userDir = path.join(root, 'src', 'user-api-keys', 'interfaces');
|
|
607
|
+
fs.mkdirSync(ifaceDir, { recursive: true });
|
|
608
|
+
fs.mkdirSync(userDir, { recursive: true });
|
|
609
|
+
fs.writeFileSync(path.join(root, 'src', 'workos.ts'), '// @oagen-ignore-file\nexport class WorkOS {}\n');
|
|
610
|
+
fs.writeFileSync(path.join(root, 'src', 'index.ts'), '// @oagen-ignore-file\n');
|
|
611
|
+
fs.writeFileSync(path.join(ifaceDir, 'organization-api-key-with-value-owner.interface.ts'), CANONICAL_OWNER_FILE);
|
|
612
|
+
fs.writeFileSync(
|
|
613
|
+
path.join(ifaceDir, 'api-key-owner.interface.ts'),
|
|
614
|
+
form === 'alias' ? ALIAS_OWNER_FILE : MATERIALIZED_OWNER_FILE,
|
|
615
|
+
);
|
|
616
|
+
fs.writeFileSync(
|
|
617
|
+
path.join(userDir, 'user-api-key-owner.interface.ts'),
|
|
618
|
+
[
|
|
619
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
620
|
+
'',
|
|
621
|
+
'export interface UserApiKeyOwner {',
|
|
622
|
+
' user: string;',
|
|
623
|
+
'}',
|
|
624
|
+
'',
|
|
625
|
+
].join('\n'),
|
|
626
|
+
);
|
|
627
|
+
execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' });
|
|
628
|
+
execFileSync('git', ['add', 'src'], { cwd: root, stdio: 'ignore' });
|
|
629
|
+
return root;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function ownerCtx(root: string, form: 'alias' | 'materialized'): EmitterContext {
|
|
633
|
+
const ifaceField = (fieldKey: string) => ({ [fieldKey]: { type: 'string', optional: false } });
|
|
634
|
+
const interfaces: Record<string, unknown> = {
|
|
635
|
+
OrganizationApiKeyWithValueOwner: {
|
|
636
|
+
fields: { ...ifaceField('user'), ...ifaceField('serviceAccount') },
|
|
637
|
+
sourceFile: 'src/api-keys/interfaces/organization-api-key-with-value-owner.interface.ts',
|
|
638
|
+
},
|
|
639
|
+
OrganizationApiKeyWithValueOwnerResponse: {
|
|
640
|
+
fields: { ...ifaceField('user'), ...ifaceField('service_account') },
|
|
641
|
+
sourceFile: 'src/api-keys/interfaces/organization-api-key-with-value-owner.interface.ts',
|
|
642
|
+
},
|
|
643
|
+
UserApiKeyOwner: {
|
|
644
|
+
fields: { ...ifaceField('user') },
|
|
645
|
+
sourceFile: 'src/user-api-keys/interfaces/user-api-key-owner.interface.ts',
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
const typeAliases: Record<string, unknown> = {};
|
|
649
|
+
const modelNameByIR = new Map<string, string>([
|
|
650
|
+
['OrganizationApiKeyWithValueOwner', 'OrganizationApiKeyWithValueOwner'],
|
|
651
|
+
]);
|
|
652
|
+
if (form === 'alias') {
|
|
653
|
+
typeAliases.ApiKeyOwner = {
|
|
654
|
+
value: 'OrganizationApiKeyWithValueOwner',
|
|
655
|
+
sourceFile: 'src/api-keys/interfaces/api-key-owner.interface.ts',
|
|
656
|
+
};
|
|
657
|
+
typeAliases.ApiKeyOwnerResponse = {
|
|
658
|
+
value: 'OrganizationApiKeyWithValueOwnerResponse',
|
|
659
|
+
sourceFile: 'src/api-keys/interfaces/api-key-owner.interface.ts',
|
|
660
|
+
};
|
|
661
|
+
// The engine's exact-name pass cannot see aliases (no fields), so its
|
|
662
|
+
// Jaccard fallback structurally "renames" the IR model.
|
|
663
|
+
modelNameByIR.set('ApiKeyOwner', 'UserApiKeyOwner');
|
|
664
|
+
} else {
|
|
665
|
+
interfaces.ApiKeyOwner = {
|
|
666
|
+
fields: { ...ifaceField('user'), ...ifaceField('serviceAccount') },
|
|
667
|
+
sourceFile: 'src/api-keys/interfaces/api-key-owner.interface.ts',
|
|
668
|
+
};
|
|
669
|
+
interfaces.ApiKeyOwnerResponse = {
|
|
670
|
+
fields: { ...ifaceField('user'), ...ifaceField('service_account') },
|
|
671
|
+
sourceFile: 'src/api-keys/interfaces/api-key-owner.interface.ts',
|
|
672
|
+
};
|
|
673
|
+
// Materialized interface → exact-name identity match.
|
|
674
|
+
modelNameByIR.set('ApiKeyOwner', 'ApiKeyOwner');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const spec = ownerSpec();
|
|
678
|
+
return {
|
|
679
|
+
...baseCtx,
|
|
680
|
+
spec,
|
|
681
|
+
outputDir: root,
|
|
682
|
+
apiSurface: { classes: {}, interfaces, typeAliases, enums: {}, exports: {} },
|
|
683
|
+
overlayLookup: {
|
|
684
|
+
methodByOperation: new Map(),
|
|
685
|
+
interfaceByName: new Map(),
|
|
686
|
+
modelNameByIR,
|
|
687
|
+
},
|
|
688
|
+
} as unknown as EmitterContext;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
describe('alias-form model files across regenerations', () => {
|
|
692
|
+
it('does not invent renamed duplicate declarations when the alias form is on disk', () => {
|
|
693
|
+
const root = createOwnerSdkRoot('alias');
|
|
694
|
+
try {
|
|
695
|
+
const ctx = ownerCtx(root, 'alias');
|
|
696
|
+
const result = nodeEmitter.generateModels(ctx.spec.models, ctx);
|
|
697
|
+
|
|
698
|
+
// No emitted file may declare or re-export under the structurally
|
|
699
|
+
// renamed name — UserApiKeyOwner belongs to a different model.
|
|
700
|
+
for (const file of result) {
|
|
701
|
+
expect(file.content).not.toContain('UserApiKeyOwner');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// The alias file keeps its exact-name alias form.
|
|
705
|
+
const aliasFile = result.find((f) => f.path === 'src/api-keys/interfaces/api-key-owner.interface.ts');
|
|
706
|
+
if (aliasFile) {
|
|
707
|
+
expect(aliasFile.content).toContain('export type ApiKeyOwner = OrganizationApiKeyWithValueOwner;');
|
|
708
|
+
}
|
|
709
|
+
} finally {
|
|
710
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('emits the same files whether the alias or the materialized form is on disk', () => {
|
|
715
|
+
const aliasRoot = createOwnerSdkRoot('alias');
|
|
716
|
+
const materializedRoot = createOwnerSdkRoot('materialized');
|
|
717
|
+
try {
|
|
718
|
+
const aliasCtx = ownerCtx(aliasRoot, 'alias');
|
|
719
|
+
const materializedCtx = ownerCtx(materializedRoot, 'materialized');
|
|
720
|
+
const aliasResult = nodeEmitter.generateModels(aliasCtx.spec.models, aliasCtx);
|
|
721
|
+
const materializedResult = nodeEmitter.generateModels(materializedCtx.spec.models, materializedCtx);
|
|
722
|
+
|
|
723
|
+
const byPath = (files: typeof aliasResult) => new Map(files.map((f) => [f.path, f.content]));
|
|
724
|
+
expect(byPath(aliasResult)).toEqual(byPath(materializedResult));
|
|
725
|
+
} finally {
|
|
726
|
+
fs.rmSync(aliasRoot, { recursive: true, force: true });
|
|
727
|
+
fs.rmSync(materializedRoot, { recursive: true, force: true });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('emits byte-identical output for the same input twice', () => {
|
|
732
|
+
const rootA = createOwnerSdkRoot('alias');
|
|
733
|
+
const rootB = createOwnerSdkRoot('alias');
|
|
734
|
+
try {
|
|
735
|
+
const first = nodeEmitter.generateModels(ownerSpec().models, ownerCtx(rootA, 'alias'));
|
|
736
|
+
const second = nodeEmitter.generateModels(ownerSpec().models, ownerCtx(rootB, 'alias'));
|
|
737
|
+
expect(first.map((f) => [f.path, f.content])).toEqual(second.map((f) => [f.path, f.content]));
|
|
738
|
+
} finally {
|
|
739
|
+
fs.rmSync(rootA, { recursive: true, force: true });
|
|
740
|
+
fs.rmSync(rootB, { recursive: true, force: true });
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
// Structural matching must be injective end-to-end (workos-node AuditLogs
|
|
747
|
+
// regression): the spec has two near-identical models, AuditLogEventActor and
|
|
748
|
+
// AuditLogEventTarget, and the live SDK declares a hand-written AuditLogActor
|
|
749
|
+
// with the same shape. The structural fallback mapped BOTH IR models onto
|
|
750
|
+
// AuditLogActor, so from regen round 2 onward
|
|
751
|
+
// audit-log-event-target.interface.ts was rewritten declaring
|
|
752
|
+
// `export interface AuditLogActor` (file stem and declaration disagree), with
|
|
753
|
+
// two serializeAuditLogActor definitions across different files. Exactly one
|
|
754
|
+
// IR model may claim a live name; the loser keeps its IR-derived name and its
|
|
755
|
+
// file stem agrees with its declaration.
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
const AUDIT_SHAPE = [
|
|
759
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
760
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
761
|
+
{ name: 'type', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
762
|
+
{ name: 'metadata', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
763
|
+
] as const;
|
|
764
|
+
|
|
765
|
+
function auditSpec(): ApiSpec {
|
|
766
|
+
return {
|
|
767
|
+
...baseSpec,
|
|
768
|
+
models: [
|
|
769
|
+
// One extra spec field each (absent from baseline) forces re-emission;
|
|
770
|
+
// differing extras keep the structural-fingerprint dedup out of play —
|
|
771
|
+
// this is the "near-identical shapes" case from the incident.
|
|
772
|
+
{
|
|
773
|
+
name: 'AuditLogEventActor',
|
|
774
|
+
fields: [...AUDIT_SHAPE, { name: 'ip_address', type: { kind: 'primitive', type: 'string' }, required: false }],
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
name: 'AuditLogEventTarget',
|
|
778
|
+
fields: [...AUDIT_SHAPE, { name: 'domain', type: { kind: 'primitive', type: 'string' }, required: false }],
|
|
779
|
+
},
|
|
780
|
+
] as ApiSpec['models'],
|
|
781
|
+
services: [
|
|
782
|
+
{
|
|
783
|
+
name: 'AuditLogs',
|
|
784
|
+
operations: [
|
|
785
|
+
{
|
|
786
|
+
...makeOp('getAuditLogEventActor', 'get', '/audit_logs/events/actor'),
|
|
787
|
+
response: { kind: 'model', name: 'AuditLogEventActor' },
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
...makeOp('getAuditLogEventTarget', 'get', '/audit_logs/events/target'),
|
|
791
|
+
response: { kind: 'model', name: 'AuditLogEventTarget' },
|
|
792
|
+
},
|
|
793
|
+
],
|
|
794
|
+
},
|
|
795
|
+
],
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const HAND_WRITTEN_ACTOR_FILE = [
|
|
800
|
+
'export interface AuditLogActor {',
|
|
801
|
+
' id?: string;',
|
|
802
|
+
' name: string;',
|
|
803
|
+
' type?: string;',
|
|
804
|
+
' metadata?: string;',
|
|
805
|
+
'}',
|
|
806
|
+
'',
|
|
807
|
+
].join('\n');
|
|
808
|
+
|
|
809
|
+
// The observed bad round-1 output: the IR-stem file carries the OTHER
|
|
810
|
+
// model's resolved name.
|
|
811
|
+
function badAutogenFile(): string {
|
|
812
|
+
return [
|
|
813
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
814
|
+
'',
|
|
815
|
+
'export interface AuditLogActor {',
|
|
816
|
+
' id?: string;',
|
|
817
|
+
' name: string;',
|
|
818
|
+
' type?: string;',
|
|
819
|
+
' metadata?: string;',
|
|
820
|
+
'}',
|
|
821
|
+
'',
|
|
822
|
+
'export interface AuditLogActorResponse {',
|
|
823
|
+
' id?: string;',
|
|
824
|
+
' name: string;',
|
|
825
|
+
' type?: string;',
|
|
826
|
+
' metadata?: string;',
|
|
827
|
+
'}',
|
|
828
|
+
'',
|
|
829
|
+
].join('\n');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function createAuditSdkRoot(): string {
|
|
833
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'node-audit-injective-'));
|
|
834
|
+
const ifaceDir = path.join(root, 'src', 'audit-logs', 'interfaces');
|
|
835
|
+
fs.mkdirSync(ifaceDir, { recursive: true });
|
|
836
|
+
fs.writeFileSync(path.join(root, 'src', 'workos.ts'), '// @oagen-ignore-file\nexport class WorkOS {}\n');
|
|
837
|
+
fs.writeFileSync(path.join(root, 'src', 'index.ts'), '// @oagen-ignore-file\n');
|
|
838
|
+
fs.writeFileSync(path.join(ifaceDir, 'audit-log-actor.interface.ts'), HAND_WRITTEN_ACTOR_FILE);
|
|
839
|
+
fs.writeFileSync(path.join(ifaceDir, 'audit-log-event-actor.interface.ts'), badAutogenFile());
|
|
840
|
+
fs.writeFileSync(path.join(ifaceDir, 'audit-log-event-target.interface.ts'), badAutogenFile());
|
|
841
|
+
execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' });
|
|
842
|
+
execFileSync('git', ['add', 'src'], { cwd: root, stdio: 'ignore' });
|
|
843
|
+
return root;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function auditCtx(root: string): EmitterContext {
|
|
847
|
+
const optionalString = { type: 'string', optional: true };
|
|
848
|
+
return {
|
|
849
|
+
...baseCtx,
|
|
850
|
+
spec: auditSpec(),
|
|
851
|
+
outputDir: root,
|
|
852
|
+
apiSurface: {
|
|
853
|
+
classes: {},
|
|
854
|
+
interfaces: {
|
|
855
|
+
AuditLogActor: {
|
|
856
|
+
fields: {
|
|
857
|
+
id: optionalString,
|
|
858
|
+
name: { type: 'string', optional: false },
|
|
859
|
+
type: optionalString,
|
|
860
|
+
metadata: optionalString,
|
|
861
|
+
},
|
|
862
|
+
sourceFile: 'src/audit-logs/interfaces/audit-log-actor.interface.ts',
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
typeAliases: {},
|
|
866
|
+
enums: {},
|
|
867
|
+
exports: {},
|
|
868
|
+
},
|
|
869
|
+
overlayLookup: {
|
|
870
|
+
methodByOperation: new Map(),
|
|
871
|
+
interfaceByName: new Map(),
|
|
872
|
+
// The non-injective overlay state observed in the incident: both IR
|
|
873
|
+
// models structurally matched onto the one hand-written declaration.
|
|
874
|
+
modelNameByIR: new Map([
|
|
875
|
+
['AuditLogEventActor', 'AuditLogActor'],
|
|
876
|
+
['AuditLogEventTarget', 'AuditLogActor'],
|
|
877
|
+
]),
|
|
878
|
+
},
|
|
879
|
+
} as unknown as EmitterContext;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
describe('structural-match injectivity across emitted model files', () => {
|
|
883
|
+
it('gives the contested live name to one model; the other keeps its IR name and stem', () => {
|
|
884
|
+
const root = createAuditSdkRoot();
|
|
885
|
+
try {
|
|
886
|
+
const ctx = auditCtx(root);
|
|
887
|
+
const result = nodeEmitter.generateModels(ctx.spec.models, ctx);
|
|
888
|
+
|
|
889
|
+
// The target file must declare ITS OWN derived name — never the
|
|
890
|
+
// actor's claimed name.
|
|
891
|
+
const targetFile = result.find((f) => f.path === 'src/audit-logs/interfaces/audit-log-event-target.interface.ts');
|
|
892
|
+
expect(targetFile).toBeDefined();
|
|
893
|
+
expect(targetFile!.content).toContain('export interface AuditLogEventTarget {');
|
|
894
|
+
expect(targetFile!.content).toContain('export interface AuditLogEventTargetResponse {');
|
|
895
|
+
expect(targetFile!.content).not.toContain('AuditLogActor');
|
|
896
|
+
|
|
897
|
+
// No exported declaration may be emitted by two different files —
|
|
898
|
+
// the incident produced two serializeAuditLogActor definitions.
|
|
899
|
+
const declared = new Map<string, string>();
|
|
900
|
+
for (const file of result) {
|
|
901
|
+
for (const m of file.content.matchAll(/^export (?:interface|class|function|const) (\w+)/gm)) {
|
|
902
|
+
expect(
|
|
903
|
+
declared.get(m[1]),
|
|
904
|
+
`duplicate declaration of ${m[1]} in ${declared.get(m[1])} and ${file.path}`,
|
|
905
|
+
).toBeUndefined();
|
|
906
|
+
declared.set(m[1], file.path);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Sanity invariant: a model file's declaration matches its stem-derived
|
|
911
|
+
// name unless that name is the unique claimed structural rename. Here
|
|
912
|
+
// the actor claims AuditLogActor; every other interface file must
|
|
913
|
+
// declare exactly its stem's PascalCase name.
|
|
914
|
+
for (const file of result) {
|
|
915
|
+
const stemMatch = file.path.match(/\/([^/]+)\.interface\.ts$/);
|
|
916
|
+
if (!stemMatch) continue;
|
|
917
|
+
if (file.path.endsWith('audit-log-event-actor.interface.ts')) continue;
|
|
918
|
+
const pascalStem = stemMatch[1]
|
|
919
|
+
.split('-')
|
|
920
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
921
|
+
.join('');
|
|
922
|
+
for (const m of file.content.matchAll(/^export interface (\w+)/gm)) {
|
|
923
|
+
expect([pascalStem, `${pascalStem}Response`]).toContain(m[1]);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
} finally {
|
|
927
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('emits identical, injective output on repeated regeneration', () => {
|
|
932
|
+
const rootA = createAuditSdkRoot();
|
|
933
|
+
const rootB = createAuditSdkRoot();
|
|
934
|
+
try {
|
|
935
|
+
const first = nodeEmitter.generateModels(auditSpec().models, auditCtx(rootA));
|
|
936
|
+
const second = nodeEmitter.generateModels(auditSpec().models, auditCtx(rootB));
|
|
937
|
+
expect(first.map((f) => [f.path, f.content])).toEqual(second.map((f) => [f.path, f.content]));
|
|
938
|
+
} finally {
|
|
939
|
+
fs.rmSync(rootA, { recursive: true, force: true });
|
|
940
|
+
fs.rmSync(rootB, { recursive: true, force: true });
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
describe('non-owned autogen resource regeneration', () => {
|
|
946
|
+
const apiKeysService: Service = {
|
|
947
|
+
name: 'ApiKeys',
|
|
948
|
+
operations: [
|
|
949
|
+
makeOp('createValidation', 'post', '/api_keys/validations'),
|
|
950
|
+
makeOp('deleteApiKey', 'delete', '/api_keys/{id}'),
|
|
951
|
+
makeOp('listOrganizationApiKeys', 'get', '/organizations/{id}/api_keys'),
|
|
952
|
+
makeOp('createOrganizationApiKey', 'post', '/organizations/{id}/api_keys'),
|
|
953
|
+
makeOp('createApiKeyExpire', 'post', '/api_keys/{id}/expire'),
|
|
954
|
+
],
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
it('keeps every existing method and appends the new operation', () => {
|
|
958
|
+
const root = createApiKeysSdkRoot();
|
|
959
|
+
try {
|
|
960
|
+
const spec: ApiSpec = { ...baseSpec, services: [apiKeysService] };
|
|
961
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
962
|
+
...baseCtx,
|
|
963
|
+
spec,
|
|
964
|
+
outputDir: root,
|
|
965
|
+
emitterOptions: { adoptMissingServices: true, ownedServices: ['Groups'] },
|
|
966
|
+
apiSurface: apiKeysBaselineSurface(),
|
|
967
|
+
} as EmitterContext);
|
|
968
|
+
|
|
969
|
+
const file = result.find((f) => f.path === 'src/api-keys/api-keys.ts');
|
|
970
|
+
expect(file).toBeDefined();
|
|
971
|
+
// All four pre-existing methods survive the regeneration...
|
|
972
|
+
expect(file!.content).toContain('async createValidation(');
|
|
973
|
+
expect(file!.content).toContain('async deleteApiKey(');
|
|
974
|
+
expect(file!.content).toContain('async listOrganizationApiKeys(');
|
|
975
|
+
expect(file!.content).toContain('async createOrganizationApiKey(');
|
|
976
|
+
// ...their bodies are preserved textually...
|
|
977
|
+
expect(file!.content).toContain('await this.workos.delete(`/api_keys/${id}`);');
|
|
978
|
+
// ...and the new operation is appended.
|
|
979
|
+
expect(file!.content).toContain('async createApiKeyExpire(');
|
|
980
|
+
// The existing import block is preserved.
|
|
981
|
+
expect(file!.content).toContain("} from './interfaces/validate-api-key.interface';");
|
|
982
|
+
} finally {
|
|
983
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it('still fully overwrites when the emission covers every existing method', () => {
|
|
988
|
+
const root = createApiKeysSdkRoot();
|
|
989
|
+
try {
|
|
990
|
+
// No apiSurface baseline → resources.ts emits the full class; the
|
|
991
|
+
// overwrite path must stay intact so emitter improvements propagate.
|
|
992
|
+
const spec: ApiSpec = { ...baseSpec, services: [apiKeysService] };
|
|
993
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
994
|
+
...baseCtx,
|
|
995
|
+
spec,
|
|
996
|
+
outputDir: root,
|
|
997
|
+
emitterOptions: { adoptMissingServices: true },
|
|
998
|
+
} as EmitterContext);
|
|
999
|
+
|
|
1000
|
+
const file = result.find((f) => f.path === 'src/api-keys/api-keys.ts');
|
|
1001
|
+
expect(file).toBeDefined();
|
|
1002
|
+
expect(file!.overwriteExisting).toBe(true);
|
|
1003
|
+
// Full regeneration: generated content replaces the file wholesale.
|
|
1004
|
+
expect(file!.content).toContain('async createApiKeyExpire(');
|
|
1005
|
+
expect(file!.content).not.toContain('await this.workos.delete(`/api_keys/${id}`);');
|
|
1006
|
+
} finally {
|
|
1007
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
});
|