Files
renovate/lib/util/common.spec.ts
Sebastian Poxhofer 0583e54718 refactor(constants): add a platform family capability table (#46191)
* refactor(constants): add a platform family capability table

Every consumer of `detectPlatform` carried its own mapping from the
detected platform to a tags datasource or a web UI path, and the seven
`*_API_USING_HOST_TYPES` arrays were checked one by one to produce that
platform in the first place.

Introduce `PLATFORM_FAMILIES`, one record keyed by family holding the
host types, the hostnames which identify the family outright, the tags
datasource, the API base URL derivation and the web UI path to a
directory at the default branch. A family is a group of platforms, so
the ids are checked against `PlatformId`: a family which is not a
platform is a compile error. Not every platform is a family, because
codecommit, gerrit, local and scm-manager are never recognized from a
URL.

`detectPlatform` loops the table for both its hostname and its hostType
matching, and the consumers look their answers up:

- `github-actions` and `pre-commit` read `tagsDatasource` instead of
  importing the tags datasource classes.
- The PR body reads `webDirPath` instead of special-casing Bitbucket and
  Bitbucket Server.

`apiBaseUrl` has no consumer yet; the changelog sources pick it up in
the next commit.

`lib/util/http/auth.ts` and `lib/util/http/host-rules.ts` still read six
of the old arrays by name, so those stay as aliases of the table and are
migrated in a follow-up. `AZURE_API_USING_HOST_TYPES` had no other
consumer and is dropped.

Behaviour changes: none. `detectPlatform` now tests every known hostname
before its name heuristics instead of interleaving the two, which is
equivalent because the hostname lists are pairwise disjoint and no known
hostname contains the name of another family.

* Apply suggestion from @secustor
2026-09-21 15:27:50 +00:00

312 lines
10 KiB
TypeScript

import { codeBlock } from 'common-tags';
import { logger } from '~test/util.ts';
import { GlobalConfig } from '../config/global.ts';
import { InheritConfig } from '../config/inherit.ts';
import { PLATFORM_FAMILIES } from '../constants/index.ts';
import { detectPlatform, getInheritedOrGlobal, parseJson } from './common.ts';
import * as hostRules from './host-rules.ts';
const validJsonString = `
{
"name": "John Doe",
"age": 30,
"city": "New York"
}
`;
const invalidJsonString = `
{
"name": "Alice",
"age": 25,
"city": "Los Angeles",
"hobbies": ["Reading", "Running", "Cooking"]
"isStudent": true
}
`;
const onlyJson5parsableString = `
{
name: "Bob",
age: 35,
city: 'San Francisco',
// This is a comment
"isMarried": false,
}
`;
const validJsoncString = `
{
// This is a comment
"name": "John Doe",
"age": 30,
"city": "New York"
}
`;
describe('util/common', () => {
beforeEach(() => hostRules.clear());
describe('detectPlatform', () => {
it.each`
url | hostType
${'some-invalid@url:::'} | ${null}
${'https://enterprise.example.com/chalk/chalk'} | ${null}
${'https://dev.azure.com/my-organization/my-project/_git/my-repo.git'} | ${'azure'}
${'https://myorg.visualstudio.com/my-project/_git/my-repo.git'} | ${'azure'}
${'https://bitbucket.org/some-org/some-repo'} | ${'bitbucket'}
${'https://bitbucket.com/some-org/some-repo'} | ${'bitbucket'}
${'https://bitbucket.example.com/some-org/some-repo'} | ${'bitbucket-server'}
${'https://gitea.com/semantic-release/gitlab'} | ${'gitea'}
${'https://gitea.example.com/semantic-release/gitlab'} | ${'gitea'}
${'https://forgejo.example.com/semantic-release/gitlab'} | ${'forgejo'}
${'https://codeberg.org/forgejo/forgejo'} | ${'forgejo'}
${'https://codefloe.com/some-org/some-repo'} | ${'forgejo'}
${'https://github.com/semantic-release/gitlab'} | ${'github'}
${'https://github-enterprise.example.com/chalk/chalk'} | ${'github'}
${'https://gitlab.com/chalk/chalk'} | ${'gitlab'}
${'https://gitlab-enterprise.example.com/chalk/chalk'} | ${'gitlab'}
`('("$url") === $hostType', ({ url, hostType }) => {
expect(detectPlatform(url)).toBe(hostType);
});
it.each(
Object.entries(PLATFORM_FAMILIES).flatMap(([family, { knownHosts }]) =>
knownHosts.map((host) => ({ family, host })),
),
)('knows $host as $family', ({ family, host }) => {
expect(detectPlatform(`https://${host}/some-org/some-repo`)).toBe(family);
});
it('uses host rules', () => {
hostRules.add({
hostType: 'azure',
matchHost: 'az.example.com',
});
hostRules.add({
hostType: 'bitbucket',
matchHost: 'bb.example.com',
});
hostRules.add({
hostType: 'gitea',
matchHost: 'gt.example.com',
});
hostRules.add({
hostType: 'forgejo',
matchHost: 'fj.example.com',
});
hostRules.add({
hostType: 'github-changelog',
matchHost: 'gh.example.com',
});
hostRules.add({
hostType: 'gitlab-changelog',
matchHost: 'gl.example.com',
});
hostRules.add({
hostType: 'unknown',
matchHost: 'f.example.com',
});
expect(detectPlatform('https://az.example.com/chalk/chalk')).toBe(
'azure',
);
expect(detectPlatform('https://bb.example.com/chalk/chalk')).toBe(
'bitbucket',
);
expect(detectPlatform('https://gt.example.com/chalk/chalk')).toBe(
'gitea',
);
expect(detectPlatform('https://fj.example.com/chalk/chalk')).toBe(
'forgejo',
);
expect(detectPlatform('https://gh.example.com/chalk/chalk')).toBe(
'github',
);
expect(detectPlatform('https://gl.example.com/chalk/chalk')).toBe(
'gitlab',
);
expect(detectPlatform('https://f.example.com/chalk/chalk')).toBeNull();
});
});
describe('parseJson', () => {
it('returns null', () => {
expect(parseJson(null, 'renovate.json')).toBeNull();
});
it('returns parsed json', () => {
expect(parseJson(validJsonString, 'renovate.json')).toEqual({
name: 'John Doe',
age: 30,
city: 'New York',
});
});
it('supports jsonc', () => {
const jsoncString = codeBlock`
{
// This is a comment (valid in JSONC, invalid in JSON5 when outside object properties)
"name": "Alice", // inline comment is valid in JSONC but not in JSON5
"age": 25, // JSON5 supports trailing commas, so this line is okay in both
"city": "Atlanta",
// JSONC allows comments here too
}
`;
expect(parseJson(jsoncString, 'renovate.json')).toEqual({
name: 'Alice',
age: 25,
city: 'Atlanta',
});
});
it('throws error for invalid json', () => {
expect(() => parseJson(invalidJsonString, 'renovate.json')).toThrow(
"JSON5: invalid character '\\\"' at 7:3",
);
});
it('catches and warns if content parsing failed with JSONC.parse but not with JSON5.parse', () => {
expect(parseJson(onlyJson5parsableString, 'renovate.json')).toEqual({
name: 'Bob',
age: 35,
city: 'San Francisco',
isMarried: false,
});
expect(logger.logger.warn).toHaveBeenCalledWith(
{ context: 'renovate.json' },
'File contents are invalid JSONC but parse using JSON5. Support for this will be removed in a future release so please change to a support .json5 file name or ensure correct JSON syntax.',
);
});
it('does not warn if filename ends with .jsonc', () => {
parseJson(validJsoncString, 'renovate.jsonc');
expect(logger.logger.warn).not.toHaveBeenCalled();
});
it('does not warn if filename ends with .json5', () => {
parseJson(onlyJson5parsableString, 'renovate.json5');
expect(logger.logger.warn).not.toHaveBeenCalled();
});
});
describe('parseJsonc', () => {
it('returns parsed jsonc', () => {
expect(parseJson(validJsoncString, 'renovate.jsonc')).toEqual({
name: 'John Doe',
age: 30,
city: 'New York',
});
});
it('throws error for invalid jsonc', () => {
expect(() => parseJson(invalidJsonString, 'renovate.jsonc')).toThrow(
'Parse error: Expected comma on line 6 column 47',
);
});
});
describe('getInheritedOrGlobal', () => {
beforeEach(() => {
GlobalConfig.reset();
InheritConfig.reset();
});
it('returns undefined if not set', () => {
expect(getInheritedOrGlobal('configFileNames')).toBeUndefined();
});
it('returns inherited value if only inherited value is set', () => {
InheritConfig.set({
configFileNames: ['inherited'],
});
expect(getInheritedOrGlobal('configFileNames')).toEqual(['inherited']);
});
it('returns global value if only global value is set', () => {
GlobalConfig.set({
configFileNames: ['global'],
});
expect(getInheritedOrGlobal('configFileNames')).toEqual(['global']);
});
it('returns inherited value - when both global + inherited are set', () => {
InheritConfig.set({
configFileNames: ['inherited'],
});
GlobalConfig.set({
configFileNames: ['global'],
});
expect(getInheritedOrGlobal('configFileNames')).toEqual(['inherited']);
});
// is not possiblle as config validation will error out: only for coverage
it('handles null inherited values', () => {
InheritConfig.set({
configFileNames: null as never,
});
GlobalConfig.set({
configFileNames: ['global'],
});
expect(getInheritedOrGlobal('configFileNames')).toBeNull();
});
// is not possiblle as config validation will error out: only for coverage
it('handles undefined inherited values', () => {
InheritConfig.set({
configFileNames: undefined,
});
GlobalConfig.set({
configFileNames: ['global'],
});
expect(getInheritedOrGlobal('configFileNames')).toBeUndefined();
});
describe('when requesting onboardingAutoCloseAge, do not allow inherit config to override global config', () => {
it('returns inherited value when inherited < global', () => {
GlobalConfig.set({
onboardingAutoCloseAge: 10,
});
InheritConfig.set({
onboardingAutoCloseAge: 5,
});
expect(getInheritedOrGlobal('onboardingAutoCloseAge')).toBe(5);
});
it('returns global value when inherited > global value', () => {
GlobalConfig.set({
onboardingAutoCloseAge: 5,
});
InheritConfig.set({
onboardingAutoCloseAge: 10,
});
expect(getInheritedOrGlobal('onboardingAutoCloseAge')).toBe(5);
});
it('returns inherited value when inherited == global', () => {
GlobalConfig.set({
onboardingAutoCloseAge: 5,
});
InheritConfig.set({
onboardingAutoCloseAge: 5,
});
expect(getInheritedOrGlobal('onboardingAutoCloseAge')).toBe(5);
});
it('returns inherited value when global value is not set', () => {
GlobalConfig.set({
onboardingAutoCloseAge: undefined,
});
InheritConfig.set({
onboardingAutoCloseAge: 10,
});
expect(getInheritedOrGlobal('onboardingAutoCloseAge')).toBe(10);
});
it('returns global value when inherited value is not set', () => {
GlobalConfig.set({
onboardingAutoCloseAge: 10,
});
expect(getInheritedOrGlobal('onboardingAutoCloseAge')).toBe(10);
});
});
});
});