@workos/oagen-emitters 0.0.1 → 0.2.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.
Files changed (49) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.oxfmtrc.json +8 -1
  6. package/.prettierignore +1 -0
  7. package/.release-please-manifest.json +3 -0
  8. package/.vscode/settings.json +3 -0
  9. package/CHANGELOG.md +61 -0
  10. package/README.md +2 -2
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.d.mts.map +1 -0
  13. package/dist/index.mjs +4070 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +14 -18
  16. package/release-please-config.json +11 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +21 -4
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-ruby.ts +17 -3
  23. package/smoke/sdk-rust.ts +16 -3
  24. package/src/node/client.ts +521 -206
  25. package/src/node/common.ts +74 -4
  26. package/src/node/config.ts +1 -0
  27. package/src/node/enums.ts +53 -9
  28. package/src/node/errors.ts +82 -3
  29. package/src/node/fixtures.ts +87 -16
  30. package/src/node/index.ts +66 -10
  31. package/src/node/manifest.ts +4 -2
  32. package/src/node/models.ts +251 -124
  33. package/src/node/naming.ts +107 -3
  34. package/src/node/resources.ts +1162 -108
  35. package/src/node/serializers.ts +512 -52
  36. package/src/node/tests.ts +650 -110
  37. package/src/node/type-map.ts +89 -11
  38. package/src/node/utils.ts +426 -113
  39. package/test/node/client.test.ts +1083 -20
  40. package/test/node/enums.test.ts +73 -4
  41. package/test/node/errors.test.ts +4 -21
  42. package/test/node/models.test.ts +499 -5
  43. package/test/node/naming.test.ts +14 -7
  44. package/test/node/resources.test.ts +1568 -9
  45. package/test/node/serializers.test.ts +241 -5
  46. package/tsconfig.json +2 -3
  47. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  48. package/dist/index.d.ts +0 -5
  49. package/dist/index.js +0 -2158
