From da37921914a8b0ba53a5a468c70944d8e638a907 Mon Sep 17 00:00:00 2001 From: Monye David Onoh Date: Fri, 13 Dec 2024 06:17:57 +0100 Subject: [PATCH] Add first class Javascript/Typescript support to the Mill build tool (#4098) https://github.com/com-lihaoyi/mill/issues/3927 ### Checklist - [x] **example/jslib/testing** - [x] 1-test-suite - [x] 2-test-deps --- .../javascriptlib/basic/1-simple/build.mill | 12 +- .../foo/test/{ => src/foo}/foo.test.ts | 2 +- .../basic/1-simple/jest.config.ts | 25 ++- .../basic/3-custom-build-logic/build.mill | 11 +- .../foo/test/{ => src/foo}/foo.test.ts | 2 +- .../basic/3-custom-build-logic/jest.config.ts | 28 +++- .../basic/4-multi-modules/build.mill | 3 +- .../basic/4-multi-modules/jest.config.ts | 4 +- .../qux/test/{ => src/qux}/qux.test.ts | 2 +- .../basic/5-client-server-hello/build.mill | 7 +- .../5-client-server-hello/jest.config.ts | 25 ++- .../test/{ => src/server}/server.test.ts | 2 +- .../6-client-server-realistic/build.mill | 11 +- .../server/jest.config.ts | 27 +++- .../test/{ => src/server}/server.test.ts | 2 +- .../1-test-suite/bar/src/calculator.ts | 12 ++ .../bar/test/src/bar/calculator.test.ts | 28 ++++ .../testing/1-test-suite/build.mill | 30 ++++ .../1-test-suite/foo/src/calculator.ts | 12 ++ .../foo/test/src/foo/calculator.test.ts | 30 ++++ .../testing/1-test-suite/jest.config.ts | 24 +++ .../testing/2-test-deps/bar/src/bar.ts | 14 ++ .../2-test-deps/bar/test/src/bar/bar.test.ts | 14 ++ .../bar/test/src/utils/bar.tests.utils.ts | 12 ++ .../testing/2-test-deps/build.mill | 37 +++++ .../testing/2-test-deps/foo/src/foo.ts | 25 +++ .../2-test-deps/foo/test/src/foo/foo.test.ts | 61 ++++++++ .../testing/2-test-deps/jest.config.ts | 24 +++ example/package.mill | 1 + .../src/mill/javascriptlib/JestModule.scala | 74 --------- .../src/mill/javascriptlib/TestModule.scala | 147 ++++++++++++++++++ .../mill/javascriptlib/TypeScriptModule.scala | 18 ++- 32 files changed, 606 insertions(+), 120 deletions(-) rename example/javascriptlib/basic/1-simple/foo/test/{ => src/foo}/foo.test.ts (96%) rename example/javascriptlib/basic/3-custom-build-logic/foo/test/{ => src/foo}/foo.test.ts (98%) rename example/javascriptlib/basic/4-multi-modules/qux/test/{ => src/qux}/qux.test.ts (97%) rename example/javascriptlib/basic/5-client-server-hello/server/test/{ => src/server}/server.test.ts (94%) rename example/javascriptlib/basic/6-client-server-realistic/server/test/{ => src/server}/server.test.ts (97%) create mode 100644 example/javascriptlib/testing/1-test-suite/bar/src/calculator.ts create mode 100644 example/javascriptlib/testing/1-test-suite/bar/test/src/bar/calculator.test.ts create mode 100644 example/javascriptlib/testing/1-test-suite/build.mill create mode 100644 example/javascriptlib/testing/1-test-suite/foo/src/calculator.ts create mode 100644 example/javascriptlib/testing/1-test-suite/foo/test/src/foo/calculator.test.ts create mode 100644 example/javascriptlib/testing/1-test-suite/jest.config.ts create mode 100644 example/javascriptlib/testing/2-test-deps/bar/src/bar.ts create mode 100644 example/javascriptlib/testing/2-test-deps/bar/test/src/bar/bar.test.ts create mode 100644 example/javascriptlib/testing/2-test-deps/bar/test/src/utils/bar.tests.utils.ts create mode 100644 example/javascriptlib/testing/2-test-deps/build.mill create mode 100644 example/javascriptlib/testing/2-test-deps/foo/src/foo.ts create mode 100644 example/javascriptlib/testing/2-test-deps/foo/test/src/foo/foo.test.ts create mode 100644 example/javascriptlib/testing/2-test-deps/jest.config.ts delete mode 100644 javascriptlib/src/mill/javascriptlib/JestModule.scala create mode 100644 javascriptlib/src/mill/javascriptlib/TestModule.scala diff --git a/example/javascriptlib/basic/1-simple/build.mill b/example/javascriptlib/basic/1-simple/build.mill index 980a6732715..8e528f8bbb5 100644 --- a/example/javascriptlib/basic/1-simple/build.mill +++ b/example/javascriptlib/basic/1-simple/build.mill @@ -2,9 +2,9 @@ package build import mill._, javascriptlib._ -object foo extends JestModule { +object foo extends TypeScriptModule { def npmDeps = Seq("immutable@4.3.7") - + object test extends TypeScriptTests with TestModule.Jest } // Documentation for mill.example.javascriptlib @@ -15,16 +15,10 @@ Hello James Bond Professor > mill foo.test PASS .../foo.test.ts -...generateUser function -...should generate a user with all specified fields... -...should default lastName and role when they are not provided... -...should default all fields when args is empty... ... Test Suites:...1 passed, 1 total... Tests:...3 passed, 3 total... -Snapshots:... -Time:... -Ran all test suites matching ... +... > mill show foo.bundle Build succeeded! diff --git a/example/javascriptlib/basic/1-simple/foo/test/foo.test.ts b/example/javascriptlib/basic/1-simple/foo/test/src/foo/foo.test.ts similarity index 96% rename from example/javascriptlib/basic/1-simple/foo/test/foo.test.ts rename to example/javascriptlib/basic/1-simple/foo/test/src/foo/foo.test.ts index 38c3254f535..850b0ff5790 100644 --- a/example/javascriptlib/basic/1-simple/foo/test/foo.test.ts +++ b/example/javascriptlib/basic/1-simple/foo/test/src/foo/foo.test.ts @@ -1,4 +1,4 @@ -import {generateUser, defaultRoles} from "../src/foo"; +import {generateUser, defaultRoles} from "foo/foo"; import {Map} from 'node_modules/immutable'; // Define the type roles object diff --git a/example/javascriptlib/basic/1-simple/jest.config.ts b/example/javascriptlib/basic/1-simple/jest.config.ts index d48a7cca0d0..8d03baaa2fc 100644 --- a/example/javascriptlib/basic/1-simple/jest.config.ts +++ b/example/javascriptlib/basic/1-simple/jest.config.ts @@ -1,13 +1,32 @@ +// @ts-nocheck +import {pathsToModuleNameMapper} from 'node_modules/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/**/*.test.ts', - '/**/test/**/*.test.js', + '/**/**/**/*.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'] + 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/build.mill b/example/javascriptlib/basic/3-custom-build-logic/build.mill index f71ee4dfc7e..dd7550f1157 100644 --- a/example/javascriptlib/basic/3-custom-build-logic/build.mill +++ b/example/javascriptlib/basic/3-custom-build-logic/build.mill @@ -2,7 +2,7 @@ package build import mill._, javascriptlib._ -object foo extends JestModule { +object foo extends TypeScriptModule { /** Total number of lines in module source files */ def lineCount = Task { @@ -20,6 +20,8 @@ object foo extends JestModule { super.mkENV() ++ Map("RESOURCE_PATH" -> resources().path.toString) } + object test extends TypeScriptTests with TestModule.Jest + } // Documentation for mill.example.javascriptlib @@ -28,15 +30,10 @@ object foo extends JestModule { > mill foo.test PASS .../foo.test.ts -...Foo.getLineCount -...should return the content of the line-count.txt file... -...should return null if the file cannot be read... ... Test Suites:...1 passed, 1 total... Tests:...2 passed, 2 total... -Snapshots:... -Time:... -Ran all test suites matching... +... > mill foo.run [Reading file:] .../out/foo/resources.dest/line-count.txt diff --git a/example/javascriptlib/basic/3-custom-build-logic/foo/test/foo.test.ts b/example/javascriptlib/basic/3-custom-build-logic/foo/test/src/foo/foo.test.ts similarity index 98% rename from example/javascriptlib/basic/3-custom-build-logic/foo/test/foo.test.ts rename to example/javascriptlib/basic/3-custom-build-logic/foo/test/src/foo/foo.test.ts index d8917373cf8..82b91dce789 100644 --- a/example/javascriptlib/basic/3-custom-build-logic/foo/test/foo.test.ts +++ b/example/javascriptlib/basic/3-custom-build-logic/foo/test/src/foo/foo.test.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import Foo from '../src/foo'; +import Foo from 'foo/foo'; // Mock the 'fs' module jest.mock('fs'); diff --git a/example/javascriptlib/basic/3-custom-build-logic/jest.config.ts b/example/javascriptlib/basic/3-custom-build-logic/jest.config.ts index 9771ed378d5..8d03baaa2fc 100644 --- a/example/javascriptlib/basic/3-custom-build-logic/jest.config.ts +++ b/example/javascriptlib/basic/3-custom-build-logic/jest.config.ts @@ -1,14 +1,32 @@ +// @ts-nocheck +import {pathsToModuleNameMapper} from 'node_modules/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/**/*.test.ts', - '/**/test/**/*.test.js', + '/**/**/**/*.test.ts', + '/**/**/**/*.test.js', ], transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', {tsconfig: 'tsconfig.json'}], - '^.+\\.(js|jsx)$': 'babel-jest', + '^.+\\.(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: {} + moduleNameMapper: pathsToModuleNameMapper(sortedModuleDeps) // use absolute paths generated in tsconfig. }; \ No newline at end of file diff --git a/example/javascriptlib/basic/4-multi-modules/build.mill b/example/javascriptlib/basic/4-multi-modules/build.mill index f4eb55495a2..01aebbc0640 100644 --- a/example/javascriptlib/basic/4-multi-modules/build.mill +++ b/example/javascriptlib/basic/4-multi-modules/build.mill @@ -9,8 +9,9 @@ object foo extends TypeScriptModule { } -object qux extends JestModule { +object qux extends TypeScriptModule { def moduleDeps = Seq(foo, foo.bar) + object test extends TypeScriptTests with TestModule.Jest } // Documentation for mill.example.javascriptlib diff --git a/example/javascriptlib/basic/4-multi-modules/jest.config.ts b/example/javascriptlib/basic/4-multi-modules/jest.config.ts index a2236af3e38..8d03baaa2fc 100644 --- a/example/javascriptlib/basic/4-multi-modules/jest.config.ts +++ b/example/javascriptlib/basic/4-multi-modules/jest.config.ts @@ -20,8 +20,8 @@ export default { preset: 'ts-jest', testEnvironment: 'node', testMatch: [ - '/**/test/**/*.test.ts', - '/**/test/**/*.test.js', + '/**/**/**/*.test.ts', + '/**/**/**/*.test.js', ], transform: { '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json' }], diff --git a/example/javascriptlib/basic/4-multi-modules/qux/test/qux.test.ts b/example/javascriptlib/basic/4-multi-modules/qux/test/src/qux/qux.test.ts similarity index 97% rename from example/javascriptlib/basic/4-multi-modules/qux/test/qux.test.ts rename to example/javascriptlib/basic/4-multi-modules/qux/test/src/qux/qux.test.ts index 3365f4a65c9..2d73bfa4134 100644 --- a/example/javascriptlib/basic/4-multi-modules/qux/test/qux.test.ts +++ b/example/javascriptlib/basic/4-multi-modules/qux/test/src/qux/qux.test.ts @@ -1,4 +1,4 @@ -import { generateUser } from "../src/qux"; +import { generateUser } from "qux/qux"; // Define the type roles object type RoleKeys = "admin" | "user"; diff --git a/example/javascriptlib/basic/5-client-server-hello/build.mill b/example/javascriptlib/basic/5-client-server-hello/build.mill index 17deb7d1eac..85251b9aaf5 100644 --- a/example/javascriptlib/basic/5-client-server-hello/build.mill +++ b/example/javascriptlib/basic/5-client-server-hello/build.mill @@ -3,7 +3,7 @@ import mill._, javascriptlib._ object client extends ReactScriptsModule -object server extends JestModule { +object server extends TypeScriptModule { override def mkENV = Task { super.mkENV() ++ Map("PORT" -> "3000") } @@ -13,6 +13,8 @@ object server extends JestModule { os.copy(clientBundle, Task.dest / "build") PathRef(Task.dest) } + + object test extends TypeScriptTests with TestModule.Jest } // Documentation for mill.example.javascriptlib @@ -33,6 +35,9 @@ PASS .../App.test.tsx ... PASS .../server.test.ts ... +Test Suites:...1 passed, 1 total... +Tests:...1 passed, 1 total... +... > mill show server.bundle # bundle the express server Build succeeded! diff --git a/example/javascriptlib/basic/5-client-server-hello/jest.config.ts b/example/javascriptlib/basic/5-client-server-hello/jest.config.ts index 066e517e83d..8d03baaa2fc 100644 --- a/example/javascriptlib/basic/5-client-server-hello/jest.config.ts +++ b/example/javascriptlib/basic/5-client-server-hello/jest.config.ts @@ -1,13 +1,32 @@ +// @ts-nocheck +import {pathsToModuleNameMapper} from 'node_modules/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/**/*.test.ts', - '/**/test/**/*.test.js', + '/**/**/**/*.test.ts', + '/**/**/**/*.test.js', ], transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', {tsconfig: 'tsconfig.json'}], + '^.+\\.(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/server/test/server.test.ts b/example/javascriptlib/basic/5-client-server-hello/server/test/src/server/server.test.ts similarity index 94% rename from example/javascriptlib/basic/5-client-server-hello/server/test/server.test.ts rename to example/javascriptlib/basic/5-client-server-hello/server/test/src/server/server.test.ts index 9d37ebb8b0d..7cb79a96ab6 100644 --- a/example/javascriptlib/basic/5-client-server-hello/server/test/server.test.ts +++ b/example/javascriptlib/basic/5-client-server-hello/server/test/src/server/server.test.ts @@ -4,7 +4,7 @@ describe('Server Tests', () => { let server: http.Server; beforeAll(() => { - server = require('../src/server').default; + server = require('server/server').default; process.env.NODE_ENV = "test"; }); diff --git a/example/javascriptlib/basic/6-client-server-realistic/build.mill b/example/javascriptlib/basic/6-client-server-realistic/build.mill index 5f535354cf6..bf679d0bb88 100644 --- a/example/javascriptlib/basic/6-client-server-realistic/build.mill +++ b/example/javascriptlib/basic/6-client-server-realistic/build.mill @@ -3,15 +3,13 @@ import mill._, javascriptlib._ object client extends ReactScriptsModule -object server extends JestModule { +object server extends TypeScriptModule { def npmDeps = Seq("@types/cors@^2.8.17", "@types/express@^5.0.0", "cors@^2.8.5", "express@^4.21.1") def npmDevDeps = super.npmDevDeps() ++ Seq("@types/supertest@^6.0.2", "supertest@^7.0.0") - override def testConfigSource = Task.Source(millSourcePath / "jest.config.ts") - override def bundleFlags = Map("external" -> Seq("express", "cors")) override def mkENV = Task { @@ -23,6 +21,10 @@ object server extends JestModule { os.copy(clientBundle, Task.dest / "build") PathRef(Task.dest) } + + object test extends TypeScriptTests with TestModule.Jest { + override def testConfigSource = Task.Source(millSourcePath / os.up / "jest.config.ts") + } } // Documentation for mill.example.javascriptlib @@ -37,6 +39,9 @@ object server extends JestModule { > mill server.test PASS .../server.test.ts ... +Test Suites:...1 passed, 1 total... +Tests:...3 passed, 3 total... +... > mill show server.bundle # bundle the express server Build succeeded! diff --git a/example/javascriptlib/basic/6-client-server-realistic/server/jest.config.ts b/example/javascriptlib/basic/6-client-server-realistic/server/jest.config.ts index 833f5e5feb2..8d03baaa2fc 100644 --- a/example/javascriptlib/basic/6-client-server-realistic/server/jest.config.ts +++ b/example/javascriptlib/basic/6-client-server-realistic/server/jest.config.ts @@ -1,13 +1,32 @@ +// @ts-nocheck +import {pathsToModuleNameMapper} from 'node_modules/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/**/*.test.ts', - '/**/test/**/*.test.js', + '/**/**/**/*.test.ts', + '/**/**/**/*.test.js', ], transform: { - '^.+\\.(ts|tsx)$': ['ts-jest', {tsconfig: 'tsconfig.json'}], + '^.+\\.(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'] + 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/server/test/server.test.ts b/example/javascriptlib/basic/6-client-server-realistic/server/test/src/server/server.test.ts similarity index 97% rename from example/javascriptlib/basic/6-client-server-realistic/server/test/server.test.ts rename to example/javascriptlib/basic/6-client-server-realistic/server/test/src/server/server.test.ts index 001eee45094..7f705f4d295 100644 --- a/example/javascriptlib/basic/6-client-server-realistic/server/test/server.test.ts +++ b/example/javascriptlib/basic/6-client-server-realistic/server/test/src/server/server.test.ts @@ -9,7 +9,7 @@ describe('Server Tests', () => { beforeAll((done) => { process.env.PORT = '3002'; process.env.NODE_ENV = 'test'; - app = require('../src/server').default; + app = require('server/server').default; server = app.listen(process.env.PORT, done); }); diff --git a/example/javascriptlib/testing/1-test-suite/bar/src/calculator.ts b/example/javascriptlib/testing/1-test-suite/bar/src/calculator.ts new file mode 100644 index 00000000000..2e9b2aa67d5 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/bar/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/testing/1-test-suite/bar/test/src/bar/calculator.test.ts b/example/javascriptlib/testing/1-test-suite/bar/test/src/bar/calculator.test.ts new file mode 100644 index 00000000000..3aeb3cf9324 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/bar/test/src/bar/calculator.test.ts @@ -0,0 +1,28 @@ +import {Calculator} from 'bar/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/testing/1-test-suite/build.mill b/example/javascriptlib/testing/1-test-suite/build.mill new file mode 100644 index 00000000000..a533508d4ed --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/build.mill @@ -0,0 +1,30 @@ +package build + +import mill._, javascriptlib._ + +object bar extends TypeScriptModule { + object test extends TypeScriptTests with TestModule.Jest +} + +object foo extends TypeScriptModule { + object test extends TypeScriptTests with TestModule.Mocha +} + +// Documentation for mill.example.javascriptlib +// This build defines two modules bar and foo with test suites configured to use +// Mocha & Jest resepectively. + +/** Usage + +> mill foo.test +... +...4 passing... +... + +> mill bar.test +...Calculator +... +Test Suites:...1 passed, 1 total... +Tests:...4 passed, 4 total... +... +*/ diff --git a/example/javascriptlib/testing/1-test-suite/foo/src/calculator.ts b/example/javascriptlib/testing/1-test-suite/foo/src/calculator.ts new file mode 100644 index 00000000000..2e9b2aa67d5 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/foo/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/testing/1-test-suite/foo/test/src/foo/calculator.test.ts b/example/javascriptlib/testing/1-test-suite/foo/test/src/foo/calculator.test.ts new file mode 100644 index 00000000000..560882e7359 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/foo/test/src/foo/calculator.test.ts @@ -0,0 +1,30 @@ +import {expect} from 'chai'; +import {Calculator} from 'foo/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); + }); + }); + + describe('Division', () => { + it('should return the quotient of two numbers', () => { + const result = calculator.divide(6, 3); + expect(result).to.equal(2); + }); + + it('should throw an error when dividing by zero', () => { + expect(() => calculator.divide(6, 0)).to.throw("Division by zero is not allowed"); + }); + }); + +}); \ No newline at end of file diff --git a/example/javascriptlib/testing/1-test-suite/jest.config.ts b/example/javascriptlib/testing/1-test-suite/jest.config.ts new file mode 100644 index 00000000000..97e37c4df11 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/jest.config.ts @@ -0,0 +1,24 @@ +// @ts-nocheck +import {pathsToModuleNameMapper} from 'node_modules/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/2-test-deps/bar/src/bar.ts b/example/javascriptlib/testing/2-test-deps/bar/src/bar.ts new file mode 100644 index 00000000000..799b95098ef --- /dev/null +++ b/example/javascriptlib/testing/2-test-deps/bar/src/bar.ts @@ -0,0 +1,14 @@ +import {Map} from 'node_modules/immutable'; + +export default interface User { + firstName: string + lastName: string + role: string +} + +const defaultRoles: Map = Map({ + prof: "Professor", + student: "Student", +}); + +export {defaultRoles} \ No newline at end of file diff --git a/example/javascriptlib/testing/2-test-deps/bar/test/src/bar/bar.test.ts b/example/javascriptlib/testing/2-test-deps/bar/test/src/bar/bar.test.ts new file mode 100644 index 00000000000..ba9340d803d --- /dev/null +++ b/example/javascriptlib/testing/2-test-deps/bar/test/src/bar/bar.test.ts @@ -0,0 +1,14 @@ +import {defaultRoles} from 'bar/bar'; +import {Map} from 'node_modules/immutable'; +import {compare} from 'bar/test/utils/bar.tests.utils'; + +test('defaultRoles map should have correct values', () => { + expect(compare(defaultRoles.size, 2)).toBeTruthy() + expect(compare(defaultRoles.get('prof'), 'Professor')).toBeTruthy() + expect(compare(defaultRoles.get('student'), 'Student')).toBeTruthy() + expect(compare(defaultRoles.has('admin'), false)).toBeTruthy() +}); + +test('defaultRoles map should be an instance of Immutable Map', () => { + expect(compare((defaultRoles instanceof Map), true)).toBeTruthy() +}); \ No newline at end of file diff --git a/example/javascriptlib/testing/2-test-deps/bar/test/src/utils/bar.tests.utils.ts b/example/javascriptlib/testing/2-test-deps/bar/test/src/utils/bar.tests.utils.ts new file mode 100644 index 00000000000..044d2995080 --- /dev/null +++ b/example/javascriptlib/testing/2-test-deps/bar/test/src/utils/bar.tests.utils.ts @@ -0,0 +1,12 @@ +function compareObjects(objA, objB) { + const keysA = Object.keys(objA); + return keysA.every(key => key in objB && objA[key] === objB[key]); +} + +export function compareObject(x: any, y: any): Boolean { + return compareObjects(x, y) +} + +export function compare(x: any, y: any): Boolean { + return x === y +} \ No newline at end of file diff --git a/example/javascriptlib/testing/2-test-deps/build.mill b/example/javascriptlib/testing/2-test-deps/build.mill new file mode 100644 index 00000000000..fb3c7e6edfb --- /dev/null +++ b/example/javascriptlib/testing/2-test-deps/build.mill @@ -0,0 +1,37 @@ +package build + +import mill._, javascriptlib._ + +object bar extends TypeScriptModule { + def npmDeps = Seq("immutable@4.3.7") + object test extends TypeScriptTests with TestModule.Jest +} + +object foo extends TypeScriptModule { + def moduleDeps = Seq(bar) + object test extends TypeScriptTests with TestModule.Jest +} + +// Documentation for mill.example.javascriptlib +// In this example, `foo` depend on `bar`, but we also make +// `foo.test` depend on `bar.test`. + +// That lets `foo.test` make use of the default function exported from +// `bar/test/utils/bar.tests.utils.ts`, allowing us to re-use this +// test helper throughout multiple modules' test suites. + +/** Usage +> mill foo.test +PASS .../foo.test.ts +... +Test Suites:...1 passed, 1 total... +Tests:...3 passed, 3 total... +... + +> mill bar.test +PASS .../bar.test.ts +... +Test Suites:...1 passed, 1 total... +Tests:...2 passed, 2 total... +... +*/ diff --git a/example/javascriptlib/testing/2-test-deps/foo/src/foo.ts b/example/javascriptlib/testing/2-test-deps/foo/src/foo.ts new file mode 100644 index 00000000000..22f9e4da858 --- /dev/null +++ b/example/javascriptlib/testing/2-test-deps/foo/src/foo.ts @@ -0,0 +1,25 @@ +import User, {defaultRoles} from 'bar/bar'; + +/** + * Generate a user object based on command-line arguments + * @param args Command-line arguments + * @returns User object + */ +export default function generateUser(args: string[]): User { + return { + firstName: args[0] || "unknown", // Default to "unknown" if first-name not found + lastName: args[1] || "unknown", // Default to "unknown" if last-name not found + role: defaultRoles.get(args[2], ""), // Default to empty string if role not found + }; +} + +// Main CLI logic +if (process.env.NODE_ENV !== "test") { + const args = process.argv.slice(2); // Skip 'node' and script name + const user = generateUser(args); + + console.log(defaultRoles.toObject()); + console.log(args[2]); + console.log(defaultRoles.get(args[2])); + console.log("Hello " + user.firstName + " " + user.lastName + " " + user.role); +} \ No newline at end of file diff --git a/example/javascriptlib/testing/2-test-deps/foo/test/src/foo/foo.test.ts b/example/javascriptlib/testing/2-test-deps/foo/test/src/foo/foo.test.ts new file mode 100644 index 00000000000..35074371bf5 --- /dev/null +++ b/example/javascriptlib/testing/2-test-deps/foo/test/src/foo/foo.test.ts @@ -0,0 +1,61 @@ +import generateUser from "foo/foo"; +import {compareObject} from 'bar/test/utils/bar.tests.utils' + +// Define the type roles object +type RoleKeys = "admin" | "user"; +type Roles = { + [key in RoleKeys]: string; +}; + +// Mock the defaultRoles.get method +jest.mock("bar/bar", () => ({ + defaultRoles: { + get: jest.fn((role: string, defaultValue: string) => { + const roles: Roles = {"admin": "Administrator", "user": "User"}; + return roles[role as RoleKeys] || defaultValue; + }), + }, +})); + +describe("generateUser function", () => { + beforeAll(() => { + process.env.NODE_ENV = "test"; // Set NODE_ENV for all tests + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should generate a user with all specified fields", () => { + const args = ["John", "Doe", "admin"]; + const user = generateUser(args); + + expect(compareObject({...user}, { + firstName: "John", + lastName: "Doe", + role: "Administrator", + })).toBeTruthy() + }); + + test("should default lastName and role when they are not provided", () => { + const args = ["Jane"]; + const user = generateUser(args); + + expect(compareObject(user, { + firstName: "Jane", + lastName: "unknown", + role: "", + })).toBeTruthy() + }); + + test("should default all fields when args is empty", () => { + const args: string[] = []; + const user = generateUser(args); + + expect(compareObject(user, { + firstName: "unknown", + lastName: "unknown", + role: "", + })).toBeTruthy() + }); +}); \ 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 new file mode 100644 index 00000000000..97e37c4df11 --- /dev/null +++ b/example/javascriptlib/testing/2-test-deps/jest.config.ts @@ -0,0 +1,24 @@ +// @ts-nocheck +import {pathsToModuleNameMapper} from 'node_modules/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 1616927e8ca..bbbd900a963 100644 --- a/example/package.mill +++ b/example/package.mill @@ -63,6 +63,7 @@ object `package` extends RootModule with Module { } object javascriptlib extends Module { object basic extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "basic")) + object testing extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "testing")) } object pythonlib extends Module { object basic extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "basic")) diff --git a/javascriptlib/src/mill/javascriptlib/JestModule.scala b/javascriptlib/src/mill/javascriptlib/JestModule.scala deleted file mode 100644 index 3dfe9a5d262..00000000000 --- a/javascriptlib/src/mill/javascriptlib/JestModule.scala +++ /dev/null @@ -1,74 +0,0 @@ -package mill.javascriptlib -import mill.* -import os.* -import mill.define.Target -import scala.collection.immutable.IndexedSeq - -trait JestModule extends TypeScriptModule { - override def npmDevDeps: T[Seq[String]] = Task { - super.npmDeps() ++ Seq( - "@types/jest@^29.5.14", - "@babel/core@^7.26.0", - "@babel/preset-env@^7.26.0", - "jest@^29.7.0", - "ts-jest@^29.2.5", - "babel-jest@^29.7.0" - ) - } - - def testSource: Target[PathRef] = Task.Source(millSourcePath / "test") - - def testConfigSource: Target[PathRef] = Task.Source(millSourcePath / os.up / "jest.config.ts") - - override def allSources: Target[IndexedSeq[PathRef]] = Task { - (os.walk(sources().path) ++ os.walk(testSource().path) ++ IndexedSeq(testConfigSource().path)) - .filter(_.ext == "ts") - .map(PathRef(_)) - } - - override def mkENV = Task { - val javascriptOut = compile()._1.path - // env - // note: ' npmInstall().path / "node_modules" ' required in NODE_PATH for jest to find preset: ts-jest - Map("NODE_PATH" -> Seq( - ".", - javascriptOut, - npmInstall().path, - npmInstall().path / "node_modules" - ).mkString(":")) - } - - // specify config file path: --config /path/to/jest/config - def getConfigFile: Task[String] = - Task { (compile()._1.path / "jest.config.ts").toString } - - override def compilerOptions: T[Map[String, ujson.Value]] = - Task { super.compilerOptions() + ("resolveJsonModule" -> ujson.Bool(true)) } - - // specify test dir path/to/test - def getPathToTest: Task[String] = - Task { compile()._2.path.toString } - - private def copyJestConfig: Task[Unit] = Task.Anon { - os.copy.over( - testConfigSource().path, - compile()._1.path / "jest.config.ts" - ) - } - - def test: Target[CommandResult] = Task { - copyJestConfig() - os.call( - ( - "node", - npmInstall().path / "node_modules/jest/bin/jest.js", - "--config", - getConfigFile(), - getPathToTest() - ), - stdout = os.Inherit, - env = mkENV(), - cwd = compile()._1.path - ) - } -} diff --git a/javascriptlib/src/mill/javascriptlib/TestModule.scala b/javascriptlib/src/mill/javascriptlib/TestModule.scala new file mode 100644 index 00000000000..c5b19f484fd --- /dev/null +++ b/javascriptlib/src/mill/javascriptlib/TestModule.scala @@ -0,0 +1,147 @@ +package mill.javascriptlib + +import mill.* +import os.* + +trait TestModule extends TaskModule { + import TestModule.TestResult + + def test(args: String*): Command[TestResult] = + Task.Command { + testTask(Task.Anon { args })() + } + + def testCachedArgs: T[Seq[String]] = Task { Seq[String]() } + + def testCached: T[Unit] = Task { + testTask(testCachedArgs)() + } + + protected def testTask(args: Task[Seq[String]]): Task[TestResult] + + override def defaultCommandName() = "test" +} + +object TestModule { + type TestResult = Unit + + trait Shared extends TypeScriptModule { + override def upstreamPathsBuilder: T[Seq[(String, String)]] = + Task { + val stuUpstreams = for { + ((_, ts), mod) <- Task.traverse(moduleDeps)(_.compile)().zip(moduleDeps) + } yield ( + mod.millSourcePath.subRelativeTo(Task.workspace).toString + "/test/utils/*", + (ts.path / "test/src/utils").toString + ) + + stuUpstreams ++ super.upstreamPathsBuilder() + } + + def getPathToTest: T[String] = Task { compile()._2.path.toString } + } + + trait Jest extends TypeScriptModule with Shared with TestModule { + override def npmDevDeps: T[Seq[String]] = Task { + Seq( + "@types/jest@^29.5.14", + "@babel/core@^7.26.0", + "@babel/preset-env@^7.26.0", + "jest@^29.7.0", + "ts-jest@^29.2.5", + "babel-jest@^29.7.0" + ) + } + + def testConfigSource: T[PathRef] = + Task.Source(Task.workspace / "jest.config.ts") + + override def allSources: T[IndexedSeq[PathRef]] = Task { + super.allSources() ++ IndexedSeq(testConfigSource()) + } + + override def compilerOptions: T[Map[String, ujson.Value]] = + Task { super.compilerOptions() + ("resolveJsonModule" -> ujson.Bool(true)) } + + def getConfigFile: T[String] = + Task { (compile()._1.path / "jest.config.ts").toString } + + private def copyConfig: Task[TestResult] = Task.Anon { + os.copy.over( + testConfigSource().path, + compile()._1.path / "jest.config.ts" + ) + } + + private def runTest: T[TestResult] = Task { + copyConfig() + os.call( + ( + "node", + npmInstall().path / "node_modules/jest/bin/jest.js", + "--config", + getConfigFile(), + getPathToTest() + ), + stdout = os.Inherit, + env = mkENV(), + cwd = compile()._1.path + ) + () + } + + protected def testTask(args: Task[Seq[String]]): Task[TestResult] = Task.Anon { + runTest() + } + + } + + trait Mocha extends TypeScriptModule with Shared with TestModule { + override def npmDevDeps: T[Seq[String]] = Task { + Seq( + "@types/chai@4.3.1", + "@types/mocha@9.1.1", + "chai@4.3.6", + "mocha@10.0.0" + ) + } + + override def getPathToTest: T[String] = + Task { super.getPathToTest() + "/**/**/*.test.ts" } + + // test-runner.js: run tests on ts files + private def testRunnerBuilder: Task[Path] = Task.Anon { + val compiled = compile()._1.path + val testRunner = compiled / "test-runner.js" + + val content = + """|require('node_modules/ts-node/register'); + |require('tsconfig-paths/register'); + |require('node_modules/mocha/bin/_mocha'); + |""".stripMargin + + os.write(testRunner, content) + + testRunner + } + + private def runTest: T[Unit] = Task { + os.call( + ( + "node", + testRunnerBuilder(), + getPathToTest() + ), + stdout = os.Inherit, + env = mkENV(), + cwd = compile()._1.path + ) + () + } + + protected def testTask(args: Task[Seq[String]]): Task[TestResult] = Task.Anon { + runTest() + } + + } +} diff --git a/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala b/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala index e6802845d56..13f158891d4 100644 --- a/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala +++ b/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala @@ -4,7 +4,7 @@ import os.* import scala.collection.immutable.IndexedSeq -trait TypeScriptModule extends Module { +trait TypeScriptModule extends Module { outer => def moduleDeps: Seq[TypeScriptModule] = Nil def npmDeps: T[Seq[String]] = Task { Seq.empty[String] } @@ -99,8 +99,16 @@ trait TypeScriptModule extends Module { def mainFilePath: Target[Path] = Task { compile()._2.path / "src" / mainFileName() } - def mkENV: Task[Map[String, String]] = - Task.Anon { Map("NODE_PATH" -> Seq(".", compile()._2.path, npmInstall().path).mkString(":")) } + def mkENV: T[Map[String, String]] = + Task { + Map("NODE_PATH" -> Seq( + ".", + compile()._1.path, + compile()._2.path, + npmInstall().path, + npmInstall().path / "node_modules" + ).mkString(":")) + } // define computed arguments and where they should be placed (before/after user arguments) def computedArgs: Task[Seq[String]] = Task { Seq.empty[String] } @@ -172,4 +180,8 @@ trait TypeScriptModule extends Module { def resources: T[PathRef] = Task { PathRef(Task.dest) } + trait TypeScriptTests extends TypeScriptModule { + override def moduleDeps: Seq[TypeScriptModule] = Seq(outer) ++ outer.moduleDeps + } + }