@workos/oagen-emitters 0.19.0 → 0.19.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 +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BXDPA9pJ.mjs → plugin-DXIciTnN.mjs} +535 -96
- package/dist/plugin-DXIciTnN.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/dotnet/fixtures.ts +28 -7
- package/src/dotnet/index.ts +42 -1
- package/src/dotnet/tests.ts +1 -1
- package/src/go/enums.ts +91 -18
- package/src/go/fixtures.ts +25 -3
- package/src/go/flat-merge.ts +253 -0
- package/src/go/models.ts +85 -20
- package/src/go/tests.ts +4 -2
- package/src/kotlin/models.ts +36 -6
- package/src/kotlin/tests.ts +36 -1
- package/src/python/fixtures.ts +34 -6
- package/src/python/models.ts +118 -34
- package/src/python/tests.ts +28 -9
- package/src/ruby/tests.ts +35 -2
- package/src/rust/enums.ts +29 -15
- package/src/rust/fixtures.ts +12 -3
- package/src/rust/models.ts +26 -8
- package/src/rust/tests.ts +1 -1
- package/src/shared/resolved-ops.ts +57 -0
- package/test/dotnet/scoped-aggregates.test.ts +247 -0
- package/test/go/scoping.test.ts +324 -0
- package/test/kotlin/models.test.ts +74 -0
- package/test/kotlin/tests.test.ts +33 -0
- package/test/python/scoped-aggregates.test.ts +205 -0
- package/test/ruby/tests.test.ts +130 -0
- package/test/rust/fixtures.test.ts +13 -7
- package/dist/plugin-BXDPA9pJ.mjs.map +0 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import type { EmitterContext, ApiSpec, Model, Enum } from '@workos/oagen';
|
|
3
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { generateModels } from '../../src/go/models.js';
|
|
8
|
+
import { generateEnums } from '../../src/go/enums.js';
|
|
9
|
+
|
|
10
|
+
const emptySpec: ApiSpec = {
|
|
11
|
+
name: 'Test',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
baseUrl: '',
|
|
14
|
+
services: [],
|
|
15
|
+
models: [],
|
|
16
|
+
enums: [],
|
|
17
|
+
sdk: defaultSdkBehavior(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function strField(name: string, required = true) {
|
|
21
|
+
return { name, type: { kind: 'primitive', type: 'string' } as const, required };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let outputDir: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
outputDir = mkdtempSync(join(tmpdir(), 'oagen-go-scope-'));
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
rmSync(outputDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Build a scoped EmitterContext pointing at the temp outputDir. */
|
|
34
|
+
function scopedCtx(opts: {
|
|
35
|
+
spec?: ApiSpec;
|
|
36
|
+
scopedServices: string[];
|
|
37
|
+
scopedModelNames?: string[];
|
|
38
|
+
scopedEnumNames?: string[];
|
|
39
|
+
priorManifestPaths: string[];
|
|
40
|
+
}): EmitterContext {
|
|
41
|
+
return {
|
|
42
|
+
namespace: 'workos',
|
|
43
|
+
namespacePascal: 'WorkOS',
|
|
44
|
+
spec: opts.spec ?? emptySpec,
|
|
45
|
+
outputDir,
|
|
46
|
+
scopedServices: new Set(opts.scopedServices),
|
|
47
|
+
scopedModelNames: opts.scopedModelNames ? new Set(opts.scopedModelNames) : undefined,
|
|
48
|
+
scopedEnumNames: opts.scopedEnumNames ? new Set(opts.scopedEnumNames) : undefined,
|
|
49
|
+
priorTargetManifestPaths: new Set(opts.priorManifestPaths),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writePrior(relPath: string, content: string) {
|
|
54
|
+
const abs = join(outputDir, relPath);
|
|
55
|
+
mkdirSync(join(abs, '..'), { recursive: true });
|
|
56
|
+
writeFileSync(abs, content, 'utf-8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('go scoped flat-file reconciliation (models.go)', () => {
|
|
60
|
+
it('drops a brand-new out-of-scope model (ADDITION fix / Option B)', () => {
|
|
61
|
+
// Prior models.go has only InScopeThing. The new spec adds OutOfScopeNew,
|
|
62
|
+
// which belongs to a service NOT selected this run.
|
|
63
|
+
writePrior(
|
|
64
|
+
'models.go',
|
|
65
|
+
[
|
|
66
|
+
'// Code generated by oagen. DO NOT EDIT.',
|
|
67
|
+
'',
|
|
68
|
+
'package workos',
|
|
69
|
+
'',
|
|
70
|
+
'// InScopeThing represents an in scope thing.',
|
|
71
|
+
'type InScopeThing struct {',
|
|
72
|
+
'\tID string `json:"id"`',
|
|
73
|
+
'}',
|
|
74
|
+
'',
|
|
75
|
+
].join('\n'),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const models: Model[] = [
|
|
79
|
+
{ name: 'InScopeThing', fields: [strField('id')] },
|
|
80
|
+
// Structurally distinct so a "missing" result can't be confused with dedup.
|
|
81
|
+
{ name: 'OutOfScopeNew', fields: [strField('id'), strField('extra')] },
|
|
82
|
+
];
|
|
83
|
+
const ctx = scopedCtx({
|
|
84
|
+
scopedServices: ['InScope'],
|
|
85
|
+
scopedModelNames: ['InScopeThing'], // OutOfScopeNew NOT in scope
|
|
86
|
+
priorManifestPaths: ['models.go'],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const content = generateModels(models, ctx)[0].content;
|
|
90
|
+
expect(content).toContain('type InScopeThing struct {');
|
|
91
|
+
// Brand-new out-of-scope model must NOT be added to the flat file.
|
|
92
|
+
expect(content).not.toContain('OutOfScopeNew');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('retains a renamed/removed type still referenced by out-of-scope code (REMOVAL fix)', () => {
|
|
96
|
+
// Prior models.go has OrganizationDomainStandAlone. The new spec renamed it
|
|
97
|
+
// to OrganizationDomain (so the old name is gone from the model set), and
|
|
98
|
+
// OrganizationDomain belongs to an out-of-scope service. The old name must
|
|
99
|
+
// be carried over so out-of-scope resource code keeps compiling.
|
|
100
|
+
writePrior(
|
|
101
|
+
'models.go',
|
|
102
|
+
[
|
|
103
|
+
'// Code generated by oagen. DO NOT EDIT.',
|
|
104
|
+
'',
|
|
105
|
+
'package workos',
|
|
106
|
+
'',
|
|
107
|
+
'// InScopeThing represents an in scope thing.',
|
|
108
|
+
'type InScopeThing struct {',
|
|
109
|
+
'\tID string `json:"id"`',
|
|
110
|
+
'}',
|
|
111
|
+
'',
|
|
112
|
+
'// OrganizationDomainStandAlone represents an organization domain stand alone.',
|
|
113
|
+
'type OrganizationDomainStandAlone struct {',
|
|
114
|
+
'\tID string `json:"id"`',
|
|
115
|
+
'\tDomain string `json:"domain"`',
|
|
116
|
+
'}',
|
|
117
|
+
'',
|
|
118
|
+
].join('\n'),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const models: Model[] = [
|
|
122
|
+
{ name: 'InScopeThing', fields: [strField('id')] },
|
|
123
|
+
// The renamed model exists in the new spec but is out of scope this run.
|
|
124
|
+
{ name: 'OrganizationDomain', fields: [strField('id'), strField('domain')] },
|
|
125
|
+
];
|
|
126
|
+
const ctx = scopedCtx({
|
|
127
|
+
scopedServices: ['InScope'],
|
|
128
|
+
scopedModelNames: ['InScopeThing'],
|
|
129
|
+
priorManifestPaths: ['models.go'],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const content = generateModels(models, ctx)[0].content;
|
|
133
|
+
expect(content).toContain('type InScopeThing struct {');
|
|
134
|
+
// The renamed-away type is preserved verbatim from the prior file.
|
|
135
|
+
expect(content).toContain('type OrganizationDomainStandAlone struct {');
|
|
136
|
+
// The new out-of-scope rename is NOT freshly added (it didn't exist before).
|
|
137
|
+
expect(content).not.toContain('type OrganizationDomain struct {');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('regenerates an in-scope model with its new fields', () => {
|
|
141
|
+
writePrior(
|
|
142
|
+
'models.go',
|
|
143
|
+
[
|
|
144
|
+
'package workos',
|
|
145
|
+
'',
|
|
146
|
+
'// InScopeThing represents an in scope thing.',
|
|
147
|
+
'type InScopeThing struct {',
|
|
148
|
+
'\tID string `json:"id"`',
|
|
149
|
+
'}',
|
|
150
|
+
'',
|
|
151
|
+
].join('\n'),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const models: Model[] = [{ name: 'InScopeThing', fields: [strField('id'), strField('new_field')] }];
|
|
155
|
+
const ctx = scopedCtx({
|
|
156
|
+
scopedServices: ['InScope'],
|
|
157
|
+
scopedModelNames: ['InScopeThing'],
|
|
158
|
+
priorManifestPaths: ['models.go'],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const content = generateModels(models, ctx)[0].content;
|
|
162
|
+
expect(content).toContain('NewField string `json:"new_field"`');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('force-retains an out-of-scope canonical referenced by an in-scope alias', () => {
|
|
166
|
+
// Two structurally-identical models. The alphabetically-first is the dedup
|
|
167
|
+
// canonical but is brand-new AND out of scope; the other is in scope and is
|
|
168
|
+
// emitted as `type InScopeAliasModel = AaaCanonical`. Without force-retain
|
|
169
|
+
// the canonical would be dropped, leaving the alias dangling
|
|
170
|
+
// (undefined: AaaCanonical) and the package failing to build.
|
|
171
|
+
writePrior('models.go', ['package workos', ''].join('\n'));
|
|
172
|
+
|
|
173
|
+
const fields = [strField('id'), strField('label')];
|
|
174
|
+
const models: Model[] = [
|
|
175
|
+
{ name: 'AaaCanonical', fields }, // sorts first -> canonical; out of scope, brand-new
|
|
176
|
+
{ name: 'InScopeAliasModel', fields }, // in scope -> alias to AaaCanonical
|
|
177
|
+
];
|
|
178
|
+
const ctx = scopedCtx({
|
|
179
|
+
scopedServices: ['InScope'],
|
|
180
|
+
scopedModelNames: ['InScopeAliasModel'],
|
|
181
|
+
priorManifestPaths: ['models.go'],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const content = generateModels(models, ctx)[0].content;
|
|
185
|
+
expect(content).toContain('type InScopeAliasModel = AaaCanonical');
|
|
186
|
+
// The canonical must be present so the alias resolves.
|
|
187
|
+
expect(content).toContain('type AaaCanonical struct {');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('full run (no scoping) emits every model unchanged', () => {
|
|
191
|
+
const models: Model[] = [
|
|
192
|
+
{ name: 'InScopeThing', fields: [strField('id')] },
|
|
193
|
+
// Structurally distinct so it isn't deduped into a type alias.
|
|
194
|
+
{ name: 'OutOfScopeNew', fields: [strField('id'), strField('extra')] },
|
|
195
|
+
];
|
|
196
|
+
const ctx: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: emptySpec };
|
|
197
|
+
const content = generateModels(models, ctx)[0].content;
|
|
198
|
+
expect(content).toContain('type InScopeThing struct {');
|
|
199
|
+
expect(content).toContain('type OutOfScopeNew struct {');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('go scoped flat-file reconciliation (enums.go)', () => {
|
|
204
|
+
it('drops a brand-new out-of-scope enum and retains a renamed-away one', () => {
|
|
205
|
+
writePrior(
|
|
206
|
+
'enums.go',
|
|
207
|
+
[
|
|
208
|
+
'package workos',
|
|
209
|
+
'',
|
|
210
|
+
'// InScopeStatus represents in scope status values.',
|
|
211
|
+
'type InScopeStatus string',
|
|
212
|
+
'',
|
|
213
|
+
'const (',
|
|
214
|
+
'\tInScopeStatusActive InScopeStatus = "active"',
|
|
215
|
+
')',
|
|
216
|
+
'',
|
|
217
|
+
'// OldRenamedState represents old renamed state values.',
|
|
218
|
+
'type OldRenamedState string',
|
|
219
|
+
'',
|
|
220
|
+
'const (',
|
|
221
|
+
'\tOldRenamedStatePending OldRenamedState = "pending"',
|
|
222
|
+
')',
|
|
223
|
+
'',
|
|
224
|
+
].join('\n'),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const enums: Enum[] = [
|
|
228
|
+
{ name: 'InScopeStatus', values: [{ name: 'active', value: 'active' }] },
|
|
229
|
+
// brand-new, out of scope
|
|
230
|
+
{ name: 'OutOfScopeNewEnum', values: [{ name: 'x', value: 'x' }] },
|
|
231
|
+
// renamed away: OldRenamedState -> NewRenamedState (out of scope)
|
|
232
|
+
{ name: 'NewRenamedState', values: [{ name: 'pending', value: 'pending' }] },
|
|
233
|
+
];
|
|
234
|
+
const ctx = scopedCtx({
|
|
235
|
+
scopedServices: ['InScope'],
|
|
236
|
+
scopedEnumNames: ['InScopeStatus'],
|
|
237
|
+
priorManifestPaths: ['enums.go'],
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const content = generateEnums(enums, ctx).find((f) => f.path === 'enums.go')!.content;
|
|
241
|
+
expect(content).toContain('type InScopeStatus string');
|
|
242
|
+
// brand-new out-of-scope enum dropped
|
|
243
|
+
expect(content).not.toContain('type OutOfScopeNewEnum string');
|
|
244
|
+
// renamed-away enum retained verbatim from prior file
|
|
245
|
+
expect(content).toContain('type OldRenamedState string');
|
|
246
|
+
// the new (out-of-scope) name is not freshly added
|
|
247
|
+
expect(content).not.toContain('type NewRenamedState string');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('force-retains an out-of-scope canonical enum referenced by an in-scope alias', () => {
|
|
251
|
+
writePrior('enums.go', ['package workos', ''].join('\n'));
|
|
252
|
+
|
|
253
|
+
const enums: Enum[] = [
|
|
254
|
+
// identical values -> deduped; alphabetically-first is the canonical
|
|
255
|
+
{ name: 'AaaCanonicalStatus', values: [{ name: 'active', value: 'active' }] }, // out of scope, brand-new
|
|
256
|
+
{ name: 'InScopeAliasStatus', values: [{ name: 'active', value: 'active' }] }, // in scope -> alias
|
|
257
|
+
];
|
|
258
|
+
const ctx = scopedCtx({
|
|
259
|
+
scopedServices: ['InScope'],
|
|
260
|
+
scopedEnumNames: ['InScopeAliasStatus'],
|
|
261
|
+
priorManifestPaths: ['enums.go'],
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const content = generateEnums(enums, ctx).find((f) => f.path === 'enums.go')!.content;
|
|
265
|
+
expect(content).toContain('type InScopeAliasStatus = AaaCanonicalStatus');
|
|
266
|
+
// The canonical enum must be present so the alias resolves.
|
|
267
|
+
expect(content).toContain('type AaaCanonicalStatus string');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('go scoped flat-file reconciliation (events.go)', () => {
|
|
272
|
+
it('keeps the prior event const set: drops new events, retains existing', () => {
|
|
273
|
+
writePrior(
|
|
274
|
+
'pkg/events/events.go',
|
|
275
|
+
[
|
|
276
|
+
'package events',
|
|
277
|
+
'',
|
|
278
|
+
'// Event is a WorkOS event type.',
|
|
279
|
+
'type Event string',
|
|
280
|
+
'',
|
|
281
|
+
'const (',
|
|
282
|
+
'\tOrganizationCreated = "organization.created"',
|
|
283
|
+
')',
|
|
284
|
+
'',
|
|
285
|
+
].join('\n'),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const enums: Enum[] = [
|
|
289
|
+
{
|
|
290
|
+
name: 'CreateWebhookEndpointEvents',
|
|
291
|
+
values: [
|
|
292
|
+
{ name: 'organization.created', value: 'organization.created' },
|
|
293
|
+
// brand-new event from an out-of-scope service
|
|
294
|
+
{ name: 'session.reauthenticated', value: 'session.reauthenticated' },
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
const ctx = scopedCtx({
|
|
299
|
+
scopedServices: ['InScope'],
|
|
300
|
+
priorManifestPaths: ['pkg/events/events.go'],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const events = generateEnums(enums, ctx).find((f) => f.path === 'pkg/events/events.go')!.content;
|
|
304
|
+
expect(events).toContain('OrganizationCreated = "organization.created"');
|
|
305
|
+
// brand-new out-of-scope event must not be added to the flat events file
|
|
306
|
+
expect(events).not.toContain('session.reauthenticated');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('full run emits all events', () => {
|
|
310
|
+
const enums: Enum[] = [
|
|
311
|
+
{
|
|
312
|
+
name: 'CreateWebhookEndpointEvents',
|
|
313
|
+
values: [
|
|
314
|
+
{ name: 'organization.created', value: 'organization.created' },
|
|
315
|
+
{ name: 'session.reauthenticated', value: 'session.reauthenticated' },
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
const ctx: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: emptySpec };
|
|
320
|
+
const events = generateEnums(enums, ctx).find((f) => f.path === 'pkg/events/events.go')!.content;
|
|
321
|
+
expect(events).toContain('organization.created');
|
|
322
|
+
expect(events).toContain('session.reauthenticated');
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -101,6 +101,80 @@ describe('kotlin/models', () => {
|
|
|
101
101
|
expect(filePaths.some((p) => p.includes('ListMetadata.kt'))).toBe(false);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it('scoped run: WorkOSEvent registry omits brand-new out-of-scope events but retains on-disk ones', () => {
|
|
105
|
+
// An event envelope model: id + event(literal) + created_at + data.
|
|
106
|
+
const eventModel = (name: string, wire: string): Model => ({
|
|
107
|
+
name,
|
|
108
|
+
fields: [
|
|
109
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
110
|
+
{ name: 'event', type: { kind: 'literal', value: wire }, required: true },
|
|
111
|
+
{ name: 'created_at', type: { kind: 'primitive', type: 'string', format: 'date-time' }, required: true },
|
|
112
|
+
{ name: 'data', type: { kind: 'map', valueType: { kind: 'primitive', type: 'unknown' } }, required: true },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const models: Model[] = [
|
|
117
|
+
eventModel('OrganizationMembershipCreated', 'organization_membership.created'), // in scope
|
|
118
|
+
eventModel('SessionReauthenticated', 'session.reauthenticated'), // brand-new, out of scope, NOT on disk
|
|
119
|
+
eventModel('PipesConnectedAccountConnectionFailed', 'connected_account.connection_failed'), // out of scope, ON disk
|
|
120
|
+
];
|
|
121
|
+
const spec: ApiSpec = { ...emptySpec, models };
|
|
122
|
+
|
|
123
|
+
// Scoped run: only OrganizationMembershipCreated is in scope this run.
|
|
124
|
+
// PipesConnectedAccountConnectionFailed is out of scope but its .kt file is
|
|
125
|
+
// still on disk (recorded in the prior manifest), so it must be retained.
|
|
126
|
+
// SessionReauthenticated is brand new + out of scope ⇒ its file is never
|
|
127
|
+
// emitted ⇒ it must be omitted from the registry.
|
|
128
|
+
const scopedCtx: EmitterContext = {
|
|
129
|
+
...ctx,
|
|
130
|
+
spec,
|
|
131
|
+
scopedServices: new Set(['OrganizationMembership']),
|
|
132
|
+
scopedModelNames: new Set(['OrganizationMembershipCreated']),
|
|
133
|
+
priorTargetManifestPaths: new Set(['src/main/kotlin/com/workos/models/PipesConnectedAccountConnectionFailed.kt']),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
generateEnums([], scopedCtx);
|
|
137
|
+
const files = generateModels(models, scopedCtx);
|
|
138
|
+
|
|
139
|
+
const registry = files.find((f) => f.path.endsWith('WorkOSEvent.kt'));
|
|
140
|
+
expect(registry).toBeDefined();
|
|
141
|
+
const content = registry!.content;
|
|
142
|
+
|
|
143
|
+
// In scope ⇒ listed.
|
|
144
|
+
expect(content).toContain('OrganizationMembershipCreated::class');
|
|
145
|
+
// Out of scope but on disk ⇒ retained.
|
|
146
|
+
expect(content).toContain('PipesConnectedAccountConnectionFailed::class');
|
|
147
|
+
// Brand-new + out of scope ⇒ omitted (this was the build break).
|
|
148
|
+
expect(content).not.toContain('SessionReauthenticated::class');
|
|
149
|
+
|
|
150
|
+
// The per-model FILE for the out-of-scope events must NOT be emitted.
|
|
151
|
+
expect(files.some((f) => f.path.endsWith('SessionReauthenticated.kt'))).toBe(false);
|
|
152
|
+
expect(files.some((f) => f.path.endsWith('PipesConnectedAccountConnectionFailed.kt'))).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('full run: WorkOSEvent registry lists every event model', () => {
|
|
156
|
+
const eventModel = (name: string, wire: string): Model => ({
|
|
157
|
+
name,
|
|
158
|
+
fields: [
|
|
159
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
160
|
+
{ name: 'event', type: { kind: 'literal', value: wire }, required: true },
|
|
161
|
+
{ name: 'created_at', type: { kind: 'primitive', type: 'string', format: 'date-time' }, required: true },
|
|
162
|
+
{ name: 'data', type: { kind: 'map', valueType: { kind: 'primitive', type: 'unknown' } }, required: true },
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
const models: Model[] = [
|
|
166
|
+
eventModel('OrganizationMembershipCreated', 'organization_membership.created'),
|
|
167
|
+
eventModel('SessionReauthenticated', 'session.reauthenticated'),
|
|
168
|
+
];
|
|
169
|
+
const spec: ApiSpec = { ...emptySpec, models };
|
|
170
|
+
|
|
171
|
+
generateEnums([], { ...ctx, spec });
|
|
172
|
+
const files = generateModels(models, { ...ctx, spec });
|
|
173
|
+
const registry = files.find((f) => f.path.endsWith('WorkOSEvent.kt'))!;
|
|
174
|
+
expect(registry.content).toContain('OrganizationMembershipCreated::class');
|
|
175
|
+
expect(registry.content).toContain('SessionReauthenticated::class');
|
|
176
|
+
});
|
|
177
|
+
|
|
104
178
|
it('deduplicates structurally identical models preferring shorter names', () => {
|
|
105
179
|
const models: Model[] = [
|
|
106
180
|
{
|
|
@@ -142,6 +142,39 @@ describe('kotlin/tests', () => {
|
|
|
142
142
|
expect(content).toContain('assertEquals(parsed.toInstant(), reparsed.toInstant())');
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
it('scoped run: round-trip test omits brand-new out-of-scope models, retains on-disk ones', () => {
|
|
146
|
+
const roundTripModel = (name: string): Model => ({
|
|
147
|
+
name,
|
|
148
|
+
fields: [
|
|
149
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
150
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
const scopedModels: Model[] = [
|
|
154
|
+
roundTripModel('Organization'), // in scope
|
|
155
|
+
roundTripModel('PipesPipe'), // brand-new, out of scope, NOT on disk
|
|
156
|
+
roundTripModel('Directory'), // out of scope but ON disk
|
|
157
|
+
];
|
|
158
|
+
const scopedSpec: ApiSpec = { ...spec, models: scopedModels };
|
|
159
|
+
const scopedCtx: EmitterContext = {
|
|
160
|
+
...ctx,
|
|
161
|
+
spec: scopedSpec,
|
|
162
|
+
resolvedOperations: buildResolvedOps(services),
|
|
163
|
+
scopedServices: new Set(['Organizations']),
|
|
164
|
+
scopedModelNames: new Set(['Organization']),
|
|
165
|
+
priorTargetManifestPaths: new Set(['src/main/kotlin/com/workos/models/Directory.kt']),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
generateEnums([], scopedCtx);
|
|
169
|
+
const files = generateTests(scopedSpec, scopedCtx);
|
|
170
|
+
const roundTrip = files.find((f) => f.path.includes('GeneratedModelRoundTripTest.kt'))!;
|
|
171
|
+
const content = roundTrip.content;
|
|
172
|
+
|
|
173
|
+
expect(content).toContain('Organization round-trips through Jackson');
|
|
174
|
+
expect(content).toContain('Directory round-trips through Jackson'); // retained (on disk)
|
|
175
|
+
expect(content).not.toContain('PipesPipe round-trips through Jackson'); // omitted (brand-new)
|
|
176
|
+
});
|
|
177
|
+
|
|
145
178
|
it('emits valid ISO-8601 for date-time fields in round-trip fixtures', () => {
|
|
146
179
|
const dtModels: Model[] = [
|
|
147
180
|
{
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateModels } from '../../src/python/models.js';
|
|
3
|
+
import { generateTests } from '../../src/python/tests.js';
|
|
4
|
+
import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
|
|
5
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
6
|
+
|
|
7
|
+
// A scoped run selects the `Widgets` mount and pulls in only `WidgetA`. The
|
|
8
|
+
// spec ALSO contains models belonging to the out-of-scope `Gadgets` service:
|
|
9
|
+
// - GadgetBrandNew : brand-new, NOT in the prior manifest → must NOT be
|
|
10
|
+
// referenced by any aggregate (the ADDITION bug).
|
|
11
|
+
// - GadgetOnDisk : already on disk (recorded in the prior manifest) →
|
|
12
|
+
// must be RETAINED by the barrel.
|
|
13
|
+
// The prior manifest also records `gadget_renamed.py`, a per-model file for a
|
|
14
|
+
// model that the current spec no longer produces (renamed/removed) → must be
|
|
15
|
+
// RETAINED via a wholesale `import *` so stale out-of-scope code still resolves.
|
|
16
|
+
const services: Service[] = [
|
|
17
|
+
{
|
|
18
|
+
name: 'Widgets',
|
|
19
|
+
operations: [
|
|
20
|
+
{
|
|
21
|
+
name: 'getWidget',
|
|
22
|
+
httpMethod: 'get',
|
|
23
|
+
path: '/widgets/{id}',
|
|
24
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
25
|
+
queryParams: [],
|
|
26
|
+
headerParams: [],
|
|
27
|
+
response: { kind: 'model', name: 'WidgetA' },
|
|
28
|
+
errors: [],
|
|
29
|
+
injectIdempotencyKey: false,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'Gadgets',
|
|
35
|
+
operations: [
|
|
36
|
+
{
|
|
37
|
+
name: 'getGadgetBrandNew',
|
|
38
|
+
httpMethod: 'get',
|
|
39
|
+
path: '/gadgets/brand-new/{id}',
|
|
40
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
41
|
+
queryParams: [],
|
|
42
|
+
headerParams: [],
|
|
43
|
+
response: { kind: 'model', name: 'GadgetBrandNew' },
|
|
44
|
+
errors: [],
|
|
45
|
+
injectIdempotencyKey: false,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'getGadgetOnDisk',
|
|
49
|
+
httpMethod: 'get',
|
|
50
|
+
path: '/gadgets/on-disk/{id}',
|
|
51
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
52
|
+
queryParams: [],
|
|
53
|
+
headerParams: [],
|
|
54
|
+
response: { kind: 'model', name: 'GadgetOnDisk' },
|
|
55
|
+
errors: [],
|
|
56
|
+
injectIdempotencyKey: false,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const models: Model[] = [
|
|
63
|
+
{
|
|
64
|
+
name: 'WidgetA',
|
|
65
|
+
fields: [
|
|
66
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
67
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'GadgetBrandNew',
|
|
72
|
+
fields: [
|
|
73
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
74
|
+
{ name: 'color', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'GadgetOnDisk',
|
|
79
|
+
fields: [
|
|
80
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
81
|
+
{ name: 'size', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const spec: ApiSpec = {
|
|
87
|
+
name: 'TestAPI',
|
|
88
|
+
version: '1.0.0',
|
|
89
|
+
baseUrl: 'https://api.workos.com',
|
|
90
|
+
services,
|
|
91
|
+
models,
|
|
92
|
+
enums: [],
|
|
93
|
+
sdk: defaultSdkBehavior(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Scoped to the Widgets mount: only WidgetA is in-scope. The prior manifest
|
|
97
|
+
// records GadgetOnDisk's file plus a now-removed gadget_renamed.py file.
|
|
98
|
+
function scopedCtx(): EmitterContext {
|
|
99
|
+
return {
|
|
100
|
+
namespace: 'workos',
|
|
101
|
+
namespacePascal: 'WorkOS',
|
|
102
|
+
spec,
|
|
103
|
+
scopedServices: new Set(['Widgets']),
|
|
104
|
+
scopedModelNames: new Set(['WidgetA']),
|
|
105
|
+
scopedEnumNames: new Set<string>(),
|
|
106
|
+
priorTargetManifestPaths: new Set([
|
|
107
|
+
'src/workos/widgets/models/widget_a.py',
|
|
108
|
+
'src/workos/gadgets/models/gadget_on_disk.py',
|
|
109
|
+
'src/workos/gadgets/models/gadget_renamed.py',
|
|
110
|
+
// Fixtures live under tests/fixtures/ and are recorded in the manifest too;
|
|
111
|
+
// the on-disk gadget's fixture is retained the same way its model file is.
|
|
112
|
+
'tests/fixtures/widget_a.json',
|
|
113
|
+
'tests/fixtures/gadget_on_disk.json',
|
|
114
|
+
]),
|
|
115
|
+
} as EmitterContext;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('python scoped aggregates', () => {
|
|
119
|
+
describe('models barrel (__init__.py)', () => {
|
|
120
|
+
it('writes only the in-scope per-model file', () => {
|
|
121
|
+
const files = generateModels(models, scopedCtx());
|
|
122
|
+
const modelFiles = files.filter((f) => f.path.endsWith('.py') && f.path.includes('/models/'));
|
|
123
|
+
const writtenModelFiles = modelFiles.filter((f) => !f.path.endsWith('__init__.py'));
|
|
124
|
+
// Only WidgetA's file is emitted; the two out-of-scope gadget files are not.
|
|
125
|
+
expect(writtenModelFiles.map((f) => f.path)).toEqual(['src/workos/widgets/models/widget_a.py']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('does NOT reference a brand-new out-of-scope model in any barrel (ADDITION fix)', () => {
|
|
129
|
+
const files = generateModels(models, scopedCtx());
|
|
130
|
+
const barrels = files.filter((f) => f.path.endsWith('__init__.py'));
|
|
131
|
+
for (const barrel of barrels) {
|
|
132
|
+
expect(barrel.content).not.toContain('GadgetBrandNew');
|
|
133
|
+
expect(barrel.content).not.toContain('gadget_brand_new');
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('RETAINS an on-disk out-of-scope model in its barrel (no dangling drop)', () => {
|
|
138
|
+
const files = generateModels(models, scopedCtx());
|
|
139
|
+
const gadgetBarrel = files.find((f) => f.path === 'src/workos/gadgets/models/__init__.py');
|
|
140
|
+
expect(gadgetBarrel).toBeDefined();
|
|
141
|
+
// GadgetOnDisk is still in the spec but out of scope; its file already
|
|
142
|
+
// exists on disk (prior manifest), so the barrel keeps re-exporting it.
|
|
143
|
+
expect(gadgetBarrel!.content).toContain('from .gadget_on_disk import GadgetOnDisk as GadgetOnDisk');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('RETAINS a renamed/removed on-disk file via wholesale import (REMOVAL fix)', () => {
|
|
147
|
+
const files = generateModels(models, scopedCtx());
|
|
148
|
+
const gadgetBarrel = files.find((f) => f.path === 'src/workos/gadgets/models/__init__.py');
|
|
149
|
+
expect(gadgetBarrel).toBeDefined();
|
|
150
|
+
// gadget_renamed.py is on disk (prior manifest) but no longer produced by
|
|
151
|
+
// the spec; retain it wholesale so stale out-of-scope imports resolve.
|
|
152
|
+
expect(gadgetBarrel!.content).toContain('from .gadget_renamed import * # noqa: F401,F403');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('still references the in-scope model in its own barrel', () => {
|
|
156
|
+
const files = generateModels(models, scopedCtx());
|
|
157
|
+
const widgetBarrel = files.find((f) => f.path === 'src/workos/widgets/models/__init__.py');
|
|
158
|
+
expect(widgetBarrel).toBeDefined();
|
|
159
|
+
expect(widgetBarrel!.content).toContain('from .widget_a import WidgetA as WidgetA');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('round-trip test + fixtures', () => {
|
|
164
|
+
it('does not reference or fixture a brand-new out-of-scope model', () => {
|
|
165
|
+
const files = generateTests(spec, scopedCtx());
|
|
166
|
+
const roundTrip = files.find((f) => f.path === 'tests/test_models_round_trip.py');
|
|
167
|
+
expect(roundTrip).toBeDefined();
|
|
168
|
+
expect(roundTrip!.content).not.toContain('GadgetBrandNew');
|
|
169
|
+
// No fixture for the brand-new out-of-scope model.
|
|
170
|
+
expect(files.find((f) => f.path === 'tests/fixtures/gadget_brand_new.json')).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('keeps the in-scope model and retains the on-disk out-of-scope model', () => {
|
|
174
|
+
const files = generateTests(spec, scopedCtx());
|
|
175
|
+
const roundTrip = files.find((f) => f.path === 'tests/test_models_round_trip.py');
|
|
176
|
+
expect(roundTrip!.content).toContain('WidgetA');
|
|
177
|
+
// GadgetOnDisk's per-model file exists on disk (prior manifest), so the
|
|
178
|
+
// round-trip test may still import it and its fixture is emitted.
|
|
179
|
+
expect(roundTrip!.content).toContain('GadgetOnDisk');
|
|
180
|
+
expect(files.find((f) => f.path === 'tests/fixtures/gadget_on_disk.json')).toBeDefined();
|
|
181
|
+
expect(files.find((f) => f.path === 'tests/fixtures/widget_a.json')).toBeDefined();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('full (non-scoped) run is unaffected', () => {
|
|
186
|
+
it('references every model in its barrel and round-trip test', () => {
|
|
187
|
+
const fullCtx: EmitterContext = {
|
|
188
|
+
namespace: 'workos',
|
|
189
|
+
namespacePascal: 'WorkOS',
|
|
190
|
+
spec,
|
|
191
|
+
};
|
|
192
|
+
const modelFiles = generateModels(models, fullCtx);
|
|
193
|
+
const gadgetBarrel = modelFiles.find((f) => f.path === 'src/workos/gadgets/models/__init__.py');
|
|
194
|
+
expect(gadgetBarrel!.content).toContain('GadgetBrandNew');
|
|
195
|
+
expect(gadgetBarrel!.content).toContain('GadgetOnDisk');
|
|
196
|
+
// No retention import on a full run.
|
|
197
|
+
expect(gadgetBarrel!.content).not.toContain('import *');
|
|
198
|
+
|
|
199
|
+
const testFiles = generateTests(spec, fullCtx);
|
|
200
|
+
const roundTrip = testFiles.find((f) => f.path === 'tests/test_models_round_trip.py');
|
|
201
|
+
expect(roundTrip!.content).toContain('GadgetBrandNew');
|
|
202
|
+
expect(testFiles.find((f) => f.path === 'tests/fixtures/gadget_brand_new.json')).toBeDefined();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|