import fs from 'fs-extra'; import { glob } from 'glob'; import { getOptions } from '../../lib/config/options/index.ts'; import type { UpdateType } from '../../lib/config/types.ts'; import { regEx } from '../../lib/util/regex.ts'; import { templateHelperNames } from '../../lib/util/template/index.ts'; import { isMinimumReleaseAgeApplicable } from '../../lib/workers/repository/process/lookup/filter-checks.ts'; const options = getOptions(); const markdownGlob = '{docs,lib}/**/*.md'; describe('docs/documentation', () => { it('has no invalid links', async () => { const markdownFiles = await glob(markdownGlob); await Promise.all( markdownFiles.map(async (markdownFile) => { const markdownText = await fs.readFile(markdownFile, 'utf8'); expect(markdownText).not.toMatch(regEx(/\.md\/#/)); }), ); }); describe('website-documentation', () => { async function getConfigOptionSubHeaders( file: string, configOption: string, ): Promise { const subHeadings = []; const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const reg = regEx(`##\\s\`?${configOption}\`?[\\s\\S]+?\n##\\s`); const match = reg.exec(content); const subHeadersMatch = match?.[0]?.matchAll( /\n###\s`?(?[\w.]+)`?\n/g, ); if (subHeadersMatch) { for (const subHeaderStr of subHeadersMatch) { if (subHeaderStr?.groups?.child) { subHeadings.push(subHeaderStr.groups.child); } } } return subHeadings; } describe('docs/usage/configuration-options.md', () => { async function getConfigHeaders(file: string): Promise { const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const matches = content.match(/\n## (?:.*?)\n/g) ?? []; return matches .map((match) => match.substring(4, match.length - 1).replace(/^`|`$/g, ''), ) .filter((header) => header !== 'managerFilePatterns') .filter( (header) => header !== 'Locations for configuration filenames', ); } function getRequiredConfigOptions(): string[] { return ( options .filter((option) => !option.globalOnly) // Only include top-level options, which have no parents (implicit root) or explicitly define the // root ('.') as their parent. .filter( (option) => !option.parents || option.parents.length === 0 || option.parents.includes('.'), ) .filter((option) => !option.autogenerated) .map((option) => option.name) .sort() ); } it('has doc headers sorted alphabetically', async () => { await expect( getConfigHeaders('configuration-options.md'), ).resolves.toEqual( (await getConfigHeaders('configuration-options.md')).sort(), ); }); it('has headers for every required option', async () => { await expect( getConfigHeaders('configuration-options.md'), ).resolves.toEqual(getRequiredConfigOptions()); }); function getPostUpdateOptionsValues(): Set { const option = options.find((o) => o.name === 'postUpdateOptions'); return new Set(option?.allowedValues ?? []); } async function getConfigSubHeaders(file: string): Promise { const postUpdateValues = getPostUpdateOptionsValues(); const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const matches = content.match(/\n### (?:.*?)\n/g) ?? []; return matches .map((match) => match.substring(5, match.length - 1).replace(/^`|`$/g, ''), ) .filter((header) => !postUpdateValues.has(header)) .sort(); } function getRequiredConfigSubOptions(): string[] { return ( options .filter((option) => option.stage !== 'global') .filter((option) => !option.globalOnly) // Only include true sub-options: options which have parents but none of those are explicitly the root ('.'). .filter( (option) => option.parents && option.parents.length > 0 && !option.parents.includes('.'), ) .filter((option) => option.name !== 'managerFilePatterns') .flatMap((option) => (option.parents ?? []) .filter((parent) => parent !== '.') .map((parent) => `${parent}.${option.name}`), ) .sort() ); } function getParentNames(): Set { const childrens = options .filter((option) => option.stage !== 'global') .filter((option) => !option.globalOnly) .filter((option) => option.parents); const parentNames = new Set(); for (const children of childrens) { const parents = children.parents ?? []; for (const parent of parents) { parentNames.add(parent); } } return parentNames; } it('has headers for every required sub-option', async () => { await expect( getConfigSubHeaders('configuration-options.md'), ).resolves.toEqual(getRequiredConfigSubOptions()); }); it.each([...getParentNames()])( '%s has sub-headers sorted alphabetically', async (parentName: string) => { await expect( getConfigOptionSubHeaders('configuration-options.md', parentName), ).resolves.toEqual( ( await getConfigOptionSubHeaders( 'configuration-options.md', parentName, ) ).sort(), ); }, ); it('has a sorted header for every postUpdateOptions value', async () => { const postUpdateHeaders = await getConfigOptionSubHeaders( 'configuration-options.md', 'postUpdateOptions', ); const postUpdateOption = options.find( (o) => o.name === 'postUpdateOptions', ); const allowedValues = [ ...(postUpdateOption?.allowedValues ?? []), ].sort(); expect(postUpdateHeaders).toEqual(allowedValues); }); }); describe('docs/usage/self-hosted-configuration.md', () => { async function getSelfHostedHeaders(file: string): Promise { const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const matches = content.match(/\n## (?:.*?)\n/g) ?? []; return matches.map((match) => match.substring(4, match.length - 1).replace(/^`|`$/g, ''), ); } // Sub-options (e.g. `hostRules.allowInternal`) are only valid in self-hosted // config, so their parent (e.g. `hostRules`) needs its own top-level header // here even though the parent option itself isn't `globalOnly`. function getSelfHostedParentNames(): Set { const childrens = options .filter((option) => option.globalOnly) .filter( (option) => option.parents && option.parents.length > 0 && !option.parents.includes('.'), ); const parentNames = new Set(); for (const children of childrens) { for (const parent of children.parents ?? []) { parentNames.add(parent); } } return parentNames; } function getRequiredSelfHostedOptions(): string[] { const topLevelOptions = options .filter((option) => option.globalOnly) // Only include top-level options, which have no parents (implicit root) or explicitly define the // root ('.') as their parent. .filter( (option) => !option.parents || option.parents.length === 0 || option.parents.includes('.'), ) .map((option) => option.name); return [ ...new Set([...topLevelOptions, ...getSelfHostedParentNames()]), ].sort(); } async function getSelfHostedSubHeaders(file: string): Promise { const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const matches = content.matchAll(/\n###\s`?(?[\w.]+)`?\n/g); return [...matches] .map((match) => match.groups?.child) .filter((child): child is string => !!child) .sort(); } function getRequiredSelfHostedSubOptions(): string[] { return ( options .filter((option) => option.globalOnly) // Only include true sub-options: options which have parents but none of those are explicitly the root ('.'). .filter( (option) => option.parents && option.parents.length > 0 && !option.parents.includes('.'), ) .flatMap((option) => (option.parents ?? []) .filter((parent) => parent !== '.') .map((parent) => `${parent}.${option.name}`), ) .sort() ); } it('has headers sorted alphabetically', async () => { await expect( getSelfHostedHeaders('self-hosted-configuration.md'), ).resolves.toEqual( (await getSelfHostedHeaders('self-hosted-configuration.md')).sort(), ); }); it('has headers for every required option', async () => { await expect( getSelfHostedHeaders('self-hosted-configuration.md'), ).resolves.toEqual(getRequiredSelfHostedOptions()); }); it('has headers for every required sub-option', async () => { await expect( getSelfHostedSubHeaders('self-hosted-configuration.md'), ).resolves.toEqual(getRequiredSelfHostedSubOptions()); }); it.each([...getSelfHostedParentNames()])( '%s has sub-headers sorted alphabetically', async (parentName: string) => { await expect( getConfigOptionSubHeaders( 'self-hosted-configuration.md', parentName, ), ).resolves.toEqual( ( await getConfigOptionSubHeaders( 'self-hosted-configuration.md', parentName, ) ).sort(), ); }, ); }); describe('docs/usage/self-hosted-experimental.md', () => { async function getSelfHostedExperimentalConfigHeaders( file: string, ): Promise { const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const matches = content.match(/\n## (?:.*?)\n/g) ?? []; return matches.map((match) => match.substring(4, match.length - 1)); } it('has headers sorted alphabetically', async () => { await expect( getSelfHostedExperimentalConfigHeaders('self-hosted-experimental.md'), ).resolves.toEqual( ( await getSelfHostedExperimentalConfigHeaders( 'self-hosted-experimental.md', ) ).sort(), ); }); }); describe('docs/usage/templates.md', async () => { async function getAdditionalHandlebarsHelpersHeaders(): Promise< string[] > { const content = await fs.readFile(`docs/usage/templates.md`, 'utf8'); const matches = content.match(/\n### (?:.*?)\n/g) ?? []; return matches.map((match) => match.substring(5, match.length - 1)); } const additionalHandlebarsHelpers = await getAdditionalHandlebarsHelpersHeaders(); it('has headers sorted alphabetically', () => { expect(additionalHandlebarsHelpers).toEqual( additionalHandlebarsHelpers.toSorted(), ); }); it('documents all added Handlebars helpers', () => { expect(additionalHandlebarsHelpers).toEqual( templateHelperNames.toSorted(), ); }); }); describe('docs/usage/key-concepts/minimum-release-age.md', () => { const supportEmoji: Record = { '✅': true, '🟡': true, '❌': false, }; async function getUpdateTypeSupportTable(): Promise< Record > { const content = await fs.readFile( 'docs/usage/key-concepts/minimum-release-age.md', 'utf8', ); // RE2 (renovate's regex engine) doesn't support lookahead, so bound // the section with plain index lookups instead of `(?=\n### )`. const start = regEx(/### Which update types take/).exec(content)?.index; let section: string | undefined; if (start !== undefined) { const end = content.indexOf('\n### ', start + 1); section = content.slice(start, end === -1 ? undefined : end); } const rows = section?.matchAll( /^\|\s*`(?\w+)`\s*\|\s*(?[^\s|]+)\s*\|/gm, ) ?? []; const table: Record = {}; for (const row of rows) { const { updateType, emoji } = row.groups!; table[updateType] = supportEmoji[emoji]; } return table; } it('matches isMinimumReleaseAgeApplicable() for every documented update type', async () => { const table = await getUpdateTypeSupportTable(); for (const [updateType, docsSupport] of Object.entries(table)) { expect(isMinimumReleaseAgeApplicable(updateType as UpdateType)).toBe( docsSupport, ); } }); }); }); });