From 71975051669bbbb3a108231e225ca0a5a4f810b0 Mon Sep 17 00:00:00 2001 From: Monye David Onoh Date: Sun, 15 Dec 2024 16:35:16 +0100 Subject: [PATCH] Add first class Javascript/Typescript support to the Mill build tool (#4135) https://github.com/com-lihaoyi/mill/issues/3927 ### Checklist - [x] **example/jslib/testing/1-test-suite** - [x] vite - [x] Jasmine --- .../1-test-suite/baz/src/calculator.js | 11 ++ .../1-test-suite/baz/src/calculator.ts | 12 ++ .../baz/test/src/baz/calculator.test.js | 24 ++++ .../baz/test/src/baz/calculator.test.ts | 28 ++++ .../testing/1-test-suite/build.mill | 18 +++ .../1-test-suite/qux/src/calculator.js | 11 ++ .../1-test-suite/qux/src/calculator.ts | 12 ++ .../qux/test/src/calculator.test.ts | 28 ++++ .../testing/1-test-suite/vite.config.ts | 12 ++ .../src/mill/javascriptlib/TestModule.scala | 129 +++++++++++++++++- .../mill/javascriptlib/TypeScriptModule.scala | 20 +-- 11 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 example/javascriptlib/testing/1-test-suite/baz/src/calculator.js create mode 100644 example/javascriptlib/testing/1-test-suite/baz/src/calculator.ts create mode 100644 example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.js create mode 100644 example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.ts create mode 100644 example/javascriptlib/testing/1-test-suite/qux/src/calculator.js create mode 100644 example/javascriptlib/testing/1-test-suite/qux/src/calculator.ts create mode 100644 example/javascriptlib/testing/1-test-suite/qux/test/src/calculator.test.ts create mode 100644 example/javascriptlib/testing/1-test-suite/vite.config.ts diff --git a/example/javascriptlib/testing/1-test-suite/baz/src/calculator.js b/example/javascriptlib/testing/1-test-suite/baz/src/calculator.js new file mode 100644 index 00000000000..7d632b027a2 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/baz/src/calculator.js @@ -0,0 +1,11 @@ +export class Calculator { + add(a, b) { + return a + b; + } + divide(a, b) { + if (b === 0) { + throw new Error("Division by zero is not allowed"); + } + return a / b; + } +} diff --git a/example/javascriptlib/testing/1-test-suite/baz/src/calculator.ts b/example/javascriptlib/testing/1-test-suite/baz/src/calculator.ts new file mode 100644 index 00000000000..2e9b2aa67d5 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/baz/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/baz/test/src/baz/calculator.test.js b/example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.js new file mode 100644 index 00000000000..b0024018ed6 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.js @@ -0,0 +1,24 @@ +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"); + }); + }); +}); diff --git a/example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.ts b/example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.ts new file mode 100644 index 00000000000..09bc7d16b00 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/baz/test/src/baz/calculator.test.ts @@ -0,0 +1,28 @@ +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/testing/1-test-suite/build.mill b/example/javascriptlib/testing/1-test-suite/build.mill index a533508d4ed..66559cc551d 100644 --- a/example/javascriptlib/testing/1-test-suite/build.mill +++ b/example/javascriptlib/testing/1-test-suite/build.mill @@ -6,10 +6,18 @@ object bar extends TypeScriptModule { object test extends TypeScriptTests with TestModule.Jest } +object baz extends TypeScriptModule { + object test extends TypeScriptTests with TestModule.Vitest +} + object foo extends TypeScriptModule { object test extends TypeScriptTests with TestModule.Mocha } +object qux extends TypeScriptModule { + object test extends TypeScriptTests with TestModule.Jasmine +} + // Documentation for mill.example.javascriptlib // This build defines two modules bar and foo with test suites configured to use // Mocha & Jest resepectively. @@ -27,4 +35,14 @@ object foo extends TypeScriptModule { Test Suites:...1 passed, 1 total... Tests:...4 passed, 4 total... ... + +> mill baz.test +.../calculator.test.ts... +...Test Files 1 passed... +...Tests 4 passed... +... + +> mill qux.test +... +4 specs, 0 failures */ diff --git a/example/javascriptlib/testing/1-test-suite/qux/src/calculator.js b/example/javascriptlib/testing/1-test-suite/qux/src/calculator.js new file mode 100644 index 00000000000..7d632b027a2 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/qux/src/calculator.js @@ -0,0 +1,11 @@ +export class Calculator { + add(a, b) { + return a + b; + } + divide(a, b) { + if (b === 0) { + throw new Error("Division by zero is not allowed"); + } + return a / b; + } +} diff --git a/example/javascriptlib/testing/1-test-suite/qux/src/calculator.ts b/example/javascriptlib/testing/1-test-suite/qux/src/calculator.ts new file mode 100644 index 00000000000..2e9b2aa67d5 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/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/testing/1-test-suite/qux/test/src/calculator.test.ts b/example/javascriptlib/testing/1-test-suite/qux/test/src/calculator.test.ts new file mode 100644 index 00000000000..cbaa895121c --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/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/testing/1-test-suite/vite.config.ts b/example/javascriptlib/testing/1-test-suite/vite.config.ts new file mode 100644 index 00000000000..a5993128c19 --- /dev/null +++ b/example/javascriptlib/testing/1-test-suite/vite.config.ts @@ -0,0 +1,12 @@ +/// +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/javascriptlib/src/mill/javascriptlib/TestModule.scala b/javascriptlib/src/mill/javascriptlib/TestModule.scala index c5b19f484fd..f65553b19e6 100644 --- a/javascriptlib/src/mill/javascriptlib/TestModule.scala +++ b/javascriptlib/src/mill/javascriptlib/TestModule.scala @@ -56,10 +56,6 @@ object TestModule { 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)) } @@ -144,4 +140,129 @@ object TestModule { } } + + trait Vitest extends TypeScriptModule with Shared with TestModule { + override def npmDevDeps: T[Seq[String]] = + Task { + Seq( + "@vitest/runner@2.1.8", + "vite@5.4.11", + "vite-tsconfig-paths@3.6.0", + "vitest@2.1.8" + ) + } + + def testConfigSource: T[PathRef] = + Task.Source(Task.workspace / "vite.config.ts") + + override def compilerOptions: T[Map[String, ujson.Value]] = + Task { + super.compilerOptions() + ( + "target" -> ujson.Str("ESNext"), + "module" -> ujson.Str("ESNext"), + "moduleResolution" -> ujson.Str("Node"), + "skipLibCheck" -> ujson.Bool(true), + "types" -> ujson.Arr( + s"${npmInstall().path}/node_modules/vitest/globals" + ) + ) + } + + def getConfigFile: T[String] = + Task { (compile()._1.path / "vite.config.ts").toString } + + private def copyConfig: Task[Unit] = Task.Anon { + os.copy.over( + testConfigSource().path, + compile()._1.path / "vite.config.ts" + ) + } + + private def runTest: T[TestResult] = Task { + copyConfig() + os.call( + ( + "node", + npmInstall().path / "node_modules/.bin/vitest", + "--run", + "--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 Jasmine extends TypeScriptModule with Shared with TestModule { + override def npmDevDeps: T[Seq[String]] = + Task { + Seq( + "@types/jasmine@5.1.2", + "jasmine@5.1.0", + "ts-node@10.9.1", + "tsconfig-paths@4.2.0", + "typescript@5.2.2" + ) + } + + override def compilerOptions: T[Map[String, ujson.Value]] = + Task { + super.compilerOptions() + ( + "target" -> ujson.Str("ES5"), + "module" -> ujson.Str("commonjs"), + "moduleResolution" -> ujson.Str("node"), + "allowJs" -> ujson.Bool(true) + ) + } + + def configBuilder: T[PathRef] = Task { + val path = compile()._1.path / "jasmine.json" + os.write( + path, + ujson.write( + ujson.Obj( + "spec_dir" -> ujson.Str("typescript/src"), + "spec_files" -> ujson.Arr(ujson.Str("**/*.test.ts")), + "stopSpecOnExpectationFailure" -> ujson.Bool(false), + "random" -> ujson.Bool(false) + ) + ) + ) + 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" + os.call( + ( + "node", + jasmine, + "--config=jasmine.json", + s"--require=$tsnode", + s"--require=$tsconfigPath" + ), + 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 13f158891d4..1a9124bdbe5 100644 --- a/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala +++ b/javascriptlib/src/mill/javascriptlib/TypeScriptModule.scala @@ -19,13 +19,13 @@ trait TypeScriptModule extends Module { outer => Task.traverse(moduleDeps)(_.npmDevDeps)().flatten ++ npmDevDeps() } - def npmInstall: Target[PathRef] = Task { + def npmInstall: T[PathRef] = Task { os.call(( "npm", "install", "--save-dev", - "@types/node@22.7.8", - "typescript@5.6.3", + "@types/node@22.10.2", + "typescript@5.7.2", "ts-node@^10.9.2", "esbuild@0.24.0", "@esbuild-plugins/tsconfig-paths@0.1.2", @@ -36,13 +36,13 @@ trait TypeScriptModule extends Module { outer => PathRef(Task.dest) } - def sources: Target[PathRef] = Task.Source(millSourcePath / "src") + def sources: T[PathRef] = Task.Source(millSourcePath / "src") - def allSources: Target[IndexedSeq[PathRef]] = + def allSources: T[IndexedSeq[PathRef]] = Task { os.walk(sources().path).filter(_.ext == "ts").map(PathRef(_)) } // specify tsconfig.compilerOptions - def compilerOptions: Task[Map[String, ujson.Value]] = Task { + def compilerOptions: Task[Map[String, ujson.Value]] = Task.Anon { Map( "esModuleInterop" -> ujson.Bool(true), "declaration" -> ujson.Bool(true), @@ -89,15 +89,15 @@ trait TypeScriptModule extends Module { outer => ) ) - os.call(npmInstall().path / "node_modules/typescript/bin/tsc") + os.call(npmInstall().path / "node_modules/.bin/tsc") os.copy.over(millSourcePath, Task.dest / "typescript") (PathRef(Task.dest), PathRef(Task.dest / "typescript")) } - def mainFileName: Target[String] = Task { s"${millSourcePath.last}.ts" } + def mainFileName: T[String] = Task { s"${millSourcePath.last}.ts" } - def mainFilePath: Target[Path] = Task { compile()._2.path / "src" / mainFileName() } + def mainFilePath: T[Path] = Task { compile()._2.path / "src" / mainFileName() } def mkENV: T[Map[String, String]] = Task { @@ -158,7 +158,7 @@ trait TypeScriptModule extends Module { outer => } - def bundle: Target[PathRef] = Task { + def bundle: T[PathRef] = Task { val env = mkENV() val tsnode = npmInstall().path / "node_modules/.bin/ts-node" val bundleScript = compile()._1.path / "build.ts"