diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 9e1937a3eba..cf280dc7bb2 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -44,6 +44,7 @@ *** xref:javascriptlib/module-config.adoc[] *** xref:javascriptlib/testing.adoc[] *** xref:javascriptlib/publishing.adoc[] +*** xref:javascriptlib/linting.adoc[] * xref:comparisons/why-mill.adoc[] ** xref:comparisons/maven.adoc[] ** xref:comparisons/gradle.adoc[] diff --git a/docs/modules/ROOT/pages/javascriptlib/linting.adoc b/docs/modules/ROOT/pages/javascriptlib/linting.adoc new file mode 100644 index 00000000000..cb089ce6af4 --- /dev/null +++ b/docs/modules/ROOT/pages/javascriptlib/linting.adoc @@ -0,0 +1,21 @@ += Linting Typescript Projects +:page-aliases: Linting_Typescript_Projects.adoc + +include::partial$gtag-config.adoc[] + +This page will discuss common topics around maintaining the code quality of Typescript +codebases using the Mill build tool + +== Linting and AutoFormatting with Eslint and Prettier + +Eslint and Prettier are tools that analyzes your Typescript source code, performing intelligent analyses and code quality checks, and is often able to automatically fix the issues that it discovers. It can also perform automated refactoring. + +include::partial$example/javascriptlib/linting/1-linting.adoc[] + +include::partial$example/javascriptlib/linting/2-autoformatting.adoc[] + +== Code Coverage with Jest, Mocha, Vitest and Jasmine + +Mill supports code coverage analysis with multiple Typescript testing frameworks. + +include::partial$example/javascriptlib/linting/3-code-coverage.adoc[] diff --git a/example/javascriptlib/basic/1-simple/jest.config.ts b/example/javascriptlib/basic/1-simple/jest.config.ts deleted file mode 100644 index eaa2b8e671f..00000000000 --- a/example/javascriptlib/basic/1-simple/jest.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {pathsToModuleNameMapper} from 'ts-jest'; -import {compilerOptions} from './tsconfig.json'; // this is a generated file. - -// Remove unwanted keys -const moduleDeps = {...compilerOptions.paths}; -delete moduleDeps['*']; -delete moduleDeps['typeRoots']; - -// moduleNameMapper evaluates in order they appear, -// sortedModuleDeps makes sure more specific path mappings always appear first -const sortedModuleDeps = Object.keys(moduleDeps) - .sort((a, b) => b.length - a.length) // Sort by descending length - .reduce((acc, key) => { - acc[key] = moduleDeps[key]; - return acc; - }, {}); - -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - '/**/**/**/*.test.ts', - '/**/**/**/*.test.js', - ], - transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json' }], - '^.+\\.(js|jsx)$': 'babel-jest', // Use babel-jest for JS/JSX files - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps) // use absolute paths generated in tsconfig. -}; \ No newline at end of file diff --git a/example/javascriptlib/basic/3-custom-build-logic/jest.config.ts b/example/javascriptlib/basic/3-custom-build-logic/jest.config.ts deleted file mode 100644 index eaa2b8e671f..00000000000 --- a/example/javascriptlib/basic/3-custom-build-logic/jest.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {pathsToModuleNameMapper} from 'ts-jest'; -import {compilerOptions} from './tsconfig.json'; // this is a generated file. - -// Remove unwanted keys -const moduleDeps = {...compilerOptions.paths}; -delete moduleDeps['*']; -delete moduleDeps['typeRoots']; - -// moduleNameMapper evaluates in order they appear, -// sortedModuleDeps makes sure more specific path mappings always appear first -const sortedModuleDeps = Object.keys(moduleDeps) - .sort((a, b) => b.length - a.length) // Sort by descending length - .reduce((acc, key) => { - acc[key] = moduleDeps[key]; - return acc; - }, {}); - -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - '/**/**/**/*.test.ts', - '/**/**/**/*.test.js', - ], - transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json' }], - '^.+\\.(js|jsx)$': 'babel-jest', // Use babel-jest for JS/JSX files - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps) // use absolute paths generated in tsconfig. -}; \ No newline at end of file diff --git a/example/javascriptlib/basic/4-multi-modules/jest.config.ts b/example/javascriptlib/basic/4-multi-modules/jest.config.ts deleted file mode 100644 index eaa2b8e671f..00000000000 --- a/example/javascriptlib/basic/4-multi-modules/jest.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {pathsToModuleNameMapper} from 'ts-jest'; -import {compilerOptions} from './tsconfig.json'; // this is a generated file. - -// Remove unwanted keys -const moduleDeps = {...compilerOptions.paths}; -delete moduleDeps['*']; -delete moduleDeps['typeRoots']; - -// moduleNameMapper evaluates in order they appear, -// sortedModuleDeps makes sure more specific path mappings always appear first -const sortedModuleDeps = Object.keys(moduleDeps) - .sort((a, b) => b.length - a.length) // Sort by descending length - .reduce((acc, key) => { - acc[key] = moduleDeps[key]; - return acc; - }, {}); - -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - '/**/**/**/*.test.ts', - '/**/**/**/*.test.js', - ], - transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json' }], - '^.+\\.(js|jsx)$': 'babel-jest', // Use babel-jest for JS/JSX files - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps) // use absolute paths generated in tsconfig. -}; \ No newline at end of file diff --git a/example/javascriptlib/basic/5-client-server-hello/jest.config.ts b/example/javascriptlib/basic/5-client-server-hello/jest.config.ts deleted file mode 100644 index eaa2b8e671f..00000000000 --- a/example/javascriptlib/basic/5-client-server-hello/jest.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {pathsToModuleNameMapper} from 'ts-jest'; -import {compilerOptions} from './tsconfig.json'; // this is a generated file. - -// Remove unwanted keys -const moduleDeps = {...compilerOptions.paths}; -delete moduleDeps['*']; -delete moduleDeps['typeRoots']; - -// moduleNameMapper evaluates in order they appear, -// sortedModuleDeps makes sure more specific path mappings always appear first -const sortedModuleDeps = Object.keys(moduleDeps) - .sort((a, b) => b.length - a.length) // Sort by descending length - .reduce((acc, key) => { - acc[key] = moduleDeps[key]; - return acc; - }, {}); - -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - '/**/**/**/*.test.ts', - '/**/**/**/*.test.js', - ], - transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json' }], - '^.+\\.(js|jsx)$': 'babel-jest', // Use babel-jest for JS/JSX files - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps) // use absolute paths generated in tsconfig. -}; \ No newline at end of file diff --git a/example/javascriptlib/basic/6-client-server-realistic/jest.config.ts b/example/javascriptlib/basic/6-client-server-realistic/jest.config.ts deleted file mode 100644 index eaa2b8e671f..00000000000 --- a/example/javascriptlib/basic/6-client-server-realistic/jest.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {pathsToModuleNameMapper} from 'ts-jest'; -import {compilerOptions} from './tsconfig.json'; // this is a generated file. - -// Remove unwanted keys -const moduleDeps = {...compilerOptions.paths}; -delete moduleDeps['*']; -delete moduleDeps['typeRoots']; - -// moduleNameMapper evaluates in order they appear, -// sortedModuleDeps makes sure more specific path mappings always appear first -const sortedModuleDeps = Object.keys(moduleDeps) - .sort((a, b) => b.length - a.length) // Sort by descending length - .reduce((acc, key) => { - acc[key] = moduleDeps[key]; - return acc; - }, {}); - -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - '/**/**/**/*.test.ts', - '/**/**/**/*.test.js', - ], - transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json' }], - '^.+\\.(js|jsx)$': 'babel-jest', // Use babel-jest for JS/JSX files - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps) // use absolute paths generated in tsconfig. -}; \ No newline at end of file diff --git a/example/javascriptlib/linting/1-linting/build.mill b/example/javascriptlib/linting/1-linting/build.mill new file mode 100644 index 00000000000..162413f8bcb --- /dev/null +++ b/example/javascriptlib/linting/1-linting/build.mill @@ -0,0 +1,43 @@ +package build + +import mill._, javascriptlib._ + +object foo extends TypeScriptModule with TsLintModule + +// Mill supports code linting via `eslint` https://eslint.org out of the box. +// You can lint your projects code by providing a configuration for eslint and running `mill _.checkFormatAll`. + +// You can define `npmLintDeps` field to add dependencies specific to linting to your module. +// The task `npmLintDeps` works the same way as `npmDeps` or `npmDevDeps`. + +/** Usage +> cat foo/src/foo.ts # initial code with lint errors. +export class Foo{ + static main( + args: string[ +]) { + console.log(`Hello World`); + } +} + +> mill foo.checkFormatAll # run eslint +... +foo/src/foo.ts + 3:5 error 'args' is defined but never used @typescript-eslint/no-unused-vars +... +...1 problem (1 error, 0 warnings) + +> sed -i.bak 's/Hello World/Hello World ${args.join('\'' '\'')}/' foo/src/foo.ts # fix lint error. + +> cat foo/src/foo.ts # initial code with lint errors. +export class Foo{ + static main( + args: string[ +]) { + console.log(`Hello World ${args.join(' ')}`); + } +} + +> mill foo.checkFormatAll # run eslint +All matched files use Eslint code style! +*/ diff --git a/example/javascriptlib/linting/1-linting/eslint.config.mjs b/example/javascriptlib/linting/1-linting/eslint.config.mjs new file mode 100644 index 00000000000..4a84a25b023 --- /dev/null +++ b/example/javascriptlib/linting/1-linting/eslint.config.mjs @@ -0,0 +1,32 @@ +// @ts-check + +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config({ + files: ['*/**/*.ts'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ], + rules: { + // styling rules + 'semi': ['error', 'always'], + 'comma-dangle': ['error', 'always-multiline'], + 'max-len': ['error', {code: 80, ignoreUrls: true}], + 'indent': ['error', 2, {SwitchCase: 1}], + 'brace-style': ['error', '1tbs'], + 'space-before-function-paren': ['error', 'never'], + 'no-multi-spaces': 'error', + 'array-bracket-spacing': ['error', 'never'], + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', {before: true, after: true}], + 'key-spacing': ['error', {beforeColon: false, afterColon: true}], + 'keyword-spacing': ['error', {before: true, after: true}], + 'space-infix-ops': 'error', + 'block-spacing': ['error', 'always'], + 'eol-last': ['error', 'always'], + 'newline-per-chained-call': ['error', {ignoreChainWithDepth: 2}], + 'padded-blocks': ['error', 'never'], + }, +}); \ No newline at end of file diff --git a/example/javascriptlib/linting/1-linting/foo/src/foo.ts b/example/javascriptlib/linting/1-linting/foo/src/foo.ts new file mode 100644 index 00000000000..5da7ade49ad --- /dev/null +++ b/example/javascriptlib/linting/1-linting/foo/src/foo.ts @@ -0,0 +1,7 @@ +export class Foo{ + static main( + args: string[ +]) { + console.log(`Hello World`); + } +} diff --git a/example/javascriptlib/linting/2-autoformatting/.prettierignore b/example/javascriptlib/linting/2-autoformatting/.prettierignore new file mode 100644 index 00000000000..4171269761e --- /dev/null +++ b/example/javascriptlib/linting/2-autoformatting/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +build +.git \ No newline at end of file diff --git a/example/javascriptlib/linting/2-autoformatting/.prettierrc b/example/javascriptlib/linting/2-autoformatting/.prettierrc new file mode 100644 index 00000000000..986beea4f82 --- /dev/null +++ b/example/javascriptlib/linting/2-autoformatting/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2 +} \ No newline at end of file diff --git a/example/javascriptlib/linting/2-autoformatting/build.mill b/example/javascriptlib/linting/2-autoformatting/build.mill new file mode 100644 index 00000000000..ee893e39609 --- /dev/null +++ b/example/javascriptlib/linting/2-autoformatting/build.mill @@ -0,0 +1,80 @@ +package build + +import mill._, javascriptlib._ + +object foo extends TypeScriptModule with TsLintModule + +// Mill supports code auto formatting via `eslint` https://eslint.org and `prettier` https://prettier.io/docs/en. +// You can reformat your projects code by providing a configuration for your preferred formtter then running +// `mill _.reformatAll` to reformat your code. You can also check for formatting errors by running +// `mill _.checkFormatAll`. + +// If both configuration files are present, the command `mill _.checkFormatAll` and +// `mill _.reformatAll` will default to eslint. If neither files are present, +// the command will exit with an error, you must include at least one configuration file. + +// When using prettier you can specify the path to check format or reformat via command line argument, +// `mill _.checkFormatAll "*/**/*.ts"` just as you would when running `prettier --check` or +// `mill _.reformatAll "*/**/*.ts"` just as you would when running `prettier --write`, +// if no path is provided mill will default to using "*/**/*.ts". +// +// Also if a `.prettierignore` is not provided, mill will generate one ignoring "node_modules" and ".git". + +/** Usage +> cat foo/src/foo.ts # initial poorly formatted source code +export class Foo{ +static main( +args: string[ +]) +{console.log("Hello World!") +} +} + +> mill foo.checkFormatAll # run linter - since both eslint and prettier configurations are present, mill will opt to use eslint. +... +foo/src/foo.ts + 2:1 error Expected indentation of 2 spaces but found 0 indent + 3:1 error Expected indentation of 4 spaces but found 0 indent + 5:1 error Opening curly brace does not appear on the same line as controlling statement brace-style + 5:1 error Statement inside of curly braces should be on next line brace-style + 5:1 error Requires a space after '{' block-spacing + 5:1 error Expected indentation of 2 spaces but found 0 indent + 5:14 error Strings must use singlequote quotes + 5:29 error Missing semicolon semi + 6:1 error Expected indentation of 2 spaces but found 0 indent + 7:2 error Newline required at end of file but not found eol-last +... +...10 problems (10 errors, 0 warnings) +...10 errors and 0 warnings potentially fixable with running foo.reformatAll. + +> mill foo.reformatAll +... +All matched files have been reformatted! + +> cat foo/src/foo.ts # code formatted with eslint configuration. +export class Foo{ + static main( + args: string[ +]) { + console.log('Hello World!'); + } +} + +> rm -rf eslint.config.mjs # since there is no eslint config file `eslint.config.(js|mjs|cjs)`, mill will use the prettier configuration available. + +> mill foo.checkFormatAll # run lint with prettier configuration. +Checking formatting... +[warn] foo/src/foo.ts +[warn] Code style issues found. Run foo.reformatAll to fix. + +> mill foo.reformatAll +... +All matched files have been reformatted! + +> cat foo/src/foo.ts # code formatted with prettier configuration. +export class Foo { + static main(args: string[]) { + console.log('Hello World!'); + } +} +*/ diff --git a/example/javascriptlib/linting/2-autoformatting/eslint.config.mjs b/example/javascriptlib/linting/2-autoformatting/eslint.config.mjs new file mode 100644 index 00000000000..913514ffdc3 --- /dev/null +++ b/example/javascriptlib/linting/2-autoformatting/eslint.config.mjs @@ -0,0 +1,34 @@ +// @ts-check + +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config({ + files: ['*/**/*.ts'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ], + rules: { + '@typescript-eslint/no-unused-vars': 'off', + // styling rules + 'semi': ['error', 'always'], + 'quotes': ['error', 'single'], + 'comma-dangle': ['error', 'always-multiline'], + 'max-len': ['error', {code: 80, ignoreUrls: true}], + 'indent': ['error', 2, {SwitchCase: 1}], + 'brace-style': ['error', '1tbs'], + 'space-before-function-paren': ['error', 'never'], + 'no-multi-spaces': 'error', + 'array-bracket-spacing': ['error', 'never'], + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', {before: true, after: true}], + 'key-spacing': ['error', {beforeColon: false, afterColon: true}], + 'keyword-spacing': ['error', {before: true, after: true}], + 'space-infix-ops': 'error', + 'block-spacing': ['error', 'always'], + 'eol-last': ['error', 'always'], + 'newline-per-chained-call': ['error', {ignoreChainWithDepth: 2}], + 'padded-blocks': ['error', 'never'], + }, +}); \ No newline at end of file diff --git a/example/javascriptlib/linting/2-autoformatting/foo/src/foo.ts b/example/javascriptlib/linting/2-autoformatting/foo/src/foo.ts new file mode 100644 index 00000000000..d433279976f --- /dev/null +++ b/example/javascriptlib/linting/2-autoformatting/foo/src/foo.ts @@ -0,0 +1,7 @@ +export class Foo{ +static main( +args: string[ +]) +{console.log("Hello World!") +} +} \ No newline at end of file diff --git a/example/javascriptlib/testing/1-test-suite/baz/src/calculator.js b/example/javascriptlib/linting/3-code-coverage/bar/src/calculator.ts similarity index 67% rename from example/javascriptlib/testing/1-test-suite/baz/src/calculator.js rename to example/javascriptlib/linting/3-code-coverage/bar/src/calculator.ts index 7d632b027a2..2e9b2aa67d5 100644 --- a/example/javascriptlib/testing/1-test-suite/baz/src/calculator.js +++ b/example/javascriptlib/linting/3-code-coverage/bar/src/calculator.ts @@ -1,11 +1,12 @@ export class Calculator { - add(a, b) { + add(a: number, b: number): number { return a + b; } - divide(a, b) { + + divide(a: number, b: number): number { if (b === 0) { throw new Error("Division by zero is not allowed"); } return a / b; } -} +} \ No newline at end of file diff --git a/example/javascriptlib/linting/3-code-coverage/bar/test/src/foo/calculator.test.ts b/example/javascriptlib/linting/3-code-coverage/bar/test/src/foo/calculator.test.ts new file mode 100644 index 00000000000..502ce62a4b5 --- /dev/null +++ b/example/javascriptlib/linting/3-code-coverage/bar/test/src/foo/calculator.test.ts @@ -0,0 +1,19 @@ +import {expect} from 'chai'; +import {Calculator} from 'bar/calculator'; + +describe('Calculator', () => { + const calculator = new Calculator(); + + describe('Addition', () => { + it('should return the sum of two numbers', () => { + const result = calculator.add(2, 3); + expect(result).to.equal(5); + }); + + it('should return the correct sum for negative numbers', () => { + const result = calculator.add(-2, -3); + expect(result).to.equal(-5); + }); + }); + +}); \ No newline at end of file diff --git a/example/javascriptlib/testing/1-test-suite/qux/src/calculator.js b/example/javascriptlib/linting/3-code-coverage/baz/src/calculator.ts similarity index 67% rename from example/javascriptlib/testing/1-test-suite/qux/src/calculator.js rename to example/javascriptlib/linting/3-code-coverage/baz/src/calculator.ts index 7d632b027a2..2e9b2aa67d5 100644 --- a/example/javascriptlib/testing/1-test-suite/qux/src/calculator.js +++ b/example/javascriptlib/linting/3-code-coverage/baz/src/calculator.ts @@ -1,11 +1,12 @@ export class Calculator { - add(a, b) { + add(a: number, b: number): number { return a + b; } - divide(a, b) { + + divide(a: number, b: number): number { if (b === 0) { throw new Error("Division by zero is not allowed"); } return a / b; } -} +} \ No newline at end of file diff --git a/example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.js b/example/javascriptlib/linting/3-code-coverage/baz/test/src/baz/calculator.test.ts similarity index 94% rename from example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.js rename to example/javascriptlib/linting/3-code-coverage/baz/test/src/baz/calculator.test.ts index b0024018ed6..09bc7d16b00 100644 --- a/example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.js +++ b/example/javascriptlib/linting/3-code-coverage/baz/test/src/baz/calculator.test.ts @@ -1,24 +1,28 @@ -import { describe, it, expect } from 'vitest'; import { Calculator } from 'baz/calculator'; + describe('Calculator', () => { const calculator = new Calculator(); + describe('Addition', () => { test('should return the sum of two numbers', () => { const result = calculator.add(2, 3); expect(result).toEqual(5); }); + test('should return the correct sum for negative numbers', () => { const result = calculator.add(-2, -3); expect(result).toEqual(-5); }); }); + describe('Division', () => { test('should return the quotient of two numbers', () => { const result = calculator.divide(6, 3); expect(result).toEqual(2); }); + it('should throw an error when dividing by zero', () => { expect(() => calculator.divide(6, 0)).toThrow("Division by zero is not allowed"); }); }); -}); +}); \ No newline at end of file diff --git a/example/javascriptlib/linting/3-code-coverage/build.mill b/example/javascriptlib/linting/3-code-coverage/build.mill new file mode 100644 index 00000000000..d5f5a7322f8 --- /dev/null +++ b/example/javascriptlib/linting/3-code-coverage/build.mill @@ -0,0 +1,109 @@ +package build + +import mill._, javascriptlib._ + +object foo extends TypeScriptModule { + object test extends TypeScriptTests with TestModule.Jest +} + +object bar extends TypeScriptModule { + object test extends TypeScriptTests with TestModule.Mocha +} + +object baz extends TypeScriptModule { + object test extends TypeScriptTests with TestModule.Vitest +} + +object qux extends TypeScriptModule { + object test extends TypeScriptTests with TestModule.Jasmine +} + +// Mill supports code coverage with `Jest`, `Mocha`, `Vitest` and `Jasmine` out of the box. +// To run a test with coverage, run the command `mill _.test.coverage`. + +// The path to generated coverage data can be retrieved with `mill _.test.coverageFiles`, +// The path to generated html file can be retrieved with `mill _.test.htmlReport`. + +// To use custom configuations for test suites, you can simply include matching test suite config file in your project root. + +// For custom configurations: + +// Jest suite expects a `jest.config.ts` file. + +// Jasmine suite expects a `jasmine.json` file. + +// Mocha suite expects a `test-runner.js` file. + +// Vitest suite expects a `vitest.config.ts` file. + +// Mocha & Jasmine both rely on `nyc` https://www.npmjs.com/package/nyc and `@istanbuljs/nyc-config-typescript` https://www.npmjs.com/package/@istanbuljs/nyc-config-typescript +// for coverage, when using either, to use custom coverage configurations you must include a `.nycrc` file in your project root. + +// Example `.nycrc` configuration + +//// SNIPPET:BUILD +// [source,json] +// ---- +// { +// "extends": "@istanbuljs/nyc-config-typescript", +// "require": ["ts-node/register", "tsconfig-paths/register"], +// "exclude": ["node_modules", "*/**/*.test.ts"], +// "reporter": ["text", "html"], +// ... +//} +// ---- +//// SNIPPET:END + +// As always for most use cases you will never need to define a custom test configurtion file. + +/** Usage + +> mill foo.test.coverage +...Calculator +... +---------------|---------|----------|---------|---------|------------------- +File...| % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s... +---------------|---------|----------|---------|---------|------------------- +...All files...|...62.5 |...50 |...66.66 |...62.5 |... +...calculator.ts...|...62.5 |...50 |...66.66 |...62.5 | 14-17... +---------------|---------|----------|---------|---------|------------------- +... +Test Suites:...1 passed, 1 total... +Tests:...4 passed, 4 total... +... + +> mill bar.test.coverage +... +...2 passing... +... +---------------|---------|----------|---------|---------|------------------- +File...| % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s... +---------------|---------|----------|---------|---------|------------------- +...All files...|...66.66 |...0 |...66.66 |...62.5 |... +...calculator.ts...|...66.66 |...0 |...66.66 |...62.5 | 7-10... +---------------|---------|----------|---------|---------|------------------- + +> mill baz.test.coverage +.../calculator.test.ts... +...Test Files 1 passed... +...Tests 4 passed... +... +...Coverage report from v8 +---------------|---------|----------|---------|---------|------------------- +File...| % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s... +---------------|---------|----------|---------|---------|------------------- +...All files...|...100 |...100 |...100 |...100 |... +...calculator.ts...|...100 |...100 |...100 |...100 |... +---------------|---------|----------|---------|---------|------------------- + +> mill qux.test.coverage +... +4 specs, 0 failures +... +---------------|---------|----------|---------|---------|------------------- +File...| % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s... +---------------|---------|----------|---------|---------|------------------- +...All files...|...100 |...100 |...100 |...100 |... +...calculator.ts...|...100 |...100 |...100 |...100 |... +---------------|---------|----------|---------|---------|------------------- +*/ diff --git a/example/javascriptlib/linting/3-code-coverage/foo/src/calculator.ts b/example/javascriptlib/linting/3-code-coverage/foo/src/calculator.ts new file mode 100644 index 00000000000..937831b5a68 --- /dev/null +++ b/example/javascriptlib/linting/3-code-coverage/foo/src/calculator.ts @@ -0,0 +1,20 @@ +export class Calculator { + add(a: number, b: number): number { + return a + b; + } + + divide(a: number, b: number): number { + if (b === 0) { + throw new Error("Division by zero is not allowed"); + } + return a / b; + } + + multiply(a: number, b: number): number { + if (b === 0) { + throw new Error("Division by zero is not allowed"); + } + return a / b; + } +} + diff --git a/example/javascriptlib/linting/3-code-coverage/foo/test/src/foo/calculator.test.ts b/example/javascriptlib/linting/3-code-coverage/foo/test/src/foo/calculator.test.ts new file mode 100644 index 00000000000..546129e8efb --- /dev/null +++ b/example/javascriptlib/linting/3-code-coverage/foo/test/src/foo/calculator.test.ts @@ -0,0 +1,28 @@ +import {Calculator} from 'foo/calculator'; + +describe('Calculator', () => { + const calculator = new Calculator(); + + describe('Addition', () => { + test('should return the sum of two numbers', () => { + const result = calculator.add(2, 3); + expect(result).toEqual(5); + }); + + test('should return the correct sum for negative numbers', () => { + const result = calculator.add(-2, -3); + expect(result).toEqual(-5); + }); + }); + + describe('Division', () => { + test('should return the quotient of two numbers', () => { + const result = calculator.divide(6, 3); + expect(result).toEqual(2); + }); + + it('should throw an error when dividing by zero', () => { + expect(() => calculator.divide(6, 0)).toThrow("Division by zero is not allowed"); + }); + }); +}); \ No newline at end of file diff --git a/example/javascriptlib/linting/3-code-coverage/qux/src/calculator.ts b/example/javascriptlib/linting/3-code-coverage/qux/src/calculator.ts new file mode 100644 index 00000000000..2e9b2aa67d5 --- /dev/null +++ b/example/javascriptlib/linting/3-code-coverage/qux/src/calculator.ts @@ -0,0 +1,12 @@ +export class Calculator { + add(a: number, b: number): number { + return a + b; + } + + divide(a: number, b: number): number { + if (b === 0) { + throw new Error("Division by zero is not allowed"); + } + return a / b; + } +} \ No newline at end of file diff --git a/example/javascriptlib/linting/3-code-coverage/qux/test/src/calculator.test.ts b/example/javascriptlib/linting/3-code-coverage/qux/test/src/calculator.test.ts new file mode 100644 index 00000000000..cbaa895121c --- /dev/null +++ b/example/javascriptlib/linting/3-code-coverage/qux/test/src/calculator.test.ts @@ -0,0 +1,28 @@ +import { Calculator } from 'qux/calculator'; + +describe('Calculator', () => { + const calculator = new Calculator(); + + describe('Addition', () => { + it('should return the sum of two numbers', () => { + const result = calculator.add(2, 3); + expect(result).toEqual(5); + }); + + it('should return the correct sum for negative numbers', () => { + const result = calculator.add(-2, -3); + expect(result).toEqual(-5); + }); + }); + + describe('Division', () => { + it('should return the quotient of two numbers', () => { + const result = calculator.divide(6, 3); + expect(result).toEqual(2); + }); + + it('should throw an error when dividing by zero', () => { + expect(() => calculator.divide(6, 0)).toThrowError("Division by zero is not allowed"); + }); + }); +}); \ No newline at end of file diff --git a/example/javascriptlib/module/5-resources/jest.config.ts b/example/javascriptlib/module/5-resources/jest.config.ts deleted file mode 100644 index 416ef18ea8a..00000000000 --- a/example/javascriptlib/module/5-resources/jest.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {pathsToModuleNameMapper} from 'ts-jest'; -import {compilerOptions} from './tsconfig.json'; // this is a generated file. - -// Remove unwanted keys -const moduleDeps = {...compilerOptions.paths}; -delete moduleDeps['*']; -delete moduleDeps['typeRoots']; - -// moduleNameMapper evaluates in order they appear, -// sortedModuleDeps makes sure more specific path mappings always appear first -const sortedModuleDeps = Object.keys(moduleDeps) - .sort((a, b) => b.length - a.length) // Sort by descending length - .reduce((acc, key) => { - acc[key] = moduleDeps[key]; - return acc; - }, {}); - -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - '/**/**/*.test.ts', - '/**/**/*.test.js', - ], - transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json' }], - '^.+\\.(js|jsx)$': 'babel-jest', // Use babel-jest for JS/JSX files - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps) // use absolute paths generated in tsconfig. -}; \ No newline at end of file diff --git a/example/javascriptlib/publishing/2-realistic/jest.config.ts b/example/javascriptlib/publishing/2-realistic/jest.config.ts deleted file mode 100644 index 528b0886da9..00000000000 --- a/example/javascriptlib/publishing/2-realistic/jest.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -// @ts-nocheck -import {pathsToModuleNameMapper} from 'ts-jest'; -import {compilerOptions} from './tsconfig.json'; // this is a generated file. - -// Remove unwanted keys -const moduleDeps = {...compilerOptions.paths}; -delete moduleDeps['*']; -delete moduleDeps['typeRoots']; - -// moduleNameMapper evaluates in order they appear, -// sortedModuleDeps makes sure more specific path mappings always appear first -const sortedModuleDeps = Object.keys(moduleDeps) - .sort((a, b) => b.length - a.length) // Sort by descending length - .reduce((acc, key) => { - acc[key] = moduleDeps[key]; - return acc; - }, {}); - -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - '/**/**/**/*.test.ts', - '/**/**/**/*.test.js', - ], - transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json' }], - '^.+\\.(js|jsx)$': 'babel-jest', // Use babel-jest for JS/JSX files - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps) // use absolute paths generated in tsconfig. -}; \ No newline at end of file diff --git a/example/javascriptlib/testing/1-test-suite/build.mill b/example/javascriptlib/testing/1-test-suite/build.mill index 1782edfa304..c7a80ca53e6 100644 --- a/example/javascriptlib/testing/1-test-suite/build.mill +++ b/example/javascriptlib/testing/1-test-suite/build.mill @@ -18,9 +18,28 @@ object qux extends TypeScriptModule { object test extends TypeScriptTests with TestModule.Jasmine } -// Documentation for mill.example.javascriptlib // This build defines 4 modules bar, baz, foo & qux with test suites configured to use -// Jest, Vitest, Mocaha & Jasmine respectively +// Jest, Vitest, Mocha & Jasmine respectively. + +// Mill will auto-magically generate test configurations for each respective test suite. +// Custom test configurations can be used by simply including a matching test suite config file in your project root, +// same directory as your `build.mill` file. +// +// You can view the generated config file by looking in the `compile` destination for your modules test. +// For example, the module `bar` will have its `jest.config.ts` file live in `out/bar/test/compile.dest/`. +// +// It is important to note, that for most use cases you will never need +// to define a custom test configurtion file. + +// For custom configurations: + +// Jest suite expects a `jest.config.ts` file. + +// Jasmine suite expects a `jasmine.json` file. + +// Mocha suite expects a `test-runner.js` file. + +// Vitest suite expects a `vitest.config.ts` file. /** Usage diff --git a/example/javascriptlib/testing/1-test-suite/jest.config.ts b/example/javascriptlib/testing/1-test-suite/jest.config.ts deleted file mode 100644 index f54bc06a50c..00000000000 --- a/example/javascriptlib/testing/1-test-suite/jest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {pathsToModuleNameMapper} from 'ts-jest'; -import {compilerOptions} from './tsconfig.json'; // this is a generated file. - -// Remove unwanted keys -const moduleDeps = {...compilerOptions.paths}; -delete moduleDeps['*']; -delete moduleDeps['typeRoots']; - - -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - '/**/**/**/*.test.ts', - '/**/**/**/*.test.js', - ], - transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', {tsconfig: 'tsconfig.json'}], - '^.+\\.(js|jsx)$': 'babel-jest', // Use babel-jest for JS/JSX files - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: pathsToModuleNameMapper(moduleDeps) // use absolute paths generated in tsconfig. -}; \ No newline at end of file diff --git a/example/javascriptlib/testing/1-test-suite/vite.config.ts b/example/javascriptlib/testing/1-test-suite/vite.config.ts deleted file mode 100644 index a5993128c19..00000000000 --- a/example/javascriptlib/testing/1-test-suite/vite.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -/// -import { defineConfig } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; - -export default defineConfig({ - plugins: [tsconfigPaths()], - test: { - globals: true, - environment: 'node', - include: ['**/**/*.test.ts'] - }, -}); \ No newline at end of file diff --git a/example/javascriptlib/testing/2-test-deps/jest.config.ts b/example/javascriptlib/testing/2-test-deps/jest.config.ts deleted file mode 100644 index f54bc06a50c..00000000000 --- a/example/javascriptlib/testing/2-test-deps/jest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {pathsToModuleNameMapper} from 'ts-jest'; -import {compilerOptions} from './tsconfig.json'; // this is a generated file. - -// Remove unwanted keys -const moduleDeps = {...compilerOptions.paths}; -delete moduleDeps['*']; -delete moduleDeps['typeRoots']; - - -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - '/**/**/**/*.test.ts', - '/**/**/**/*.test.js', - ], - transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', {tsconfig: 'tsconfig.json'}], - '^.+\\.(js|jsx)$': 'babel-jest', // Use babel-jest for JS/JSX files - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: pathsToModuleNameMapper(moduleDeps) // use absolute paths generated in tsconfig. -}; \ No newline at end of file diff --git a/example/package.mill b/example/package.mill index d316089cb1b..ea564d19c17 100644 --- a/example/package.mill +++ b/example/package.mill @@ -73,6 +73,7 @@ object `package` extends RootModule with Module { object dependencies extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "dependencies")) object publishing extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "publishing")) + object linting extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "linting")) } object pythonlib extends Module { object basic extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "basic")) diff --git a/javascriptlib/src/mill/javascriptlib/TestModule.scala b/javascriptlib/src/mill/javascriptlib/TestModule.scala index c46648c4177..5e2d0d4008b 100644 --- a/javascriptlib/src/mill/javascriptlib/TestModule.scala +++ b/javascriptlib/src/mill/javascriptlib/TestModule.scala @@ -1,7 +1,6 @@ package mill.javascriptlib import mill.* -import os.* trait TestModule extends TaskModule { import TestModule.TestResult @@ -25,6 +24,71 @@ trait TestModule extends TaskModule { object TestModule { type TestResult = Unit + trait Coverage extends TypeScriptModule with TestModule { + override def npmDevDeps: T[Seq[String]] = Task { + super.npmDevDeps() ++ Seq("serve@14.2.4") + } + + private[TestModule] def runCoverage: T[TestResult] + + protected def coverageTask(args: Task[Seq[String]]): Task[TestResult] = Task { runCoverage() } + + def coverage(args: String*): Command[TestResult] = + Task.Command { + coverageTask(Task.Anon { args })() + } + + // = '/out'; allow coverage resolve distributed source files. + // & define coverage files relative to . + private[TestModule] def coverageSetupSymlinks: Task[Unit] = Task.Anon { + os.symlink(Task.workspace / "out/node_modules", npmInstall().path / "node_modules") + os.symlink(Task.workspace / "out/tsconfig.json", compile()._1.path / "tsconfig.json") + if (os.exists(compile()._1.path / ".nycrc")) + os.symlink(Task.workspace / "out/.nycrc", compile()._1.path / ".nycrc") + } + + def istanbulNycrcConfigBuilder: Task[PathRef] = Task.Anon { + val compiled = compile()._1.path + val fileName = ".nycrc" + val config = compiled / fileName + val customConfig = Task.workspace / fileName + + val content = + s"""|{ + | "report-dir": ${ujson.Str(s"${moduleDeps.head}_coverage")}, + | "extends": "@istanbuljs/nyc-config-typescript", + | "all": true, + | "include": ${ujson.Arr.from(coverageDirs())}, + | "exclude": ["node_modules", "*/**/*.test.ts"], + | "reporter": ["text", "html"], + | "require": ["ts-node/register", "tsconfig-paths/register"] + |} + |""".stripMargin + + if (!os.exists(customConfig)) os.write.over(config, content) + else os.copy.over(customConfig, config) + + os.write.over(config, content) + + PathRef(config) + } + + // web browser - serve coverage report + def htmlReport: T[PathRef] = Task { + runCoverage() + val htmlPath = coverageFiles().path / "index.html" + println(s"HTML Report: $htmlPath") + PathRef(htmlPath) + } + + // coverage files - returnn coverage files directory + def coverageFiles: T[PathRef] = Task { + val dir = Task.workspace / "out" / s"${moduleDeps.head}_coverage" + println(s"coverage files: $dir") + PathRef(dir) + } + } + trait Shared extends TypeScriptModule { override def upstreamPathsBuilder: T[Seq[(String, String)]] = Task { @@ -47,9 +111,9 @@ object TestModule { def port: T[String] } - trait Jest extends TypeScriptModule with Shared with TestModule { + trait Jest extends Coverage with Shared { override def npmDevDeps: T[Seq[String]] = Task { - Seq( + super.npmDevDeps() ++ Seq( "@types/jest@^29.5.14", "@babel/core@^7.26.0", "@babel/preset-env@^7.26.0", @@ -59,30 +123,57 @@ object TestModule { ) } - def testConfigSource: T[PathRef] = - Task.Source(Task.workspace / "jest.config.ts") - override def compilerOptions: T[Map[String, ujson.Value]] = - Task { super.compilerOptions() + ("resolveJsonModule" -> ujson.Bool(true)) } + Task { + super.compilerOptions() + ("resolveJsonModule" -> ujson.Bool(true)) + } - def getConfigFile: T[String] = - Task { (compile()._1.path / "jest.config.ts").toString } + def conf: Task[PathRef] = Task.Anon { + val compiled = compile()._1.path + val fileName = "jest.config.ts" + val config = compiled / fileName + val customConfig = Task.workspace / fileName - private def copyConfig: Task[TestResult] = Task.Anon { - os.copy.over( - testConfigSource().path, - compile()._1.path / "jest.config.ts" - ) + val content = + s"""|import {pathsToModuleNameMapper} from 'ts-jest'; + |import {compilerOptions} from './tsconfig.json'; + | + |const moduleDeps = {...compilerOptions.paths}; + |delete moduleDeps['typeRoots']; + | + |const sortedModuleDeps = Object.keys(moduleDeps) + | .sort((a, b) => b.length - a.length) // Sort by descending length + | .reduce((acc, key) => { + | acc[key] = moduleDeps[key]; + | return acc; + | }, {}); + | + |export default { + |preset: 'ts-jest', + |testEnvironment: 'node', + | testMatch: ['/**/**/**/*.test.ts', '/**/**/**/*.test.js'], + |transform: ${ujson.Obj("^.+\\.(ts|tsx)$" -> ujson.Arr.from(Seq( + ujson.Str("ts-jest"), + ujson.Obj("tsconfig" -> "tsconfig.json") + )))}, + |moduleFileExtensions: ${ujson.Arr.from(Seq("ts", "tsx", "js", "jsx", "json", "node"))}, + |moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps) + |} + |""".stripMargin + + if (!os.exists(customConfig)) os.write.over(config, content) + else os.copy.over(customConfig, config) + + PathRef(config) } private def runTest: T[TestResult] = Task { - copyConfig() os.call( ( "node", npmInstall().path / "node_modules/jest/bin/jest.js", "--config", - getConfigFile(), + conf().path, getPathToTest() ), stdout = os.Inherit, @@ -96,15 +187,89 @@ object TestModule { runTest() } + // with coverage + def coverageConf: Task[PathRef] = Task.Anon { + val compiled = compile()._1.path + val fileName = "jest.config.ts" + val config = compiled / fileName + val customConfig = Task.workspace / fileName + + val content = + s"""|import {pathsToModuleNameMapper} from 'ts-jest'; + |import {compilerOptions} from './tsconfig.json'; + | + |const moduleDeps = {...compilerOptions.paths}; + |delete moduleDeps['typeRoots']; + | + |const sortedModuleDeps = Object.keys(moduleDeps) + | .sort((a, b) => b.length - a.length) // Sort by descending length + | .reduce((acc, key) => { + | acc[key] = moduleDeps[key]; + | return acc; + | }, {}); + | + |export default { + |rootDir: ${ujson.Str((Task.workspace / "out").toString)}, + |preset: 'ts-jest', + |testEnvironment: 'node', + |testMatch: [${ujson.Str( + s"/${compile()._2.path.subRelativeTo(Task.workspace / "out") / "src"}/**/*.test.ts" + )}], + |transform: ${ujson.Obj("^.+\\.(ts|tsx)$" -> ujson.Arr.from(Seq( + ujson.Str("ts-jest"), + ujson.Obj("tsconfig" -> "tsconfig.json") + )))}, + |moduleFileExtensions: ${ujson.Arr.from(Seq("ts", "tsx", "js", "jsx", "json", "node"))}, + |moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps), + | + |collectCoverage: true, + |collectCoverageFrom: ${ujson.Arr.from(coverageDirs())}, + |coveragePathIgnorePatterns: [${ujson.Str(".*\\.test\\.ts$")}], + |coverageDirectory: '${moduleDeps.head}_coverage', + |coverageReporters: ['text', 'html'], + |} + |""".stripMargin + + if (!os.exists(customConfig)) os.write.over(config, content) + else os.copy.over(customConfig, config) + + PathRef(config) + } + + def runCoverage: T[TestResult] = Task { + coverageSetupSymlinks() + os.call( + ( + "node", + "node_modules/jest/bin/jest.js", + "--config", + coverageConf().path, + "--coverage", + getPathToTest() + ), + stdout = os.Inherit, + env = forkEnv(), + cwd = Task.workspace / "out" + ) + + // remove symlink + os.remove(Task.workspace / "out/node_modules") + os.remove(Task.workspace / "out/tsconfig.json") + () + } + } - trait Mocha extends TypeScriptModule with Shared with TestModule { + trait Mocha extends Coverage with Shared { override def npmDevDeps: T[Seq[String]] = Task { - Seq( + super.npmDevDeps() ++ Seq( "@types/chai@4.3.1", "@types/mocha@9.1.1", "chai@4.3.6", - "mocha@10.0.0" + "mocha@10.0.0", + "@istanbuljs/nyc-config-typescript@1.0.2", + "nyc@17.1.0", + "source-map-support@0.5.21" ) } @@ -112,9 +277,9 @@ object TestModule { Task { super.getPathToTest() + "/**/**/*.test.ts" } // test-runner.js: run tests on ts files - private def testRunnerBuilder: Task[Path] = Task.Anon { + def conf: Task[PathRef] = Task.Anon { val compiled = compile()._1.path - val testRunner = compiled / "test-runner.js" + val runner = compiled / "test-runner.js" val content = """|require('ts-node/register'); @@ -122,16 +287,16 @@ object TestModule { |require('mocha/bin/_mocha'); |""".stripMargin - os.write(testRunner, content) + os.write.over(runner, content) - testRunner + PathRef(runner) } private def runTest: T[Unit] = Task { os.call( ( "node", - testRunnerBuilder(), + conf().path, getPathToTest() ), stdout = os.Inherit, @@ -145,22 +310,42 @@ object TestModule { runTest() } + // with coverage + def runCoverage: T[TestResult] = Task { + istanbulNycrcConfigBuilder() + coverageSetupSymlinks() + os.call( + ( + "./node_modules/.bin/nyc", + "node", + conf().path, + getPathToTest() + ), + stdout = os.Inherit, + env = forkEnv(), + cwd = Task.workspace / "out" + ) + + // remove symlink + os.remove(Task.workspace / "out/node_modules") + os.remove(Task.workspace / "out/tsconfig.json") + os.remove(Task.workspace / "out/.nycrc") + () + } } - trait Vitest extends TypeScriptModule with Shared with TestModule { + trait Vitest extends Coverage with Shared { override def npmDevDeps: T[Seq[String]] = Task { - Seq( - "@vitest/runner@2.1.8", - "vite@5.4.11", + super.npmDevDeps() ++ Seq( + "@vitest/runner@3.0.3", + "vite@6.0.11", + "vitest@3.0.3", "vite-tsconfig-paths@3.6.0", - "vitest@2.1.8" + "@vitest/coverage-v8@3.0.3" ) } - def testConfigSource: T[PathRef] = - Task.Source(Task.workspace / "vite.config.ts") - override def compilerOptions: T[Map[String, ujson.Value]] = Task { super.compilerOptions() + ( @@ -174,25 +359,40 @@ object TestModule { ) } - def getConfigFile: T[String] = - Task { (compile()._1.path / "vite.config.ts").toString } + def conf: Task[PathRef] = Task.Anon { + val compiled = compile()._1.path + val fileName = "vitest.config.ts" + val config = compiled / fileName + val customConfig = Task.workspace / fileName - private def copyConfig: Task[Unit] = Task.Anon { - os.copy.over( - testConfigSource().path, - compile()._1.path / "vite.config.ts" - ) + val content = + """|import { defineConfig } from 'vite'; + |import tsconfigPaths from 'vite-tsconfig-paths'; + | + |export default defineConfig({ + | plugins: [tsconfigPaths()], + | test: { + | globals: true, + | environment: 'node', + | include: ['**/**/*.test.ts'] + | }, + |}); + |""".stripMargin + + if (!os.exists(customConfig)) os.write.over(config, content) + else os.copy.over(customConfig, config) + + PathRef(config) } private def runTest: T[TestResult] = Task { - copyConfig() os.call( ( - "node", + npmInstall().path / "node_modules/.bin/ts-node", npmInstall().path / "node_modules/.bin/vitest", "--run", "--config", - getConfigFile(), + conf().path, getPathToTest() ), stdout = os.Inherit, @@ -206,17 +406,77 @@ object TestModule { runTest() } + // coverage + def coverageConf: Task[PathRef] = Task.Anon { + val compiled = compile()._1.path + val fileName = "vitest.config.ts" + val config = compiled / fileName + val customConfig = Task.workspace / fileName + + val content = + s"""|import { defineConfig } from 'vite'; + |import tsconfigPaths from 'vite-tsconfig-paths'; + | + |export default defineConfig({ + | plugins: [tsconfigPaths()], + | test: { + | globals: true, + | environment: 'node', + | include: [${ujson.Str( + s"${compile()._2.path.subRelativeTo(Task.workspace / "out") / "src"}/**/*.test.ts" + )}], + | coverage: { + | provider: 'v8', + | reporter: ['text', 'json', 'html'], + | reportsDirectory: '${moduleDeps.head}_coverage', + | include: ${ujson.Arr.from( + coverageDirs() + )}, // Specify files to include for coverage + | exclude: ['*/**/*.test.ts'], // Specify files to exclude from coverage + | }, + | }, + |}); + |""".stripMargin + + if (!os.exists(customConfig)) os.write.over(config, content) + else os.copy.over(customConfig, config) + + PathRef(config) + } + + def runCoverage: T[TestResult] = Task { + coverageSetupSymlinks() + os.call( + ( + npmInstall().path / "node_modules/.bin/ts-node", + npmInstall().path / "node_modules/.bin/vitest", + "--run", + "--config", + coverageConf().path, + "--coverage", + getPathToTest() + ), + stdout = os.Inherit, + env = forkEnv(), + cwd = Task.workspace / "out" + ) + // remove symlink + os.remove(Task.workspace / "out/node_modules") + os.remove(Task.workspace / "out/tsconfig.json") + () + } + } - trait Jasmine extends TypeScriptModule with Shared with TestModule { + trait Jasmine extends Coverage with Shared { override def npmDevDeps: T[Seq[String]] = Task { - Seq( + super.npmDevDeps() ++ Seq( "@types/jasmine@5.1.2", "jasmine@5.1.0", - "ts-node@10.9.1", - "tsconfig-paths@4.2.0", - "typescript@5.2.2" + "@istanbuljs/nyc-config-typescript@1.0.2", + "nyc@17.1.0", + "source-map-support@0.5.21" ) } @@ -230,9 +490,9 @@ object TestModule { ) } - def configBuilder: T[PathRef] = Task { + def conf: Task[PathRef] = Task.Anon { val path = compile()._1.path / "jasmine.json" - os.write( + os.write.over( path, ujson.write( ujson.Obj( @@ -243,14 +503,15 @@ object TestModule { ) ) ) + PathRef(path) } private def runTest: T[Unit] = Task { - configBuilder() - val jasmine = npmInstall().path / "node_modules/jasmine/bin/jasmine.js" - val tsnode = npmInstall().path / "node_modules/ts-node/register/transpile-only.js" - val tsconfigPath = npmInstall().path / "node_modules/tsconfig-paths/register.js" + conf() + val jasmine = "node_modules/jasmine/bin/jasmine.js" + val tsnode = "node_modules/ts-node/register/transpile-only.js" + val tsconfigPath = "node_modules/tsconfig-paths/register.js" os.call( ( "node", @@ -270,6 +531,52 @@ object TestModule { runTest() } + // with coverage + def coverageConf: T[PathRef] = Task { + val path = compile()._1.path / "jasmine.json" + val specDir = compile()._2.path.subRelativeTo(Task.workspace / "out") / "src" + os.write.over( + path, + ujson.write( + ujson.Obj( + "spec_dir" -> ujson.Str(specDir.toString), + "spec_files" -> ujson.Arr(ujson.Str("**/*.test.ts")), + "stopSpecOnExpectationFailure" -> ujson.Bool(false), + "random" -> ujson.Bool(false) + ) + ) + ) + PathRef(path) + } + + def runCoverage: T[TestResult] = Task { + istanbulNycrcConfigBuilder() + coverageSetupSymlinks() + val jasmine = "node_modules/jasmine/bin/jasmine.js" + val tsnode = "node_modules/ts-node/register/transpile-only.js" + val tsconfigPath = "node_modules/tsconfig-paths/register.js" + val relConfigPath = coverageConf().path.subRelativeTo(Task.workspace / "out") + os.call( + ( + "./node_modules/.bin/nyc", + "node", + jasmine, + s"--config=$relConfigPath", + s"--require=$tsnode", + s"--require=$tsconfigPath" + ), + stdout = os.Inherit, + env = forkEnv(), + cwd = Task.workspace / "out" + ) + + // remove symlink + os.remove(Task.workspace / "out/node_modules") + os.remove(Task.workspace / "out/tsconfig.json") + os.remove(Task.workspace / "out/.nycrc") + () + } + } trait Cypress extends TypeScriptModule with IntegrationSuite with TestModule { diff --git a/javascriptlib/src/mill/javascriptlib/TsLintModule.scala b/javascriptlib/src/mill/javascriptlib/TsLintModule.scala new file mode 100644 index 00000000000..caca06365e5 --- /dev/null +++ b/javascriptlib/src/mill/javascriptlib/TsLintModule.scala @@ -0,0 +1,227 @@ +package mill.javascriptlib + +import mill.* +import mill.api.Result +import scala.util.{Try, Success, Failure} +import os.* + +trait TsLintModule extends Module { + sealed trait Lint + private case object Eslint extends Lint + private case object Prettier extends Lint + + def npmLintDeps: T[Seq[String]] = Task { Seq.empty[String] } + + private def npmInstallLint: T[PathRef] = Task { + Try(os.copy.over(T.workspace / ".npmrc", Task.dest / ".npmrc")).getOrElse(()) + os.call(( + "npm", + "install", + "--userconfig", + ".npmrc", + "--save-dev", + "prettier@3.4.2", + "eslint@9.18.0", + "typescript-eslint@8.21.0", + "@eslint/js@9.18.0", + npmLintDeps() + )) + PathRef(Task.dest) + } + + // Handle config - prioritize eslint config + private def fmtConfig: T[Seq[PathRef]] = Task.Sources( + T.workspace / "eslint.config.mjs", + T.workspace / "eslint.config.cjs", + T.workspace / "eslint.config.js", + T.workspace / ".prettierrc" + ) + + private def resolvedFmtConfig: Task[Lint] = Task.Anon { + val locs = fmtConfig() + + val lintT: Path => Lint = _.last match { + case s if s.contains("eslint.config") => Eslint + case _ => Prettier + } + + locs.find(p => os.exists(p.path)) match { + case None => + Result.Failure(s"Lint couldn't find an eslint.config.(js|mjs|cjs) or a `.pretiierrc` file") + case Some(c) => Result.Success(lintT(c.path)) + } + } + + // eslint + def checkFormatEslint(args: mill.define.Args): Command[Unit] = Task.Command { + resolvedFmtConfig() match { + case Eslint => + val cwd = T.workspace + os.symlink(cwd / "node_modules", npmInstallLint().path / "node_modules") + val eslint = npmInstallLint().path / "node_modules/.bin/eslint" + val logPath = npmInstallLint().path / "eslint.log" + val result = + Try { + os.call( + (eslint, "."), + stdout = os.PathRedirect(logPath), + stderr = os.PathRedirect(logPath), + cwd = cwd + ) + } + + val replacements = Seq( + s"$cwd/" -> "", + "potentially fixable with the `--fix` option" -> + s"potentially fixable with running ${millSourcePath.last}.reformatAll" + ) + + os.remove(cwd / "node_modules") + result match { + case Failure(e: os.SubprocessException) if e.result.exitCode == 1 => + val lines = os.read.lines(logPath) + val logMssg = lines.map(line => + replacements.foldLeft(line) { case (currentLine, (target, replacement)) => + currentLine.replace(target, replacement) + } + ) + println(logMssg.mkString("\n")) + case Failure(e: os.SubprocessException) => + println(s"Eslint exited with code: ${e.result.exitCode}") + println(os.read.lines(logPath).mkString("\n")) + case Failure(_) => + println(os.read.lines(logPath).mkString("\n")) + case Success(_) => println("All matched files use Eslint code style!") + } + case _ => + } + } + + def reformatEslint(args: mill.define.Args): Command[Unit] = Task.Command { + resolvedFmtConfig() match { + case Eslint => + val cwd = T.workspace + os.symlink(cwd / "node_modules", npmInstallLint().path / "node_modules") + val eslint = npmInstallLint().path / "node_modules/.bin/eslint" + val logPath = npmInstallLint().path / "eslint.log" + + val result = + Try { + os.call( + (eslint, ".", "--fix"), + stdout = os.PathRedirect(logPath), + stderr = os.PathRedirect(logPath), + cwd = cwd + ) + } + + os.remove(cwd / "node_modules") + result match { + case Failure(e: os.SubprocessException) => + println(s"Eslint exited with code: ${e.result.exitCode}") + println(os.read.lines(logPath).mkString("\n")) + case Failure(_) => + println(os.read.lines(logPath).mkString("\n")) + case Success(_) => println("All matched files have been reformatted!") + } + case _ => + } + } + + // prettier + def checkFormatPrettier(args: mill.define.Args): Command[Unit] = Task.Command { + resolvedFmtConfig() match { + case Prettier => + val cwd = T.workspace + val prettier = npmInstallLint().path / "node_modules/.bin/prettier" + val logPath = npmInstallLint().path / "prettier.log" + val defaultArgs = if (args.value.isEmpty) Seq("*/**/*.ts") else args.value + val userPrettierIgnore = os.exists(cwd / ".prettierignore") + if (!userPrettierIgnore) os.symlink(cwd / ".prettierignore", prettierIgnore().path) + val result = + Try { + os.call( + (prettier, "--check", defaultArgs), // todo: collect from command line? + stdout = os.Inherit, + stderr = os.PathRedirect(logPath), + cwd = cwd + ) + } + + if (!userPrettierIgnore) os.remove(cwd / ".prettierignore") + result match { + case Failure(e: os.SubprocessException) if e.result.exitCode == 1 => + val lines = os.read.lines(logPath) + val logMssg = lines.map(_.replace( + "[warn] Code style issues found in the above file. Run Prettier with --write to fix.", + s"[warn] Code style issues found. Run ${millSourcePath.last}.reformatAll to fix." + )) + println(logMssg.mkString("\n")) + case Failure(e: os.SubprocessException) if e.result.exitCode == 2 => + println(os.read.lines(logPath).mkString("\n")) + case Failure(e: os.SubprocessException) => + println(s"Prettier exited with code: ${e.result.exitCode}") + println(os.read.lines(logPath).mkString("\n")) + case Failure(_) => + println(os.read.lines(logPath).mkString("\n")) + case Success(_) => + } + case _ => + } + + } + + def reformatPrettier(args: mill.define.Args): Command[Unit] = Task.Command { + resolvedFmtConfig() match { + case Prettier => + val cwd = T.workspace + val prettier = npmInstallLint().path / "node_modules/.bin/prettier" + val logPath = npmInstallLint().path / "prettier.log" + val defaultArgs = if (args.value.isEmpty) Seq("*/**/*.ts") else args.value + val userPrettierIgnore = os.exists(cwd / ".prettierignore") + if (!userPrettierIgnore) os.symlink(cwd / ".prettierignore", prettierIgnore().path) + val result = + Try { + os.call( + (prettier, "--write", defaultArgs), // todo: collect from command line? + stdout = os.Inherit, + stderr = os.PathRedirect(logPath), + cwd = cwd + ) + } + + if (!userPrettierIgnore) os.remove(cwd / ".prettierignore") + result match { + case Failure(e: os.SubprocessException) => + println(s"Prettier exited with code: ${e.result.exitCode}") + println(os.read.lines(logPath).mkString("\n")) + case Failure(_) => + println(os.read.lines(logPath).mkString("\n")) + case Success(_) => println("All matched files have been reformatted!") + } + case _ => + } + } + + private def prettierIgnore: T[PathRef] = Task { + val config = T.dest / ".prettierignore" + val content = + s"""|node_modules + |.git + |""".stripMargin + os.write.over(config, content) + + PathRef(config) + } + + def checkFormatAll(args: mill.define.Args): Command[Unit] = Task.Command { + checkFormatEslint(args)() + checkFormatPrettier(args)() + } + + def reformatAll(args: mill.define.Args): Command[Unit] = Task.Command { + reformatEslint(args)() + reformatPrettier(args)() + } + +} diff --git a/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala b/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala index ac4ddb358da..01830c0fcd5 100644 --- a/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala +++ b/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala @@ -34,11 +34,11 @@ trait TypeScriptModule extends Module { outer => "--userconfig", ".npmrc", "--save-dev", - "@types/node@22.10.2", + "@types/node@22.10.9", "@types/esbuild-copy-static-files@0.1.4", - "typescript@5.7.2", + "typescript@5.7.3", "ts-node@^10.9.2", - "esbuild@0.24.0", + "esbuild@0.24.2", "esbuild-plugin-copy@2.1.1", "@esbuild-plugins/tsconfig-paths@0.1.2", "esbuild-copy-static-files@0.1.0", @@ -62,7 +62,20 @@ trait TypeScriptModule extends Module { outer => os.walk(sources().path).filter(fileExt).map(PathRef(_)) } - private def compiledSources: Task[IndexedSeq[PathRef]] = Task.Anon { + // Generate coverage directories for TestModule + private[javascriptlib] def coverageDirs: T[Seq[String]] = Task { + Task.traverse(moduleDeps)(mod => { + Task.Anon { + val comp = mod.compile() + val generated = mod.generatedSources() + val combined = Seq(comp._2) ++ generated + + combined.map(_.path.subRelativeTo(Task.workspace / "out").toString + "/**/**/*.ts") + } + })().flatten + } + + private[javascriptlib] def compiledSources: Task[IndexedSeq[PathRef]] = Task.Anon { val generated = for { pr <- generatedSources() file <- os.walk(pr.path)