From bb5377b3d8f7d453a20beff59c8ccbc337ba97c7 Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Wed, 26 Feb 2025 13:40:30 +0100
Subject: [PATCH] test: fix code coverage (#34495)

---
 .github/label-actions.yml                     |  2 +-
 docs/development/best-practices.md            |  2 +-
 docs/development/issue-labeling.md            |  2 +-
 docs/development/local-development.md         |  2 +-
 lib/config-validator.ts                       |  1 -
 lib/config/decrypt.ts                         |  4 +-
 lib/config/decrypt/legacy.ts                  |  1 -
 lib/config/migrate-validate.ts                |  5 +-
 lib/config/migration.ts                       |  3 +-
 lib/config/parse.spec.ts                      | 59 +++++++++++++++++++
 lib/config/parse.ts                           |  4 +-
 lib/config/presets/gitea/index.spec.ts        | 29 ++++++---
 lib/config/presets/gitea/index.ts             |  1 -
 lib/config/presets/github/index.spec.ts       | 29 ++++++---
 lib/config/presets/github/index.ts            |  1 -
 lib/config/presets/http/index.spec.ts         |  9 +++
 lib/config/presets/http/index.ts              |  1 -
 lib/config/presets/index.spec.ts              | 43 +++++++++++++-
 lib/config/presets/index.ts                   |  7 ---
 lib/config/presets/internal/index.spec.ts     |  4 ++
 lib/config/presets/internal/index.ts          |  4 +-
 lib/config/validation.ts                      |  6 +-
 lib/constants/category.ts                     |  1 -
 lib/instrumentation/index.ts                  | 13 ++--
 .../config-serializer.spec.ts.snap            | 14 -----
 lib/logger/cmd-serializer.spec.ts             | 13 ++++
 lib/logger/cmd-serializer.ts                  |  1 -
 lib/logger/config-serializer.spec.ts          | 14 ++++-
 lib/logger/config-serializer.ts               |  1 -
 lib/logger/index.spec.ts                      | 14 +++++
 lib/logger/index.ts                           |  7 +--
 lib/logger/once.ts                            |  7 ++-
 lib/logger/pretty-stdout.ts                   |  1 -
 lib/logger/remap.spec.ts                      |  2 +-
 lib/logger/renovate-logger.ts                 |  2 +-
 lib/logger/utils.spec.ts                      | 20 +++++++
 lib/logger/utils.ts                           | 12 ++--
 lib/proxy.ts                                  |  3 +-
 lib/renovate.ts                               |  4 +-
 lib/util/cache/package/redis.ts               |  1 -
 lib/util/http/legacy.ts                       |  1 -
 lib/workers/repository/cache.ts               |  2 -
 test/host-rules.ts                            |  7 +++
 vitest.config.ts                              |  1 +
 44 files changed, 258 insertions(+), 102 deletions(-)
 create mode 100644 lib/config/parse.spec.ts
 delete mode 100644 lib/logger/__snapshots__/config-serializer.spec.ts.snap
 create mode 100644 lib/logger/cmd-serializer.spec.ts
 create mode 100644 test/host-rules.ts

diff --git a/.github/label-actions.yml b/.github/label-actions.yml
index 42c351f76b..6f5e664689 100644
--- a/.github/label-actions.yml
+++ b/.github/label-actions.yml
@@ -260,7 +260,7 @@
 
 
     ```ts
-    // v8 ignore next: typescript strict null check
+    /* v8 ignore next: typescript strict null check */
     if (!url) {
       return null;
     }
diff --git a/docs/development/best-practices.md b/docs/development/best-practices.md
index ca4482cfa3..73efcabbda 100644
--- a/docs/development/best-practices.md
+++ b/docs/development/best-practices.md
@@ -36,7 +36,7 @@ Read the [GitHub Docs, renaming a branch](https://docs.github.com/en/repositorie
 - Avoid `Boolean` instead use `is` functions from `@sindresorhus/is` package, for example: `is.string`
 
 ```ts
-// v8 ignore next: can never happen
+/* v8 ignore next: can never happen */
 ```
 
 ### Functions
diff --git a/docs/development/issue-labeling.md b/docs/development/issue-labeling.md
index d5277ee170..3bae833dde 100644
--- a/docs/development/issue-labeling.md
+++ b/docs/development/issue-labeling.md
@@ -203,7 +203,7 @@ Add a label `auto:logs` to indicate that there's a problem with the logs, and th
 
 Add a label `auto:needs-details` to discussions which need more details to move forward.
 
-Add a label `auto:no-coverage-ignore` if PR authors avoid needed unit tests by v8 ignoring code with the `// v8 ignore` comment.
+Add a label `auto:no-coverage-ignore` if PR authors avoid needed unit tests by v8 ignoring code with the `/* v8 ignore ... */` comment.
 
 Add a label `auto:no-done-comments` if PR authors unnecessary "Done" comments, or type comments to ask for a review instead of requesting a new review through GitHub's UI.
 
diff --git a/docs/development/local-development.md b/docs/development/local-development.md
index 0b15c230ef..59eaa8f731 100644
--- a/docs/development/local-development.md
+++ b/docs/development/local-development.md
@@ -158,7 +158,7 @@ e.g. `pnpm vitest composer -u` would update the saved snapshots for _all_ tests
 ### Coverage
 
 The Renovate project maintains 100% test coverage, so any Pull Request will fail if it does not have full coverage for code.
-Using `// v8 ignore` is not ideal, but can be a pragmatic solution if adding more tests wouldn't really prove anything.
+Using `/* v8 ignore ... */` is not ideal, but can be a pragmatic solution if adding more tests wouldn't really prove anything.
 
 To view the current test coverage locally, open up `coverage/index.html` in your browser.
 
diff --git a/lib/config-validator.ts b/lib/config-validator.ts
index 210b78d81f..bb8bfb2176 100644
--- a/lib/config-validator.ts
+++ b/lib/config-validator.ts
@@ -1,5 +1,4 @@
 #!/usr/bin/env node
-// istanbul ignore file
 import 'source-map-support/register';
 import './punycode.cjs';
 import { dequal } from 'dequal';
diff --git a/lib/config/decrypt.ts b/lib/config/decrypt.ts
index 6cb34bb1fa..63622820d1 100644
--- a/lib/config/decrypt.ts
+++ b/lib/config/decrypt.ts
@@ -38,13 +38,14 @@ export async function tryDecrypt(
       );
     } else {
       decryptedStr = tryDecryptPublicKeyPKCS1(privateKey, encryptedStr);
-      // istanbul ignore if
+      /* v8 ignore start: not testable */
       if (is.string(decryptedStr)) {
         logger.warn(
           { keyName },
           'Encrypted value is using deprecated PKCS1 padding, please change to using PGP encryption.',
         );
       }
+      /* v8 ignore stop */
     }
   }
   return decryptedStr;
