From 31d03bbf0dd483b9a453e20d34c73c7b11e8caae Mon Sep 17 00:00:00 2001 From: Monye David Onoh Date: Tue, 7 Jan 2025 22:53:11 +0100 Subject: [PATCH] Add first class Javascript/Typescript support to the Mill build tool (#4253) This pr implements the examples for jslib/dependencies. https://github.com/com-lihaoyi/mill/issues/3927 Checklist: - [x] **example/jslib/testing** - [x] 3-integration-suite-cypress - [x] 3-integration-suite-playwright --- .../ROOT/pages/javascriptlib/testing.adoc | 11 +- .../3-integration-suite-cypress/build.mill | 39 +++++ .../client/public/index.html | 20 +++ .../client/src/app/App.tsx | 22 +++ .../client/src/index.tsx | 12 ++ .../cypress.config.ts | 10 ++ .../server/src/server.ts | 21 +++ .../test/src/server/cypress/e2e/app.cy.ts | 9 ++ .../3-integration-suite-playwright/build.mill | 39 +++++ .../client/public/index.html | 20 +++ .../client/src/app/App.tsx | 22 +++ .../client/src/index.tsx | 12 ++ .../playwright.config.ts | 27 ++++ .../server/src/server.ts | 21 +++ .../test/src/server/playwright/app.test.ts | 13 ++ .../src/mill/javascriptlib/TestModule.scala | 141 ++++++++++++++++++ 16 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 example/javascriptlib/testing/3-integration-suite-cypress/build.mill create mode 100644 example/javascriptlib/testing/3-integration-suite-cypress/client/public/index.html create mode 100644 example/javascriptlib/testing/3-integration-suite-cypress/client/src/app/App.tsx create mode 100644 example/javascriptlib/testing/3-integration-suite-cypress/client/src/index.tsx create mode 100644 example/javascriptlib/testing/3-integration-suite-cypress/cypress.config.ts create mode 100644 example/javascriptlib/testing/3-integration-suite-cypress/server/src/server.ts create mode 100644 example/javascriptlib/testing/3-integration-suite-cypress/server/test/src/server/cypress/e2e/app.cy.ts create mode 100644 example/javascriptlib/testing/3-integration-suite-playwright/build.mill create mode 100644 example/javascriptlib/testing/3-integration-suite-playwright/client/public/index.html create mode 100644 example/javascriptlib/testing/3-integration-suite-playwright/client/src/app/App.tsx create mode 100644 example/javascriptlib/testing/3-integration-suite-playwright/client/src/index.tsx create mode 100644 example/javascriptlib/testing/3-integration-suite-playwright/playwright.config.ts create mode 100644 example/javascriptlib/testing/3-integration-suite-playwright/server/src/server.ts create mode 100644 example/javascriptlib/testing/3-integration-suite-playwright/server/test/src/server/playwright/app.test.ts diff --git a/docs/modules/ROOT/pages/javascriptlib/testing.adoc b/docs/modules/ROOT/pages/javascriptlib/testing.adoc index bc1a3f7b57b..ce86231289b 100644 --- a/docs/modules/ROOT/pages/javascriptlib/testing.adoc +++ b/docs/modules/ROOT/pages/javascriptlib/testing.adoc @@ -8,7 +8,14 @@ This page will discuss common topics around working with test suites using the M include::partial$example/javascriptlib/testing/1-test-suite.adoc[] - == Test Dependencies -include::partial$example/javascriptlib/testing/2-test-deps.adoc[] \ No newline at end of file +include::partial$example/javascriptlib/testing/2-test-deps.adoc[] + +== Integration Suite with Cypress + +include::partial$example/javascriptlib/testing/3-integration-suite-cypress.adoc[] + +== Integration Suite with PlayWright + +include::partial$example/javascriptlib/testing/3-integration-suite-playwright.adoc[] \ No newline at end of file diff --git a/example/javascriptlib/testing/3-integration-suite-cypress/build.mill b/example/javascriptlib/testing/3-integration-suite-cypress/build.mill new file mode 100644 index 00000000000..fc45cfef287 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-cypress/build.mill @@ -0,0 +1,39 @@ +package build + +import mill._, javascriptlib._ + +object client extends ReactScriptsModule + +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") + + /** Bundle client as resource */ + def resources = Task { + os.copy(client.bundle().path, Task.dest / "build") + super.resources() ++ Seq(PathRef(Task.dest)) + } + + override def forkEnv = super.forkEnv() + ("PORT" -> "4000") + + object test extends TypeScriptTests with TestModule.Cypress { + def service = server + def port = "4000" + } +} + +// Documentation for mill.example.javascriptlib +// In this example we demonstrate integration testing using cypress +// `mill server.test` will start the service on the speicifed port, run tests with configurations defined in cypress.config.ts +// and kill the service once completed + +/** Usage + +> mill server.test +... +...Server listening on port 4000 +... app.cy.ts... +... All specs passed!... +... +*/ diff --git a/example/javascriptlib/testing/3-integration-suite-cypress/client/public/index.html b/example/javascriptlib/testing/3-integration-suite-cypress/client/public/index.html new file mode 100644 index 00000000000..9b54e4e9c48 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-cypress/client/public/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + React App + + + +
+ + diff --git a/example/javascriptlib/testing/3-integration-suite-cypress/client/src/app/App.tsx b/example/javascriptlib/testing/3-integration-suite-cypress/client/src/app/App.tsx new file mode 100644 index 00000000000..7cb389c54c2 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-cypress/client/src/app/App.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +function App() { + return ( +
+
+

Hello, Cypress & PlayWright

+

Brought to you by ✨✨mill.✨✨

+ + Learn React + +
+
+ ); +} + +export default App; diff --git a/example/javascriptlib/testing/3-integration-suite-cypress/client/src/index.tsx b/example/javascriptlib/testing/3-integration-suite-cypress/client/src/index.tsx new file mode 100644 index 00000000000..c3ddcfb70ba --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-cypress/client/src/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './app/App'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); +root.render( + + + +); \ No newline at end of file diff --git a/example/javascriptlib/testing/3-integration-suite-cypress/cypress.config.ts b/example/javascriptlib/testing/3-integration-suite-cypress/cypress.config.ts new file mode 100644 index 00000000000..5dfc7c5f894 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-cypress/cypress.config.ts @@ -0,0 +1,10 @@ +// @ts-nocheck +import { defineConfig } from 'node_modules/cypress'; + +export default defineConfig({ + e2e: { + specPattern: '**/e2e/*.cy.ts', + baseUrl: 'http://localhost:4000', + supportFile: false + } +}); \ No newline at end of file diff --git a/example/javascriptlib/testing/3-integration-suite-cypress/server/src/server.ts b/example/javascriptlib/testing/3-integration-suite-cypress/server/src/server.ts new file mode 100644 index 00000000000..d98689f6cb3 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-cypress/server/src/server.ts @@ -0,0 +1,21 @@ +import express, {Express} from 'express'; +import cors from 'cors'; + +const Resources: string = (process.env.RESOURCESDEST || "@server/resources.dest") + "/build" // `RESOURCES` is generated on bundle +const Client = require.resolve(`${Resources}/index.html`); + +const app: Express = express(); +const port = process.env.PORT || 3001; +const BuildPath = Client.replace(/index\.html$/, ""); + +app.use(cors()); +app.use(express.json()); + +// Middleware to serve static files from the "build" directory +app.use(express.static(BuildPath)); + +app.listen(port, () => { + console.log(`Server listening on port ${port}`); +}); + +export default app; \ No newline at end of file diff --git a/example/javascriptlib/testing/3-integration-suite-cypress/server/test/src/server/cypress/e2e/app.cy.ts b/example/javascriptlib/testing/3-integration-suite-cypress/server/test/src/server/cypress/e2e/app.cy.ts new file mode 100644 index 00000000000..1f87138ad9e --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-cypress/server/test/src/server/cypress/e2e/app.cy.ts @@ -0,0 +1,9 @@ +describe('React App', () => { + it('displays the heading', () => { + // Visit the base URL + cy.visit('/'); + + // Check if the heading is visible and contains "Hello, Cypress!" + cy.get('[data-testid="heading"]').should('be.visible').and('contain.text', 'Hello, Cypress & PlayWright'); + }); +}); \ No newline at end of file diff --git a/example/javascriptlib/testing/3-integration-suite-playwright/build.mill b/example/javascriptlib/testing/3-integration-suite-playwright/build.mill new file mode 100644 index 00000000000..5c1536cf4f0 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-playwright/build.mill @@ -0,0 +1,39 @@ +package build + +import mill._, javascriptlib._ + +object client extends ReactScriptsModule + +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") + + /** Bundle client as resource */ + def resources = Task { + os.copy(client.bundle().path, Task.dest / "build") + super.resources() ++ Seq(PathRef(Task.dest)) + } + + def forkEnv = super.forkEnv() + ("PORT" -> "3000") + + object test extends TypeScriptTests with TestModule.PlayWright { + def service = server + def port = "6000" + } +} + +// Documentation for mill.example.javascriptlib +// In this example we demonstrate integration testing using playwright +// `mill server.test` will start the service on the speicifed port, run tests with configurations defined in playwright.config.ts +// and kill the service once completed + +/** Usage + +> mill server.test +... +...Server listening on port 6000 +... +...1 passed... +... +*/ diff --git a/example/javascriptlib/testing/3-integration-suite-playwright/client/public/index.html b/example/javascriptlib/testing/3-integration-suite-playwright/client/public/index.html new file mode 100644 index 00000000000..9b54e4e9c48 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-playwright/client/public/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + React App + + + +
+ + diff --git a/example/javascriptlib/testing/3-integration-suite-playwright/client/src/app/App.tsx b/example/javascriptlib/testing/3-integration-suite-playwright/client/src/app/App.tsx new file mode 100644 index 00000000000..7cb389c54c2 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-playwright/client/src/app/App.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +function App() { + return ( +
+
+

Hello, Cypress & PlayWright

+

Brought to you by ✨✨mill.✨✨

+ + Learn React + +
+
+ ); +} + +export default App; diff --git a/example/javascriptlib/testing/3-integration-suite-playwright/client/src/index.tsx b/example/javascriptlib/testing/3-integration-suite-playwright/client/src/index.tsx new file mode 100644 index 00000000000..c3ddcfb70ba --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-playwright/client/src/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './app/App'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); +root.render( + + + +); \ No newline at end of file diff --git a/example/javascriptlib/testing/3-integration-suite-playwright/playwright.config.ts b/example/javascriptlib/testing/3-integration-suite-playwright/playwright.config.ts new file mode 100644 index 00000000000..65dd628d2a9 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-playwright/playwright.config.ts @@ -0,0 +1,27 @@ +import {defineConfig} from '@playwright/test'; +import * as glob from 'node_modules/glob'; +import * as path from 'path'; + +const testFiles = glob.sync('**/playwright/*.test.ts', {absolute: true}); + +export default defineConfig({ + testDir: './', + testMatch: testFiles.map(file => path.relative(process.cwd(), file)), + timeout: 30000, + retries: 1, + use: { + baseURL: 'http://localhost:6000', + headless: true, + trace: 'on-first-retry', + launchOptions: { + args: ['--explicitly-allowed-ports=6000'] + }, + channel: 'chrome', // Use the stable Chrome channel + }, + projects: [ + { + name: 'chromium', + use: {browserName: 'chromium'} + } + ] +}); \ No newline at end of file diff --git a/example/javascriptlib/testing/3-integration-suite-playwright/server/src/server.ts b/example/javascriptlib/testing/3-integration-suite-playwright/server/src/server.ts new file mode 100644 index 00000000000..d98689f6cb3 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-playwright/server/src/server.ts @@ -0,0 +1,21 @@ +import express, {Express} from 'express'; +import cors from 'cors'; + +const Resources: string = (process.env.RESOURCESDEST || "@server/resources.dest") + "/build" // `RESOURCES` is generated on bundle +const Client = require.resolve(`${Resources}/index.html`); + +const app: Express = express(); +const port = process.env.PORT || 3001; +const BuildPath = Client.replace(/index\.html$/, ""); + +app.use(cors()); +app.use(express.json()); + +// Middleware to serve static files from the "build" directory +app.use(express.static(BuildPath)); + +app.listen(port, () => { + console.log(`Server listening on port ${port}`); +}); + +export default app; \ No newline at end of file diff --git a/example/javascriptlib/testing/3-integration-suite-playwright/server/test/src/server/playwright/app.test.ts b/example/javascriptlib/testing/3-integration-suite-playwright/server/test/src/server/playwright/app.test.ts new file mode 100644 index 00000000000..3f2e3144fe9 --- /dev/null +++ b/example/javascriptlib/testing/3-integration-suite-playwright/server/test/src/server/playwright/app.test.ts @@ -0,0 +1,13 @@ +import { test, expect } from 'node_modules/@playwright/test'; + +test.describe('React App', () => { + test('displays the heading', async ({ page }) => { + // Visit the base URL + await page.goto('/'); + + // Check if the heading is visible + const heading = page.locator('[data-testid="heading"]'); + await expect(heading).toBeVisible(); + await expect(heading).toHaveText('Hello, Cypress & PlayWright'); + }); +}); \ No newline at end of file diff --git a/javascriptlib/src/mill/javascriptlib/TestModule.scala b/javascriptlib/src/mill/javascriptlib/TestModule.scala index f36a17b6595..1ba98ad2174 100644 --- a/javascriptlib/src/mill/javascriptlib/TestModule.scala +++ b/javascriptlib/src/mill/javascriptlib/TestModule.scala @@ -41,6 +41,12 @@ object TestModule { def getPathToTest: T[String] = Task { compile()._2.path.toString } } + trait IntegrationSuite extends TypeScriptModule { + def service: TypeScriptModule + + def port: T[String] + } + trait Jest extends TypeScriptModule with Shared with TestModule { override def npmDevDeps: T[Seq[String]] = Task { Seq( @@ -269,4 +275,139 @@ object TestModule { } } + + trait Cypress extends TypeScriptModule with IntegrationSuite with TestModule { + override def npmDevDeps: T[Seq[String]] = Task { + Seq( + "cypress@13.17.0" + ) + } + + def testConfigSource: T[PathRef] = + Task.Source(Task.workspace / "cypress.config.ts") + + override def compilerOptions: T[Map[String, ujson.Value]] = + Task { + super.compilerOptions() + ( + "target" -> ujson.Str("ES5"), + "module" -> ujson.Str("ESNext"), + "moduleResolution" -> ujson.Str("Node"), + "skipLibCheck" -> ujson.Bool(true), + "types" -> ujson.Arr( + s"${npmInstall().path}/node_modules/cypress/types" + ) + ) + } + + private def mkConfig: Task[TestResult] = Task { + val tsc = npmInstall().path / "node_modules/.bin/tsc" + os.call(( + tsc, + testConfigSource().path.toString, + "--outDir", + compile()._1.path, + "--target", + "ES2020", + "--module", + "CommonJS", + "--esModuleInterop" + )) + () + } + + override def forkEnv: T[Map[String, String]] = + Task { + Map("NODE_PATH" -> Seq( + npmInstall().path, + npmInstall().path / "node_modules" + ).mkString(":")) + } + + private def runTest: T[TestResult] = Task { + val mainFile = service.mainFilePath() + val tsnode = npmInstall().path / "node_modules/.bin/ts-node" + val tsconfigpaths = npmInstall().path / "node_modules/tsconfig-paths/register" + val port_ = port() + val env = service.forkEnv() + ("PORT" -> port_) + + val serviceProcess = os.proc("node", tsnode, "-r", tsconfigpaths, mainFile).spawn( + stdout = os.Inherit, + env = env, + cwd = service.compile()._1.path + ) + + mkConfig() + val cypress = npmInstall().path / "node_modules/.bin/cypress" + os.call( + ( + cypress, + "run" + ), + stdout = os.Inherit, + env = forkEnv(), + cwd = compile()._1.path + ) + + serviceProcess.destroy() + } + + protected def testTask(args: Task[Seq[String]]): Task[TestResult] = Task.Anon { + runTest() + } + + } + + trait PlayWright extends TypeScriptModule with IntegrationSuite with TestModule { + override def npmDevDeps: T[Seq[String]] = Task { + super.npmDevDeps() ++ Seq( + "playwright@1.49.0", + "@playwright/test@1.49.0", + "glob@10.4.5" + ) + } + + def testConfigSource: T[PathRef] = + Task.Source(Task.workspace / "playwright.config.ts") + + private def copyConfig: Task[TestResult] = Task.Anon { + os.copy.over( + testConfigSource().path, + compile()._1.path / "playwright.config.ts" + ) + } + + private def runTest: T[TestResult] = Task { + val mainFile = service.mainFilePath() + val tsnode = npmInstall().path / "node_modules/.bin/ts-node" + val tsconfigpaths = npmInstall().path / "node_modules/tsconfig-paths/register" + val port_ = port() + val env = service.forkEnv() + ("PORT" -> port_) + + val serviceProcess = os.proc("node", tsnode, "-r", tsconfigpaths, mainFile).spawn( + stdout = os.Inherit, + env = env, + cwd = service.compile()._1.path + ) + + copyConfig() + os.call( + ( + "node", + npmInstall().path / "node_modules/.bin/playwright", + "test" + ), + stdout = os.Inherit, + env = forkEnv(), + cwd = compile()._1.path + ) + + serviceProcess.destroy() + } + + protected def testTask(args: Task[Seq[String]]): Task[TestResult] = Task.Anon { + runTest() + } + + } + }