Repository: Redocly/redocly-cli
Pull request #1444 (2024-02-19 07:09:42)

fix: join command failing on null values with --prefix-components-with-info-prop option
## What/Why/How?

Since 1.9.0 `join` fails when used with the --prefix-components-with-info-prop option and a schema contains `null` value.

This PR resolves the issue by moving the crawl [visitor](https://github.com/Redocly/redocly-cli/pull/1444/files#diff-e52f3b6820e3de0cd0ec76de555265a5139350fdfa3cd210402edeaa9fdd56eeR151) out of the loop. 

Also, did a small refactoring of `split` command (reusing shared things with `join`, types correction).

## Reference

Fixes https://github.com/Redocly/redocly-cli/issues/1440


## Testing

## Screenshots (optional)

## Has code been changed?

- [ ] Tested with redoc/reference-docs/workflows (internal)
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: d4d58d370b8ab12cbaaf796023a43000cf29e5c0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Redocly/redocly-cli/new/fix/join-failing-with-prefix-components-with-info-prop?filename=.changeset/twenty-llamas-invent.md&value=---%0A%22%40redocly%2Fcli%22%3A%20patch%0A---%0A%0Afix%3A%20join%20command%20failing%20on%20null%20values%20with%20--prefix-components-with-info-prop%20option%0A)


github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 

❌ An unexpected error occurred. For more details, [check console](https://github.com/Redocly/redocly-cli/actions/runs/7955833453) 
```
Error: The process '/opt/hostedtoolcache/node/20.11.0/x64/bin/npm' failed with exit code 2
```

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 76.97%     |    4458/5792    |
|                                                                           🟡                                                                           | Branches   | 67.05%     |    2360/3520    |
|                                                                           🟡                                                                           | Functions  | 70.28%     |     726/1033    |
|                                                                           🟡                                                                           | Lines      | 77.15%     |    4187/5427    |


## Test suite run success
713 tests passing in 101 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from d4d58d370b8ab12cbaaf796023a43000cf29e5c0</p>

github-actions[bot]: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 975.2 ± 18.7 | 953.0 | 1010.4 | 1.00 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 999.7 ± 14.3 | 979.0 | 1019.0 | 1.03 ± 0.02 |

<!-- thollander/actions-comment-pull-request "latest-vs-next-comparison" -->
tatomyr: @lornajane updated the description.

Patch:
diff --git a/.changeset/spotty-pets-doubt.md b/.changeset/spotty-pets-doubt.md
new file mode 100644
index 00000000..5cb448ed
--- /dev/null
+++ b/.changeset/spotty-pets-doubt.md
@@ -0,0 +1,5 @@
+---
+"@redocly/cli": patch
+---
+
+Fixed a problem where the `join` command did not process schemas containing `null` values when the `--prefix-components-with-info-prop` option was used.'
diff --git a/packages/cli/src/__mocks__/@redocly/openapi-core.ts b/packages/cli/src/__mocks__/@redocly/openapi-core.ts
index 8347ad5b..29dc9642 100644
--- a/packages/cli/src/__mocks__/@redocly/openapi-core.ts
+++ b/packages/cli/src/__mocks__/@redocly/openapi-core.ts
@@ -1,5 +1,5 @@
 import { ConfigFixture } from './../../__tests__/fixtures/config';
-import { firstDocument, secondDocument } from '../documents';
+import { firstDocument, secondDocument, thirdDocument } from '../documents';
 
 import type { Document } from '@redocly/openapi-core';
 
@@ -47,6 +47,9 @@ export class BaseResolver {
     )
     .mockImplementationOnce(() =>
       Promise.resolve({ source: { absoluteRef: 'ref' }, parsed: secondDocument })
+    )
+    .mockImplementationOnce(() =>
+      Promise.resolve({ source: { absoluteRef: 'ref' }, parsed: thirdDocument })
     );
 }
 