package/src/node/tests.ts CHANGED
@@ -1,15 +1,26 @@
1
1
  import type { ApiSpec, Service, Operation, Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { planOperation, toCamelCase } from '@workos/oagen';
3
+ import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
3
4
  import {
4
5
  fieldName,
5
6
  wireFieldName,
6
7
  fileName,
7
- serviceDirName,
8
+ resolveServiceDir,
8
9
  servicePropertyName,
9
10
  resolveMethodName,
10
- resolveServiceName,
11
+ resolveInterfaceName,
11
12
  } from './naming.js';
12
13
  import { generateFixtures } from './fixtures.js';
14
+ import { resolveResourceClassName } from './resources.js';
15
+ import {
16
+ assignModelsToServices,
17
+ createServiceDirResolver,
18
+ isServiceCoveredByExisting,
19
+ uncoveredOperations,
20
+ relativeImport,
21
+ isListMetadataModel,
22
+ isListWrapperModel,
23
+ } from './utils.js';
13
24
 
14
25
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
15
26
  const files: GeneratedFile[] = [];
@@ -17,15 +28,27 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
17
28
  // Generate fixture JSON files
18
29
  const fixtures = generateFixtures(spec, ctx);
19
30
  for (const f of fixtures) {
20
- files.push({ path: f.path, content: f.content, headerPlacement: 'skip' });
31
+ files.push({ path: f.path, content: f.content, headerPlacement: 'skip', integrateTarget: false });
21
32
  }
22
33
 
23
34
  // Build model lookup for response field assertions
24
35
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
25
36
 
26
- // Generate test files per service
37
+ // Generate test files per service — skip services whose endpoints are fully
38
+ // covered by existing hand-written service classes. For partially covered
39
+ // services, generate tests only for uncovered operations.
27
40
  for (const service of spec.services) {
28
- files.push(generateServiceTest(service, spec, ctx, modelMap));
41
+ if (isServiceCoveredByExisting(service, ctx)) continue;
42
+ const ops = uncoveredOperations(service, ctx);
43
+ if (ops.length === 0) continue;
44
+ const testService = ops.length < service.operations.length ? { ...service, operations: ops } : service;
45
+ files.push(generateServiceTest(testService, spec, ctx, modelMap));
46
+ }
47
+
48
+ // Generate serializer round-trip tests
49
+ const serializerTests = generateSerializerTests(spec, ctx);
50
+ for (const f of serializerTests) {
51
+ files.push(f);
29
52
  }
30
53
 
31
54
  return files;
@@ -37,40 +60,97 @@ function generateServiceTest(
37
60
  ctx: EmitterContext,
38
61
  modelMap: Map<string, Model>,
39
62
  ): GeneratedFile {
40
- const resolvedName = resolveServiceName(service, ctx);
41
- const serviceDir = serviceDirName(resolvedName);
63
+ const resolvedName = resolveResourceClassName(service, ctx);
64
+ const serviceDir = resolveServiceDir(resolvedName);
42
65
  const serviceClass = resolvedName;
43
66
  const serviceProp = servicePropertyName(resolvedName);
44
67
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
45
68
 
69
+ const plans = service.operations.map((op) => ({
70
+ op,
71
+ plan: planOperation(op),
72
+ method: resolveMethodName(op, service, ctx),
73
+ }));
74
+
75
+ // Sort plans to match the existing file's method order (same as resources.ts).
76
+ if (ctx.overlayLookup?.methodByOperation) {
77
+ const methodOrder = new Map<string, number>();
78
+ let pos = 0;
79
+ for (const [, info] of ctx.overlayLookup.methodByOperation) {
80
+ if (!methodOrder.has(info.methodName)) {
81
+ methodOrder.set(info.methodName, pos++);
82
+ }
83
+ }
84
+ if (methodOrder.size > 0) {
85
+ plans.sort((a, b) => {
86
+ const aPos = methodOrder.get(a.method) ?? Number.MAX_SAFE_INTEGER;
87
+ const bPos = methodOrder.get(b.method) ?? Number.MAX_SAFE_INTEGER;
88
+ return aPos - bPos;
89
+ });
90
+ }
91
+ }
92
+
93
+ // Compute model-to-service mapping so fixture imports use the correct cross-service path.
94
+ // A test for service A may reference a response model owned by service B — the fixture
95
+ // lives in service B's fixtures directory, not service A's.
96
+ const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
97
+
46
98
  const lines: string[] = [];
47
99
 
48
100
  lines.push("import fetch from 'jest-fetch-mock';");
101
+
102
+ // Conditionally import test utilities based on what test types exist
103
+ const hasPaginated = plans.some((p) => p.plan.isPaginated);
104
+ const hasBody = plans.some((p) => p.plan.hasBody);
105
+ const testUtils = ['fetchOnce', 'fetchURL', 'fetchMethod'];
106
+ if (hasPaginated) testUtils.push('fetchSearchParams');
107
+ if (hasBody) testUtils.push('fetchBody');
108
+ // Import shared test helpers for error and pagination tests
109
+ if (hasPaginated) testUtils.push('testEmptyResults', 'testPaginationParams');
110
+ // Only import testUnauthorized when at least one operation has a response model or is paginated
111
+ const hasErrorTests = plans.some((p) => p.plan.responseModelName || p.plan.isPaginated);
112
+ if (hasErrorTests) testUtils.push('testUnauthorized');
49
113
  lines.push('import {');
50
- lines.push(' fetchOnce,');
51
- lines.push(' fetchURL,');
52
- lines.push(' fetchSearchParams,');
53
- lines.push(' fetchBody,');
114
+ for (const util of testUtils) {
115
+ lines.push(` ${util},`);
116
+ }
54
117
  lines.push("} from '../common/utils/test-utils';");
55
118
  lines.push("import { WorkOS } from '../workos';");
56
119
  lines.push('');
57
120
 
58
- // Import fixtures
121
+ // Import fixtures — use correct cross-service paths when the response model
122
+ // is owned by a different service than the current test file.
59
123
  const fixtureImports = new Set<string>();
60
- for (const op of service.operations) {
61
- const plan = planOperation(op);
124
+ for (const { op, plan } of plans) {
62
125
  if (plan.isPaginated && op.pagination) {
63
- const itemModelName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : null;
126
+ let itemModelName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : null;
64
127
  if (itemModelName) {
65
- fixtureImports.add(
66
- `import list${itemModelName}Fixture from './fixtures/list-${fileName(itemModelName)}.fixture.json';`,
67
- );
128
+ // Unwrap list wrapper models to match the fixture file naming in fixtures.ts
129
+ const rawModel = modelMap.get(itemModelName);
130
+ if (rawModel) {
131
+ const unwrapped = unwrapListModel(rawModel, modelMap);
132
+ if (unwrapped) {
133
+ itemModelName = unwrapped.name;
134
+ }
135
+ }
136
+ // List fixtures are always generated in the current service's directory
137
+ // (the service owning the list operation), not in the model's home service.
138
+ // Always use a local import path.
139
+ const fixturePath = `./fixtures/list-${fileName(itemModelName)}.fixture.json`;
140
+ fixtureImports.add(`import list${itemModelName}Fixture from '${fixturePath}';`);
68
141
  }
69
142
  } else if (plan.responseModelName) {
70
- fixtureImports.add(
71
- `import ${toCamelCase(plan.responseModelName)}Fixture from './fixtures/${fileName(plan.responseModelName)}.fixture.json';`,
72
- );
143
+ const respService = modelToService.get(plan.responseModelName);
144
+ const respDir = resolveDir(respService);
145
+ const fixturePath =
146
+ respDir === serviceDir
147
+ ? `./fixtures/${fileName(plan.responseModelName)}.fixture.json`
148
+ : `../${respDir}/fixtures/${fileName(plan.responseModelName)}.fixture.json`;
149
+ fixtureImports.add(`import ${toCamelCase(plan.responseModelName)}Fixture from '${fixturePath}';`);
73
150
  }
151
+ // NOTE: Request body fixtures are not imported for body tests because
152
+ // fixtures use wire format (snake_case) but methods expect domain types
153
+ // (camelCase). Body tests use `{} as any` instead.
74
154
  }
75
155
  for (const imp of fixtureImports) {
76
156
  lines.push(imp);
@@ -79,31 +159,37 @@ function generateServiceTest(
79
159
  lines.push('');
80
160
  lines.push("const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');");
81
161
  lines.push('');
162
+
163
+ // Generate per-entity assertion helpers for models used in 2+ tests.
164
+ // This deduplicates the field assertion blocks that would otherwise be
165
+ // copy-pasted across list/find/create/update test cases.
166
+ const { lines: helperLines, helpers: entityHelperNames } = generateEntityHelpers(plans, modelMap, ctx);
167
+ for (const line of helperLines) {
168
+ lines.push(line);
169
+ }
170
+
82
171
  lines.push(`describe('${serviceClass}', () => {`);
83
172
  lines.push(' beforeEach(() => fetch.resetMocks());');
84
173
 
85
- for (const op of service.operations) {
86
- const plan = planOperation(op);
87
- const method = resolveMethodName(op, service, ctx);
88
-
174
+ for (const { op, plan, method } of plans) {
89
175
  lines.push('');
90
176
  lines.push(` describe('${method}', () => {`);
91
177
 
92
178
  if (plan.isPaginated) {
93
- renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap);
179
+ renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
94
180
  } else if (plan.isDelete) {
95
- renderDeleteTest(lines, op, method, serviceProp);
181
+ renderDeleteTest(lines, op, plan, method, serviceProp, modelMap);
96
182
  } else if (plan.hasBody && plan.responseModelName) {
97
- renderBodyTest(lines, op, plan, method, serviceProp, modelMap);
183
+ renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
98
184
  } else if (plan.responseModelName) {
99
- renderGetTest(lines, op, plan, method, serviceProp, modelMap);
185
+ renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
100
186
  } else {
101
- renderVoidTest(lines, op, method, serviceProp);
187
+ renderVoidTest(lines, op, plan, method, serviceProp, modelMap);
102
188
  }
103
189
 
104
190
  // Error case test for all non-void operations
105
191
  if (plan.responseModelName || plan.isPaginated) {
106
- renderErrorTest(lines, op, plan, method, serviceProp);
192
+ renderErrorTest(lines, op, plan, method, serviceProp, modelMap);
107
193
  }
108
194
 
109
195
  lines.push(' });');
@@ -111,7 +197,36 @@ function generateServiceTest(
111
197
 
112
198
  lines.push('});');
113
199
 
114
- return { path: testPath, content: lines.join('\n'), skipIfExists: true, integrateTarget: false };
200
+ return { path: testPath, content: lines.join('\n'), skipIfExists: true };
201
+ }
202
+
203
+ /** Compute the test value for a single path parameter.
204
+ * Uses distinct values per param name so multi-param paths don't all get 'test_id'.
205
+ */
206
+ function pathParamTestValue(param: { type: TypeRef; name?: string } | undefined, paramName?: string): string {
207
+ if (param?.type.kind === 'enum' && param.type.values?.length) {
208
+ const first = param.type.values[0];
209
+ return typeof first === 'string' ? first : String(first);
210
+ }
211
+ // Use distinct values for different path params to detect ordering bugs
212
+ const name = paramName ?? (param as any)?.name;
213
+ if (name) return `test_${fieldName(name)}`;
214
+ return 'test_id';
215
+ }
216
+
217
+ /** Build test arguments for all path params (handles multiple path params). */
218
+ function buildTestPathArgs(op: Operation): string {
219
+ // Detect path template variables (may be more than op.pathParams if spec is incomplete)
220
+ const templateVars = [...op.path.matchAll(/\{(\w+)\}/g)].map(([, name]) => fieldName(name));
221
+ const declaredNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
222
+ const paramByName = new Map(op.pathParams.map((p) => [fieldName(p.name), p]));
223
+ // Merge declared + template vars, deduplicate, preserve order
224
+ const allVars: string[] = [];
225
+ for (const p of op.pathParams) allVars.push(fieldName(p.name));
226
+ for (const v of templateVars) {
227
+ if (!declaredNames.has(v)) allVars.push(v);
228
+ }
229
+ return allVars.map((varName) => `'${pathParamTestValue(paramByName.get(varName), varName)}'`).join(', ');
115
230
  }
116
231
 
117
232
  function renderPaginatedTest(
@@ -121,46 +236,94 @@ function renderPaginatedTest(
121
236
  method: string,
122
237
  serviceProp: string,
123
238
  modelMap: Map<string, Model>,
239
+ ctx?: EmitterContext,
240
+ entityHelpers?: Set<string>,
124
241
  ): void {
125
- const itemModelName = op.pagination?.itemType.kind === 'model' ? op.pagination.itemType.name : 'Item';
242
+ let itemModelName = op.pagination?.itemType.kind === 'model' ? op.pagination.itemType.name : 'Item';
243
+ // Unwrap list wrapper models to match the fixture file naming in fixtures.ts
244
+ const rawModel = itemModelName !== 'Item' ? modelMap.get(itemModelName) : null;
245
+ if (rawModel) {
246
+ const unwrapped = unwrapListModel(rawModel, modelMap);
247
+ if (unwrapped) {
248
+ itemModelName = unwrapped.name;
249
+ }
250
+ }
251
+ const pathArgs = buildTestPathArgs(op);
126
252
 
127
253
  lines.push(" it('returns paginated results', async () => {");
128
254
  lines.push(` fetchOnce(list${itemModelName}Fixture);`);
129
255
  lines.push('');
130
- lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}();`);
256
+ lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${pathArgs});`);
131
257
  lines.push('');
132
- lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
258
+ lines.push(" expect(fetchMethod()).toBe('GET');");
259
+ // Fix #12: Full URL path assertion instead of toContain()
260
+ const expectedPath = buildExpectedPath(op);
261
+ lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPath}');`);
133
262
  lines.push(" expect(fetchSearchParams()).toHaveProperty('order');");
134
263
  lines.push(' expect(Array.isArray(data)).toBe(true);');
135
264
  lines.push(' expect(listMetadata).toBeDefined();');
136
265
 
137
- // Assert on first item fields when item model is available
138
- const itemModel = modelMap.get(itemModelName);
139
- if (itemModel) {
140
- const assertions = buildFieldAssertions(itemModel, 'data[0]');
141
- if (assertions.length > 0) {
142
- lines.push(' expect(data.length).toBeGreaterThan(0);');
143
- for (const assertion of assertions) {
144
- lines.push(` ${assertion}`);
266
+ // Assert on first item fields use entity helper if available
267
+ const paginatedHelperName = ctx ? `expect${resolveInterfaceName(itemModelName, ctx)}` : null;
268
+ if (paginatedHelperName && entityHelpers?.has(paginatedHelperName)) {
269
+ lines.push(' expect(data.length).toBeGreaterThan(0);');
270
+ lines.push(` ${paginatedHelperName}(data[0]);`);
271
+ } else {
272
+ const itemModel = modelMap.get(itemModelName);
273
+ if (itemModel) {
274
+ const assertions = buildFieldAssertions(itemModel, 'data[0]', modelMap);
275
+ if (assertions.length > 0) {
276
+ lines.push(' expect(data.length).toBeGreaterThan(0);');
277
+ for (const assertion of assertions) {
278
+ lines.push(` ${assertion}`);
279
+ }
145
280
  }
146
281
  }
147
282
  }
148
283
 
149
284
  lines.push(' });');
285
+
286
+ // Edge case: handles empty results — use shared helper
287
+ lines.push('');
288
+ lines.push(` testEmptyResults(() => workos.${serviceProp}.${method}(${pathArgs}));`);
289
+
290
+ // Edge case: forwards pagination params — use shared helper
291
+ lines.push('');
292
+ lines.push(` testPaginationParams(`);
293
+ lines.push(` (opts) => workos.${serviceProp}.${method}(${pathArgs ? pathArgs + ', ' : ''}opts),`);
294
+ lines.push(` list${itemModelName}Fixture,`);
295
+ lines.push(' );');
150
296
  }
151
297
 
152
- function renderDeleteTest(lines: string[], op: Operation, method: string, serviceProp: string): void {
153
- const hasPathParam = op.pathParams.length > 0;
154
- const args = hasPathParam ? "'test_id'" : '';
298
+ function renderDeleteTest(
299
+ lines: string[],
300
+ op: Operation,
301
+ plan: any,
302
+ method: string,
303
+ serviceProp: string,
304
+ modelMap: Map<string, Model>,
305
+ ): void {
306
+ const pathArgs = buildTestPathArgs(op);
307
+ // Build realistic payload for body-bearing delete operations
308
+ const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
309
+ const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
310
+ const args = plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs;
155
311
 
156
312
  lines.push(" it('sends a DELETE request', async () => {");
157
313
  lines.push(' fetchOnce({}, { status: 204 });');
158
314
  lines.push('');
159
315
  lines.push(` await workos.${serviceProp}.${method}(${args});`);
160
316
  lines.push('');
161
- lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
162
- if (hasPathParam) {
163
- lines.push(" expect(fetchURL()).toContain('test_id');");
317
+ lines.push(" expect(fetchMethod()).toBe('DELETE');");
318
+ // Fix #12: Full URL path assertion instead of toContain()
319
+ const expectedPathDel = buildExpectedPath(op);
320
+ lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPathDel}');`);
321
+ if (plan.hasBody) {
322
+ if (payload) {
323
+ lines.push(` expect(fetchBody()).toEqual(expect.objectContaining(${payload.snakeCaseObj}));`);
324
+ } else {
325
+ lines.push(' expect(fetchBody()).toBeDefined();');
326
+ }
164
327
  }
165
328
  lines.push(' });');
166
329
  }
@@ -172,30 +335,53 @@ function renderBodyTest(
172
335
  method: string,
173
336
  serviceProp: string,
174
337
  modelMap: Map<string, Model>,
338
+ ctx?: EmitterContext,
339
+ entityHelpers?: Set<string>,
175
340
  ): void {
176
341
  const responseModelName = plan.responseModelName!;
177
342
  const fixture = `${toCamelCase(responseModelName)}Fixture`;
178
- const hasPathParam = op.pathParams.length > 0;
179
- const pathArg = hasPathParam ? "'test_id', " : '';
343
+ const pathArgs = buildTestPathArgs(op);
344
+
345
+ // Build realistic payload from request body model fields
346
+ const payload = buildTestPayload(op, modelMap);
347
+ const payloadArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
348
+ const allArgs = pathArgs ? `${pathArgs}, ${payloadArg}` : payloadArg;
180
349
 
181
350
  lines.push(" it('sends the correct request and returns result', async () => {");
182
351
  lines.push(` fetchOnce(${fixture});`);
183
352
  lines.push('');
184
- lines.push(` const result = await workos.${serviceProp}.${method}(${pathArg}{});`);
353
+ lines.push(` const result = await workos.${serviceProp}.${method}(${allArgs});`);
185
354
  lines.push('');
186
- lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
187
- if (hasPathParam) {
188
- lines.push(" expect(fetchURL()).toContain('test_id');");
355
+ lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
356
+
357
+ // Fix #12: Full URL path assertion instead of toContain()
358
+ const expectedPath = buildExpectedPath(op);
359
+ lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPath}');`);
360
+
361
+ // Fix #10: Assert serialized wire format of request body
362
+ if (payload) {
363
+ lines.push(` expect(fetchBody()).toEqual(expect.objectContaining(${payload.snakeCaseObj}));`);
364
+ } else {
365
+ lines.push(' expect(fetchBody()).toBeDefined();');
189
366
  }
190
- lines.push(' expect(fetchBody()).toBeDefined();');
191
- lines.push(' expect(result).toBeDefined();');
192
367
 
193
- // Response field assertions
194
- const responseModel = modelMap.get(responseModelName);
195
- if (responseModel) {
196
- const assertions = buildFieldAssertions(responseModel, 'result');
197
- for (const assertion of assertions) {
198
- lines.push(` ${assertion}`);
368
+ // Use entity helper if available, otherwise inline assertions
369
+ const bodyHelperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
370
+ if (bodyHelperName && entityHelpers?.has(bodyHelperName)) {
371
+ lines.push(` ${bodyHelperName}(result);`);
372
+ } else {
373
+ const responseModel = modelMap.get(responseModelName);
374
+ if (responseModel) {
375
+ const assertions = buildFieldAssertions(responseModel, 'result', modelMap);
376
+ if (assertions.length > 0) {
377
+ for (const assertion of assertions) {
378
+ lines.push(` ${assertion}`);
379
+ }
380
+ } else {
381
+ lines.push(' expect(result).toBeDefined();');
382
+ }
383
+ } else {
384
+ lines.push(' expect(result).toBeDefined();');
199
385
  }
200
386
  }
201
387
 
@@ -209,116 +395,304 @@ function renderGetTest(
209
395
  method: string,
210
396
  serviceProp: string,
211
397
  modelMap: Map<string, Model>,
398
+ ctx?: EmitterContext,
399
+ entityHelpers?: Set<string>,
212
400
  ): void {
213
401
  const responseModelName = plan.responseModelName!;
214
402
  const fixture = `${toCamelCase(responseModelName)}Fixture`;
215
- const hasPathParam = op.pathParams.length > 0;
216
- const args = hasPathParam ? "'test_id'" : '';
403
+ const pathArgs = buildTestPathArgs(op);
217
404
 
218
405
  lines.push(" it('returns the expected result', async () => {");
219
406
  lines.push(` fetchOnce(${fixture});`);
220
407
  lines.push('');
221
- lines.push(` const result = await workos.${serviceProp}.${method}(${args});`);
408
+ lines.push(` const result = await workos.${serviceProp}.${method}(${pathArgs});`);
222
409
  lines.push('');
223
- lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
224
- if (hasPathParam) {
225
- lines.push(" expect(fetchURL()).toContain('test_id');");
226
- }
227
- lines.push(' expect(result).toBeDefined();');
228
-
229
- // Response field assertions
230
- const responseModel = modelMap.get(responseModelName);
231
- if (responseModel) {
232
- const assertions = buildFieldAssertions(responseModel, 'result');
233
- for (const assertion of assertions) {
234
- lines.push(` ${assertion}`);
410
+ lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
411
+ // Fix #12: Full URL path assertion instead of toContain()
412
+ const expectedPathGet = buildExpectedPath(op);
413
+ lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPathGet}');`);
414
+
415
+ // Use entity helper if available, otherwise inline assertions
416
+ const helperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
417
+ if (helperName && entityHelpers?.has(helperName)) {
418
+ lines.push(` ${helperName}(result);`);
419
+ } else {
420
+ const responseModel = modelMap.get(responseModelName);
421
+ if (responseModel) {
422
+ const assertions = buildFieldAssertions(responseModel, 'result', modelMap);
423
+ if (assertions.length > 0) {
424
+ for (const assertion of assertions) {
425
+ lines.push(` ${assertion}`);
426
+ }
427
+ } else {
428
+ lines.push(' expect(result).toBeDefined();');
429
+ }
430
+ } else {
431
+ lines.push(' expect(result).toBeDefined();');
235
432
  }
236
433
  }
237
434
 
238
435
  lines.push(' });');
239
436
  }
240
437
 
241
- function renderVoidTest(lines: string[], op: Operation, method: string, serviceProp: string): void {
242
- const hasPathParam = op.pathParams.length > 0;
243
- const args = hasPathParam ? "'test_id'" : '';
438
+ function renderVoidTest(
439
+ lines: string[],
440
+ op: Operation,
441
+ plan: any,
442
+ method: string,
443
+ serviceProp: string,
444
+ modelMap: Map<string, Model>,
445
+ ): void {
446
+ const pathArgs = buildTestPathArgs(op);
447
+ // Build realistic payload for body-bearing void operations
448
+ const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
449
+ const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
450
+ const args = plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs;
244
451
 
245
452
  lines.push(" it('sends the request', async () => {");
246
453
  lines.push(' fetchOnce({});');
247
454
  lines.push('');
248
455
  lines.push(` await workos.${serviceProp}.${method}(${args});`);
249
456
  lines.push('');
250
- lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
251
- if (hasPathParam) {
252
- lines.push(" expect(fetchURL()).toContain('test_id');");
457
+ lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
458
+ // Fix #12: Full URL path assertion instead of toContain()
459
+ const expectedPathVoid = buildExpectedPath(op);
460
+ lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPathVoid}');`);
461
+ if (plan.hasBody && payload) {
462
+ lines.push(` expect(fetchBody()).toEqual(expect.objectContaining(${payload.snakeCaseObj}));`);
253
463
  }
254
464
  lines.push(' });');
255
465
  }
256
466
 
257
- function renderErrorTest(lines: string[], op: Operation, plan: any, method: string, serviceProp: string): void {
258
- const hasPathParam = op.pathParams.length > 0;
467
+ function renderErrorTest(
468
+ lines: string[],
469
+ op: Operation,
470
+ plan: any,
471
+ method: string,
472
+ serviceProp: string,
473
+ modelMap: Map<string, Model>,
474
+ ): void {
475
+ const args = buildCallArgs(op, plan, modelMap);
476
+
477
+ lines.push('');
478
+ lines.push(` testUnauthorized(() => workos.${serviceProp}.${method}(${args}));`);
479
+
480
+ // Add error-status tests based on the operation's error responses
481
+ const errorStatuses = new Set(op.errors.map((e) => e.statusCode));
482
+
483
+ // 404 test for find/get methods
484
+ if (errorStatuses.has(404) && (method.startsWith('get') || method.startsWith('find'))) {
485
+ lines.push('');
486
+ lines.push(" it('throws NotFoundException on 404', async () => {");
487
+ lines.push(" fetchOnce('', { status: 404 });");
488
+ lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
489
+ lines.push(' });');
490
+ }
491
+
492
+ // 422 test for create/update methods
493
+ if (errorStatuses.has(422) && (method.startsWith('create') || method.startsWith('update'))) {
494
+ lines.push('');
495
+ lines.push(" it('throws UnprocessableEntityException on 422', async () => {");
496
+ lines.push(" fetchOnce('', { status: 422 });");
497
+ lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
498
+ lines.push(' });');
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Build the argument string for a method call in tests.
504
+ * Shared by renderErrorTest and other test renderers.
505
+ */
506
+ function buildCallArgs(op: Operation, plan: any, modelMap: Map<string, Model>): string {
507
+ const pathArgs = buildTestPathArgs(op);
259
508
  const isPaginated = plan.isPaginated;
260
509
  const hasBody = plan.hasBody;
261
510
 
262
- let args: string;
263
- if (isPaginated) {
264
- args = '';
265
- } else if (hasBody && hasPathParam) {
266
- args = "'test_id', {}";
267
- } else if (hasBody) {
268
- args = '{}';
269
- } else if (hasPathParam) {
270
- args = "'test_id'";
271
- } else {
272
- args = '';
511
+ if (isPaginated) return pathArgs || '';
512
+ if (hasBody) {
513
+ const payload = buildTestPayload(op, modelMap);
514
+ const bodyArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
515
+ return pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg;
273
516
  }
517
+ return pathArgs || '';
518
+ }
274
519
 
275
- lines.push('');
276
- lines.push(" it('throws on unauthorized', async () => {");
277
- lines.push(" fetchOnce({ message: 'Unauthorized' }, { status: 401 });");
278
- lines.push('');
279
- lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
280
- lines.push(' });');
520
+ /**
521
+ * Generate per-entity assertion helper functions for models used in 2+ tests.
522
+ * Returns lines like: function expectConnection(result: any) { expect(...) }
523
+ */
524
+ /**
525
+ * Generate per-entity assertion helper functions for models used in 2+ tests.
526
+ * Returns { lines, helpers } where helpers is a Set of helper function names.
527
+ */
528
+ function generateEntityHelpers(
529
+ plans: { op: Operation; plan: any; method: string }[],
530
+ modelMap: Map<string, Model>,
531
+ ctx: EmitterContext,
532
+ ): { lines: string[]; helpers: Set<string> } {
533
+ // Count how many tests reference each response model
534
+ const modelUsage = new Map<string, number>();
535
+ for (const { op, plan } of plans) {
536
+ let modelName: string | null = null;
537
+ if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
538
+ modelName = op.pagination.itemType.name;
539
+ const rawModel = modelMap.get(modelName);
540
+ if (rawModel) {
541
+ const unwrapped = unwrapListModel(rawModel, modelMap);
542
+ if (unwrapped) modelName = unwrapped.name;
543
+ }
544
+ } else if (plan.responseModelName) {
545
+ modelName = plan.responseModelName;
546
+ }
547
+ if (modelName) {
548
+ modelUsage.set(modelName, (modelUsage.get(modelName) ?? 0) + 1);
549
+ }
550
+ }
551
+
552
+ const lines: string[] = [];
553
+ const helpers = new Set<string>();
554
+ for (const [modelName, count] of modelUsage) {
555
+ if (count < 2) continue;
556
+ const model = modelMap.get(modelName);
557
+ if (!model) continue;
558
+ const assertions = buildFieldAssertions(model, 'result', modelMap);
559
+ if (assertions.length === 0) continue;
560
+
561
+ const domainName = resolveInterfaceName(modelName, ctx);
562
+ const helperName = `expect${domainName}`;
563
+ if (helpers.has(helperName)) continue;
564
+ helpers.add(helperName);
565
+
566
+ lines.push(`function ${helperName}(result: any) {`);
567
+ for (const assertion of assertions) {
568
+ lines.push(` ${assertion}`);
569
+ }
570
+ lines.push('}');
571
+ lines.push('');
572
+ }
573
+ return { lines, helpers };
281
574
  }
282
575
 
283
576
  /**
284
577
  * Build field-level assertions for top-level primitive fields of a response model.
285
578
  * Returns lines like: expect(result.fieldName).toBe(fixtureValue);
579
+ *
580
+ * When the top level has no assertable primitive fields (e.g. wrapper types
581
+ * whose only required fields are nested models), recurse one level into those
582
+ * nested models so we still get meaningful assertions instead of a bare
583
+ * `toBeDefined()`.
286
584
  */
287
- function buildFieldAssertions(model: Model, accessor: string): string[] {
585
+ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<string, Model>): string[] {
288
586
  const assertions: string[] = [];
289
587
 
290
588
  for (const field of model.fields) {
291
589
  if (!field.required) continue;
292
- const value = fixtureValueForType(field.type, field.name);
590
+ // When a field has an example value, use it as the expected assertion value
591
+ if (field.example !== undefined) {
592
+ const domainField = fieldName(field.name);
593
+ if (typeof field.example === 'object' && field.example !== null) {
594
+ // Objects and arrays need toEqual with JSON serialization
595
+ assertions.push(`expect(${accessor}.${domainField}).toEqual(${JSON.stringify(field.example)});`);
596
+ } else {
597
+ const exampleLiteral = typeof field.example === 'string' ? `'${field.example}'` : String(field.example);
598
+ assertions.push(`expect(${accessor}.${domainField}).toBe(${exampleLiteral});`);
599
+ }
600
+ continue;
601
+ }
602
+ const value = fixtureValueForType(field.type, field.name, model.name);
293
603
  if (value === null) continue;
294
604
  const domainField = fieldName(field.name);
295
605
  assertions.push(`expect(${accessor}.${domainField}).toBe(${value});`);
296
606
  }
297
607
 
608
+ // When no primitive assertions were found (e.g. wrapper types like
609
+ // ResetPasswordResponse { user: User }), recurse one level into nested
610
+ // model-type fields to generate assertions on their primitive fields.
611
+ if (assertions.length === 0 && modelMap) {
612
+ for (const field of model.fields) {
613
+ if (!field.required) continue;
614
+ if (field.type.kind === 'model') {
615
+ const nestedModel = modelMap.get(field.type.name);
616
+ if (nestedModel) {
617
+ const nestedAccessor = `${accessor}.${fieldName(field.name)}`;
618
+ // Recurse without modelMap to limit depth to one level
619
+ const nested = buildFieldAssertions(nestedModel, nestedAccessor);
620
+ assertions.push(...nested);
621
+ }
622
+ }
623
+ }
624
+ }
625
+
298
626
  return assertions;
299
627
  }
300
628
 
301
629
  /**
302
- * Return a JS literal string for the expected fixture value of a primitive field.
303
- * Returns null for non-primitive or complex types (arrays, models, etc.).
630
+ * Return a JS literal string for the expected fixture value of a field.
631
+ * Returns null for types that cannot be deterministically generated.
632
+ * When a modelMap is provided, recursively builds object literals for nested model types.
633
+ * When wire is true, uses snake_case keys for nested model objects (wire format).
304
634
  */
305
- function fixtureValueForType(ref: TypeRef, name: string): string | null {
635
+ function fixtureValueForType(
636
+ ref: TypeRef,
637
+ name: string,
638
+ modelName: string,
639
+ modelMap?: Map<string, Model>,
640
+ wire?: boolean,
641
+ ): string | null {
306
642
  switch (ref.kind) {
307
643
  case 'primitive':
308
- return fixtureValueForPrimitive(ref.type, ref.format, name);
644
+ return fixtureValueForPrimitive(ref.type, ref.format, name, modelName);
309
645
  case 'literal':
310
646
  return typeof ref.value === 'string' ? `'${ref.value}'` : String(ref.value);
647
+ case 'enum':
648
+ // Use the first enum value as a realistic fixture value
649
+ if (ref.values?.length) {
650
+ const first = ref.values[0];
651
+ return typeof first === 'string' ? `'${first}'` : String(first);
652
+ }
653
+ return null;
654
+ case 'array': {
655
+ // For arrays of primitives/enums, generate a single-element array assertion.
656
+ // For arrays of models/complex types, return null to skip the assertion —
657
+ // the fixture will have populated items that we can't predict here.
658
+ const itemValue = fixtureValueForType(ref.items, name, modelName, modelMap, wire);
659
+ if (itemValue !== null) return `[${itemValue}]`;
660
+ return null;
661
+ }
662
+ case 'model': {
663
+ if (!modelMap) return null;
664
+ const nested = modelMap.get(ref.name);
665
+ if (!nested) return null;
666
+ const requiredFields = nested.fields.filter((f) => f.required);
667
+ const entries: string[] = [];
668
+ for (const field of requiredFields) {
669
+ const value = fixtureValueForType(field.type, field.name, nested.name, modelMap, wire);
670
+ if (value === null) return null; // Can't build a complete object
671
+ const key = wire ? wireFieldName(field.name) : fieldName(field.name);
672
+ entries.push(`${key}: ${value}`);
673
+ }
674
+ return `{ ${entries.join(', ')} }`;
675
+ }
311
676
  default:
312
677
  return null;
313
678
  }
314
679
  }
315
680
 
316
- function fixtureValueForPrimitive(type: string, format: string | undefined, name: string): string | null {
681
+ function fixtureValueForPrimitive(
682
+ type: string,
683
+ format: string | undefined,
684
+ name: string,
685
+ modelName: string,
686
+ ): string | null {
317
687
  switch (type) {
318
688
  case 'string':
319
689
  if (format === 'date-time') return "'2023-01-01T00:00:00.000Z'";
320
690
  if (format === 'date') return "'2023-01-01'";
321
691
  if (format === 'uuid') return "'00000000-0000-0000-0000-000000000000'";
692
+ if (name === 'id') {
693
+ const prefix = ID_PREFIXES[modelName] ?? '';
694
+ return `'${prefix}01234'`;
695
+ }
322
696
  if (name.includes('id')) return `'${wireFieldName(name)}_01234'`;
323
697
  if (name.includes('email')) return "'test@example.com'";
324
698
  if (name.includes('url') || name.includes('uri')) return "'https://example.com'";
@@ -334,3 +708,169 @@ function fixtureValueForPrimitive(type: string, format: string | undefined, name
334
708
  return null;
335
709
  }
336
710
  }
711
+
712
+ /**
713
+ * Build the expected full URL path for an operation, substituting path params
714
+ * with their test values. Returns a string like '/organizations/test_id'.
715
+ */
716
+ function buildExpectedPath(op: Operation): string {
717
+ let path = op.path;
718
+ for (const param of op.pathParams) {
719
+ path = path.replace(`{${param.name}}`, pathParamTestValue(param, fieldName(param.name)));
720
+ }
721
+ return path;
722
+ }
723
+
724
+ /**
725
+ * Build a realistic test payload for a request body model.
726
+ * Returns { camelCaseObj, snakeCaseObj } as inline JS object literal strings,
727
+ * or null if the request body is not a named model.
728
+ *
729
+ * camelCaseObj is what the SDK consumer passes (e.g. { organizationName: 'Test' })
730
+ * snakeCaseObj is the expected wire format (e.g. { organization_name: 'Test' })
731
+ */
732
+ function buildTestPayload(
733
+ op: Operation,
734
+ modelMap: Map<string, Model>,
735
+ ): { camelCaseObj: string; snakeCaseObj: string } | null {
736
+ if (!op.requestBody || op.requestBody.kind !== 'model') return null;
737
+
738
+ const model = modelMap.get(op.requestBody.name);
739
+ if (!model) return null;
740
+
741
+ const fields = model.fields.filter((f) => f.required);
742
+ // Only use fields that we can generate deterministic values for (primitives, enums, and nested models)
743
+ const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null);
744
+
745
+ // Only generate a typed payload when ALL required fields have fixture values.
746
+ // A partial payload missing required fields would fail TypeScript type checking.
747
+ if (usableFields.length === 0 || usableFields.length < fields.length) return null;
748
+
749
+ const camelEntries: string[] = [];
750
+ const snakeEntries: string[] = [];
751
+
752
+ for (const field of usableFields) {
753
+ const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap)!;
754
+ const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true)!;
755
+ const camelKey = fieldName(field.name);
756
+ const snakeKey = wireFieldName(field.name);
757
+ camelEntries.push(`${camelKey}: ${camelValue}`);
758
+ snakeEntries.push(`${snakeKey}: ${wireValue}`);
759
+ }
760
+
761
+ return {
762
+ camelCaseObj: `{ ${camelEntries.join(', ')} }`,
763
+ snakeCaseObj: `{ ${snakeEntries.join(', ')} }`,
764
+ };
765
+ }
766
+
767
+ /**
768
+ * Compute a fallback body argument when buildTestPayload returns null.
769
+ * If the request body model has no required fields (all optional), an empty
770
+ * object `{}` is a valid value and doesn't need a type assertion. Otherwise,
771
+ * fall back to `{} as any` to bypass type checking for complex required fields.
772
+ */
773
+ function fallbackBodyArg(op: Operation, modelMap: Map<string, Model>): string {
774
+ if (!op.requestBody || op.requestBody.kind !== 'model') return '{} as any';
775
+ const model = modelMap.get(op.requestBody.name);
776
+ if (!model) return '{} as any';
777
+ const hasRequiredFields = model.fields.some((f) => f.required);
778
+ return hasRequiredFields ? '{} as any' : '{}';
779
+ }
780
+
781
+ /**
782
+ * Determine whether a model should get a round-trip serializer test.
783
+ * Includes all models with at least one field — every model gets both
784
+ * serialize and deserialize functions, so all benefit from round-trip testing.
785
+ */
786
+ function modelNeedsRoundTripTest(model: Model): boolean {
787
+ return model.fields.length > 0;
788
+ }
789
+
790
+ /**
791
+ * Generate serializer round-trip tests for models that have both serialize and
792
+ * deserialize functions and have nested types requiring non-trivial serialization.
793
+ */
794
+ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
795
+ const files: GeneratedFile[] = [];
796
+ const modelToService = assignModelsToServices(spec.models, spec.services);
797
+ const serviceNameMap = new Map<string, string>();
798
+ for (const service of spec.services) {
799
+ serviceNameMap.set(service.name, resolveResourceClassName(service, ctx));
800
+ }
801
+ const resolveDir = (irService: string | undefined) =>
802
+ irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
803
+
804
+ // Only generate round-trip tests for models with fields that have serializers generated.
805
+ // Skip list metadata and list wrapper models since their serializers are not emitted.
806
+ const eligibleModels = spec.models.filter(
807
+ (m) => modelNeedsRoundTripTest(m) && !isListMetadataModel(m) && !isListWrapperModel(m),
808
+ );
809
+
810
+ if (eligibleModels.length === 0) return files;
811
+
812
+ // Group eligible models by service directory for one test file per service
813
+ const modelsByDir = new Map<string, Model[]>();
814
+ for (const model of eligibleModels) {
815
+ const service = modelToService.get(model.name);
816
+ const dirName = resolveDir(service);
817
+ if (!modelsByDir.has(dirName)) {
818
+ modelsByDir.set(dirName, []);
819
+ }
820
+ modelsByDir.get(dirName)!.push(model);
821
+ }
822
+
823
+ for (const [dirName, models] of modelsByDir) {
824
+ const testPath = `src/${dirName}/serializers.spec.ts`;
825
+ const lines: string[] = [];
826
+
827
+ // Collect imports
828
+ const serializerImports: string[] = [];
829
+ const fixtureImports: string[] = [];
830
+
831
+ for (const model of models) {
832
+ const domainName = resolveInterfaceName(model.name, ctx);
833
+ const service = modelToService.get(model.name);
834
+ const modelDir = resolveDir(service);
835
+ const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
836
+ const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.fixture.json`;
837
+
838
+ serializerImports.push(
839
+ `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
840
+ );
841
+ fixtureImports.push(`import ${toCamelCase(model.name)}Fixture from '${relativeImport(testPath, fixturePath)}';`);
842
+ }
843
+
844
+ for (const imp of serializerImports) {
845
+ lines.push(imp);
846
+ }
847
+ for (const imp of fixtureImports) {
848
+ lines.push(imp);
849
+ }
850
+ lines.push('');
851
+
852
+ for (const model of models) {
853
+ const domainName = resolveInterfaceName(model.name, ctx);
854
+ const fixtureName = `${toCamelCase(model.name)}Fixture`;
855
+
856
+ lines.push(`describe('${domainName}Serializer', () => {`);
857
+ lines.push(" it('round-trips through serialize/deserialize', () => {");
858
+ lines.push(` const fixture = ${fixtureName};`);
859
+ lines.push(` const deserialized = deserialize${domainName}(fixture);`);
860
+ lines.push(` const reserialized = serialize${domainName}(deserialized);`);
861
+ lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
862
+ lines.push(' });');
863
+ lines.push('});');
864
+ lines.push('');
865
+ }
866
+
867
+ files.push({
868
+ path: testPath,
869
+ content: lines.join('\n'),
870
+ skipIfExists: true,
871
+ integrateTarget: false,
872
+ });
873
+ }
874
+
875
+ return files;
876
+ }