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..c2e06e61e --- /dev/null +++ b/@internal/FilesystemPath/definition.declared.ts @@ -0,0 +1,21 @@ +import type { + ParsedPath, +} from 'node:path'; + +import { + Effect, +} from 'effect'; + +type FilesystemPath = ParsedPath; + +function FilesystemPath( + namespaceOnly: never = Effect.die( + () => new Error(`Unexpected call of module augmentation provision for ${FilesystemPath.name}.`), + ).pipe(Effect.runSync), +): never { + 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..071560e76 --- /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 bbd6cad73..5f25e1b6a 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..0f386674e --- /dev/null +++ b/scripts/stubbed-test-suites/main.ts @@ -0,0 +1,79 @@ +import Directory from '../../@internal/Directory'; +import File from '../../@internal/File'; +import FilesystemPath from '../../@internal/FilesystemPath'; + +interface TestCompanionCreationResult { + created: boolean; + pathForTestFile: string; +} + +// take a given file path and generate a stub for a test suite +function createAndSaveTestCompanionForUnitWith( + givenAbsolutePath: string, +): TestCompanionCreationResult { + 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 50dd89ca6..8ff9caf19 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -3,8 +3,11 @@ "include": [ "*.config.*", "eslint.*", + "@internal/**/*", + "vitest/**/*", "cypress/eslint.config.*", "cypress/package.d.ts", + "scripts/**/*" ], "compilerOptions": { "noEmit": true, @@ -15,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.ts b/vitest/Coverage/Summary/ByFilePath.ts new file mode 100644 index 000000000..86db365fb --- /dev/null +++ b/vitest/Coverage/Summary/ByFilePath.ts @@ -0,0 +1,18 @@ +import { + Schema, +} from 'effect'; + +import { + Coverage_Summary_ByLanguageConstruct, +} from './ByLanguageConstruct'; + +const Coverage_Summary_ByFilePath = Schema.Record({ + key : Schema.String, + value: Coverage_Summary_ByLanguageConstruct, +}); + +type Coverage_Summary_ByFilePath = typeof Coverage_Summary_ByFilePath.Type; + +export { + Coverage_Summary_ByFilePath, +}; diff --git a/vitest/Coverage/Summary/ByLanguageConstruct.ts b/vitest/Coverage/Summary/ByLanguageConstruct.ts new file mode 100644 index 000000000..92e89c655 --- /dev/null +++ b/vitest/Coverage/Summary/ByLanguageConstruct.ts @@ -0,0 +1,23 @@ +import { + Schema, +} from 'effect'; + +import { + Coverage_Summary_ByMetric, +} from './ByMetric'; + +class Coverage_Summary_ByLanguageConstruct + extends Schema.Class< + Coverage_Summary_ByLanguageConstruct + >( + 'Coverage_Summary_ByLanguageConstruct', + )({ + lines : Coverage_Summary_ByMetric, + functions : Coverage_Summary_ByMetric, + statements: Coverage_Summary_ByMetric, + branches : Coverage_Summary_ByMetric, + }) {} + +export { + Coverage_Summary_ByLanguageConstruct, +}; diff --git a/vitest/Coverage/Summary/ByMetric.ts b/vitest/Coverage/Summary/ByMetric.ts new file mode 100644 index 000000000..338d313bc --- /dev/null +++ b/vitest/Coverage/Summary/ByMetric.ts @@ -0,0 +1,19 @@ +import { + Schema, +} from 'effect'; + +class Coverage_Summary_ByMetric + extends Schema.Class< + Coverage_Summary_ByMetric + >( + 'Coverage_Summary_ByMetric', + )({ + total : Schema.Number, + covered: Schema.Number, + skipped: Schema.Number, + pct : Schema.Number, + }) {} + +export { + Coverage_Summary_ByMetric, +}; 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..8af6c8298 --- /dev/null +++ b/vitest/Coverage/Summary/File/find.ts @@ -0,0 +1,35 @@ +import { + Effect, +} from 'effect'; + +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; + }, +): Effect.Effect => Effect.gen(function* () { + const pathToCoverageSummary = FilesystemPath.resolved({ + from: givenProjectRoot, + to : givenPathFromSummaryFileToProjectRoot, + }); + + if ( + File.doesExistAt(pathToCoverageSummary) + ) return pathToCoverageSummary; + + return yield* Effect.fail(new Error(`No coverage summary file found at ${pathToCoverageSummary.root + pathToCoverageSummary.name}`)); +}); + +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/FromMixed.ts b/vitest/Coverage/Summary/FromMixed.ts new file mode 100644 index 000000000..74c998fc6 --- /dev/null +++ b/vitest/Coverage/Summary/FromMixed.ts @@ -0,0 +1,39 @@ +import { + Schema, +} from 'effect'; + +import { + Coverage_Summary_Mixed, +} from './Mixed'; + +import { + Coverage_Summary, +} from './definition.declared.ts'; + +const Coverage_Summary_FromMixed = Schema.transform( + Coverage_Summary_Mixed.Schema, + Coverage_Summary, + { + decode: (someMixedRecord) => { + const { + [Coverage_Summary_Mixed.keyForAggregate]: aggregateSummary, // TODO this is missing the "branchTrue" key + ...summaryByFile + } = someMixedRecord; + + return { + aggregate : aggregateSummary, + byFilePath: summaryByFile, + }; + }, + encode: ($0) => { + return { + [Coverage_Summary_Mixed.keyForAggregate]: $0.aggregate, + ...$0.byFilePath, + }; + }, + }, +); + +export { + Coverage_Summary_FromMixed, +}; diff --git a/vitest/Coverage/Summary/Mixed/Schema.ts b/vitest/Coverage/Summary/Mixed/Schema.ts new file mode 100644 index 000000000..1c35f1d44 --- /dev/null +++ b/vitest/Coverage/Summary/Mixed/Schema.ts @@ -0,0 +1,16 @@ +import { + Schema, +} from 'effect'; + +import { + Coverage_Summary_ByLanguageConstruct, +} from '../ByLanguageConstruct'; + +const Coverage_Summary_Mixed_Schema = Schema.Record({ + key : Schema.String, + value: Coverage_Summary_ByLanguageConstruct, +}); + +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..722c1fde9 --- /dev/null +++ b/vitest/Coverage/Summary/Mixed/definition.declared.ts @@ -0,0 +1,18 @@ +import { + Coverage_Summary_Mixed_Schema, +} from './Schema'; + +import { + Coverage_Summary_Mixed_keyForAggregate, +} from './keyForAggregate'; + +type Coverage_Summary_Mixed = typeof Coverage_Summary_Mixed_Schema.Type; + +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/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..e767c0f62 --- /dev/null +++ b/vitest/Coverage/Summary/definition.declared.ts @@ -0,0 +1,25 @@ +import { + Schema, +} from 'effect'; + +import { + Coverage_Summary_ByFilePath, +} from './ByFilePath'; + +import { + Coverage_Summary_ByLanguageConstruct, +} from './ByLanguageConstruct'; + +class Coverage_Summary + extends Schema.Class< + Coverage_Summary + >( + 'Coverage_Summary', + )({ + aggregate : Coverage_Summary_ByLanguageConstruct, + byFilePath: Coverage_Summary_ByFilePath, + }) {} + +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..1b7a4ca9e --- /dev/null +++ b/vitest/Coverage/Summary/parsedFrom.ts @@ -0,0 +1,36 @@ +import { + Effect, + Schema, +} from 'effect'; + +import File from '../../../@internal/File'; +import type FilesystemPath from '../../../@internal/FilesystemPath'; + +import { + Coverage_Summary_FromMixed, +} from './FromMixed'; + +import type { + Coverage_Summary, +} from './definition.declared.ts'; + +const Coverage_Summary_parsedFrom = ( + givenPath: FilesystemPath, +): Effect.Effect => Effect.gen(function* () { + if ( + givenPath.ext !== '.json' + ) return yield* Effect.fail(new Error('Path is not a JSON file')); + + if ( + !File.doesExistAt(givenPath) + ) return yield* Effect.fail(new Error('File does not exist')); + + const fileContentsInJSON = File.contentsReadFrom(givenPath); + const decodeCoverageSummaryFrom = Schema.decodeUnknown(Schema.parseJson(Coverage_Summary_FromMixed)); + const decodedCoverageSummary = yield* decodeCoverageSummaryFrom(fileContentsInJSON); + return decodedCoverageSummary; +}); + +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..0c2c59829 --- /dev/null +++ b/vitest/TestCompanion/doesExistFor.ts @@ -0,0 +1,21 @@ +import { + Effect, +} from 'effect'; + +import File from '../../@internal/File'; +import type FilesystemPath from '../../@internal/FilesystemPath'; + +import { + TestCompanion_pathAssumedFor, +} from './pathAssumedFor'; + +const TestCompanion_doesExistFor = ( + givenUnitPath: FilesystemPath, +): Effect.Effect => Effect.gen(function* () { + const pathToExpectedTestCompanion = yield* TestCompanion_pathAssumedFor(givenUnitPath); + 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..ceda2c80d --- /dev/null +++ b/vitest/TestCompanion/pathAssumedFor.ts @@ -0,0 +1,31 @@ +import { + Effect, +} from 'effect'; + +import FilesystemPath from '../../@internal/FilesystemPath'; + +import { + TestCompanion_extension, +} from './extension'; + +import { + TestCompanion_preExtensionSuffix, +} from './preExtensionSuffix'; + +const TestCompanion_pathAssumedFor = ( + givenUnit: FilesystemPath, +): Effect.Effect => Effect.gen(function* () { + if ( + givenUnit.ext !== TestCompanion_extension + ) return yield* Effect.fail(new Error('Only TypeScript files are considered for test companions')); + + 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..bd22ddcae --- /dev/null +++ b/vitest/TestableUnit/allThoseMissingCompanionAccordingTo.ts @@ -0,0 +1,32 @@ +import { + Effect, +} from 'effect'; + +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).pipe(Effect.runSync)); + 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..f0ab35cfd --- /dev/null +++ b/vitest/TestableUnit/getAllThatAreMissingTestCompanion.ts @@ -0,0 +1,38 @@ +import { + Effect, +} from 'effect'; + +import { + toPath, +} from '../../@internal/File'; + +import type FilesystemPath from '../../@internal/FilesystemPath'; + +import { + Coverage, +} from '../Coverage'; + +import { + TestableUnit_allThoseMissingCompanionAccordingTo, +} from './allThoseMissingCompanionAccordingTo'; + +const TestableUnit_getAllThatAreMissingTestCompanion: Effect.Effect = Effect.gen(function* () { + const pathFromCurrentModuleToProjectRoot = '../../../'; + + const projectRoot = toPath(new URL( + pathFromCurrentModuleToProjectRoot, + import.meta.url, + )); + + const fileFoundForCoverageSummary = yield* Coverage.Summary.File.find({ + withRoot: projectRoot, + }); + + const parsedCoverageSummary = yield* Coverage.Summary.parsedFrom(fileFoundForCoverageSummary); + + 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..b10aee652 --- /dev/null +++ b/vitest/ensure-all-units-have-test-companion.ts @@ -0,0 +1,43 @@ +import { + Effect, +} from 'effect'; + +import { + toPath, +} from '../@internal/File'; + +import { + serialized, +} from '../@internal/FilesystemPath'; + +import { + TestableUnit, +} from './TestableUnit'; + +const unitsWithoutCompanions = TestableUnit.getAllThatAreMissingTestCompanion.pipe( + Effect.mapError((caughtFailure) => { + console.error(caughtFailure); + process.exit(1); + }), + Effect.runSync, +); + +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);