diff --git a/packages/cli/src/__mocks__/documents.ts b/packages/cli/src/__mocks__/documents.ts
index 95a55a9c..d3f5bd84 100644
--- a/packages/cli/src/__mocks__/documents.ts
+++ b/packages/cli/src/__mocks__/documents.ts
@@ -4,7 +4,7 @@ export const firstDocument = {
   info: {
     description: 'example test',
     version: '1.0.0',
-    title: 'Swagger Petstore',
+    title: 'First API',
     termsOfService: 'http://swagger.io/terms/',
     license: {
       name: 'Apache 2.0',
@@ -36,7 +36,7 @@ export const secondDocument = {
   info: {
     description: 'example test',
     version: '1.0.0',
-    title: 'Swagger Petstore',
+    title: 'Second API',
     termsOfService: 'http://swagger.io/terms/',
     license: {
       name: 'Apache 2.0',
@@ -61,3 +61,64 @@ export const secondDocument = {
   },
   components: {},
 };
+
+export const thirdDocument = {
+  openapi: '3.0.0',
+  info: {
+    title: 'Third API',
+    version: '1.0',
+  },
+  servers: [
+    {
+      url: 'https://api.server.test/v1',
+    },
+  ],
+  paths: {},
+  components: {
+    schemas: {
+      SchemaWithNull: {
+        type: 'string',
+        default: null,
+        nullable: true,
+      },
+      SchemaWithRef: {
+        type: 'object',
+        properties: {
+          schemaType: {
+            type: 'string',
+            enum: ['foo'],
+          },
+          foo: {
+            $ref: '#/components/schemas/SchemaWithNull',
+          },
+        },
+      },
+      SchemaWithDiscriminator: {
+        discriminator: {
+          propertyName: 'schemaType',
+          mapping: {
+            foo: '#/components/schemas/SchemaWithRef',
+            bar: '#/components/schemas/SchemaWithNull',
+          },
+        },
+        oneOf: [
+          {
+            $ref: '#/components/schemas/SchemaWithRef',
+          },
+          {
+            type: 'object',
+            properties: {
+              schemaType: {
+                type: 'string',
+                enum: ['bar'],
+              },
+              bar: {
+                type: 'string',
+              },
+            },
+          },
+        ],
+      },
+    },
+  },
+};
diff --git a/packages/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts
index 531e5590..7d7a5d11 100644
--- a/packages/cli/src/commands/join.ts
+++ b/packages/cli/src/commands/join.ts
@@ -4,22 +4,16 @@ import { performance } from 'perf_hooks';
 const isEqual = require('lodash.isequal');
 import {
   Config,
-  Oas3Definition,
   SpecVersion,
   BaseResolver,
-  Document,
   StyleguideConfig,
-  Oas3Tag,
   formatProblems,
   getTotals,
   lintDocument,
   detectSpec,
   bundleDocument,
-  Referenced,
   isRef,
-  RuleSeverity,
 } from '@redocly/openapi-core';
-
 import {
   getFallbackApisOrExit,
   printExecutionTime,
@@ -32,16 +26,24 @@ import {
   checkForDeprecatedOptions,
 } from '../utils/miscellaneous';
 import { isObject, isString, keysOf } from '../utils/js-utils';
-import {
+import { COMPONENTS, OPENAPI3_METHOD } from './split/types';
+import { crawl, startsWithComponents } from './split';
+
+import type {
+  Oas3Definition,
+  Document,
+  Oas3Tag,
+  Referenced,
+  RuleSeverity,
+} from '@redocly/openapi-core';
+import type { BundleResult } from '@redocly/openapi-core/lib/bundle';
+import type {
   Oas3Parameter,
   Oas3PathItem,
   Oas3Server,
   Oas3_1Definition,
 } from '@redocly/openapi-core/lib/typings/openapi';
-import { OPENAPI3_METHOD } from './split/types';
-import { BundleResult } from '@redocly/openapi-core/lib/bundle';
 
-const COMPONENTS = 'components';
 const Tags = 'tags';
 const xTagGroups = 'x-tagGroups';
 let potentialConflictsTotal = 0;
@@ -801,30 +803,19 @@ async function validateApi(
   }
 }
 
-function crawl(object: any, visitor: any) {
-  if (!isObject(object)) return;
-  for (const key of Object.keys(object)) {
-    visitor(object[key], key);
-    crawl(object[key], visitor);
-  }
-}
-
-function replace$Refs(obj: any, componentsPrefix: string) {
-  crawl(obj, (node: any) => {
-    if (node.$ref && isString(node.$ref) && node.$ref.startsWith(`#/${COMPONENTS}/`)) {
+function replace$Refs(obj: unknown, componentsPrefix: string) {
+  crawl(obj, (node: Record<string, unknown>) => {
+    if (node.$ref && typeof node.$ref === 'string' && startsWithComponents(node.$ref)) {
       const name = path.basename(node.$ref);
       node.$ref = node.$ref.replace(name, componentsPrefix + '_' + name);
-    } else if (
-      node.discriminator &&
-      node.discriminator.mapping &&
-      isObject(node.discriminator.mapping)
-    ) {
+    } else if (isObject(node.discriminator) && isObject(node.discriminator.mapping)) {
       const { mapping } = node.discriminator;
       for (const name of Object.keys(mapping)) {
-        if (isString(mapping[name]) && mapping[name].startsWith(`#/${COMPONENTS}/`)) {
-          mapping[name] = mapping[name]
+        const mappingPointer = mapping[name];
+        if (typeof mappingPointer === 'string' && startsWithComponents(mappingPointer)) {
+          mapping[name] = mappingPointer
             .split('/')
-            .map((name: string, i: number, arr: []) => {
+            .map((name, i, arr) => {
               return arr.length - 1 === i && !name.includes(componentsPrefix)
                 ? componentsPrefix + '_' + name
                 : name;
diff --git a/packages/cli/src/commands/split/index.ts b/packages/cli/src/commands/split/index.ts
index a5d0aa19..a0740b10 100644
--- a/packages/cli/src/commands/split/index.ts
+++ b/packages/cli/src/commands/split/index.ts
@@ -1,11 +1,9 @@
 import { red, blue, yellow, green } from 'colorette';
 import * as fs from 'fs';
 import { parseYaml, slash, isRef, isTruthy } from '@redocly/openapi-core';
-import type { OasRef } from '@redocly/openapi-core';
 import * as path from 'path';
 import { performance } from 'perf_hooks';
 const isEqual = require('lodash.isequal');
-
 import {
   printExecutionTime,
   pathToFilename,
@@ -16,8 +14,16 @@ import {
   writeToFileByExtension,
   getAndValidateFileExtension,
 } from '../../utils/miscellaneous';
-import { isString, isObject, isEmptyObject } from '../../utils/js-utils';
+import { isObject, isEmptyObject } from '../../utils/js-utils';
 import {
+  OPENAPI3_COMPONENT,
+  COMPONENTS,
+  OPENAPI3_METHOD_NAMES,
+  OPENAPI3_COMPONENT_NAMES,
+} from './types';
+
+import type { OasRef } from '@redocly/openapi-core';
+import type {
   Definition,
   Oas2Definition,
   Oas3Schema,
@@ -26,13 +32,8 @@ import {
   Oas3Components,
   Oas3ComponentName,
   ComponentsFiles,
-  refObj,
+  RefObject,
   Oas3PathItem,
-  OPENAPI3_COMPONENT,
-  COMPONENTS,
-  componentsPath,
-  OPENAPI3_METHOD_NAMES,
-  OPENAPI3_COMPONENT_NAMES,
   Referenced,
 } from './types';
 
@@ -93,8 +94,8 @@ function splitDefinition(
   writeToFileByExtension(openapi, path.join(openapiDir, `openapi.${ext}`));
 }
 
-function isStartsWithComponents(node: string) {
-  return node.startsWith(componentsPath);
+export function startsWithComponents(node: string) {
+  return node.startsWith(`#/${COMPONENTS}/`);
 }
 
 function isSupportedExtension(filename: string) {
@@ -144,33 +145,31 @@ function traverseDirectoryDeepCallback(
   writeToFileByExtension(pathData, filename);
 }
 
-function crawl(object: any, visitor: any) {
+export function crawl(object: unknown, visitor: (node: Record<string, unknown>) => void) {
   if (!isObject(object)) return;
+
+  visitor(object);
   for (const key of Object.keys(object)) {
-    visitor(object, key);
     crawl(object[key], visitor);
   }
 }
 
-function replace$Refs(obj: any, relativeFrom: string, componentFiles = {} as ComponentsFiles) {
-  crawl(obj, (node: any) => {
-    if (node.$ref && isString(node.$ref) && isStartsWithComponents(node.$ref)) {
-      replace(node, '$ref');
-    } else if (
-      node.discriminator &&
-      node.discriminator.mapping &&
-      isObject(node.discriminator.mapping)
-    ) {
+function replace$Refs(obj: unknown, relativeFrom: string, componentFiles = {} as ComponentsFiles) {
+  crawl(obj, (node: Record<string, unknown>) => {
+    if (node.$ref && typeof node.$ref === 'string' && startsWithComponents(node.$ref)) {
+      replace(node as RefObject, '$ref');
+    } else if (isObject(node.discriminator) && isObject(node.discriminator.mapping)) {
       const { mapping } = node.discriminator;
       for (const name of Object.keys(mapping)) {
-        if (isString(mapping[name]) && isStartsWithComponents(mapping[name])) {
-          replace(node.discriminator.mapping, name);
+        const mappingPointer = mapping[name];
+        if (typeof mappingPointer === 'string' && startsWithComponents(mappingPointer)) {
+          replace(node.discriminator.mapping as RefObject, name);
         }
       }
     }
   });
 
-  function replace(node: refObj, key: string) {
+  function replace(node: RefObject, key: string) {
     const splittedNode = node[key].split('/');
     const name = splittedNode.pop();
     const groupName = splittedNode[2];
diff --git a/packages/cli/src/commands/split/types.ts b/packages/cli/src/commands/split/types.ts
index 808da7fc..2b56b24c 100644
--- a/packages/cli/src/commands/split/types.ts
+++ b/packages/cli/src/commands/split/types.ts
@@ -28,7 +28,7 @@ export type Definition = Oas3_1Definition | Oas3Definition | Oas2Definition;
 export interface ComponentsFiles {
   [schemas: string]: any;
 }
-export interface refObj {
+export interface RefObject {
   [$ref: string]: string;
 }
 
@@ -36,8 +36,6 @@ export const COMPONENTS = 'components';
 export const PATHS = 'paths';
 export const WEBHOOKS = 'webhooks';
 export const xWEBHOOKS = 'x-webhooks';
-export const componentsPath = `#/${COMPONENTS}/`;
-
 export enum OPENAPI3_METHOD {
   get = 'get',
   put = 'put',
diff --git a/packages/cli/src/utils/js-utils.ts b/packages/cli/src/utils/js-utils.ts
index 41946c64..f30dd80b 100644
--- a/packages/cli/src/utils/js-utils.ts
+++ b/packages/cli/src/utils/js-utils.ts
@@ -1,4 +1,4 @@
-export function isObject(obj: any) {
+export function isObject(obj: unknown): obj is Record<string, unknown> {
   const type = typeof obj;
   return type === 'function' || (type === 'object' && !!obj);
 }


Test patch:
diff --git a/__tests__/commands.test.ts b/__tests__/commands.test.ts
index 6da454a6..3ff7a505 100644
--- a/__tests__/commands.test.ts
+++ b/__tests__/commands.test.ts
@@ -204,6 +204,29 @@ describe('E2E', () => {
       const lintResult = getCommandOutput(lintArgs, folderPath);
       (<any>expect(lintResult)).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js'));
     });
+
+    test('openapi json file with discriminator', () => {
+      const folderPath = join(__dirname, `split/discriminator-in-json`);
+      const file = '../../../__tests__/split/discriminator-in-json/openapi.json';
+
+      const args = getParams('../../../packages/cli/src/index.ts', 'split', [
+        file,
+        '--outDir=output',
+      ]);
+
+      // run the split command and write the result to files
+      spawnSync('ts-node', args, {
+        cwd: folderPath,
+        env: {
+          ...process.env,
+          NODE_ENV: 'production',
+          NO_COLOR: 'TRUE',
+        },
+      });
+
+      const result = getCommandOutput(args, folderPath);
+      (<any>expect(result)).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js'));
+    });
   });
 
   describe('join', () => {
diff --git a/__tests__/join/prefix-components-with-info-prop/bar.yaml b/__tests__/join/prefix-components-with-info-prop/bar.yaml
index 67ebc9c2..b8020bf8 100644
--- a/__tests__/join/prefix-components-with-info-prop/bar.yaml
+++ b/__tests__/join/prefix-components-with-info-prop/bar.yaml
@@ -29,6 +29,8 @@ components:
     some-property:
       description: bar description
       type: string
+      nullable: true
+      default: null
     another-property:
       description: description
       $ref: '#/components/schemas/some-property'
diff --git a/__tests__/join/prefix-components-with-info-prop/snapshot.js b/__tests__/join/prefix-components-with-info-prop/snapshot.js
index 832c6e65..d6413847 100644
--- a/__tests__/join/prefix-components-with-info-prop/snapshot.js
+++ b/__tests__/join/prefix-components-with-info-prop/snapshot.js
@@ -71,6 +71,8 @@ components:
     Bar Example OpenAPI 3 definition._some-property:
       description: bar description
       type: string
+      nullable: true
+      default: null
     Bar Example OpenAPI 3 definition._another-property:
       description: description
       $ref: '#/components/schemas/Bar Example OpenAPI 3 definition._some-property'
diff --git a/__tests__/split/discriminator-in-json/openapi.json b/__tests__/split/discriminator-in-json/openapi.json
new file mode 100644
index 00000000..9954b487
--- /dev/null
+++ b/__tests__/split/discriminator-in-json/openapi.json
@@ -0,0 +1,76 @@
+{
+  "openapi": "3.0.0",
+  "info": {
+    "title": "Test",
+    "version": "1.0"
+  },
+  "servers": [
+    {
+      "url": "https://api.server.test/v1"
+    }
+  ],
+  "paths": {
+    "/test": {
+      "get": {
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/SchemaWithDiscriminator"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "SchemaWithNull": {
+        "type": "string",
+        "default": null,
+        "nullable": true
+      },
+      "SchemaWithRef": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "schemaType": "string",
+            "const": "foo"
+          },
+          "foo": {
+            "$ref": "#/components/schemas/SchemaWithNull"
+          }
+        }
+      },
+      "SchemaWithDiscriminator": {
+        "discriminator": {
+          "propertyName": "schemaType",
+          "mapping": {
+            "foo": "#/components/schemas/SchemaWithRef",
+            "bar": "#/components/schemas/SchemaWithNull"
+          }
+        },
+        "oneOf": [
+          {
+            "$ref": "#/components/schemas/SchemaWithRef"
+          },
+          {
+            "type": "object",
+            "properties": {
+              "schemaType": {
+                "schemaType": "string",
+                "const": "bar"
+              },
+              "bar": {
+                "type": "string"
+              }
+            }
+          }
+        ]
+      }
+    }
+  }
+}
diff --git a/__tests__/split/discriminator-in-json/snapshot.js b/__tests__/split/discriminator-in-json/snapshot.js
new file mode 100644
index 00000000..36026cf2
--- /dev/null
+++ b/__tests__/split/discriminator-in-json/snapshot.js
@@ -0,0 +1,95 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E split openapi json file with discriminator 1`] = `
+
+{
+  "type": "string",
+  "default": null,
+  "nullable": true
+}{
+  "type": "object",
+  "properties": {
+    "type": {
+      "schemaType": "string",
+      "const": "foo"
+    },
+    "foo": {
+      "$ref": "./SchemaWithNull.json"
+    }
+  }
+}{
+  "discriminator": {
+    "propertyName": "schemaType",
+    "mapping": {
+      "foo": "./SchemaWithRef.json",
+      "bar": "./SchemaWithNull.json"
+    }
+  },
+  "oneOf": [
+    {
+      "$ref": "./SchemaWithRef.json"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "schemaType": {
+          "schemaType": "string",
+          "const": "bar"
+        },
+        "bar": {
+          "type": "string"
+        }
+      }
+    }
+  ]
+}{
+  "get": {
+    "responses": {
+      "200": {
+        "content": {
+          "application/json": {
+            "schema": {
+              "$ref": "#/components/schemas/SchemaWithDiscriminator"
+            }
+          }
+        }
+      }
+    }
+  }
+}{
+  "get": {
+    "responses": {
+      "200": {
+        "content": {
+          "application/json": {
+            "schema": {
+              "$ref": "../components/schemas/SchemaWithDiscriminator.json"
+            }
+          }
+        }
+      }
+    }
+  }
+}{
+  "openapi": "3.0.0",
+  "info": {
+    "title": "Test",
+    "version": "<version>"
+  },
+  "servers": [
+    {
+      "url": "https://api.server.test/v1"
+    }
+  ],
+  "paths": {
+    "/test": {
+      "$ref": "paths/test.json"
+    }
+  }
+}🪓 Document: ../../../__tests__/split/discriminator-in-json/openapi.json is successfully split
+    and all related files are saved to the directory: output 
+
+../../../__tests__/split/discriminator-in-json/openapi.json: split processed in <test>ms
+
+
+`;
diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts
index a82f9edd..c5c0a43a 100644
--- a/packages/cli/src/__tests__/commands/join.test.ts
+++ b/packages/cli/src/__tests__/commands/join.test.ts
@@ -1,7 +1,7 @@
-import { handleJoin } from '../../commands/join';
-import { exitWithError, writeToFileByExtension, writeYaml } from '../../utils/miscellaneous';
 import { yellow } from 'colorette';
 import { detectSpec } from '@redocly/openapi-core';
+import { handleJoin } from '../../commands/join';
+import { exitWithError, writeToFileByExtension } from '../../utils/miscellaneous';
 import { loadConfig } from '../../__mocks__/@redocly/openapi-core';
 import { ConfigFixture } from '../fixtures/config';
 
@@ -9,7 +9,7 @@ jest.mock('../../utils/miscellaneous');
 
 jest.mock('colorette');
 
-describe('handleJoin fails', () => {
+describe('handleJoin', () => {
   const colloreteYellowMock = yellow as jest.Mock<any, any>;
   colloreteYellowMock.mockImplementation((string: string) => string);
 
@@ -181,4 +181,138 @@ describe('handleJoin fails', () => {
     expect(config.styleguide.skipDecorators).not.toHaveBeenCalled();
     expect(config.styleguide.skipPreprocessors).not.toHaveBeenCalled();
   });
+
+  it('should handle join with prefix-components-with-info-prop and null values', async () => {
+    (detectSpec as jest.Mock).mockReturnValue('oas3_0');
+
+    await handleJoin(
+      {
+        apis: ['first.yaml', 'second.yaml', 'third.yaml'],
+        'prefix-components-with-info-prop': 'title',
+        output: 'join-result.yaml',
+      },
+      ConfigFixture as any,
+      'cli-version'
+    );
+
+    expect(writeToFileByExtension).toHaveBeenCalledWith(
+      {
+        openapi: '3.0.0',
+        info: {
+          description: 'example test',
+          version: '1.0.0',
+          title: 'First API',
+          termsOfService: 'http://swagger.io/terms/',
+          license: {
+            name: 'Apache 2.0',
+            url: 'http://www.apache.org/licenses/LICENSE-2.0.html',
+          },
+        },
+        servers: [
+          {
+            url: 'http://localhost:8080',
+          },
+          {
+            url: 'https://api.server.test/v1',
+          },
+        ],
+        tags: [
+          {
+            name: 'pet',
+            'x-displayName': 'pet',
+          },
+        ],
+        paths: {
+          '/GETUser/{userId}': {
+            summary: 'get user by id',
+            description: 'user info',
+            servers: [
+              {
+                url: '/user',
+              },
+              {
+                url: '/pet',
+                description: 'pet server',
+              },
+            ],
+            get: {
+              tags: ['pet'],
+              summary: 'Find pet by ID',
+              description: 'Returns a single pet',
+              operationId: 'getPetById',
+              servers: [
+                {
+                  url: '/pet',
+                },
+              ],
+            },
+            parameters: [
+              {
+                name: 'param1',
+                in: 'header',
+                schema: {
+                  description: 'string',
+                },
+              },
+            ],
+          },
+        },
+        components: {
+          schemas: {
+            'Third API_SchemaWithNull': {
+              type: 'string',
+              default: null,
+              nullable: true,
+            },
+            'Third API_SchemaWithRef': {
+              type: 'object',
+              properties: {
+                schemaType: {
+                  type: 'string',
+                  enum: ['foo'],
+                },
+                foo: {
+                  $ref: '#/components/schemas/Third API_SchemaWithNull',
+                },
+              },
+            },
+            'Third API_SchemaWithDiscriminator': {
+              discriminator: {
+                propertyName: 'schemaType',
+                mapping: {
+                  foo: '#/components/schemas/Third API_SchemaWithRef',
+                  bar: '#/components/schemas/Third API_SchemaWithNull',
+                },
+              },
+              oneOf: [
+                {
+                  $ref: '#/components/schemas/Third API_SchemaWithRef',
+                },
+                {
+                  type: 'object',
+                  properties: {
+                    schemaType: {
+                      type: 'string',
+                      enum: ['bar'],
+                    },
+                    bar: {
+                      type: 'string',
+                    },
+                  },
+                },
+              ],
+            },
+          },
+        },
+        'x-tagGroups': [
+          {
+            name: 'First API',
+            tags: ['pet'],
+          },
+        ],
+      },
+      'join-result.yaml',
+      true
+    );
+  });
 });