@workos/oagen-emitters 0.12.1 → 0.12.3

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.
Files changed (45) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
  12. package/dist/plugin-D2N2ZT5W.mjs.map +1 -0
  13. package/dist/plugin.mjs +1 -1
  14. package/package.json +6 -6
  15. package/renovate.json +46 -6
  16. package/src/node/client.ts +19 -32
  17. package/src/node/enums.ts +67 -30
  18. package/src/node/errors.ts +2 -8
  19. package/src/node/field-plan.ts +188 -52
  20. package/src/node/fixtures.ts +11 -33
  21. package/src/node/index.ts +354 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +547 -351
  24. package/src/node/naming.ts +122 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/path-expression.ts +11 -4
  28. package/src/node/resources.ts +473 -48
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +152 -93
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/test/node/client.test.ts +106 -1201
  35. package/test/node/enums.test.ts +59 -130
  36. package/test/node/errors.test.ts +2 -3
  37. package/test/node/live-surface.test.ts +240 -0
  38. package/test/node/models.test.ts +396 -765
  39. package/test/node/naming.test.ts +69 -234
  40. package/test/node/resources.test.ts +435 -2025
  41. package/test/node/tests.test.ts +214 -0
  42. package/test/node/type-map.test.ts +49 -54
  43. package/test/node/utils.test.ts +29 -80
  44. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  45. package/test/node/serializers.test.ts +0 -444
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { generateEnums } from '../../src/node/enums.js';
3
- import type { EmitterContext, ApiSpec, Enum, Service } from '@workos/oagen';
2
+ import type { EmitterContext, ApiSpec, Enum } from '@workos/oagen';
4
3
  import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateEnums } from '../../src/node/enums.js';
5
5
 