@@ -56,7 +57,6 @@ function validateDecryptedValue(
 ): string | null {
   try {
     const decryptedObj = DecryptedObject.safeParse(decryptedObjStr);
-    // istanbul ignore if
     if (!decryptedObj.success) {
       const error = new Error('config-validation');
       error.validationError = `Could not parse decrypted config.`;
diff --git a/lib/config/decrypt/legacy.ts b/lib/config/decrypt/legacy.ts
index b88f515aef..c220bb3ca9 100644
--- a/lib/config/decrypt/legacy.ts
+++ b/lib/config/decrypt/legacy.ts
@@ -1,4 +1,3 @@
-/** istanbul ignore file */
 import crypto from 'node:crypto';
 import { logger } from '../../logger';
 
diff --git a/lib/config/migrate-validate.ts b/lib/config/migrate-validate.ts
index 2ba1903233..16818bb055 100644
--- a/lib/config/migrate-validate.ts
+++ b/lib/config/migrate-validate.ts
@@ -33,7 +33,7 @@ export async function migrateAndValidate(
       warnings: ValidationMessage[];
       errors: ValidationMessage[];
     } = await configValidation.validateConfig('repo', massagedConfig);
-    // istanbul ignore if
+    /* v8 ignore start: hard to test */
     if (is.nonEmptyArray(warnings)) {
       logger.warn({ warnings }, 'Found renovate config warnings');
     }
@@ -45,7 +45,8 @@ export async function migrateAndValidate(
       massagedConfig.warnings = (config.warnings ?? []).concat(warnings);
     }
     return massagedConfig;
-  } catch (err) /* istanbul ignore next */ {
+    /* v8 ignore next 3: TODO: test me */
+  } catch (err) {
     logger.debug({ config: input }, 'migrateAndValidate error');
     throw err;
   }
diff --git a/lib/config/migration.ts b/lib/config/migration.ts
index 1ac07046d6..86cf232a98 100644
--- a/lib/config/migration.ts
+++ b/lib/config/migration.ts
@@ -196,7 +196,8 @@ export function migrateConfig(
       };
     }
     return { isMigrated, migratedConfig };
-  } catch (err) /* istanbul ignore next */ {
+    /* v8 ignore next 4: TODO: test me */
+  } catch (err) {
     logger.debug({ config, err }, 'migrateConfig() error');
     throw err;
   }
