Skip to content
Snippets Groups Projects
validation.spec.ts 57.03 KiB
import { configFileNames } from './app-strings';
import { GlobalConfig } from './global';
import type { RenovateConfig } from './types';
import * as configValidation from './validation';

describe('config/validation', () => {
  describe('getParentName()', () => {
    it('ignores encrypted in root', () => {
      expect(configValidation.getParentName('encrypted')).toBeEmptyString();
    });

    it('handles array types', () => {
      expect(configValidation.getParentName('hostRules[1]')).toBe('hostRules');
    });

    it('handles encrypted within array types', () => {
      expect(configValidation.getParentName('hostRules[0].encrypted')).toBe(
        'hostRules',
      );
    });
  });

  describe('validateConfig(config)', () => {
    it('returns deprecation warnings', async () => {
      const config = {
        prTitle: 'something',
      };
      const { warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(1);
      expect(warnings).toMatchSnapshot();
    });

    it('catches global options in repo config', async () => {
      const config = {
        binarySource: 'something',
        username: 'user',
      };
      const { warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(2);
      expect(warnings).toMatchObject([
        {
          message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
        },
        {
          message: `The "username" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
        },
      ]);
    });

    it('catches global options in inherit config', async () => {
      const config = {
        binarySource: 'something',
        username: 'user',
      };
      const { warnings } = await configValidation.validateConfig(
        'inherit',
        config,
      );
      expect(warnings).toHaveLength(2);
      expect(warnings).toMatchObject([
        {
          message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
        },
        {
          message: `The "username" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
        },
      ]);
    });

    it('only warns for actual globals in repo config', async () => {
      const config = {
        hostRules: [
          {
            username: 'user',
            token: 'token',
            password: 'pass',
          },
        ],
      };
      const { warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
    });

    it('does not warn for valid inheritConfig', async () => {
      const config = {
        onboarding: false,
      };
      const { warnings } = await configValidation.validateConfig(
        'inherit',
        config,
      );
      expect(warnings).toHaveLength(0);
    });

    it('does not warn for valid platformConfig', async () => {
      const config = {
        platformConfig: 'auto',
      };
      const { warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
    });

    it('warns for invalid platformConfig', async () => {
      const config = {
        platformConfig: 'invalid',
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toHaveLength(1);
    });

    it('catches invalid templates', async () => {
      const config = {
        commitMessage: '{{{something}}',
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toHaveLength(1);
      expect(errors).toMatchSnapshot();
    });

    it('catches invalid jsonata expressions', async () => {
      const config = {
        packageRules: [
          {
            matchJsonata: ['packageName = "foo"', '{{{something wrong}'],
            enabled: true,
          },
        ],
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toHaveLength(1);
      expect(errors[0].message).toContain('Invalid JSONata expression');
    });

    it('catches invalid allowedVersions regex', async () => {
      const config = {
        packageRules: [
          {
            matchPackageNames: ['foo'],
            allowedVersions: '/^2/',
          },
          {
            matchPackageNames: ['bar'],
            allowedVersions: '/***$}{]][/',
          },
          {
            matchPackageNames: ['baz'],
            allowedVersions: '!/^2/',
          },
          {
            matchPackageNames: ['quack'],
            allowedVersions: '!/***$}{]][/',
          },
          {
            matchPackageNames: ['quack'],
            allowedVersions: '/quaCk/i',
          },
        ],
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toHaveLength(2);
      expect(errors).toMatchSnapshot();
    });

    it('catches invalid matchCurrentValue', async () => {
      const config = {
        packageRules: [
          {
            matchPackageNames: ['foo'],
            matchCurrentValue: '/^2/',
            enabled: true,
          },
          {
            matchPackageNames: ['bar'],
            matchCurrentValue: '^1',
            enabled: true,
          },
          {
            matchPackageNames: ['quack'],
            matchCurrentValue: '<1.0.0',
            enabled: true,
          },
          {
            matchPackageNames: ['foo'],
            matchCurrentValue: '/^2/i',
            enabled: true,
          },
          {
            matchPackageNames: ['bad'],
            matchNewValue: '/^2(/',
            enabled: true,
          },
        ],
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toHaveLength(1);
    });

    it('catches invalid matchNewValue', async () => {
      const config = {
        packageRules: [
          {
            matchPackageNames: ['foo'],
            matchNewValue: '/^2/',
            enabled: true,
          },
          {
            matchPackageNames: ['bar'],
            matchNewValue: '^1',
            enabled: true,
          },
          {
            matchPackageNames: ['quack'],
            matchNewValue: '<1.0.0',
            enabled: true,
          },
          {
            matchPackageNames: ['foo'],
            matchNewValue: '/^2/i',
            enabled: true,
          },
          {
            matchPackageNames: ['bad'],
            matchNewValue: '/^2(/',
            enabled: true,
          },
        ],
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toHaveLength(1);
    });

    it('validates matchBaseBranches', async () => {
      const config = {
        baseBranches: ['foo'],
        packageRules: [
          {
            matchBaseBranches: ['foo'],
            addLabels: ['foo'],
          },
        ],
      };
      const { errors, warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(errors).toHaveLength(0);
      expect(warnings).toHaveLength(0);
    });

    it('catches invalid matchBaseBranches when baseBranches is not defined', async () => {
      const config = {
        packageRules: [
          {
            matchBaseBranches: ['foo'],
            addLabels: ['foo'],
          },
        ],
      };
      const { errors, warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(errors).toHaveLength(0);
      expect(warnings).toHaveLength(1);
    });

    it('catches invalid matchCurrentVersion regex', async () => {
      const config = {
        packageRules: [
          {
            matchPackageNames: ['foo'],
            matchCurrentVersion: '/^2/',
            enabled: true,
          },
          {
            matchPackageNames: ['bar'],
            matchCurrentVersion: '/***$}{]][/',
            enabled: true,
          },
          {
            matchPackageNames: ['baz'],
            matchCurrentVersion: '!/^2/',
            enabled: true,
          },
          {
            matchPackageNames: ['quack'],
            matchCurrentVersion: '!/***$}{]][/',
            enabled: true,
          },
          {
            matchPackageNames: ['foo'],
            matchCurrentVersion: '/^2/i',
            enabled: true,
          },
        ],
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toHaveLength(2);
      expect(errors).toMatchSnapshot();
    });

    it('catches invalid customDatasources content', async () => {
      const config = {
        customDatasources: {
          foo: {
            description: 3,
            randomKey: '',
            defaultRegistryUrlTemplate: [],
            transformTemplates: [{}],
          },
          bar: {
            description: 'foo',
            defaultRegistryUrlTemplate: 'bar',
            transformTemplates: ['foo = "bar"', 'bar[0'],
          },
        },
      } as any;
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toMatchObject([
        {
          message:
            'Invalid `customDatasources.defaultRegistryUrlTemplate` configuration: is a string',
        },
        {
          message:
            'Invalid `customDatasources.description` configuration: is not an array of strings',
        },
        {
          message:
            'Invalid `customDatasources.randomKey` configuration: key is not allowed',
        },
        {
          message:
            'Invalid `customDatasources.transformTemplates` configuration: is not an array of string',
        },
      ]);
    });
    it('validates invalid statusCheckNames', async () => {
      const config = {
        statusCheckNames: {
          randomKey: '',
          mergeConfidence: 10,
          configValidation: '',
          artifactError: null,
        },
      };
      // @ts-expect-error invalid options
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toMatchObject([
        {
          message:
            'Invalid `statusCheckNames.mergeConfidence` configuration: status check is not a string.',
        },
        {
          message:
            'Invalid `statusCheckNames.statusCheckNames.randomKey` configuration: key is not allowed.',
        },
      ]);
      expect(errors).toHaveLength(2);
    });

    it('catches invalid customDatasources record type', async () => {
      const config = {
        customDatasources: {
          randomKey: '',
        },
      } as any;
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toMatchObject([
        {
          message:
            'Invalid `customDatasources.randomKey` configuration: customDatasource is not an object',
        },
      ]);
    });

    it('catches invalid baseBranches regex', async () => {
      const config = {
        baseBranches: ['/***$}{]][/', '/branch/i'],
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toEqual([
        {
          topic: 'Configuration Error',
          message: 'Invalid regExp for baseBranches: `/***$}{]][/`',
        },
      ]);
    });

    it('returns nested errors', async () => {
      const config: RenovateConfig = {
        foo: 1,
        schedule: ['after 5pm'],
        timezone: 'Asia/Singapore',
        packageRules: [
          {
            matchPackageNames: [
              '*',
              '/abc ([a-z]+) ([a-z]+))/',
              '!/abc ([a-z]+) ([a-z]+))/',
            ],
            enabled: true,
          },
        ],
        lockFileMaintenance: {
          bar: 2,
        },
        major: null,
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(4);
      expect(errors).toMatchSnapshot();
    });

    it('included unsupported manager', async () => {
      const config = {
        packageRules: [
          {
            matchManagers: ['foo'],
            enabled: true,
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
      expect(errors[0].message).toContain('ansible');
    });

    it('included managers of the wrong type', async () => {
      const config = {
        packageRules: [
          {
            matchManagers: 'string not an array',
            enabled: true,
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config as any,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(2);
      expect(errors).toMatchSnapshot();
    });

    it.each([
      ['empty configuration', {}],
      ['configuration with enabledManagers empty', { enabledManagers: [] }],
      ['single enabled manager', { enabledManagers: ['npm'] }],
      [
        'multiple enabled managers',
        {
          enabledManagers: ['npm', 'gradle', 'maven', 'custom.regex'],
        },
      ],
    ])('validates enabled managers for %s', async (_case, config) => {
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it.each([
      ['single not supported manager', { enabledManagers: ['foo'] }],
      ['multiple not supported managers', { enabledManagers: ['foo', 'bar'] }],
      [
        'combined supported and not supported managers',
        { enabledManagers: ['foo', 'npm', 'gradle', 'maven'] },
      ],
    ])(
      'errors if included not supported enabled managers for %s',
      async (_case, config) => {
        const { warnings, errors } = await configValidation.validateConfig(
          'repo',
          config,
        );
        expect(warnings).toHaveLength(0);
        expect(errors).toHaveLength(1);
        expect(errors).toMatchSnapshot();
      },
    );

    it('errors for all types', async () => {
      const config: RenovateConfig = {
        allowedVersions: 'foo',
        enabled: 1 as any,
        enabledManagers: ['npm'],
        schedule: ['every 15 mins every weekday'],
        timezone: 'Asia',
        labels: 5 as any,
        azureWorkItemId: false as any,
        semanticCommitType: 7 as any,
        lockFileMaintenance: false as any,
        extends: [':timezone(Europe/Brussel)'],
        packageRules: [
          {
            foo: 1,
          },
          'what?' as any,
          {
            matchPackageNames: '/abc ([a-z]+) ([a-z]+))/',
            matchDepNames: ['abc ([a-z]+) ([a-z]+))'],
            enabled: false,
          },
        ],
        major: null,
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(1);
      expect(errors).toMatchSnapshot();
      expect(errors).toHaveLength(12);
    });

    it('selectors outside packageRules array trigger errors', async () => {
      const config = {
        matchDepNames: ['angular'],
        matchPackageNames: ['angular'],
        meteor: {
          packageRules: [
            {
              matchDepNames: ['meteor'],
              matchPackageNames: ['meteor'],
              enabled: true,
            },
          ],
        },
        ansible: {
          minor: {
            matchDepNames: ['meteor'],
            matchPackageNames: ['testPackage'],
          },
        },
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(4);
      expect(errors).toMatchSnapshot();
      expect(errors).toHaveLength(4);
    });

    it('ignore packageRule nesting validation for presets', async () => {
      const config = {
        description: ['All angular.js packages'],
        matchPackageNames: [
          'angular',
          'angular-animate',
          'angular-scroll',
          'angular-sanitize',
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toMatchSnapshot();
      expect(errors).toHaveLength(0);
    });

    it('errors for unsafe fileMatches', async () => {
      const config = {
        npm: {
          fileMatch: ['abc ([a-z]+) ([a-z]+))'],
        },
        dockerfile: {
          fileMatch: ['x?+'],
        },
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(2);
      expect(errors).toMatchSnapshot();
    });

    it('validates regEx for each fileMatch', async () => {
      const config: RenovateConfig = {
        customManagers: [
          {
            customType: 'regex',
            fileMatch: ['js', '***$}{]]['],
            matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
            datasourceTemplate: 'maven',
            versioningTemplate: 'gradle',
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
      expect(errors).toMatchSnapshot();
    });

    it('errors if customManager has empty fileMatch', async () => {
      const config = {
        customManagers: [
          {
            customType: 'regex',
            fileMatch: [],
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config as any,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
      expect(errors).toMatchInlineSnapshot(`
        [
          {
            "message": "Each Custom Manager must contain a non-empty fileMatch array",
            "topic": "Configuration Error",
          },
        ]
      `);
    });

    it('errors if no customManager customType', async () => {
      const config = {
        customManagers: [
          {
            fileMatch: ['some-file'],
            matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
            datasourceTemplate: 'maven',
            versioningTemplate: 'gradle',
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config as any,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
      expect(errors).toMatchInlineSnapshot(`
        [
          {
            "message": "Each Custom Manager must contain a non-empty customType string",
            "topic": "Configuration Error",
          },
        ]
      `);
    });

    it('errors if invalid customManager customType', async () => {
      const config = {
        customManagers: [
          {
            customType: 'unknown',
            fileMatch: ['some-file'],
            matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
            datasourceTemplate: 'maven',
            versioningTemplate: 'gradle',
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config as any,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
      expect(errors).toMatchInlineSnapshot(`
        [
          {
            "message": "Invalid customType: unknown. Key is not a custom manager",
            "topic": "Configuration Error",
          },
        ]
      `);
    });

    it('errors if empty customManager matchStrings', async () => {
      const config = {
        customManagers: [
          {
            customType: 'regex',
            fileMatch: ['foo'],
            matchStrings: [],
            depNameTemplate: 'foo',
            datasourceTemplate: 'bar',
            currentValueTemplate: 'baz',
          },
          {
            customType: 'regex',
            fileMatch: ['foo'],
            depNameTemplate: 'foo',
            datasourceTemplate: 'bar',
            currentValueTemplate: 'baz',
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config as RenovateConfig,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(2);
      expect(errors).toMatchInlineSnapshot(`
        [
          {
            "message": "Each Custom Manager must contain a non-empty matchStrings array",
            "topic": "Configuration Error",
          },
          {
            "message": "Each Custom Manager must contain a non-empty matchStrings array",
            "topic": "Configuration Error",
          },
        ]
      `);
    });

    it('errors if no customManager fileMatch', async () => {
      const config = {
        customManagers: [
          {
            matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
            datasourceTemplate: 'maven',
            versioningTemplate: 'gradle',
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config as any,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
    });

    it('validates regEx for each matchStrings', async () => {
      const config: RenovateConfig = {
        customManagers: [
          {
            customType: 'regex',
            fileMatch: ['Dockerfile'],
            matchStrings: ['***$}{]]['],
            depNameTemplate: 'foo',
            datasourceTemplate: 'bar',
            currentValueTemplate: 'baz',
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
    });

    // testing if we get all errors at once or not (possible), this does not include customType or fileMatch
    // since they are common to all custom managers
    it('validates all possible regex manager options', async () => {
      const config: RenovateConfig = {
        customManagers: [
          {
            customType: 'regex',
            fileMatch: ['Dockerfile'],
            matchStrings: ['***$}{]]['], // invalid matchStrings regex, no depName, datasource and currentValue
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(4);
    });

    it('passes if customManager fields are present', async () => {
      const config: RenovateConfig = {
        customManagers: [
          {
            customType: 'regex',
            fileMatch: ['Dockerfile'],
            matchStrings: ['ENV (?<currentValue>.*?)\\s'],
            depNameTemplate: 'foo',
            datasourceTemplate: 'bar',
            registryUrlTemplate: 'foobar',
            extractVersionTemplate: '^(?<version>v\\d+\\.\\d+)',
            depTypeTemplate: 'apple',
          },
          {
            customType: 'regex',
            fileMatch: ['Dockerfile'],
            matchStrings: ['ENV (?<currentValue>.*?)\\s'],
            packageNameTemplate: 'foo',
            datasourceTemplate: 'bar',
            registryUrlTemplate: 'foobar',
            extractVersionTemplate: '^(?<version>v\\d+\\.\\d+)',
            depTypeTemplate: 'apple',
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('errors if extra customManager fields are present', async () => {
      const config = {
        customManagers: [
          {
            customType: 'regex',
            fileMatch: ['Dockerfile'],
            matchStrings: ['ENV (?<currentValue>.*?)\\s'],
            depNameTemplate: 'foo',
            datasourceTemplate: 'bar',
            depTypeTemplate: 'apple',
            automerge: true,
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config as any,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
    });

    it('errors if customManager fields are missing', async () => {
      const config: RenovateConfig = {
        customManagers: [
          {
            customType: 'regex',
            fileMatch: ['Dockerfile'],
            matchStrings: ['ENV (.*?)\\s'],
            depNameTemplate: 'foo',
            datasourceTemplate: 'bar',
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toMatchSnapshot();
      expect(errors).toHaveLength(1);
    });

    it('ignore keys', async () => {
      const config = {
        $schema: 'renovate.json',
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('validates timezone preset', async () => {
      const config = {
        extends: [':timezone', ':timezone(Europe/Berlin)'],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('does not validate constraints children', async () => {
      const config = {
        constraints: { packageRules: [{}] },
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config as never, // TODO: #15963
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('validates object with ignored children', async () => {
      const config = {
        prBodyDefinitions: {},
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('validates valid registryAlias objects', async () => {
      const config = {
        registryAliases: {
          example1: 'http://www.example.com',
          example2: 'https://www.example2.com/example',
        },
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('errors if registryAliases depth is more than 1', async () => {
      const config = {
        registryAliases: {
          sample: {
            example1: 'http://www.example.com',
          } as unknown as string, // intentional incorrect config to check error message
        },
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toMatchObject([
        {
          message:
            'Invalid `registryAliases.registryAliases.sample` configuration: value is not a string',
          topic: 'Configuration Error',
        },
      ]);
    });

    it('errors if registryAliases have invalid value', async () => {
      const config = {
        registryAliases: {
          example1: 123 as never,
          example2: 'http://www.example.com',
        },
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toMatchObject([
        {
          message:
            'Invalid `registryAliases.registryAliases.example1` configuration: value is not a string',
          topic: 'Configuration Error',
        },
      ]);
    });

    it('errors if fileMatch has wrong parent', async () => {
      const config: RenovateConfig = {
        fileMatch: ['foo'],
        npm: {
          fileMatch: ['package\\.json'],
          minor: {
            fileMatch: ['bar'],
          },
        },
        customManagers: [
          {
            customType: 'regex',
            fileMatch: ['build.gradle'],
            matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
            datasourceTemplate: 'maven',
            versioningTemplate: 'gradle',
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(errors).toHaveLength(1);
      expect(warnings).toHaveLength(1);
      expect(errors).toMatchSnapshot();
      expect(warnings).toMatchSnapshot();
    });

    it('errors if manager objects are nested', async () => {
      const config = {
        pyenv: {
          enabled: false,
        },
        maven: {
          gradle: {
            enabled: false,
          },
        },
      } as never;
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(errors).toHaveLength(1);
      expect(warnings).toHaveLength(0);
      expect(errors).toMatchSnapshot();
    });

    it('warns if hostType has the wrong parent', async () => {
      const config = {
        hostType: 'npm',
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(errors).toHaveLength(0);
      expect(warnings).toHaveLength(1);
      expect(warnings).toMatchSnapshot();
    });

    it('validates preset values', async () => {
      const config = {
        extends: ['foo', 'bar', 42] as never,
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
    });

    it('warns if only selectors in packageRules', async () => {
      const config = {
        packageRules: [{ matchDepTypes: ['foo'], matchPackageNames: ['bar'] }],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(1);
      expect(warnings).toMatchSnapshot();
      expect(errors).toHaveLength(0);
    });

    it('errors if invalid combinations in packageRules', async () => {
      const config = {
        packageRules: [
          {
            matchUpdateTypes: ['major'],
            registryUrls: ['https://registry.npmjs.org'],
          },
        ],
      } as any;
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
      expect(errors).toMatchSnapshot();
    });

    it('warns on nested group packageRules', async () => {
      const config = {
        extends: ['group:fortawesome'],
        packageRules: [
          {
            automerge: true,
            extends: ['group:fortawesome'],
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
        true,
      );
      expect(errors).toHaveLength(0);
      expect(warnings).toHaveLength(1);
    });

    // adding this test explicitly because we used to validate the customEnvVariables inside repo config previously
    it('warns if customEnvVariables are found in repo config', async () => {
      const config = {
        customEnvVariables: {
          example1: 'abc',
          example2: 123,
        },
      };
      const { warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toMatchObject([
        {
          topic: 'Configuration Error',
          message: `The "customEnvVariables" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
        },
      ]);
    });

    it('errors if schedule is cron and has no * minutes', async () => {
      const config = {
        schedule: ['30 5 * * *'],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toMatchObject([
        {
          message:
            'Invalid schedule: `Invalid schedule: "30 5 * * *" has cron syntax, but doesn\'t have * as minutes`',
          topic: 'Configuration Error',
        },
      ]);
    });

    it('errors if invalid matchHost values in hostRules', async () => {
      GlobalConfig.set({ allowedHeaders: ['X-*'] });

      const config = {
        hostRules: [
          {
            matchHost: '://',
            token: 'token',
          },
          {
            matchHost: '',
            token: 'token',
          },
          {
            matchHost: undefined,
            token: 'token',
          },
          {
            hostType: 'github',
            token: 'token',
          },
        ],
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toMatchObject([
        {
          topic: 'Configuration Error',
          message:
            'Configuration option `hostRules[2].matchHost` should be a string',
        },
        {
          topic: 'Configuration Error',
          message:
            'Invalid value for hostRules matchHost. It cannot be an empty string.',
        },
        {
          topic: 'Configuration Error',
          message: 'hostRules matchHost `://` is not a valid URL.',
        },
      ]);
    });

    it('errors if forbidden header in hostRules', async () => {
      GlobalConfig.set({ allowedHeaders: ['X-*'] });

      const config = {
        hostRules: [
          {
            matchHost: 'https://domain.com/all-versions',
            headers: {
              'X-Auth-Token': 'token',
              unallowedHeader: 'token',
            },
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toMatchObject([
        {
          message:
            "hostRules header `unallowedHeader` is not allowed by this bot's `allowedHeaders`.",
          topic: 'Configuration Error',
        },
      ]);
    });

    it('errors if headers values are not string', async () => {
      GlobalConfig.set({ allowedHeaders: ['X-*'] });

      const config = {
        hostRules: [
          {
            matchHost: 'https://domain.com/all-versions',
            headers: {
              'X-Auth-Token': 10,
            } as unknown as Record<string, string>,
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toMatchObject([
        {
          message:
            'Invalid hostRules headers value configuration: header must be a string.',
          topic: 'Configuration Error',
        },
      ]);
    });

    it('errors if allowedHeaders is empty or not defined', async () => {
      GlobalConfig.set({});

      const config = {
        hostRules: [
          {
            matchHost: 'https://domain.com/all-versions',
            headers: {
              'X-Auth-Token': 'token',
            },
          },
        ],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toMatchObject([
        {
          message:
            "hostRules header `X-Auth-Token` is not allowed by this bot's `allowedHeaders`.",
          topic: 'Configuration Error',
        },
      ]);
    });

    it('catches invalid variable name in env config option', async () => {
      GlobalConfig.set({ allowedEnv: ['SOME*'] });
      const config = {
        env: {
          randomKey: '',
          SOME_VAR: 'some_value',
          SOME_OTHER_VAR: 10,
        },
      };
      const { errors, warnings } = await configValidation.validateConfig(
        'repo',
        // @ts-expect-error: testing invalid values in env object
        config,
      );
      expect(errors).toMatchObject([
        {
          message:
            "Env variable name `randomKey` is not allowed by this bot's `allowedEnv`.",
        },
        {
          message:
            'Invalid env variable value: `env.SOME_OTHER_VAR` must be a string.',
        },
      ]);
      expect(errors).toHaveLength(2);
      expect(warnings).toHaveLength(0);
    });

    it('catches env config option if configured inside a parent', async () => {
      GlobalConfig.set({ allowedEnv: ['SOME*'] });
      const config = {
        npm: {
          env: {
            SOME_VAR: 'some_value',
          },
        },
        packageRules: [
          {
            matchManagers: ['regex'],
            env: {
              SOME_VAR: 'some_value',
            },
          },
        ],
      };
      const { errors, warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(errors).toMatchObject([
        {
          message:
            'The "env" object can only be configured at the top level of a config but was found inside "npm"',
          topic: 'Configuration Error',
        },
        {
          message:
            'The "env" object can only be configured at the top level of a config but was found inside "packageRules[0]"',
          topic: 'Configuration Error',
        },
      ]);
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(2);
    });

    it('catches when * or ** is combined with others patterns in a regexOrGlob option', async () => {
      const config = {
        packageRules: [
          {
            matchRepositories: ['groupA/**', 'groupB/**'], // valid
            enabled: false,
          },
          {
            matchRepositories: ['*', 'repo'], // invalid
            enabled: true,
          },
        ],
      };
      const { errors, warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(errors).toMatchObject([
        {
          message:
            'packageRules[1].matchRepositories: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.',
          topic: 'Configuration Error',
        },
      ]);
      expect(errors).toHaveLength(1);
      expect(warnings).toHaveLength(0);
    });

    it('catches when negative number is used for integer type', async () => {
      const config = {
        azureWorkItemId: -2,
      };
      const { errors } = await configValidation.validateConfig('repo', config);
      expect(errors).toMatchObject([
        {
          message:
            'Configuration option `azureWorkItemId` should be a positive integer. Found negative value instead.',
          topic: 'Configuration Error',
        },
      ]);
    });
    it('validates prPriority', async () => {
      const config = {
        packageRules: [
          {
            matchDepNames: ['somedep'],
            prPriority: -2,
          },
          {
            matchDepNames: ['some-other-dep'],
            prPriority: 2,
          },
        ],
      };
      const { errors, warnings } = await configValidation.validateConfig(
        'repo',
        config,
      );
      expect(errors).toBeEmptyArray();
      expect(warnings).toBeEmptyArray();
    });
  });

  describe('validateConfig() -> globaOnly options', () => {
    it('returns errors for invalid options', async () => {
      const config = {
        logFile: 'something',
        logFileLevel: 'DEBUG',
      };
      const { errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(errors).toMatchObject([
        {
          message: 'Invalid configuration option: logFile',
          topic: 'Configuration Error',
        },
        {
          message: 'Invalid configuration option: logFileLevel',
          topic: 'Configuration Error',
        },
      ]);
    });

    it('validates hostRules.headers', async () => {
      const config = {
        hostRules: [
          {
            matchHost: 'https://domain.com/all-versions',
            headers: {
              'X-Auth-Token': 'token',
            },
          },
        ],
        allowedHeaders: ['X-Auth-Token'],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('errors if hostRules.headers is defined but allowedHeaders is not', async () => {
      const config = {
        hostRules: [
          {
            matchHost: 'https://domain.com/all-versions',
            headers: {
              'X-Auth-Token': 'token',
            },
          },
        ],
      };
      const { errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(errors).toMatchObject([
        {
          message:
            "hostRules header `X-Auth-Token` is not allowed by this bot's `allowedHeaders`.",
          topic: 'Configuration Error',
        },
      ]);
    });

    it('validates env', async () => {
      const config = {
        env: {
          SOME_VAR: 'SOME_VALUE',
        },
        allowedEnv: ['SOME*'],
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('errors if env object is defined but allowedEnv is empty or undefined', async () => {
      const config = {
        env: {
          SOME_VAR: 'SOME_VALUE',
        },
      };
      const { errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(errors).toMatchObject([
        {
          message:
            "Env variable name `SOME_VAR` is not allowed by this bot's `allowedEnv`.",
          topic: 'Configuration Error',
        },
      ]);
    });

    it('validates options with different type but defaultValue=null', async () => {
      const config = {
        minimumReleaseAge: null,
        groupName: null,
        groupSlug: null,
        dependencyDashboardLabels: null,
        defaultRegistryUrls: null,
        registryUrls: null,
        hostRules: [
          {
            artifactAuth: null,
            concurrentRequestLimit: null,
            httpsCertificate: null,
            httpsPrivateKey: null,
            httpsCertificateAuthority: null,
          },
        ],
        encrypted: null,
        milestone: null,
        branchConcurrentLimit: null,
        hashedBranchLength: null,
        assigneesSampleSize: null,
        reviewersSampleSize: null,
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'repo',
        // @ts-expect-error: contains invalid values
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });
  });

  describe('validate globalOptions()', () => {
    it('binarySource', async () => {
      const config = {
        binarySource: 'invalid' as never,
      };
      const { warnings } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toEqual([
        {
          message:
            'Invalid value `invalid` for `binarySource`. The allowed values are docker, global, install, hermit.',
          topic: 'Configuration Error',
        },
      ]);
    });

    describe('validates string type options', () => {
      it('binarySource', async () => {
        const config = {
          binarySource: 'invalid' as never,
        };
        const { warnings } = await configValidation.validateConfig(
          'global',
          config,
        );
        expect(warnings).toEqual([
          {
            message:
              'Invalid value `invalid` for `binarySource`. The allowed values are docker, global, install, hermit.',
            topic: 'Configuration Error',
          },
        ]);
      });

      it('baseDir', async () => {
        const config = {
          baseDir: false as never,
        };
        const { warnings } = await configValidation.validateConfig(
          'global',
          config,
        );
        expect(warnings).toEqual([
          {
            message: 'Configuration option `baseDir` should be a string.',
            topic: 'Configuration Error',
          },
        ]);
      });

      it('requireConfig', async () => {
        const config = {
          requireConfig: 'invalid' as never,
        };
        const { warnings } = await configValidation.validateConfig(
          'global',
          config,
        );
        expect(warnings).toEqual([
          {
            message:
              'Invalid value `invalid` for `requireConfig`. The allowed values are required, optional, ignored.',
            topic: 'Configuration Error',
          },
        ]);
      });

      it('dryRun', async () => {
        const config = {
          dryRun: 'invalid' as never,
        };
        const { warnings } = await configValidation.validateConfig(
          'global',
          config,
        );
        expect(warnings).toEqual([
          {
            message:
              'Invalid value `invalid` for `dryRun`. The allowed values are extract, lookup, full.',
            topic: 'Configuration Error',
          },
        ]);
      });

      it('repositoryCache', async () => {
        const config = {
          repositoryCache: 'invalid' as never,
        };
        const { warnings } = await configValidation.validateConfig(
          'global',
          config,
        );
        expect(warnings).toEqual([
          {
            message:
              'Invalid value `invalid` for `repositoryCache`. The allowed values are enabled, disabled, reset.',
            topic: 'Configuration Error',
          },
        ]);
      });

      it('onboardingConfigFileName', async () => {
        const config = {
          onboardingConfigFileName: 'invalid' as never,
        };
        const { warnings } = await configValidation.validateConfig(
          'global',
          config,
        );
        expect(warnings).toEqual([
          {
            message: `Invalid value \`invalid\` for \`onboardingConfigFileName\`. The allowed values are ${configFileNames.join(', ')}.`,
            topic: 'Configuration Error',
          },
        ]);
      });

      it('onboardingConfig', async () => {
        const config = {
          onboardingConfig: {
            extends: ['config:recommended'],
            binarySource: 'global', // should not allow globalOnly options inside onboardingConfig
            fileMatch: ['somefile'], // invalid at top level
          },
        };
        const { warnings } = await configValidation.validateConfig(
          'global',
          config,
        );
        expect(warnings).toEqual([
          {
            message:
              '"fileMatch" may not be defined at the top level of a config and must instead be within a manager block',
            topic: 'Config error',
          },
          {
            topic: 'Configuration Error',
            message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
          },
        ]);
      });

      it('force', async () => {
        const config = {
          force: {
            extends: ['config:recommended'],
            binarySource: 'global',
            fileMatch: ['somefile'], // invalid at top level
            constraints: {
              python: '2.7',
            },
          },
        };
        const { warnings } = await configValidation.validateConfig(
          'global',
          config,
        );
        expect(warnings).toEqual([
          {
            message:
              '"fileMatch" may not be defined at the top level of a config and must instead be within a manager block',
            topic: 'Config error',
          },
        ]);
      });

      it('gitUrl', async () => {
        const config = {
          gitUrl: 'invalid' as never,
        };
        const { warnings } = await configValidation.validateConfig(
          'global',
          config,
        );
        expect(warnings).toEqual([
          {
            message:
              'Invalid value `invalid` for `gitUrl`. The allowed values are default, ssh, endpoint.',
            topic: 'Configuration Error',
          },
        ]);
      });
    });

    it('validates boolean type options', async () => {
      const config = {
        unicodeEmoji: false,
        detectGlobalManagerConfig: 'invalid-type',
      };
      const { warnings } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toMatchObject([
        {
          message: `Configuration option \`detectGlobalManagerConfig\` should be a boolean. Found: ${JSON.stringify(
            'invalid-type',
          )} (string).`,
          topic: 'Configuration Error',
        },
      ]);
    });

    it('validates integer type options', async () => {
      const config = {
        prCommitsPerRunLimit: 2,
        gitTimeout: 'invalid-type',
      };
      const { warnings } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toMatchObject([
        {
          message: `Configuration option \`gitTimeout\` should be an integer. Found: ${JSON.stringify(
            'invalid-type',
          )} (string).`,
          topic: 'Configuration Error',
        },
      ]);
    });

    it('validates array type options', async () => {
      const config = {
        allowedPostUpgradeCommands: ['cmd'],
        checkedBranches: 'invalid-type',
        gitNoVerify: ['invalid'],
        mergeConfidenceDatasources: [1],
      };
      const { warnings } = await configValidation.validateConfig(
        'global',
        // @ts-expect-error: contains invalid values
        config,
      );
      expect(warnings).toMatchObject([
        {
          message:
            'Configuration option `checkedBranches` should be a list (Array).',
          topic: 'Configuration Error',
        },
        {
          topic: 'Configuration Error',
          message:
            'Invalid value `1` for `mergeConfidenceDatasources`. The allowed values are go, maven, npm, nuget, packagist, pypi, rubygems.',
        },
        {
          message:
            'Invalid value for `gitNoVerify`. The allowed values are commit, push.',
          topic: 'Configuration Error',
        },
      ]);
    });

    it('validates object type options', async () => {
      const config = {
        productLinks: {
          documentation: 'https://docs.renovatebot.com/',
          help: 'https://github.com/renovatebot/renovate/discussions',
          homepage: 'https://github.com/renovatebot/renovate',
        },
        secrets: 'invalid-type',
        cacheTtlOverride: {
          someField: false,
        },
      };
      const { warnings } = await configValidation.validateConfig(
        'global',
        // @ts-expect-error: contains invalid values
        config,
      );
      expect(warnings).toMatchObject([
        {
          topic: 'Configuration Error',
          message:
            'Configuration option `cacheTtlOverride.someField` should be an integer. Found: false (boolean).',
        },
        {
          message: 'Configuration option `secrets` should be a JSON object.',
          topic: 'Configuration Error',
        },
      ]);
    });

    it('warns if negative number is used for integer type', async () => {
      const config = {
        prCommitsPerRunLimit: -2,
      };
      const { warnings } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toMatchObject([
        {
          message:
            'Configuration option `prCommitsPerRunLimit` should be a positive integer. Found negative value instead.',
          topic: 'Configuration Error',
        },
      ]);
    });

    it('warns on invalid customEnvVariables objects', async () => {
      const config = {
        customEnvVariables: {
          example1: 'abc',
          example2: 123,
        },
      };
      const { warnings } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toMatchObject([
        {
          message:
            'Invalid `customEnvVariables.example2` configuration: value must be a string.',
          topic: 'Configuration Error',
        },
      ]);
    });

    it('validates valid customEnvVariables objects', async () => {
      const config = {
        customEnvVariables: {
          example1: 'abc',
          example2: 'https://www.example2.com/example',
        },
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('validates options with different type but defaultValue=null', async () => {
      const config = {
        onboardingCommitMessage: null,
        dryRun: null,
        logContext: null,
        endpoint: null,
        skipInstalls: null,
        autodiscoverFilter: null,
        autodiscoverNamespaces: null,
        autodiscoverTopics: null,
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'global',
        // @ts-expect-error: contains invalid values
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('fails for missing reportPath if reportType is "s3"', async () => {
      const config: RenovateConfig = {
        reportType: 's3',
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
    });

    it('validates reportPath if reportType is "s3"', async () => {
      const config: RenovateConfig = {
        reportType: 's3',
        reportPath: 's3://bucket-name/key-name',
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('fails for missing reportPath if reportType is "file"', async () => {
      const config: RenovateConfig = {
        reportType: 'file',
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(1);
    });

    it('validates reportPath if reportType is "file"', async () => {
      const config: RenovateConfig = {
        reportType: 'file',
        reportPath: './report.json',
      };
      const { warnings, errors } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toHaveLength(0);
      expect(errors).toHaveLength(0);
    });

    it('catches when * or ** is combined with others patterns in a regexOrGlob option', async () => {
      const config = {
        packageRules: [
          {
            matchRepositories: ['*', 'repo'], // invalid
            enabled: false,
          },
        ],
        allowedHeaders: ['*', '**'], // invalid
        autodiscoverProjects: ['**', 'project'], // invalid
        allowedEnv: ['env_var'], // valid
      };
      const { errors, warnings } = await configValidation.validateConfig(
        'global',
        config,
      );
      expect(warnings).toMatchObject([
        {
          message:
            'allowedHeaders: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.',
          topic: 'Configuration Error',
        },
        {
          message:
            'autodiscoverProjects: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.',
          topic: 'Configuration Error',
        },
      ]);

      expect(errors).toMatchObject([
        {
          message:
            'packageRules[0].matchRepositories: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.',
          topic: 'Configuration Error',
        },
      ]);
      expect(warnings).toHaveLength(2);
      expect(errors).toHaveLength(1);
    });
  });
});