6
6
  const emptySpec: ApiSpec = {
7
7
  name: 'Test',
@@ -24,33 +24,7 @@ describe('generateEnums', () => {
24
24
  expect(generateEnums([], ctx)).toEqual([]);
25
25
  });
26
26
 
27
- it('generates string literal union type', () => {
28
- const service: Service = {
29
- name: 'Organizations',
30
- operations: [
31
- {
32
- name: 'getOrganization',
33
- httpMethod: 'get',
34
- path: '/organizations/{id}',
35
- pathParams: [
36
- {
37
- name: 'id',
38
- type: { kind: 'primitive', type: 'string' },
39
- required: true,
40
- },
41
- ],
42
- queryParams: [],
43
- headerParams: [],
44
- response: {
45
- kind: 'model',
46
- name: 'Organization',
47
- },
48
- errors: [],
49
- injectIdempotencyKey: false,
50
- },
51
- ],
52
- };
53
-
27
+ it('generates const-object enum with derived type alias', () => {
54
28
  const enums: Enum[] = [
55
29
  {
56
30
  name: 'Status',
@@ -62,138 +36,93 @@ describe('generateEnums', () => {
62
36
  },
63
37
  ];
64
38
 
65
- // Enum not referenced by any service → placed in common/
66
- const files = generateEnums(enums, {
67
- ...ctx,
68
- spec: { ...emptySpec, services: [service] },
69
- });
70
- expect(files.length).toBe(1);
71
- expect(files[0].content).toMatchInlineSnapshot(`
72
- "export type Status =
73
- | 'active'
74
- | 'inactive'
75
- | 'pending';"
76
- `);
77
- });
39
+ const result = generateEnums(enums, ctx);
78
40
 
79
- it('places enum in service directory when referenced', () => {
80
- const service: Service = {
81
- name: 'Organizations',
82
- operations: [
83
- {
84
- name: 'getOrganization',
85
- httpMethod: 'get',
86
- path: '/organizations/{id}',
87
- pathParams: [
88
- {
89
- name: 'id',
90
- type: { kind: 'primitive', type: 'string' },
91
- required: true,
92
- },
93
- ],
94
- queryParams: [],
95
- headerParams: [],
96
- response: { kind: 'enum', name: 'OrgStatus' },
97
- errors: [],
98
- injectIdempotencyKey: false,
99
- },
100
- ],
101
- };
41
+ expect(result).toHaveLength(1);
42
+ expect(result[0].content).toContain('export const Status = {');
43
+ expect(result[0].content).toContain("Active: 'active'");
44
+ expect(result[0].content).toContain("Inactive: 'inactive'");
45
+ expect(result[0].content).toContain("Pending: 'pending'");
46
+ expect(result[0].content).toContain('} as const;');
47
+ expect(result[0].content).toContain('export type Status =');
48
+ expect(result[0].content).toContain('(typeof Status)[keyof typeof Status]');
49
+ });
102
50
 
51
+ it('places enum in common when not referenced by service', () => {
103
52
  const enums: Enum[] = [
104
53
  {
105
- name: 'OrgStatus',
106
- values: [
107
- { name: 'ACTIVE', value: 'active' },
108
- { name: 'INACTIVE', value: 'inactive' },
109
- ],
54
+ name: 'Status',
55
+ values: [{ name: 'ACTIVE', value: 'active' }],
110
56
  },
111
57
  ];
112
58
 
113
- const files = generateEnums(enums, {
114
- ...ctx,
115
- spec: { ...emptySpec, services: [service] },
116
- });
117
- expect(files[0].path).toBe('src/organizations/interfaces/org-status.interface.ts');
59
+ const result = generateEnums(enums, ctx);
60
+
61
+ expect(result[0].path).toBe('src/common/interfaces/status.interface.ts');
118
62
  });
119
63
 
120
- it('derives PascalCase member names when merging new enum values into baseline', () => {
64
+ it('places enum in service directory when referenced', () => {
121
65
  const enums: Enum[] = [
122
66
  {
123
- name: 'OrganizationDomainState',
124
- values: [
125
- { name: 'FAILED', value: 'failed' },
126
- { name: 'PENDING', value: 'pending' },
127
- { name: 'VERIFIED', value: 'verified' },
128
- { name: 'LEGACY_VERIFIED', value: 'legacy_verified' },
129
- { name: 'UNVERIFIED', value: 'unverified' },
130
- ],
67
+ name: 'OrgStatus',
68
+ values: [{ name: 'ACTIVE', value: 'active' }],
131
69
  },
132
70
  ];
133
71
 
134
- const testCtx: EmitterContext = {
135
- ...ctx,
136
- apiSurface: {
137
- language: 'node',
138
- extractedFrom: 'test',
139
- extractedAt: '2024-01-01',
140
- classes: {},
141
- interfaces: {},
142
- typeAliases: {},
143
- enums: {
144
- OrganizationDomainState: {
145
- name: 'OrganizationDomainState',
146
- members: {
147
- Failed: 'failed',
148
- Pending: 'pending',
149
- Verified: 'verified',
72
+ const specWithServices: ApiSpec = {
73
+ ...emptySpec,
74
+ enums,
75
+ services: [
76
+ {
77
+ name: 'Organizations',
78
+ operations: [
79
+ {
80
+ name: 'listOrganizations',
81
+ httpMethod: 'get',
82
+ path: '/organizations',
83
+ pathParams: [],
84
+ queryParams: [
85
+ {
86
+ name: 'status',
87
+ type: { kind: 'enum', name: 'OrgStatus', values: ['active'] },
88
+ required: false,
89
+ },
90
+ ],
91
+ headerParams: [],
92
+ response: { kind: 'primitive', type: 'unknown' },
93
+ errors: [],
94
+ injectIdempotencyKey: false,
150
95
  },
151
- },
96
+ ],
152
97
  },
153
- exports: {},
154
- },
98
+ ],
155
99
  };
156
100
 
157
- const files = generateEnums(enums, testCtx);
158
- const content = files[0].content;
159
-
160
- // Existing members should be preserved as-is
161
- expect(content).toContain("Failed = 'failed',");
162
- expect(content).toContain("Pending = 'pending',");
163
- expect(content).toContain("Verified = 'verified',");
101
+ const ctxWithServices: EmitterContext = {
102
+ ...ctx,
103
+ spec: specWithServices,
104
+ };
164
105
 
165
- // New members should be PascalCase, not lowercased
166
- expect(content).toContain("LegacyVerified = 'legacy_verified',");
167
- expect(content).toContain("Unverified = 'unverified',");
106
+ const result = generateEnums(enums, ctxWithServices);
168
107
 
169
- // Should NOT produce lowercased member names
170
- expect(content).not.toContain('legacyverified');
108
+ expect(result[0].path).toBe('src/organizations/interfaces/org-status.interface.ts');
171
109
  });
172
110
 
173
111
  it('renders @deprecated on enum values', () => {
174
112
  const enums: Enum[] = [
175
113
  {
176
- name: 'Status',
114
+ name: 'Method',
177
115
  values: [
178
116
  { name: 'ACTIVE', value: 'active' },
179
- {
180
- name: 'LEGACY',
181
- value: 'legacy',
182
- description: 'No longer supported.',
183
- deprecated: true,
184
- },
185
- { name: 'OLD', value: 'old', deprecated: true },
117
+ { name: 'OLD', value: 'old', deprecated: true, description: 'No longer supported.' },
118
+ { name: 'BARE', value: 'bare', deprecated: true },
186
119
  ],
187
120
  },
188
121
  ];
189
122
 
190
- const files = generateEnums(enums, ctx);
191
- const content = files[0].content;
192
-
193
- // Value with description + deprecated gets multiline JSDoc
194
- expect(content).toContain(' /**\n * No longer supported.\n * @deprecated\n */');
123
+ const result = generateEnums(enums, ctx);
195
124
 
196
- // Value with only deprecated gets single-line JSDoc
197
- expect(content).toContain(' /** @deprecated */');
125
+ expect(result[0].content).toContain('No longer supported.\n * @deprecated');
126
+ expect(result[0].content).toContain('/** @deprecated */');
198
127
  });
199
128
  });
@@ -2,8 +2,7 @@ import { describe, it, expect } from 'vitest';
2
2
  import { generateErrors } from '../../src/node/errors.js';
3
3
 
4
4
  describe('generateErrors', () => {
5
- it('returns empty array without context (static exceptions now hand-maintained)', () => {
6
- const files = generateErrors();
7
- expect(files).toEqual([]);
5
+ it('returns empty array without context', () => {
6
+ expect(generateErrors()).toEqual([]);
8
7
  });
9
8
  });
@@ -0,0 +1,240 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+
7
+ import { buildLiveSurface, emptyLiveSurface, pathExists, shouldSkipPath } from '../../src/node/live-surface.js';
8
+
9
+ let tmpRoot: string;
10
+
11
+ beforeAll(() => {
12
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'live-surface-'));
13
+ const src = path.join(tmpRoot, 'src');
14
+ fs.mkdirSync(path.join(src, 'organizations', 'interfaces'), { recursive: true });
15
+ fs.mkdirSync(path.join(src, 'common', 'utils'), { recursive: true });
16
+ fs.mkdirSync(path.join(src, 'webhooks'), { recursive: true });
17
+
18
+ // Plain hand-written class file (no header, no marker)
19
+ fs.writeFileSync(
20
+ path.join(src, 'organizations', 'organizations.ts'),
21
+ [
22
+ "import { WorkOS } from '../workos.js';",
23
+ 'export class Organizations {',
24
+ ' constructor(private workos: WorkOS) {}',
25
+ ' async listOrganizations(options?: { limit?: number }) {',
26
+ ' return this.workos.get("/organizations", options);',
27
+ ' }',
28
+ ' async getOrganization(id: string) {',
29
+ ' return this.workos.get(`/organizations/${id}`);',
30
+ ' }',
31
+ '}',
32
+ ].join('\n'),
33
+ );
34
+
35
+ fs.writeFileSync(
36
+ path.join(src, 'organizations', 'interfaces', 'organization.interface.ts'),
37
+ [
38
+ 'export interface Organization {',
39
+ ' id: string;',
40
+ ' name: string;',
41
+ ' createdAt: string;',
42
+ '}',
43
+ '',
44
+ 'export interface OrganizationResponse {',
45
+ ' id: string;',
46
+ ' name: string;',
47
+ " 'created_at': string;",
48
+ '}',
49
+ '',
50
+ 'export type Status = "active" | "suspended";',
51
+ ].join('\n'),
52
+ );
53
+
54
+ // Auto-generated file (header present, no ignore marker)
55
+ fs.mkdirSync(path.join(src, 'admin-portal'), { recursive: true });
56
+ fs.writeFileSync(
57
+ path.join(src, 'admin-portal', 'admin-portal.ts'),
58
+ [
59
+ '// This file is auto-generated by oagen. Do not edit.',
60
+ "import { WorkOS } from '../workos.js';",
61
+ 'export class AdminPortal {',
62
+ ' constructor(private workos: WorkOS) {}',
63
+ ' async generateLink(opts: { adminEmails?: string[] }) { return opts; }',
64
+ '}',
65
+ ].join('\n'),
66
+ );
67
+
68
+ // Const-object enum (workos-node house style) with acronym casing.
69
+ fs.mkdirSync(path.join(src, 'common', 'interfaces'), { recursive: true });
70
+ fs.writeFileSync(
71
+ path.join(src, 'common', 'interfaces', 'generate-link-intent.interface.ts'),
72
+ [
73
+ '// This file is auto-generated by oagen. Do not edit.',
74
+ '',
75
+ 'export const GenerateLinkIntent = {',
76
+ " SSO: 'sso',",
77
+ " DSync: 'dsync',",
78
+ " AuditLogs: 'audit_logs',",
79
+ '} as const;',
80
+ 'export type GenerateLinkIntent = (typeof GenerateLinkIntent)[keyof typeof GenerateLinkIntent];',
81
+ ].join('\n'),
82
+ );
83
+
84
+ // Protected file
85
+ fs.writeFileSync(
86
+ path.join(src, 'webhooks', 'webhooks.ts'),
87
+ [
88
+ '// @oagen-ignore-file',
89
+ 'export class Webhooks {',
90
+ ' verifySignature(payload: string) { return true; }',
91
+ '}',
92
+ ].join('\n'),
93
+ );
94
+
95
+ fs.writeFileSync(
96
+ path.join(src, 'common', 'utils', 'pagination.ts'),
97
+ [
98
+ '// @oagen-ignore-file',
99
+ 'export function autoPaginate<T>(input: T[]): T[] { return input; }',
100
+ 'export async function fetchPage(url: string) { return { data: [], after: null }; }',
101
+ ].join('\n'),
102
+ );
103
+
104
+ // A spec file that should be ignored
105
+ fs.writeFileSync(
106
+ path.join(src, 'organizations', 'organizations.spec.ts'),
107
+ ["import { Organizations } from './organizations.js';", "describe('x', () => { it('y', () => {}) })"].join('\n'),
108
+ );
109
+
110
+ execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
111
+ execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
112
+ fs.mkdirSync(path.join(src, 'connect'), { recursive: true });
113
+ fs.writeFileSync(path.join(src, 'connect', 'connect.ts'), 'export class Connect {}');
114
+ });
115
+
116
+ afterAll(() => {
117
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
118
+ });
119
+
120
+ describe('buildLiveSurface', () => {
121
+ it('returns an empty surface when src/ is missing', () => {
122
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'no-src-'));
123
+ try {
124
+ const surface = buildLiveSurface(empty);
125
+ expect(surface.files.size).toBe(0);
126
+ expect(surface.classes.size).toBe(0);
127
+ expect(surface.interfaces.size).toBe(0);
128
+ } finally {
129
+ fs.rmSync(empty, { recursive: true, force: true });
130
+ }
131
+ });
132
+
133
+ it('records files relative to root with POSIX separators', () => {
134
+ const surface = buildLiveSurface(tmpRoot);
135
+ expect(surface.files.has('src/organizations/organizations.ts')).toBe(true);
136
+ expect(surface.files.has('src/organizations/interfaces/organization.interface.ts')).toBe(true);
137
+ expect(surface.files.has('src/webhooks/webhooks.ts')).toBe(true);
138
+ });
139
+
140
+ it('distinguishes git-tracked baseline files from untracked junk', () => {
141
+ const surface = buildLiveSurface(tmpRoot);
142
+ expect(surface.trackedFiles.has('src/organizations/organizations.ts')).toBe(true);
143
+ expect(surface.trackedFiles.has('src/connect/connect.ts')).toBe(false);
144
+ });
145
+
146
+ it('does not parse declarations from untracked files when git baseline exists', () => {
147
+ const surface = buildLiveSurface(tmpRoot);
148
+ expect(surface.classes.has('Connect')).toBe(false);
149
+ });
150
+
151
+ it('detects @oagen-ignore-file markers', () => {
152
+ const surface = buildLiveSurface(tmpRoot);
153
+ expect(surface.protectedFiles.has('src/webhooks/webhooks.ts')).toBe(true);
154
+ expect(surface.protectedFiles.has('src/common/utils/pagination.ts')).toBe(true);
155
+ expect(surface.protectedFiles.has('src/organizations/organizations.ts')).toBe(false);
156
+ });
157
+
158
+ it('detects auto-generated header', () => {
159
+ const surface = buildLiveSurface(tmpRoot);
160
+ expect(surface.autogenFiles.has('src/admin-portal/admin-portal.ts')).toBe(true);
161
+ expect(surface.autogenFiles.has('src/organizations/organizations.ts')).toBe(false);
162
+ // Protected files are NOT also recorded as autogen even if their content
163
+ // happens to mention the phrase — the marker takes precedence.
164
+ expect(surface.autogenFiles.has('src/webhooks/webhooks.ts')).toBe(false);
165
+ });
166
+
167
+ it('extracts exported class names with their methods', () => {
168
+ const surface = buildLiveSurface(tmpRoot);
169
+ const orgs = surface.classes.get('Organizations');
170
+ expect(orgs).toBeDefined();
171
+ expect(orgs?.filePath).toBe('src/organizations/organizations.ts');
172
+ expect(orgs?.methods.has('listOrganizations')).toBe(true);
173
+ expect(orgs?.methods.has('getOrganization')).toBe(true);
174
+ });
175
+
176
+ it('extracts protected class declarations as well', () => {
177
+ const surface = buildLiveSurface(tmpRoot);
178
+ expect(surface.classes.get('Webhooks')?.filePath).toBe('src/webhooks/webhooks.ts');
179
+ });
180
+
181
+ it('extracts exported interface names with their fields', () => {
182
+ const surface = buildLiveSurface(tmpRoot);
183
+ const org = surface.interfaces.get('Organization');
184
+ expect(org?.filePath).toBe('src/organizations/interfaces/organization.interface.ts');
185
+ expect(org?.fields.has('id')).toBe(true);
186
+ expect(org?.fields.has('name')).toBe(true);
187
+ expect(org?.fields.has('createdAt')).toBe(true);
188
+
189
+ const wire = surface.interfaces.get('OrganizationResponse');
190
+ expect(wire?.fields.has('created_at')).toBe(true);
191
+ });
192
+
193
+ it('records exported type aliases', () => {
194
+ const surface = buildLiveSurface(tmpRoot);
195
+ expect(surface.interfaces.has('Status')).toBe(true);
196
+ });
197
+
198
+ it('records exported functions', () => {
199
+ const surface = buildLiveSurface(tmpRoot);
200
+ expect(surface.functions.get('autoPaginate')).toBe('src/common/utils/pagination.ts');
201
+ expect(surface.functions.get('fetchPage')).toBe('src/common/utils/pagination.ts');
202
+ });
203
+
204
+ it('extracts const-object enum members with acronym casing preserved', () => {
205
+ const surface = buildLiveSurface(tmpRoot);
206
+ const members = surface.constObjectEnums.get('GenerateLinkIntent');
207
+ expect(members).toBeDefined();
208
+ expect(members?.get('sso')).toBe('SSO');
209
+ expect(members?.get('dsync')).toBe('DSync');
210
+ expect(members?.get('audit_logs')).toBe('AuditLogs');
211
+ });
212
+
213
+ it('skips .spec.ts files when extracting symbols', () => {
214
+ const surface = buildLiveSurface(tmpRoot);
215
+ // The spec file is recorded in files but its symbols are not parsed (no class extraction).
216
+ expect(surface.files.has('src/organizations/organizations.spec.ts')).toBe(true);
217
+ // No class declarations exist in the spec file anyway, so no false-positive check needed.
218
+ });
219
+ });
220
+
221
+ describe('helpers', () => {
222
+ it('shouldSkipPath returns true for protected files', () => {
223
+ const surface = buildLiveSurface(tmpRoot);
224
+ expect(shouldSkipPath(surface, 'src/webhooks/webhooks.ts')).toBe(true);
225
+ expect(shouldSkipPath(surface, 'src/organizations/organizations.ts')).toBe(false);
226
+ });
227
+
228
+ it('pathExists reflects walked file set', () => {
229
+ const surface = buildLiveSurface(tmpRoot);
230
+ expect(pathExists(surface, 'src/organizations/organizations.ts')).toBe(true);
231
+ expect(pathExists(surface, 'src/missing/file.ts')).toBe(false);
232
+ });
233
+
234
+ it('emptyLiveSurface produces a usable zero state', () => {
235
+ const surface = emptyLiveSurface();
236
+ expect(surface.files.size).toBe(0);
237
+ expect(shouldSkipPath(surface, 'anything')).toBe(false);
238
+ expect(pathExists(surface, 'anything')).toBe(false);
239
+ });
240
+ });