diff --git a/lib/config/parse.spec.ts b/lib/config/parse.spec.ts
new file mode 100644
index 0000000000..ce5ba6498e
--- /dev/null
+++ b/lib/config/parse.spec.ts
@@ -0,0 +1,59 @@
+import jsonValidator from 'json-dup-key-validator';
+import { parseFileConfig } from './parse';
+
+vi.mock('json-dup-key-validator', { spy: true });
+
+describe('config/parse', () => {
+  describe('json', () => {
+    it('parses', () => {
+      expect(parseFileConfig('config.json', '{}')).toEqual({
+        success: true,
+        parsedContents: {},
+      });
+    });
+
+    it('returns error', () => {
+      // syntax validation
+      expect(parseFileConfig('config.json', '{')).toEqual({
+        success: false,
+        validationError: 'Invalid JSON (parsing failed)',
+        validationMessage: 'Syntax error: unclosed statement near {',
+      });
+
+      // duplicate keys
+      vi.mocked(jsonValidator).validate.mockReturnValueOnce(undefined);
+      expect(parseFileConfig('config.json', '{')).toEqual({
+        success: false,
+        validationError: 'Duplicate keys in JSON',
+        validationMessage: '"Syntax error: unclosed statement near {"',
+      });
+
+      // JSON.parse
+      vi.mocked(jsonValidator).validate.mockReturnValue(undefined);
+      expect(parseFileConfig('config.json', '{')).toEqual({
+        success: false,
+        validationError: 'Invalid JSON (parsing failed)',
+        validationMessage:
+          'JSON.parse error:  `JSON5: invalid end of input at 1:2`',
+      });
+    });
+  });
+
+  describe('json5', () => {
+    it('parses', () => {
+      expect(parseFileConfig('config.json5', '{}')).toEqual({
+        success: true,
+        parsedContents: {},
+      });
+    });
+
+    it('returns error', () => {
+      expect(parseFileConfig('config.json5', '{')).toEqual({
+        success: false,
+        validationError: 'Invalid JSON5 (parsing failed)',
+        validationMessage:
+          'JSON5.parse error: `JSON5: invalid end of input at 1:2`',
+      });
+    });
+  });
+});
diff --git a/lib/config/parse.ts b/lib/config/parse.ts
index 45bbb8e651..991a1eb214 100644
--- a/lib/config/parse.ts
+++ b/lib/config/parse.ts
@@ -15,7 +15,7 @@ export function parseFileConfig(
   if (fileType === '.json5') {
     try {
       return { success: true, parsedContents: JSON5.parse(fileContents) };
-    } catch (err) /* istanbul ignore next */ {
+    } catch (err) {
       logger.debug({ fileName, fileContents }, 'Error parsing JSON5 file');
       const validationError = 'Invalid JSON5 (parsing failed)';
       const validationMessage = `JSON5.parse error: \`${err.message.replaceAll(
@@ -62,7 +62,7 @@ export function parseFileConfig(
         success: true,
         parsedContents: parseJson(fileContents, fileName),
       };
-    } catch (err) /* istanbul ignore next */ {
+    } catch (err) {
       logger.debug({ fileContents }, 'Error parsing renovate config');
       const validationError = 'Invalid JSON (parsing failed)';
       const validationMessage = `JSON.parse error:  \`${err.message.replaceAll(
diff --git a/lib/config/presets/gitea/index.spec.ts b/lib/config/presets/gitea/index.spec.ts
index 888afb7b11..27b717a02b 100644
--- a/lib/config/presets/gitea/index.spec.ts
+++ b/lib/config/presets/gitea/index.spec.ts
@@ -1,22 +1,17 @@
-import { mockDeep } from 'jest-mock-extended';
 import * as httpMock from '../../../../test/http-mock';
-import { mocked } from '../../../../test/util';
-import * as _hostRules from '../../../util/host-rules';
+import { ExternalHostError } from '../../../types/errors/external-host-error';
 import { setBaseUrl } from '../../../util/http/gitea';
 import { toBase64 } from '../../../util/string';
 import { PRESET_INVALID_JSON, PRESET_NOT_FOUND } from '../util';
 import * as gitea from '.';
-
-vi.mock('../../../util/host-rules', () => mockDeep());
-
-const hostRules = mocked(_hostRules);
+import { hostRules } from '~test/host-rules';
 
 const giteaApiHost = gitea.Endpoint;
 const basePath = '/api/v1/repos/some/repo/contents';
 
 describe('config/presets/gitea/index', () => {
   beforeEach(() => {
-    hostRules.find.mockReturnValue({ token: 'abc' });
+    hostRules.add({ token: 'abc' });
     setBaseUrl(giteaApiHost);
   });
 
@@ -54,6 +49,24 @@ describe('config/presets/gitea/index', () => {
       );
       expect(res).toEqual({ from: 'api' });
     });
+
+    it('throws external host error', async () => {
+      httpMock
+        .scope(giteaApiHost)
+        .get(`${basePath}/some-filename.json`)
+        .reply(404, {});
+
+      hostRules.add({ abortOnError: true });
+
+      await expect(
+        gitea.fetchJSONFile(
+          'some/repo',
+          'some-filename.json',
+          giteaApiHost,
+          null,
+        ),
+      ).rejects.toThrow(ExternalHostError);
+    });
   });
 
   describe('getPreset()', () => {
diff --git a/lib/config/presets/gitea/index.ts b/lib/config/presets/gitea/index.ts
index bbe1dae14f..203de5bb16 100644
--- a/lib/config/presets/gitea/index.ts
+++ b/lib/config/presets/gitea/index.ts
@@ -20,7 +20,6 @@ export async function fetchJSONFile(
       baseUrl: endpoint,
     });
   } catch (err) {
-    // istanbul ignore if: not testable with nock
     if (err instanceof ExternalHostError) {
       throw err;
     }
diff --git a/lib/config/presets/github/index.spec.ts b/lib/config/presets/github/index.spec.ts
index 623841f3c2..1829799e34 100644
--- a/lib/config/presets/github/index.spec.ts
+++ b/lib/config/presets/github/index.spec.ts
@@ -1,21 +1,16 @@
-import { mockDeep } from 'jest-mock-extended';
 import * as httpMock from '../../../../test/http-mock';
-import { mocked } from '../../../../test/util';
-import * as _hostRules from '../../../util/host-rules';
+import { ExternalHostError } from '../../../types/errors/external-host-error';
 import { toBase64 } from '../../../util/string';
 import { PRESET_INVALID_JSON, PRESET_NOT_FOUND } from '../util';
 import * as github from '.';
-
-vi.mock('../../../util/host-rules', () => mockDeep());
-
-const hostRules = mocked(_hostRules);
+import { hostRules } from '~test/host-rules';
 
 const githubApiHost = github.Endpoint;
 const basePath = '/repos/some/repo/contents';
 
 describe('config/presets/github/index', () => {
   beforeEach(() => {
-    hostRules.find.mockReturnValue({ token: 'abc' });
+    hostRules.add({ token: 'abc' });
   });
 
   describe('fetchJSONFile()', () => {
@@ -35,6 +30,24 @@ describe('config/presets/github/index', () => {
       );
       expect(res).toEqual({ from: 'api' });
     });
+
+    it('throws external host error', async () => {
+      httpMock
+        .scope(githubApiHost)
+        .get(`${basePath}/some-filename.json`)
+        .reply(404, {});
+
+      hostRules.add({ abortOnError: true });
+
+      await expect(
+        github.fetchJSONFile(
+          'some/repo',
+          'some-filename.json',
+          githubApiHost,
+          undefined,
+        ),
+      ).rejects.toThrow(ExternalHostError);
+    });
   });
 
   describe('getPreset()', () => {
diff --git a/lib/config/presets/github/index.ts b/lib/config/presets/github/index.ts
index a906323310..643cfbc976 100644
--- a/lib/config/presets/github/index.ts
+++ b/lib/config/presets/github/index.ts
@@ -26,7 +26,6 @@ export async function fetchJSONFile(
   try {
     res = await http.getJsonUnchecked(url);
   } catch (err) {
-    // istanbul ignore if: not testable with nock
     if (err instanceof ExternalHostError) {
       throw err;
     }
diff --git a/lib/config/presets/http/index.spec.ts b/lib/config/presets/http/index.spec.ts
index 315619b874..3fafe4799c 100644
--- a/lib/config/presets/http/index.spec.ts
+++ b/lib/config/presets/http/index.spec.ts
@@ -1,6 +1,8 @@
 import * as httpMock from '../../../../test/http-mock';
+import { ExternalHostError } from '../../../types/errors/external-host-error';
 import { PRESET_DEP_NOT_FOUND, PRESET_INVALID_JSON } from '../util';
 import * as http from '.';
+import { hostRules } from '~test/host-rules';
 
 const host = 'https://my.server/';
 const filePath = '/test-preset.json';
@@ -46,5 +48,12 @@ describe('config/presets/http/index', () => {
         PRESET_DEP_NOT_FOUND,
       );
     });
+    it('throws external host error', async () => {
+      httpMock.scope(host).get(filePath).reply(404, {});
+
+      hostRules.add({ abortOnError: true });
+
+      await expect(http.getPreset({ repo })).rejects.toThrow(ExternalHostError);
+    });
   });
 });
diff --git a/lib/config/presets/http/index.ts b/lib/config/presets/http/index.ts
index 646b39a462..90bd220f14 100644
--- a/lib/config/presets/http/index.ts
+++ b/lib/config/presets/http/index.ts
@@ -22,7 +22,6 @@ export async function getPreset({
   try {
     response = await http.get(url);
   } catch (err) {
-    // istanbul ignore if: not testable with nock
     if (err instanceof ExternalHostError) {
       throw err;
     }
diff --git a/lib/config/presets/index.spec.ts b/lib/config/presets/index.spec.ts
index fc9e14a60b..708a93cb92 100644
--- a/lib/config/presets/index.spec.ts
+++ b/lib/config/presets/index.spec.ts
@@ -1,6 +1,7 @@
 import { mockDeep } from 'jest-mock-extended';
 import { Fixtures } from '../../../test/fixtures';
-import { mocked } from '../../../test/util';
+import { PLATFORM_RATE_LIMIT_EXCEEDED } from '../../constants/error-messages';
+import { ExternalHostError } from '../../types/errors/external-host-error';
 import * as memCache from '../../util/cache/memory';
 import * as _packageCache from '../../util/cache/package';
 import { GlobalConfig } from '../global';
@@ -15,6 +16,7 @@ import {
   PRESET_RENOVATE_CONFIG_NOT_FOUND,
 } from './util';
 import * as presets from '.';
+import { logger, mocked } from '~test/util';
 
 vi.mock('./npm');
 vi.mock('./github');
@@ -83,9 +85,25 @@ describe('config/presets/index', () => {
       expect(res).toEqual({ foo: 1 });
     });
 
+    it('skips duplicate resolves', async () => {
+      config.extends = ['local>some/repo:a', 'local>some/repo:b'];
+      local.getPreset.mockResolvedValueOnce({ extends: ['local>some/repo:c'] });
+      local.getPreset.mockResolvedValueOnce({ extends: ['local>some/repo:c'] });
+      local.getPreset.mockResolvedValueOnce({ foo: 1 });
+      expect(await presets.resolveConfigPresets(config)).toEqual({
+        foo: 1,
+      });
+      expect(local.getPreset).toHaveBeenCalledTimes(3);
+      expect(logger.logger.debug).toHaveBeenCalledWith(
+        'Already seen preset local>some/repo:c in [local>some/repo:a, local>some/repo:c]',
+      );
+    });
+
     it('throws if invalid preset file', async () => {
       config.foo = 1;
-      config.extends = ['notfound'];
+      config.extends = ['local>some/repo'];
+
+      local.getPreset.mockResolvedValueOnce({ extends: ['notfound'] });
       let e: Error | undefined;
       try {
         await presets.resolveConfigPresets(config);
@@ -95,7 +113,8 @@ describe('config/presets/index', () => {
       expect(e).toBeDefined();
       expect(e!.validationSource).toBeUndefined();
       expect(e!.validationError).toBe(
-        "Cannot find preset's package (notfound)",
+        "Cannot find preset's package (notfound)." +
+          ' Note: this is a *nested* preset so please contact the preset author if you are unable to fix it yourself.',
       );
       expect(e!.validationMessage).toBeUndefined();
     });
@@ -464,6 +483,24 @@ describe('config/presets/index', () => {
 
       expect(packageCache.set.mock.calls[0][3]).toBe(60);
     });
+
+    it('throws', async () => {
+      config.extends = ['local>username/preset-repo'];
+
+      local.getPreset.mockRejectedValueOnce(
+        new ExternalHostError(new Error('whoops')),
+      );
+      await expect(presets.resolveConfigPresets(config)).rejects.toThrow(
+        ExternalHostError,
+      );
+
+      local.getPreset.mockRejectedValueOnce(
+        new Error(PLATFORM_RATE_LIMIT_EXCEEDED),
+      );
+      await expect(presets.resolveConfigPresets(config)).rejects.toThrow(
+        PLATFORM_RATE_LIMIT_EXCEEDED,
+      );
+    });
   });
 
   describe('replaceArgs', () => {
diff --git a/lib/config/presets/index.ts b/lib/config/presets/index.ts
index b482521ff4..68b756241e 100644
--- a/lib/config/presets/index.ts
+++ b/lib/config/presets/index.ts
@@ -159,7 +159,6 @@ export async function getPreset(
   }
   logger.trace({ presetConfig }, `Applied params to preset ${preset}`);
   const presetKeys = Object.keys(presetConfig);
-  // istanbul ignore if
   if (
     presetKeys.length === 2 &&
     presetKeys.includes('description') &&
@@ -212,7 +211,6 @@ export async function resolveConfigPresets(
           ignorePresets,
           existingPresets.concat([preset]),
         );
-        // istanbul ignore if
         if (inputConfig?.ignoreDeps?.length === 0) {
           delete presetConfig.description;
         }
@@ -271,11 +269,9 @@ async function fetchPreset(
     return await getPreset(preset, baseConfig ?? inputConfig);
   } catch (err) {
     logger.debug({ preset, err }, 'Preset fetch error');
-    // istanbul ignore if
     if (err instanceof ExternalHostError) {
       throw err;
     }
-    // istanbul ignore if
     if (err.message === PLATFORM_RATE_LIMIT_EXCEEDED) {
       throw err;
     }
@@ -295,7 +291,6 @@ async function fetchPreset(
     } else {
       error.validationError = `Preset caused unexpected error (${preset})`;
     }
-    // istanbul ignore if
     if (existingPresets.length) {
       error.validationError +=
         '. Note: this is a *nested* preset so please contact the preset author if you are unable to fix it yourself.';
@@ -313,7 +308,6 @@ function shouldResolvePreset(
   existingPresets: string[],
   ignorePresets: string[],
 ): boolean {
-  // istanbul ignore if
   if (existingPresets.includes(preset)) {
     logger.debug(
       `Already seen preset ${preset} in [${existingPresets.join(', ')}]`,
@@ -321,7 +315,6 @@ function shouldResolvePreset(
     return false;
   }
   if (ignorePresets.includes(preset)) {
-    // istanbul ignore next
     logger.debug(
       `Ignoring preset ${preset} in [${existingPresets.join(', ')}]`,
     );
diff --git a/lib/config/presets/internal/index.spec.ts b/lib/config/presets/internal/index.spec.ts
index e3dfbd4154..a85b009b51 100644
--- a/lib/config/presets/internal/index.spec.ts
+++ b/lib/config/presets/internal/index.spec.ts
@@ -55,4 +55,8 @@ describe('config/presets/internal/index', () => {
       .flat()
       .forEach((preset) => expect(preset).not.toMatch(/{{.*}}/));
   });
+
+  it('returns undefined for unknown preset', () => {
+    expect(internal.getPreset({ repo: 'some/repo' })).toBeUndefined();
+  });
 });
diff --git a/lib/config/presets/internal/index.ts b/lib/config/presets/internal/index.ts
index d9ec8c4bdf..b550eca4a8 100644
--- a/lib/config/presets/internal/index.ts
+++ b/lib/config/presets/internal/index.ts
@@ -41,7 +41,5 @@ export function getPreset({
   repo,
   presetName,
 }: PresetConfig): Preset | undefined {
-  return groups[repo] && presetName
-    ? groups[repo][presetName]
-    : /* istanbul ignore next */ undefined;
+  return groups[repo] && presetName ? groups[repo][presetName] : undefined;
 }
diff --git a/lib/config/validation.ts b/lib/config/validation.ts
index c89f07f4ee..27962e25b3 100644
--- a/lib/config/validation.ts
+++ b/lib/config/validation.ts
@@ -166,7 +166,7 @@ export async function validateConfig(
 
   for (const [key, val] of Object.entries(config)) {
     const currentPath = parentPath ? `${parentPath}.${key}` : key;
-    // istanbul ignore if
+    /* v8 ignore next 7: TODO: test me */
     if (key === '__proto__') {
       errors.push({
         topic: 'Config security error',
@@ -818,11 +818,9 @@ export async function validateConfig(
   }
 
   function sortAll(a: ValidationMessage, b: ValidationMessage): number {
-    // istanbul ignore else: currently never happen
     if (a.topic === b.topic) {
       return a.message > b.message ? 1 : -1;
     }
-    // istanbul ignore next: currently never happen
     return a.topic > b.topic ? 1 : -1;
   }
 
@@ -843,7 +841,7 @@ async function validateGlobalConfig(
   currentPath: string | undefined,
   config: RenovateConfig,
 ): Promise<void> {
-  // istanbul ignore if
+  /* v8 ignore next 5: not testable yet */
   if (getDeprecationMessage(key)) {
     warnings.push({
       topic: 'Deprecation Warning',
diff --git a/lib/constants/category.ts b/lib/constants/category.ts
index 94c22015c9..e0a11c9a75 100644
--- a/lib/constants/category.ts
+++ b/lib/constants/category.ts
@@ -1,4 +1,3 @@
-// istanbul ignore next
 export const Categories = [
   'ansible',
   'batect',
diff --git a/lib/instrumentation/index.ts b/lib/instrumentation/index.ts
index 71e66aab5e..23d7e630b9 100644
--- a/lib/instrumentation/index.ts
+++ b/lib/instrumentation/index.ts
@@ -74,11 +74,8 @@ export function init(): void {
 
   instrumentations = [
     new HttpInstrumentation({
-      applyCustomAttributesOnSpan: /* istanbul ignore next */ (
-        span,
-        request,
-        response,
-      ) => {
+      /* v8 ignore start: not easily testable */
+      applyCustomAttributesOnSpan: (span, request, response) => {
         // ignore 404 errors when the branch protection of Github could not be found. This is expected if no rules are configured
         if (
           request instanceof ClientRequest &&
@@ -89,6 +86,7 @@ export function init(): void {
           span.setStatus({ code: SpanStatusCode.OK });
         }
       },
+      /* v8 ignore stop */
     }),
     new BunyanInstrumentation(),
   ];
@@ -97,8 +95,7 @@ export function init(): void {
   });
 }
 
-/* istanbul ignore next */
-
+/* v8 ignore start: not easily testable */
 // https://github.com/open-telemetry/opentelemetry-js-api/issues/34
 export async function shutdown(): Promise<void> {
   const traceProvider = getTracerProvider();
@@ -111,8 +108,8 @@ export async function shutdown(): Promise<void> {
     }
   }
 }
+/* v8 ignore stop */
 
-/* istanbul ignore next */
 export function disableInstrumentations(): void {
   for (const instrumentation of instrumentations) {
     instrumentation.disable();
diff --git a/lib/logger/__snapshots__/config-serializer.spec.ts.snap b/lib/logger/__snapshots__/config-serializer.spec.ts.snap
deleted file mode 100644
index bd6053b5b2..0000000000
--- a/lib/logger/__snapshots__/config-serializer.spec.ts.snap
+++ /dev/null
@@ -1,14 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`logger/config-serializer > squashes templates 1`] = `
-{
-  "nottoken": "b",
-  "prBody": "[Template]",
-}
-`;
-
-exports[`logger/config-serializer > suppresses content 1`] = `
-{
-  "content": "[content]",
-}
-`;
diff --git a/lib/logger/cmd-serializer.spec.ts b/lib/logger/cmd-serializer.spec.ts
new file mode 100644
index 0000000000..ab877f9c5f
--- /dev/null
+++ b/lib/logger/cmd-serializer.spec.ts
@@ -0,0 +1,13 @@
+import cmdSerializer from './cmd-serializer';
+
+describe('logger/cmd-serializer', () => {
+  it('returns array', () => {
+    expect(cmdSerializer([''])).toEqual(['']);
+  });
+
+  it('redacts', () => {
+    expect(cmdSerializer(' https://token@domain.com')).toEqual(
+      ' https://**redacted**@domain.com',
+    );
+  });
+});
diff --git a/lib/logger/cmd-serializer.ts b/lib/logger/cmd-serializer.ts
index 6b3492d3ba..7dd7c78f37 100644
--- a/lib/logger/cmd-serializer.ts
+++ b/lib/logger/cmd-serializer.ts
@@ -1,4 +1,3 @@
-// istanbul ignore next
 export default function cmdSerializer(
   cmd: string | string[],
 ): string | string[] {
diff --git a/lib/logger/config-serializer.spec.ts b/lib/logger/config-serializer.spec.ts
index 8a2dfd95ef..64847e096a 100644
--- a/lib/logger/config-serializer.spec.ts
+++ b/lib/logger/config-serializer.spec.ts
@@ -6,7 +6,8 @@ describe('logger/config-serializer', () => {
       nottoken: 'b',
       prBody: 'foo',
     };
-    expect(configSerializer(config)).toMatchSnapshot({
+    expect(configSerializer(config)).toEqual({
+      nottoken: 'b',
       prBody: '[Template]',
     });
   });
@@ -15,8 +16,17 @@ describe('logger/config-serializer', () => {
     const config = {
       content: {},
     };
-    expect(configSerializer(config)).toMatchSnapshot({
+    expect(configSerializer(config)).toEqual({
       content: '[content]',
     });
   });
+
+  it('suppresses packageFiles', () => {
+    const config = {
+      packageFiles: [],
+    };
+    expect(configSerializer(config)).toEqual({
+      packageFiles: '[Array]',
+    });
+  });
 });
diff --git a/lib/logger/config-serializer.ts b/lib/logger/config-serializer.ts
index 4e484b92ab..b8a4c9e1bd 100644
--- a/lib/logger/config-serializer.ts
+++ b/lib/logger/config-serializer.ts
@@ -22,7 +22,6 @@ export default function configSerializer(
       if (contentFields.includes(key)) {
         this.update('[content]');
       }
-      // istanbul ignore if
       if (arrayFields.includes(key)) {
         this.update('[Array]');
       }
diff --git a/lib/logger/index.spec.ts b/lib/logger/index.spec.ts
index fce2edf9be..92a61baeff 100644
--- a/lib/logger/index.spec.ts
+++ b/lib/logger/index.spec.ts
@@ -5,10 +5,12 @@ import { partial } from '../../test/util';
 import { add } from '../util/host-rules';
 import { addSecretForSanitizing as addSecret } from '../util/sanitize';
 import type { RenovateLogger } from './renovate-logger';
+import { ProblemStream } from './utils';
 import {
   addMeta,
   addStream,
   clearProblems,
+  createDefaultStreams,
   getContext,
   getProblems,
   levels,
@@ -161,6 +163,18 @@ describe('logger/index', () => {
     });
   });
 
+  describe('createDefaultStreams', () => {
+    it('creates log file stream', () => {
+      expect(
+        createDefaultStreams('info', new ProblemStream(), 'file.log'),
+      ).toMatchObject([
+        { name: 'stdout', type: 'raw' },
+        { name: 'problems', type: 'raw' },
+        { name: 'logfile' },
+      ]);
+    });
+  });
+
   it('sets level', () => {
     expect(logLevel()).toBeDefined(); // depends on passed env
     expect(() => levels('stdout', 'debug')).not.toThrow();
diff --git a/lib/logger/index.ts b/lib/logger/index.ts
index 301fdb323d..0f670affe2 100644
--- a/lib/logger/index.ts
+++ b/lib/logger/index.ts
@@ -34,7 +34,6 @@ export function createDefaultStreams(
     stream: process.stdout,
   };
 
-  // istanbul ignore if: not testable
   if (getEnv('LOG_FORMAT') !== 'json') {
     // TODO: typings (#9615)
     const prettyStdOut = new RenovateStream() as any;
@@ -50,7 +49,6 @@ export function createDefaultStreams(
     type: 'raw',
   };
 
-  // istanbul ignore next: not easily testable
   const logFileStream: bunyan.Stream | undefined = is.string(logFile)
     ? createLogFileStream(logFile)
     : undefined;
@@ -60,7 +58,6 @@ export function createDefaultStreams(
   ) as bunyan.Stream[];
 }
 
-// istanbul ignore next: not easily testable
 function createLogFileStream(logFile: string): bunyan.Stream {
   // Ensure log file directory exists
   const directoryName = upath.dirname(logFile);
@@ -135,9 +132,7 @@ export function withMeta<T>(obj: Record<string, unknown>, cb: () => T): T {
   }
 }
 
-export /* istanbul ignore next */ function addStream(
-  stream: bunyan.Stream,
-): void {
+export function addStream(stream: bunyan.Stream): void {
   loggerInternal.addStream(stream);
 }
 
diff --git a/lib/logger/once.ts b/lib/logger/once.ts
index 41aa9b55d1..17e8328811 100644
--- a/lib/logger/once.ts
+++ b/lib/logger/once.ts
@@ -1,5 +1,7 @@
 type OmitFn = (...args: any[]) => any;
 
+// TODO: use `callsite` package instead?
+
 /**
  * Get the single frame of this function's callers stack.
  *
@@ -24,7 +26,8 @@ function getCallSite(omitFn: OmitFn): string | null {
     if (callsite) {
       result = callsite.toString();
     }
-  } catch /* istanbul ignore next */ {
+    /* v8 ignore next 2: should not happen */
+  } catch {
     // no-op
   } finally {
     Error.stackTraceLimit = stackTraceLimitOrig;
@@ -39,7 +42,7 @@ const keys = new Set<string>();
 export function once(callback: () => void, omitFn: OmitFn = once): void {
   const key = getCallSite(omitFn);
 
-  // istanbul ignore if
+  /* v8 ignore next 3: should not happen */
   if (!key) {
     return;
   }
diff --git a/lib/logger/pretty-stdout.ts b/lib/logger/pretty-stdout.ts
index f358730a3d..dea632c4bb 100644
--- a/lib/logger/pretty-stdout.ts
+++ b/lib/logger/pretty-stdout.ts
@@ -100,7 +100,6 @@ export class RenovateStream extends Stream {
     this.writable = true;
   }
 
-  // istanbul ignore next
   write(data: BunyanRecord): boolean {
     this.emit('data', formatRecord(data));
     return true;
diff --git a/lib/logger/remap.spec.ts b/lib/logger/remap.spec.ts
index 3e7608971e..fe0fce5d36 100644
--- a/lib/logger/remap.spec.ts
+++ b/lib/logger/remap.spec.ts
@@ -23,7 +23,7 @@ describe('logger/remap', () => {
 
   it('performs global remaps', () => {
     setGlobalLogLevelRemaps([{ matchMessage: '*foo*', newLogLevel: 'error' }]);
-    setRepositoryLogLevelRemaps(undefined);
+    setRepositoryLogLevelRemaps([]);
 
     const res = getRemappedLevel('foo');
 
diff --git a/lib/logger/renovate-logger.ts b/lib/logger/renovate-logger.ts
index 096b763ec4..0d964a332f 100644
--- a/lib/logger/renovate-logger.ts
+++ b/lib/logger/renovate-logger.ts
@@ -115,7 +115,7 @@ export class RenovateLogger implements Logger {
 
       if (is.string(msg)) {
         const remappedLevel = getRemappedLevel(msg);
-        // istanbul ignore if: not easily testable
+        /* v8 ignore next 4: not easily testable */
         if (remappedLevel) {
           meta.oldLevel = level;
           level = remappedLevel;
diff --git a/lib/logger/utils.spec.ts b/lib/logger/utils.spec.ts
index df0fb726fe..c9c94cb007 100644
--- a/lib/logger/utils.spec.ts
+++ b/lib/logger/utils.spec.ts
@@ -1,3 +1,4 @@
+import { TimeoutError } from 'got';
 import { z } from 'zod';
 import prepareError, {
   prepareZodIssues,
@@ -106,6 +107,9 @@ describe('logger/utils', () => {
     }
 
     it('prepareZodIssues', () => {
+      expect(prepareZodIssues(null)).toBe(null);
+      expect(prepareZodIssues({ _errors: ['a', 'b'] })).toEqual(['a', 'b']);
+
       expect(prepareIssues(z.string(), 42)).toBe(
         'Expected string, received number',
       );
@@ -214,5 +218,21 @@ describe('logger/utils', () => {
         stack: expect.stringMatching(/^ZodError: Schema error/),
       });
     });
+
+    it('handles HTTP timout error', () => {
+      const err = new TimeoutError(
+        // @ts-expect-error some types are private
+        new Error('timeout'),
+        {},
+        { context: { hostType: 'foo' } },
+      );
+      Object.assign(err, {
+        response: {},
+      });
+      expect(prepareError(err)).toMatchObject({
+        message: 'timeout',
+        name: 'TimeoutError',
+      });
+    });
   });
 });
diff --git a/lib/logger/utils.ts b/lib/logger/utils.ts
index d734057792..723f8e4e00 100644
--- a/lib/logger/utils.ts
+++ b/lib/logger/utils.ts
@@ -57,14 +57,12 @@ type ZodShortenedIssue =
     };
 
 export function prepareZodIssues(input: unknown): ZodShortenedIssue {
-  // istanbul ignore if
   if (!is.plainObject(input)) {
     return null;
   }
 
   let err: null | string | string[] = null;
   if (is.array(input._errors, is.string)) {
-    // istanbul ignore else
     if (input._errors.length === 1) {
       err = input._errors[0];
     } else if (input._errors.length > 1) {
@@ -96,11 +94,11 @@ export function prepareZodIssues(input: unknown): ZodShortenedIssue {
 }
 
 export function prepareZodError(err: ZodError): Record<string, unknown> {
-  // istanbul ignore next
   Object.defineProperty(err, 'message', {
     get: () => 'Schema error',
+    /* v8 ignore next 3: TODO: drop set? */
     set: () => {
-      // intentionally empty
+      /* intentionally empty */
     },
   });
 
@@ -144,13 +142,11 @@ export default function prepareError(err: Error): Record<string, unknown> {
     options.method = err.options.method;
     options.http2 = err.options.http2;
 
-    // istanbul ignore else
     if (err.response) {
       response.response = {
-        statusCode: err.response?.statusCode,
-        statusMessage: err.response?.statusMessage,
+        statusCode: err.response.statusCode,
+        statusMessage: err.response.statusMessage,
         body:
-          // istanbul ignore next: not easily testable
           err.name === 'TimeoutError'
             ? undefined
             : structuredClone(err.response.body),
diff --git a/lib/proxy.ts b/lib/proxy.ts
index 6cc1550ce0..25c2b82c58 100644
--- a/lib/proxy.ts
+++ b/lib/proxy.ts
@@ -8,13 +8,14 @@ let agent = false;
 
 export function bootstrap(): void {
   envVars.forEach((envVar) => {
-    /* istanbul ignore if: env is case-insensitive on windows */
+    /* v8 ignore start: env is case-insensitive on windows */
     if (
       typeof process.env[envVar] === 'undefined' &&
       typeof process.env[envVar.toLowerCase()] !== 'undefined'
     ) {
       process.env[envVar] = process.env[envVar.toLowerCase()];
     }
+    /* v8 ignore stop */
 
     if (process.env[envVar]) {
       logger.debug(`Detected ${envVar} value in env`);
diff --git a/lib/renovate.ts b/lib/renovate.ts
index d60ae5b2d7..a9c1ef2c7f 100644
--- a/lib/renovate.ts
+++ b/lib/renovate.ts
@@ -7,7 +7,7 @@ import { logger } from './logger';
 import { bootstrap } from './proxy';
 import { start } from './workers/global';
 
-// istanbul ignore next
+/* v8 ignore next 3: not easily testable */
 process.on('unhandledRejection', (err) => {
   logger.error({ err }, 'unhandledRejection');
 });
@@ -19,7 +19,7 @@ bootstrap();
   process.exitCode = await instrument('run', () => start());
   await telemetryShutdown(); //gracefully shutdown OpenTelemetry
 
-  // istanbul ignore if
+  /* v8 ignore next 3: no test required */
   if (process.env.RENOVATE_X_HARD_EXIT) {
     process.exit(process.exitCode);
   }
diff --git a/lib/util/cache/package/redis.ts b/lib/util/cache/package/redis.ts
index 24c36a8d4a..5e3a640c9e 100644
--- a/lib/util/cache/package/redis.ts
+++ b/lib/util/cache/package/redis.ts
@@ -1,4 +1,3 @@
-/* istanbul ignore file */
 import { DateTime } from 'luxon';
 import { createClient, createCluster } from 'redis';
 import { logger } from '../../../logger';
diff --git a/lib/util/http/legacy.ts b/lib/util/http/legacy.ts
index 3582c251d1..318a17106c 100644
--- a/lib/util/http/legacy.ts
+++ b/lib/util/http/legacy.ts
@@ -1,4 +1,3 @@
-// istanbul ignore file
 import { RequestError as HttpError } from 'got';
 import { parseUrl } from '../url';
 
diff --git a/lib/workers/repository/cache.ts b/lib/workers/repository/cache.ts
index 55dc4beac8..30d92fd33f 100644
--- a/lib/workers/repository/cache.ts
+++ b/lib/workers/repository/cache.ts
@@ -1,5 +1,3 @@
-/* istanbul ignore file */
-
 import { REPOSITORY_CHANGED } from '../../constants/error-messages';
 import { logger } from '../../logger';
 import { platform } from '../../modules/platform';
diff --git a/test/host-rules.ts b/test/host-rules.ts
new file mode 100644
index 0000000000..ba3c997cad
--- /dev/null
+++ b/test/host-rules.ts
@@ -0,0 +1,7 @@
+import { hostRules } from './util';
+
+export * as hostRules from '../lib/util/host-rules';
+
+beforeEach(() => {
+  hostRules.clear();
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index d42fd4df7d..ae7716726c 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -105,6 +105,7 @@ export default defineConfig(() =>
             '__mocks__/**',
             // fully ignored files
             'lib/config-validator.ts',
+            'lib/constants/category.ts',
             'lib/modules/datasource/hex/v2/package.ts',
             'lib/modules/datasource/hex/v2/signed.ts',
             'lib/util/cache/package/redis.ts',
-- 
GitLab