diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index c1e03e8d7..37b7cac3c 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -81,4 +81,14 @@ jobs: if: steps.test_units.outcome == 'failure' run: | echo "::notice::Run \`npm run test:unit\` in your dev environment for a detailed report about failed unit tests" + exit 1 + - name: Ensure all units are covered + if: steps.changed-files.outputs.any_changed == 'true' + id: test_unit_missing + run: npm run test:unit:missing + continue-on-error: true + - name: If there are missing unit tests, highlight debug tools + if: steps.test_unit_missing.outcome == 'failure' + run: | + echo "::notice::Run \`npm run test:unit -- run && npm run test:unit:missing\` in your dev environment to see which units are missing a test companion" exit 1 \ No newline at end of file diff --git a/@internal/Directory/createAt.ts b/@internal/Directory/createAt.ts new file mode 100644 index 000000000..ee809167e --- /dev/null +++ b/@internal/Directory/createAt.ts @@ -0,0 +1,21 @@ +import { + mkdirSync, +} from 'node:fs'; + +import type FilesystemPath from '../FilesystemPath'; + +import { + serialized, +} from '../FilesystemPath'; + +const Directory_createAt = ( + givenPath: FilesystemPath, +): void => { + mkdirSync(serialized(givenPath), { + recursive: true, + }); +}; + +export { + Directory_createAt, +}; diff --git a/@internal/Directory/definition.assembled.members.ts b/@internal/Directory/definition.assembled.members.ts new file mode 100644 index 000000000..74102dc5d --- /dev/null +++ b/@internal/Directory/definition.assembled.members.ts @@ -0,0 +1,3 @@ +export { + Directory_createAt as createAt, +} from './createAt'; diff --git a/@internal/Directory/definition.assembled.ts b/@internal/Directory/definition.assembled.ts new file mode 100644 index 000000000..700322793 --- /dev/null +++ b/@internal/Directory/definition.assembled.ts @@ -0,0 +1 @@ +export * as Directory from './definition.assembled.members.ts'; diff --git a/@internal/Directory/exports.object.primary.ts b/@internal/Directory/exports.object.primary.ts new file mode 100644 index 000000000..8ac7983ac --- /dev/null +++ b/@internal/Directory/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.assembled.ts'; diff --git a/@internal/Directory/index.ts b/@internal/Directory/index.ts new file mode 100644 index 000000000..3f4016845 --- /dev/null +++ b/@internal/Directory/index.ts @@ -0,0 +1,3 @@ +export { + Directory as default, +} from './exports.object.primary.ts'; diff --git a/@internal/File/contentsReadFrom.ts b/@internal/File/contentsReadFrom.ts new file mode 100644 index 000000000..912860c0d --- /dev/null +++ b/@internal/File/contentsReadFrom.ts @@ -0,0 +1,25 @@ +import { + readFileSync, +} from 'node:fs'; + +import type FilesystemPath from '../FilesystemPath'; + +import { + serialized, +} from '../FilesystemPath'; + +const File_contentsReadAt = ( + givenPath: FilesystemPath, +): string => { + const someAbsolutePath = serialized(givenPath); + + const contentsFromGivenPath = readFileSync(someAbsolutePath, { + encoding: 'utf8', + }); + + return contentsFromGivenPath; +}; + +export { + File_contentsReadAt as File_contentsReadFrom, +}; diff --git a/@internal/File/definition.assembled.members.ts b/@internal/File/definition.assembled.members.ts new file mode 100644 index 000000000..66f77c4ed --- /dev/null +++ b/@internal/File/definition.assembled.members.ts @@ -0,0 +1,11 @@ +export { + File_doesExistAt as doesExistAt, +} from './doesExistAt'; + +export { + File_contentsReadFrom as contentsReadFrom, +} from './contentsReadFrom'; + +export { + File_write as write, +} from './write'; diff --git a/@internal/File/definition.assembled.ts b/@internal/File/definition.assembled.ts new file mode 100644 index 000000000..838989058 --- /dev/null +++ b/@internal/File/definition.assembled.ts @@ -0,0 +1 @@ +export * as File from './definition.assembled.members.ts'; diff --git a/@internal/File/doesExistAt.ts b/@internal/File/doesExistAt.ts new file mode 100644 index 000000000..67d411619 --- /dev/null +++ b/@internal/File/doesExistAt.ts @@ -0,0 +1,20 @@ +import { + existsSync, +} from 'node:fs'; + +import type FilesystemPath from '../FilesystemPath'; + +import { + serialized, +} from '../FilesystemPath'; + +const File_doesExistAt = ( + givenPath: FilesystemPath, +): boolean => { + const derivedAbsolutePath = serialized(givenPath); + return existsSync(derivedAbsolutePath); +}; + +export { + File_doesExistAt, +}; diff --git a/@internal/File/exports.object.primary.ts b/@internal/File/exports.object.primary.ts new file mode 100644 index 000000000..8ac7983ac --- /dev/null +++ b/@internal/File/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.assembled.ts'; diff --git a/@internal/File/exports.toolbox.ts b/@internal/File/exports.toolbox.ts new file mode 100644 index 000000000..a5157b743 --- /dev/null +++ b/@internal/File/exports.toolbox.ts @@ -0,0 +1,3 @@ +export * from './toPath'; + +export * from './contentsReadFrom'; diff --git a/@internal/File/index.ts b/@internal/File/index.ts new file mode 100644 index 000000000..89a23c0b7 --- /dev/null +++ b/@internal/File/index.ts @@ -0,0 +1,5 @@ +export { + File as default, +} from './exports.object.primary.ts'; + +export * from './exports.toolbox.ts'; diff --git a/@internal/File/toPath.ts b/@internal/File/toPath.ts new file mode 100644 index 000000000..495c7aaf8 --- /dev/null +++ b/@internal/File/toPath.ts @@ -0,0 +1,16 @@ +import { + fileURLToPath, +} from 'node:url'; + +import FilesystemPath from '../FilesystemPath'; + +const toPath = ( + givenFile: URL, +): FilesystemPath => { + const absolutePathToFile = fileURLToPath(givenFile); + return FilesystemPath.parsedFrom(absolutePathToFile); +}; + +export { + toPath, +}; diff --git a/@internal/File/write.ts b/@internal/File/write.ts new file mode 100644 index 000000000..d79328579 --- /dev/null +++ b/@internal/File/write.ts @@ -0,0 +1,27 @@ +import { + writeFileSync, +} from 'node:fs'; + +import type FilesystemPath from '../FilesystemPath'; + +import { + serialized, +} from '../FilesystemPath'; + +const File_write = ( + { + contents: givenContents, + to: givenPath, + }: { + contents: string; + to: FilesystemPath; + }, +): void => { + writeFileSync(serialized(givenPath), givenContents, { + encoding: 'utf8', + }); +}; + +export { + File_write, +}; diff --git a/@internal/FilesystemPath/definition.declared.augmentation.ts b/@internal/FilesystemPath/definition.declared.augmentation.ts new file mode 100644 index 000000000..fcf8255fc --- /dev/null +++ b/@internal/FilesystemPath/definition.declared.augmentation.ts @@ -0,0 +1,29 @@ +import { + FilesystemPath, +} from './definition.declared.ts'; + +import { + FilesystemPath_from, +} from './from.ts'; + +import { + FilesystemPath_parsedFrom, +} from './parsedFrom.ts'; + +import { + FilesystemPath_resolved, +} from './resolved.ts'; + +FilesystemPath.from /* */ = FilesystemPath_from; +FilesystemPath.parsedFrom /**/ = FilesystemPath_parsedFrom; +FilesystemPath.resolved /* */ = FilesystemPath_resolved; + +declare module './definition.declared.ts' { + namespace FilesystemPath { + export { + FilesystemPath_from /* */ as from, + FilesystemPath_parsedFrom /**/ as parsedFrom, + FilesystemPath_resolved /* */ as resolved, + }; + } +} diff --git a/@internal/FilesystemPath/definition.declared.ts b/@internal/FilesystemPath/definition.declared.ts new file mode 100644 index 000000000..b6f308888 --- /dev/null +++ b/@internal/FilesystemPath/definition.declared.ts @@ -0,0 +1,17 @@ +import type { + ParsedPath, +} from 'node:path'; + +type FilesystemPath = ParsedPath; + +function FilesystemPath( + namespaceOnly: never = (() => { + throw new Error(`Unexpected call of module augmentation provision for ${FilesystemPath.name}.`); // eslint-disable-line no-restricted-syntax -- TODO use `Attempt` instead + })(), +) { + return namespaceOnly; +} + +export { + FilesystemPath, +}; diff --git a/@internal/FilesystemPath/definition.declared.withAugmentation.ts b/@internal/FilesystemPath/definition.declared.withAugmentation.ts new file mode 100644 index 000000000..f11cfa9fa --- /dev/null +++ b/@internal/FilesystemPath/definition.declared.withAugmentation.ts @@ -0,0 +1,3 @@ +import './definition.declared.augmentation.ts'; + +export * from './definition.declared.ts'; diff --git a/@internal/FilesystemPath/exports.object.primary.ts b/@internal/FilesystemPath/exports.object.primary.ts new file mode 100644 index 000000000..52e0e7aef --- /dev/null +++ b/@internal/FilesystemPath/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.declared.withAugmentation.ts'; diff --git a/@internal/FilesystemPath/exports.toolbox.ts b/@internal/FilesystemPath/exports.toolbox.ts new file mode 100644 index 000000000..3f5ba70fe --- /dev/null +++ b/@internal/FilesystemPath/exports.toolbox.ts @@ -0,0 +1 @@ +export * from './serialized'; diff --git a/@internal/FilesystemPath/from.ts b/@internal/FilesystemPath/from.ts new file mode 100644 index 000000000..2c044eabd --- /dev/null +++ b/@internal/FilesystemPath/from.ts @@ -0,0 +1,39 @@ +import type { + FilesystemPath, +} from './definition.declared.ts'; + +const FilesystemPath_from = ( + givenPath: FilesystemPath, + { + name: nameMutatedFrom = $0 => $0, + extension: extensionMutatedFrom = $0 => $0, + }: { + name?: ( + originalName: string + ) => string; + extension?: ( + originalExtension: string + ) => string; + }, +): FilesystemPath => { + const mutablePath = { + ...givenPath, + }; + + mutablePath.name = nameMutatedFrom(mutablePath.name); + const mutatedExtension = extensionMutatedFrom(mutablePath.ext); + + if ( + (0 < mutatedExtension.length) + && !mutatedExtension.startsWith('.') + ) throw new Error(`Extension must start with a period when not empty: ${mutatedExtension}`); // eslint-disable-line no-restricted-syntax -- TODO replace with `Attempt` + + mutablePath.ext = mutatedExtension; + mutablePath.base = `${mutablePath.name}${mutablePath.ext}`; + + return mutablePath; +}; + +export { + FilesystemPath_from, +}; diff --git a/@internal/FilesystemPath/index.ts b/@internal/FilesystemPath/index.ts new file mode 100644 index 000000000..63c02974e --- /dev/null +++ b/@internal/FilesystemPath/index.ts @@ -0,0 +1,5 @@ +export { + FilesystemPath as default, +} from './exports.object.primary.ts'; + +export * from './exports.toolbox.ts'; diff --git a/@internal/FilesystemPath/parsedFrom.ts b/@internal/FilesystemPath/parsedFrom.ts new file mode 100644 index 000000000..dfbde808c --- /dev/null +++ b/@internal/FilesystemPath/parsedFrom.ts @@ -0,0 +1,3 @@ +export { + parse as FilesystemPath_parsedFrom, +} from 'node:path'; diff --git a/@internal/FilesystemPath/resolved.ts b/@internal/FilesystemPath/resolved.ts new file mode 100644 index 000000000..74a0f06ed --- /dev/null +++ b/@internal/FilesystemPath/resolved.ts @@ -0,0 +1,33 @@ +import { + resolve, +} from 'node:path'; + +import type { + FilesystemPath, +} from './definition.declared.ts'; + +import { + FilesystemPath_parsedFrom, +} from './parsedFrom'; + +import { + serialized, +} from './serialized'; + +const FilesystemPath_resolved = ( + { + from: givenRoot, + to: givenPathFromTargetToRoot, + }: { + from: FilesystemPath; + to: string; + }, +): FilesystemPath => { + const absolutePathToRoot = serialized(givenRoot); + const absolutePathToTarget = resolve(absolutePathToRoot, givenPathFromTargetToRoot); + return FilesystemPath_parsedFrom(absolutePathToTarget); +}; + +export { + FilesystemPath_resolved, +}; diff --git a/@internal/FilesystemPath/serialized.ts b/@internal/FilesystemPath/serialized.ts new file mode 100644 index 000000000..acb03ec7a --- /dev/null +++ b/@internal/FilesystemPath/serialized.ts @@ -0,0 +1,3 @@ +export { + format as serialized, +} from 'node:path'; diff --git a/package.json b/package.json index c6afbdc4e..33fea14f2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", "test:unit": "vitest", + "test:unit:missing": "jiti vitest/ensure-all-units-have-test-companion.ts", "prepare": "cypress install && husky", "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'", "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'", diff --git a/scripts/stubbed-test-suites/main.ts b/scripts/stubbed-test-suites/main.ts new file mode 100644 index 000000000..8994c4fa4 --- /dev/null +++ b/scripts/stubbed-test-suites/main.ts @@ -0,0 +1,74 @@ +import Directory from '../../@internal/Directory'; +import File from '../../@internal/File'; +import FilesystemPath from '../../@internal/FilesystemPath'; + +// take a given file path and generate a stub for a test suite +function createAndSaveTestCompanionForUnitWith( + givenAbsolutePath: string, +) { + const componentsOfAbsolutePath = givenAbsolutePath.split('/'); + const fileNameWithExtension = componentsOfAbsolutePath.pop() ?? ''; + const [fileName, fileExtension] = fileNameWithExtension.split('.'); + + if ( + !fileName || !fileExtension + ) throw new Error(`Could not parse file name and extension from: ${givenAbsolutePath}`); // eslint-disable-line no-restricted-syntax -- "@/library/Attempt is not accessible outside of src" + + const testFileName = `${fileName}.test.${fileExtension}`; + const absolutePathToTestCompanion = [...componentsOfAbsolutePath, testFileName].join('/'); + const symbolName = 'someSymbol'; + + const contentsOfTestCompanion = `import { + describe, +} from 'vitest'; + +import { + ${symbolName}, +} from './${fileNameWithExtension}'; + +describe.todo(${symbolName}); +`; + + const pathToTestCompanion = FilesystemPath.parsedFrom(absolutePathToTestCompanion); + const pathToDirectoryOfTestCompanion = FilesystemPath.parsedFrom(pathToTestCompanion.dir); + + if ( + !File.doesExistAt(pathToDirectoryOfTestCompanion) + ) Directory.createAt(pathToDirectoryOfTestCompanion); + + const testCompanionShouldBeCreated = !File.doesExistAt(pathToTestCompanion); + + if ( + testCompanionShouldBeCreated + ) File.write({ + contents: contentsOfTestCompanion, + to : pathToTestCompanion, + }); + + return { + created : testCompanionShouldBeCreated, + pathForTestFile: absolutePathToTestCompanion, + }; +} + +const inputPath = process.argv[2]; + +if (!inputPath) { + console.error('Usage: npx jiti scripts/generate-stub-for-test-suite.ts '); + process.exit(1); +} + +// eslint-disable-next-line no-restricted-syntax -- "@/library/Attempt is not accessible outside of src" +try { + const result = createAndSaveTestCompanionForUnitWith(inputPath); + + const messageForResult = result.created + ? `Created test stub: ${result.pathForTestFile}` + : `Test file already exists: ${result.pathForTestFile}`; + + console.log(messageForResult); +} +catch (err) { + console.error('Error:', (err as Error).message); + process.exit(1); +} diff --git a/src/features/TextToImage/Client/SDXL/toSharkUIOutput/Image/Description/all.test.ts b/src/features/TextToImage/Client/SDXL/toSharkUIOutput/Image/Description/all.test.ts new file mode 100644 index 000000000..6a87739f4 --- /dev/null +++ b/src/features/TextToImage/Client/SDXL/toSharkUIOutput/Image/Description/all.test.ts @@ -0,0 +1,9 @@ +import { + describe, +} from 'vitest'; + +import { + toSharkUIOutput_Image_Description_all, +} from './all.ts'; + +describe.todo(toSharkUIOutput_Image_Description_all); diff --git a/tsconfig.node.json b/tsconfig.node.json index c8fa9cdc1..8ff9caf19 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,15 +1,13 @@ { "extends": "@tsconfig/node22/tsconfig.json", "include": [ - "vite.config.*", - "vitest.config.*", - "cypress.config.*", - "nightwatch.conf.*", - "playwright.config.*", + "*.config.*", "eslint.*", + "@internal/**/*", + "vitest/**/*", "cypress/eslint.config.*", "cypress/package.d.ts", - "lint-staged.config.*" + "scripts/**/*" ], "compilerOptions": { "noEmit": true, @@ -20,6 +18,7 @@ "types": [ "node", "vite/client", - ] + ], + "allowImportingTsExtensions": true } } diff --git a/vite.config.ts b/vite.config.ts index 1d0452381..6753ff841 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,3 @@ -import { - fileURLToPath, - URL, -} from 'node:url'; - import vue from '@vitejs/plugin-vue'; import { @@ -12,6 +7,14 @@ import { import vueDevTools from 'vite-plugin-vue-devtools'; import vuetify from 'vite-plugin-vuetify'; +import { + toPath, +} from './@internal/File'; + +import { + serialized, +} from './@internal/FilesystemPath'; + // https://vite.dev/config/ const viteConfig = defineConfig({ plugins: [ @@ -27,7 +30,7 @@ const viteConfig = defineConfig({ ], resolve: { alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), + '@': serialized(toPath(new URL('./src', import.meta.url))), }, }, }); diff --git a/vitest.config.ts b/vitest.config.ts index 12fc411ca..dd0a74ed6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,13 +1,18 @@ -import { - fileURLToPath, -} from 'node:url'; - import { mergeConfig, defineConfig, configDefaults, + coverageConfigDefaults, } from 'vitest/config'; +import { + toPath, +} from './@internal/File'; + +import { + serialized, +} from './@internal/FilesystemPath'; + import viteConfig from './vite.config'; const vitestConfig = mergeConfig( @@ -16,7 +21,26 @@ const vitestConfig = mergeConfig( test: { environment: 'jsdom', exclude : [...configDefaults.exclude, 'e2e/**'], - root : fileURLToPath(new URL('./', import.meta.url)), + root : serialized(toPath(new URL('./', import.meta.url))), + coverage : { + enabled: true, + include: [ + 'src/library/**/*.ts', + 'src/features/**/*.ts', + ], + exclude: [ + ...coverageConfigDefaults.exclude, + 'src/**/index.ts', // barrel files as entry points + 'src/**/exports.*.ts', // barrel files as export control + 'src/**/definition.assembled*.ts', // barrel files as object assemblers + 'src/**/definition.declared.augmentation.ts', // side-effect files for module augmentation + 'src/**/definition.declared.withAugmentation.ts', // barrel files as object augmenters + 'src/**/core.ts', // 3rd party facades + ], + reporter: [ + 'json-summary', // Used to generate list of units missing test companions + ], + }, }, }), ); diff --git a/vitest/Coverage/Summary/ByFilePath/Schema.ts b/vitest/Coverage/Summary/ByFilePath/Schema.ts new file mode 100644 index 000000000..1a88bbed4 --- /dev/null +++ b/vitest/Coverage/Summary/ByFilePath/Schema.ts @@ -0,0 +1,14 @@ +import Schema from 'zod'; + +import { + Coverage_Summary_ByLanguageConstruct, +} from '../ByLanguageConstruct'; + +const Coverage_Summary_ByFilePath_Schema = Schema.record( + Schema.string(), + Coverage_Summary_ByLanguageConstruct.Schema, +); + +export { + Coverage_Summary_ByFilePath_Schema, +}; diff --git a/vitest/Coverage/Summary/ByFilePath/definition.declared.ts b/vitest/Coverage/Summary/ByFilePath/definition.declared.ts new file mode 100644 index 000000000..cfb409f53 --- /dev/null +++ b/vitest/Coverage/Summary/ByFilePath/definition.declared.ts @@ -0,0 +1,15 @@ +import type Schema from 'zod'; + +import { + Coverage_Summary_ByFilePath_Schema, +} from './Schema'; + +type Coverage_Summary_ByFilePath = Schema.infer; + +const Coverage_Summary_ByFilePath = { + Schema: Coverage_Summary_ByFilePath_Schema, +}; + +export { + Coverage_Summary_ByFilePath, +}; diff --git a/vitest/Coverage/Summary/ByFilePath/exports.object.primary.ts b/vitest/Coverage/Summary/ByFilePath/exports.object.primary.ts new file mode 100644 index 000000000..0751b4162 --- /dev/null +++ b/vitest/Coverage/Summary/ByFilePath/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.declared.ts'; diff --git a/vitest/Coverage/Summary/ByFilePath/index.ts b/vitest/Coverage/Summary/ByFilePath/index.ts new file mode 100644 index 000000000..70dad59f8 --- /dev/null +++ b/vitest/Coverage/Summary/ByFilePath/index.ts @@ -0,0 +1 @@ +export * from './exports.object.primary.ts'; diff --git a/vitest/Coverage/Summary/ByLanguageConstruct/Schema.ts b/vitest/Coverage/Summary/ByLanguageConstruct/Schema.ts new file mode 100644 index 000000000..354d72e45 --- /dev/null +++ b/vitest/Coverage/Summary/ByLanguageConstruct/Schema.ts @@ -0,0 +1,16 @@ +import Schema from 'zod'; + +import { + Coverage_Summary_ByMetric, +} from '../ByMetric'; + +const Coverage_Summary_ByLanguageConstruct_Schema = Schema.object({ + lines : Coverage_Summary_ByMetric.Schema, + functions : Coverage_Summary_ByMetric.Schema, + statements: Coverage_Summary_ByMetric.Schema, + branches : Coverage_Summary_ByMetric.Schema, +}); + +export { + Coverage_Summary_ByLanguageConstruct_Schema, +}; diff --git a/vitest/Coverage/Summary/ByLanguageConstruct/definition.declared.ts b/vitest/Coverage/Summary/ByLanguageConstruct/definition.declared.ts new file mode 100644 index 000000000..8cb402418 --- /dev/null +++ b/vitest/Coverage/Summary/ByLanguageConstruct/definition.declared.ts @@ -0,0 +1,15 @@ +import type Schema from 'zod'; + +import { + Coverage_Summary_ByLanguageConstruct_Schema, +} from './Schema'; + +type Coverage_Summary_ByLanguageConstruct = Schema.infer; + +const Coverage_Summary_ByLanguageConstruct = { + Schema: Coverage_Summary_ByLanguageConstruct_Schema, +}; + +export { + Coverage_Summary_ByLanguageConstruct, +}; diff --git a/vitest/Coverage/Summary/ByLanguageConstruct/exports.object.primary.ts b/vitest/Coverage/Summary/ByLanguageConstruct/exports.object.primary.ts new file mode 100644 index 000000000..0751b4162 --- /dev/null +++ b/vitest/Coverage/Summary/ByLanguageConstruct/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.declared.ts'; diff --git a/vitest/Coverage/Summary/ByLanguageConstruct/index.ts b/vitest/Coverage/Summary/ByLanguageConstruct/index.ts new file mode 100644 index 000000000..70dad59f8 --- /dev/null +++ b/vitest/Coverage/Summary/ByLanguageConstruct/index.ts @@ -0,0 +1 @@ +export * from './exports.object.primary.ts'; diff --git a/vitest/Coverage/Summary/ByMetric/Schema.ts b/vitest/Coverage/Summary/ByMetric/Schema.ts new file mode 100644 index 000000000..b25187332 --- /dev/null +++ b/vitest/Coverage/Summary/ByMetric/Schema.ts @@ -0,0 +1,15 @@ +import Schema from 'zod'; + +const Coverage_Summary_ByMetric_Schema = Schema.record( + Schema.enum([ + 'total', + 'covered', + 'skipped', + 'pct', + ]), + Schema.number(), +); + +export { + Coverage_Summary_ByMetric_Schema, +}; diff --git a/vitest/Coverage/Summary/ByMetric/definition.declared.ts b/vitest/Coverage/Summary/ByMetric/definition.declared.ts new file mode 100644 index 000000000..afcd9b508 --- /dev/null +++ b/vitest/Coverage/Summary/ByMetric/definition.declared.ts @@ -0,0 +1,15 @@ +import type Schema from 'zod'; + +import { + Coverage_Summary_ByMetric_Schema, +} from './Schema'; + +type Coverage_Summary_ByMetric = Schema.infer; + +const Coverage_Summary_ByMetric = { + Schema: Coverage_Summary_ByMetric_Schema, +}; + +export { + Coverage_Summary_ByMetric, +}; diff --git a/vitest/Coverage/Summary/ByMetric/exports.object.primary.ts b/vitest/Coverage/Summary/ByMetric/exports.object.primary.ts new file mode 100644 index 000000000..0751b4162 --- /dev/null +++ b/vitest/Coverage/Summary/ByMetric/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.declared.ts'; diff --git a/vitest/Coverage/Summary/ByMetric/index.ts b/vitest/Coverage/Summary/ByMetric/index.ts new file mode 100644 index 000000000..70dad59f8 --- /dev/null +++ b/vitest/Coverage/Summary/ByMetric/index.ts @@ -0,0 +1 @@ +export * from './exports.object.primary.ts'; diff --git a/vitest/Coverage/Summary/File/defaultPathRelativeToProjectRoot.ts b/vitest/Coverage/Summary/File/defaultPathRelativeToProjectRoot.ts new file mode 100644 index 000000000..5af4a7290 --- /dev/null +++ b/vitest/Coverage/Summary/File/defaultPathRelativeToProjectRoot.ts @@ -0,0 +1,5 @@ +const Coverage_Summary_File_defaultPathRelativeToProjectRoot = './coverage/coverage-summary.json'; + +export { + Coverage_Summary_File_defaultPathRelativeToProjectRoot, +}; diff --git a/vitest/Coverage/Summary/File/definition.assembled.members.ts b/vitest/Coverage/Summary/File/definition.assembled.members.ts new file mode 100644 index 000000000..f1bfc26e8 --- /dev/null +++ b/vitest/Coverage/Summary/File/definition.assembled.members.ts @@ -0,0 +1,3 @@ +export { + Coverage_Summary_File_find as find, +} from './find'; diff --git a/vitest/Coverage/Summary/File/definition.assembled.ts b/vitest/Coverage/Summary/File/definition.assembled.ts new file mode 100644 index 000000000..41864cee7 --- /dev/null +++ b/vitest/Coverage/Summary/File/definition.assembled.ts @@ -0,0 +1 @@ +export * as Coverage_Summary_File from './definition.assembled.members.ts'; diff --git a/vitest/Coverage/Summary/File/exports.object.primary.ts b/vitest/Coverage/Summary/File/exports.object.primary.ts new file mode 100644 index 000000000..8ac7983ac --- /dev/null +++ b/vitest/Coverage/Summary/File/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.assembled.ts'; diff --git a/vitest/Coverage/Summary/File/find.ts b/vitest/Coverage/Summary/File/find.ts new file mode 100644 index 000000000..2db22f2f7 --- /dev/null +++ b/vitest/Coverage/Summary/File/find.ts @@ -0,0 +1,31 @@ +import File from '../../../../@internal/File'; +import FilesystemPath from '../../../../@internal/FilesystemPath'; + +import { + Coverage_Summary_File_defaultPathRelativeToProjectRoot, +} from './defaultPathRelativeToProjectRoot'; + +const Coverage_Summary_File_find = ( + { + withRoot: givenProjectRoot, + at: givenPathFromSummaryFileToProjectRoot = Coverage_Summary_File_defaultPathRelativeToProjectRoot, + }: { + withRoot: FilesystemPath; + at?: string; + }, +): FilesystemPath | null => { + const pathToCoverageSummary = FilesystemPath.resolved({ + from: givenProjectRoot, + to : givenPathFromSummaryFileToProjectRoot, + }); + + if ( + File.doesExistAt(pathToCoverageSummary) + ) return pathToCoverageSummary; + + return null; +}; + +export { + Coverage_Summary_File_find, +}; diff --git a/vitest/Coverage/Summary/File/index.ts b/vitest/Coverage/Summary/File/index.ts new file mode 100644 index 000000000..70dad59f8 --- /dev/null +++ b/vitest/Coverage/Summary/File/index.ts @@ -0,0 +1 @@ +export * from './exports.object.primary.ts'; diff --git a/vitest/Coverage/Summary/Mixed/Schema.ts b/vitest/Coverage/Summary/Mixed/Schema.ts new file mode 100644 index 000000000..e86db93e3 --- /dev/null +++ b/vitest/Coverage/Summary/Mixed/Schema.ts @@ -0,0 +1,35 @@ +import Schema from 'zod'; + +import { + Coverage_Summary_ByLanguageConstruct, +} from '../ByLanguageConstruct'; + +import { + Coverage_Summary_Mixed_keyForAggregate, +} from './keyForAggregate'; + +const Coverage_Summary_Mixed_Schema = Schema + .record( + Schema.string(), + Coverage_Summary_ByLanguageConstruct.Schema, + ) + .transform((someImplicitlyMixedRecord) => { + const { + [Coverage_Summary_Mixed_keyForAggregate]: aggregateSummary, + ...filePathRecord + } = someImplicitlyMixedRecord; + + const someExplicitlyMixedRecord: { + [Coverage_Summary_Mixed_keyForAggregate]: Coverage_Summary_ByLanguageConstruct; // TODO this is missing the "branchTrue" key + [filePath: string]: Coverage_Summary_ByLanguageConstruct; + } = { + [Coverage_Summary_Mixed_keyForAggregate]: aggregateSummary, + ...filePathRecord, + }; + + return someExplicitlyMixedRecord; + }); + +export { + Coverage_Summary_Mixed_Schema, +}; diff --git a/vitest/Coverage/Summary/Mixed/definition.declared.ts b/vitest/Coverage/Summary/Mixed/definition.declared.ts new file mode 100644 index 000000000..f85bedfc1 --- /dev/null +++ b/vitest/Coverage/Summary/Mixed/definition.declared.ts @@ -0,0 +1,20 @@ +import type Schema from 'zod'; + +import { + Coverage_Summary_Mixed_Schema, +} from './Schema'; + +import { + Coverage_Summary_Mixed_keyForAggregate, +} from './keyForAggregate'; + +type Coverage_Summary_Mixed = Schema.infer; + +const Coverage_Summary_Mixed = Object.freeze({ + Schema : Coverage_Summary_Mixed_Schema, + keyForAggregate: Coverage_Summary_Mixed_keyForAggregate, +}); + +export { + Coverage_Summary_Mixed, +}; diff --git a/vitest/Coverage/Summary/Mixed/exports.object.primary.ts b/vitest/Coverage/Summary/Mixed/exports.object.primary.ts new file mode 100644 index 000000000..0751b4162 --- /dev/null +++ b/vitest/Coverage/Summary/Mixed/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.declared.ts'; diff --git a/vitest/Coverage/Summary/Mixed/index.ts b/vitest/Coverage/Summary/Mixed/index.ts new file mode 100644 index 000000000..70dad59f8 --- /dev/null +++ b/vitest/Coverage/Summary/Mixed/index.ts @@ -0,0 +1 @@ +export * from './exports.object.primary.ts'; diff --git a/vitest/Coverage/Summary/Mixed/keyForAggregate.ts b/vitest/Coverage/Summary/Mixed/keyForAggregate.ts new file mode 100644 index 000000000..114f6b9aa --- /dev/null +++ b/vitest/Coverage/Summary/Mixed/keyForAggregate.ts @@ -0,0 +1,5 @@ +const Coverage_Summary_Mixed_keyForAggregate = 'total'; + +export { + Coverage_Summary_Mixed_keyForAggregate, +}; diff --git a/vitest/Coverage/Summary/Schema.ts b/vitest/Coverage/Summary/Schema.ts new file mode 100644 index 000000000..08148b748 --- /dev/null +++ b/vitest/Coverage/Summary/Schema.ts @@ -0,0 +1,22 @@ +import { + Coverage_Summary_Mixed, +} from './Mixed'; + +const Coverage_Summary_Schema = Coverage_Summary_Mixed.Schema + .transform((someMixedRecord) => { + const { + [Coverage_Summary_Mixed.keyForAggregate]: aggregateSummary, + ...summaryByFile + } = someMixedRecord; + + const someSplitRecord = { + aggregate : aggregateSummary, + byFilePath: summaryByFile, + }; + + return someSplitRecord; + }); + +export { + Coverage_Summary_Schema, +}; diff --git a/vitest/Coverage/Summary/definition.declared.augmentation.ts b/vitest/Coverage/Summary/definition.declared.augmentation.ts new file mode 100644 index 000000000..3b84ec1e1 --- /dev/null +++ b/vitest/Coverage/Summary/definition.declared.augmentation.ts @@ -0,0 +1,41 @@ +import { + Coverage_Summary_ByFilePath, +} from './ByFilePath'; + +import { + Coverage_Summary_ByLanguageConstruct, +} from './ByLanguageConstruct'; + +import { + Coverage_Summary_ByMetric, +} from './ByMetric'; + +import { + Coverage_Summary_File, +} from './File'; + +import { + Coverage_Summary, +} from './definition.declared.ts'; + +import { + Coverage_Summary_parsedFrom, +} from './parsedFrom'; + +Coverage_Summary.parsedFrom /* */ = Coverage_Summary_parsedFrom; +Coverage_Summary.File /* */ = Coverage_Summary_File; +Coverage_Summary.ByFilePath /* */ = Coverage_Summary_ByFilePath; +Coverage_Summary.ByLanguageConstruct = Coverage_Summary_ByLanguageConstruct; +Coverage_Summary.ByMetric /* */ = Coverage_Summary_ByMetric; + +declare module './definition.declared.ts' { + namespace Coverage_Summary { + export { + Coverage_Summary_parsedFrom /* */ as parsedFrom, + Coverage_Summary_File /* */ as File, + Coverage_Summary_ByFilePath /* */ as ByFilePath, + Coverage_Summary_ByLanguageConstruct as ByLanguageConstruct, + Coverage_Summary_ByMetric /* */ as ByMetric, + }; + } +} diff --git a/vitest/Coverage/Summary/definition.declared.ts b/vitest/Coverage/Summary/definition.declared.ts new file mode 100644 index 000000000..0f3abaeee --- /dev/null +++ b/vitest/Coverage/Summary/definition.declared.ts @@ -0,0 +1,21 @@ +import type Schema from 'zod'; + +import type { + Coverage_Summary_Schema, +} from './Schema'; + +type Coverage_Summary = Schema.infer; + +function Coverage_Summary( + namespaceOnly: never = (() => { + throw new Error(// eslint-disable-line no-restricted-syntax -- 'library/Attempt' is not accessible here. + `Unexpected call of module augmentation provision for "${Coverage_Summary.name}".`, + ); + })(), +) { + return namespaceOnly; +} + +export { + Coverage_Summary, +}; diff --git a/vitest/Coverage/Summary/definition.declared.withAugmentation.ts b/vitest/Coverage/Summary/definition.declared.withAugmentation.ts new file mode 100644 index 000000000..f11cfa9fa --- /dev/null +++ b/vitest/Coverage/Summary/definition.declared.withAugmentation.ts @@ -0,0 +1,3 @@ +import './definition.declared.augmentation.ts'; + +export * from './definition.declared.ts'; diff --git a/vitest/Coverage/Summary/exports.object.primary.ts b/vitest/Coverage/Summary/exports.object.primary.ts new file mode 100644 index 000000000..52e0e7aef --- /dev/null +++ b/vitest/Coverage/Summary/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.declared.withAugmentation.ts'; diff --git a/vitest/Coverage/Summary/index.ts b/vitest/Coverage/Summary/index.ts new file mode 100644 index 000000000..70dad59f8 --- /dev/null +++ b/vitest/Coverage/Summary/index.ts @@ -0,0 +1 @@ +export * from './exports.object.primary.ts'; diff --git a/vitest/Coverage/Summary/parsedFrom.ts b/vitest/Coverage/Summary/parsedFrom.ts new file mode 100644 index 000000000..00f59f72c --- /dev/null +++ b/vitest/Coverage/Summary/parsedFrom.ts @@ -0,0 +1,37 @@ +import File from '../../../@internal/File'; +import type FilesystemPath from '../../../@internal/FilesystemPath'; + +import { + Coverage_Summary_Schema, +} from './Schema'; + +import type { + Coverage_Summary, +} from './definition.declared.ts'; + +const Coverage_Summary_parsedFrom = ( + givenPath: FilesystemPath, +): Coverage_Summary | null => { + if ( + givenPath.ext !== '.json' + ) return null; + + if ( + !File.doesExistAt(givenPath) + ) return null; + + const fileContentsInJSON = File.contentsReadFrom(givenPath); + const rawCoverageSummary = JSON.parse(fileContentsInJSON) as unknown; + const resultOfParsingCoverageSummary = Coverage_Summary_Schema.safeParse(rawCoverageSummary); + + if ( + !resultOfParsingCoverageSummary.success + ) return null; + + const parsedCoverageSummary = resultOfParsingCoverageSummary.data; + return parsedCoverageSummary; +}; + +export { + Coverage_Summary_parsedFrom, +}; diff --git a/vitest/Coverage/definition.assembled.members.ts b/vitest/Coverage/definition.assembled.members.ts new file mode 100644 index 000000000..e2fcdd921 --- /dev/null +++ b/vitest/Coverage/definition.assembled.members.ts @@ -0,0 +1,3 @@ +export { + Coverage_Summary as Summary, +} from './Summary'; diff --git a/vitest/Coverage/definition.assembled.ts b/vitest/Coverage/definition.assembled.ts new file mode 100644 index 000000000..e65ccbb9b --- /dev/null +++ b/vitest/Coverage/definition.assembled.ts @@ -0,0 +1 @@ +export * as Coverage from './definition.assembled.members.ts'; diff --git a/vitest/Coverage/exports.object.primary.ts b/vitest/Coverage/exports.object.primary.ts new file mode 100644 index 000000000..8ac7983ac --- /dev/null +++ b/vitest/Coverage/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.assembled.ts'; diff --git a/vitest/Coverage/index.ts b/vitest/Coverage/index.ts new file mode 100644 index 000000000..70dad59f8 --- /dev/null +++ b/vitest/Coverage/index.ts @@ -0,0 +1 @@ +export * from './exports.object.primary.ts'; diff --git a/vitest/TestCompanion/definition.assembled.members.ts b/vitest/TestCompanion/definition.assembled.members.ts new file mode 100644 index 000000000..90236a8bb --- /dev/null +++ b/vitest/TestCompanion/definition.assembled.members.ts @@ -0,0 +1,15 @@ +export { + TestCompanion_extension as extension, +} from './extension'; + +export { + TestCompanion_preExtensionSuffix as preExtensionSuffix, +} from './preExtensionSuffix'; + +export { + TestCompanion_pathAssumedFor as pathAssumedFor, +} from './pathAssumedFor'; + +export { + TestCompanion_doesExistFor as doesExistFor, +} from './doesExistFor'; diff --git a/vitest/TestCompanion/definition.assembled.ts b/vitest/TestCompanion/definition.assembled.ts new file mode 100644 index 000000000..b8a35999f --- /dev/null +++ b/vitest/TestCompanion/definition.assembled.ts @@ -0,0 +1 @@ +export * as TestCompanion from './definition.assembled.members.ts'; diff --git a/vitest/TestCompanion/doesExistFor.ts b/vitest/TestCompanion/doesExistFor.ts new file mode 100644 index 000000000..ed4de9d74 --- /dev/null +++ b/vitest/TestCompanion/doesExistFor.ts @@ -0,0 +1,22 @@ +import File from '../../@internal/File'; +import type FilesystemPath from '../../@internal/FilesystemPath'; + +import { + TestCompanion_pathAssumedFor, +} from './pathAssumedFor'; + +const TestCompanion_doesExistFor = ( + givenUnitPath: FilesystemPath, +): boolean => { + const pathToExpectedTestCompanion = TestCompanion_pathAssumedFor(givenUnitPath); + + if ( + pathToExpectedTestCompanion === null + ) throw Error('Only TypeScript files are considered for test companions'); // eslint-disable-line no-restricted-syntax + + return File.doesExistAt(pathToExpectedTestCompanion); +}; + +export { + TestCompanion_doesExistFor, +}; diff --git a/vitest/TestCompanion/exports.object.primary.ts b/vitest/TestCompanion/exports.object.primary.ts new file mode 100644 index 000000000..8ac7983ac --- /dev/null +++ b/vitest/TestCompanion/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.assembled.ts'; diff --git a/vitest/TestCompanion/extension.ts b/vitest/TestCompanion/extension.ts new file mode 100644 index 000000000..7eb22f935 --- /dev/null +++ b/vitest/TestCompanion/extension.ts @@ -0,0 +1,5 @@ +const TestCompanion_extension = '.ts'; + +export { + TestCompanion_extension, +}; diff --git a/vitest/TestCompanion/index.ts b/vitest/TestCompanion/index.ts new file mode 100644 index 000000000..70dad59f8 --- /dev/null +++ b/vitest/TestCompanion/index.ts @@ -0,0 +1 @@ +export * from './exports.object.primary.ts'; diff --git a/vitest/TestCompanion/pathAssumedFor.ts b/vitest/TestCompanion/pathAssumedFor.ts new file mode 100644 index 000000000..f6a5adbdd --- /dev/null +++ b/vitest/TestCompanion/pathAssumedFor.ts @@ -0,0 +1,27 @@ +import FilesystemPath from '../../@internal/FilesystemPath'; + +import { + TestCompanion_extension, +} from './extension'; + +import { + TestCompanion_preExtensionSuffix, +} from './preExtensionSuffix'; + +const TestCompanion_pathAssumedFor = ( + givenUnit: FilesystemPath, +): FilesystemPath | null => { + if ( + givenUnit.ext !== TestCompanion_extension + ) return null; + + const potentialTestCompanion = FilesystemPath.from(givenUnit, { + name: $0 => $0 + TestCompanion_preExtensionSuffix, + }); + + return potentialTestCompanion; +}; + +export { + TestCompanion_pathAssumedFor, +}; diff --git a/vitest/TestCompanion/preExtensionSuffix.ts b/vitest/TestCompanion/preExtensionSuffix.ts new file mode 100644 index 000000000..f69a08afb --- /dev/null +++ b/vitest/TestCompanion/preExtensionSuffix.ts @@ -0,0 +1,5 @@ +const TestCompanion_preExtensionSuffix = '.test'; + +export { + TestCompanion_preExtensionSuffix, +}; diff --git a/vitest/TestableUnit/allThoseMissingCompanionAccordingTo.ts b/vitest/TestableUnit/allThoseMissingCompanionAccordingTo.ts new file mode 100644 index 000000000..ea7d89681 --- /dev/null +++ b/vitest/TestableUnit/allThoseMissingCompanionAccordingTo.ts @@ -0,0 +1,28 @@ +import FilesystemPath from '../../@internal/FilesystemPath'; + +import type { + Coverage, +} from '../Coverage'; + +import { + TestCompanion, +} from '../TestCompanion'; + +const TestableUnit_allThoseMissingCompanionAccordingTo = ( + givenSummary: Coverage.Summary, +): FilesystemPath[] => { + const pathsToUnitsThatHaveNoTestImplementation = Object.entries(givenSummary.byFilePath) + .filter(([, eachMetric]) => { + return (0 === eachMetric.functions.pct) + || (0 === eachMetric.statements.pct); + }) + .map(([$0]) => $0); + + const unitsThatHaveNoTestImplementation = pathsToUnitsThatHaveNoTestImplementation.map(FilesystemPath.parsedFrom); + const unitsThatHaveNoTestCompanion = unitsThatHaveNoTestImplementation.filter($0 => !TestCompanion.doesExistFor($0)); + return unitsThatHaveNoTestCompanion; +}; + +export { + TestableUnit_allThoseMissingCompanionAccordingTo, +}; diff --git a/vitest/TestableUnit/definition.assembled.members.ts b/vitest/TestableUnit/definition.assembled.members.ts new file mode 100644 index 000000000..6c64c971b --- /dev/null +++ b/vitest/TestableUnit/definition.assembled.members.ts @@ -0,0 +1,7 @@ +export { + TestableUnit_getAllThatAreMissingTestCompanion as getAllThatAreMissingTestCompanion, +} from './getAllThatAreMissingTestCompanion'; + +export { + TestableUnit_allThoseMissingCompanionAccordingTo as allThoseMissingCompanionAccordingTo, +} from './allThoseMissingCompanionAccordingTo'; diff --git a/vitest/TestableUnit/definition.assembled.ts b/vitest/TestableUnit/definition.assembled.ts new file mode 100644 index 000000000..b9ce8af04 --- /dev/null +++ b/vitest/TestableUnit/definition.assembled.ts @@ -0,0 +1 @@ +export * as TestableUnit from './definition.assembled.members.ts'; diff --git a/vitest/TestableUnit/exports.object.primary.ts b/vitest/TestableUnit/exports.object.primary.ts new file mode 100644 index 000000000..8ac7983ac --- /dev/null +++ b/vitest/TestableUnit/exports.object.primary.ts @@ -0,0 +1 @@ +export * from './definition.assembled.ts'; diff --git a/vitest/TestableUnit/getAllThatAreMissingTestCompanion.ts b/vitest/TestableUnit/getAllThatAreMissingTestCompanion.ts new file mode 100644 index 000000000..0d91a5dfb --- /dev/null +++ b/vitest/TestableUnit/getAllThatAreMissingTestCompanion.ts @@ -0,0 +1,42 @@ +import { + toPath, +} from '../../@internal/File'; + +import type FilesystemPath from '../../@internal/FilesystemPath'; + +import { + Coverage, +} from '../Coverage'; + +import { + TestableUnit_allThoseMissingCompanionAccordingTo, +} from './allThoseMissingCompanionAccordingTo'; + +const TestableUnit_getAllThatAreMissingTestCompanion = (): FilesystemPath[] | null => { + const pathFromCurrentModuleToProjectRoot = '../../../'; + + const projectRoot = toPath(new URL( + pathFromCurrentModuleToProjectRoot, + import.meta.url, + )); + + const fileFoundForCoverageSummary = Coverage.Summary.File.find({ + withRoot: projectRoot, + }); + + if ( + fileFoundForCoverageSummary === null + ) return null; + + const parsedCoverageSummary = Coverage.Summary.parsedFrom(fileFoundForCoverageSummary); + + if ( + parsedCoverageSummary === null + ) return null; + + return TestableUnit_allThoseMissingCompanionAccordingTo(parsedCoverageSummary); +}; + +export { + TestableUnit_getAllThatAreMissingTestCompanion, +}; diff --git a/vitest/TestableUnit/index.ts b/vitest/TestableUnit/index.ts new file mode 100644 index 000000000..70dad59f8 --- /dev/null +++ b/vitest/TestableUnit/index.ts @@ -0,0 +1 @@ +export * from './exports.object.primary.ts'; diff --git a/vitest/ensure-all-units-have-test-companion.ts b/vitest/ensure-all-units-have-test-companion.ts new file mode 100644 index 000000000..cda0112ee --- /dev/null +++ b/vitest/ensure-all-units-have-test-companion.ts @@ -0,0 +1,37 @@ +import { + toPath, +} from '../@internal/File'; + +import { + serialized, +} from '../@internal/FilesystemPath'; + +import { + TestableUnit, +} from './TestableUnit'; + +const unitsWithoutCompanions = TestableUnit.getAllThatAreMissingTestCompanion(); + +if (unitsWithoutCompanions === null) { + console.error('Could not determine which units are missing test companions'); + process.exit(1); +} +else if ( + unitsWithoutCompanions.length === 0 +) process.exit(0); + +const pathFromCurrentModuleToProjectRoot = '../'; + +const projectRoot = toPath(new URL( + pathFromCurrentModuleToProjectRoot, + import.meta.url, +)); + +const absolutePathToProjectRoot = serialized(projectRoot); + +const serializedUnitsWithoutCompanions = unitsWithoutCompanions + .map($0 => serialized($0).replace(absolutePathToProjectRoot, '')) + .join('\n'); + +console.error(`The following ${unitsWithoutCompanions.length.toString()} unit(s) have no test companion:\n${serializedUnitsWithoutCompanions}`); +process.exit(1);