diff --git a/.gitignore b/.gitignore index 69f95b1b683..a458c538d1b 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,7 @@ internal/documentation/.vitepress/cache internal/documentation/dist internal/documentation/schema/* internal/documentation/docs/api -internal/documentation/tmp \ No newline at end of file +internal/documentation/tmp + +# E2E-tests +internal/e2e-tests/tmp \ No newline at end of file diff --git a/internal/e2e-tests/README.md b/internal/e2e-tests/README.md new file mode 100644 index 00000000000..60295c7f9ce --- /dev/null +++ b/internal/e2e-tests/README.md @@ -0,0 +1,36 @@ +# E2E Tests for UI5 CLI + +End-to-end test environment for the UI5 CLI containing realistic user scenarios. + +## Usage + +```bash +npm install +npm run unit +``` + +## How It Works + +Tests are run with the **Node.js built-in test runner**. Each test: + +1. Copies a fixture project to a temporary directory (`./tmp`) +2. Runs `npm install` there (child process) +3. Runs `ui5 build` with varying configurations (child process) + +The UI5 CLI executables are sourced directly from this repository at `packages/cli/bin/ui5.cjs`. + +Some scenarios include multiple sequential builds with file modifications in between to simulate real-world development workflows. Node modules are getting installed on the fly for the first build and reused for subsequent builds (no reinstall). + + + + +## Fixtures + +Located under `./fixtures/`: + +| Fixture | Description | +|---|---| +| `application.a` | Sample JavaScript project (controller + `manifest.json`) | +| `application.a.ts` | Sample TypeScript project (based on [generator-ui5-ts-app](https://github.com/ui5-community/generator-ui5-ts-app)) | + +> **Note:** These tests are not yet included in any CI pipeline. diff --git a/internal/e2e-tests/fixtures/application.a.ts/package.json b/internal/e2e-tests/fixtures/application.a.ts/package.json new file mode 100644 index 00000000000..24b042192ad --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/package.json @@ -0,0 +1,10 @@ +{ + "name": "application.a.ts", + "version": "1.0.0", + "description": "UI5 Application: application.a.ts", + "license": "Apache-2.0", + "devDependencies": { + "@openui5/types": "1.115.1", + "ui5-tooling-transpile": "3.11.0" + } +} diff --git a/internal/e2e-tests/fixtures/application.a.ts/tsconfig.json b/internal/e2e-tests/fixtures/application.a.ts/tsconfig.json new file mode 100644 index 00000000000..76f47447feb --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "node", + "skipLibCheck": true, + "allowJs": true, + "strict": true, + "strictNullChecks": false, + "strictPropertyInitialization": false, + "rootDir": "./webapp", + "types": ["@openui5/types", "@types/qunit"], + "paths": { + "application/a/ts/*": ["./webapp/*"], + "unit/*": ["./webapp/test/unit/*"], + "integration/*": ["./webapp/test/integration/*"] + } + }, + "include": ["./webapp/**/*"], + "exclude": ["./webapp/coverage/**/*"] +} diff --git a/internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile.yaml b/internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile.yaml new file mode 100644 index 00000000000..6b6160533ca --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/ui5-tooling-transpile.yaml @@ -0,0 +1,12 @@ +specVersion: "5.0" +metadata: + name: application.a.ts +type: application +builder: + customTasks: + - name: ui5-tooling-transpile-task + afterTask: replaceVersion +server: + customMiddleware: + - name: ui5-tooling-transpile-middleware + afterMiddleware: compression diff --git a/internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts b/internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts new file mode 100644 index 00000000000..e20a74790f2 --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/webapp/controller/Test.controller.ts @@ -0,0 +1,22 @@ +type randomTSType = { + first: { + a: number, + b: number, + c: number + }, + second: string +} + +export default class Main { + onInit(): void { + const z : randomTSType = { + first: { + a: 1, + b: 2, + c: 3 + }, + second: "test" + }; + console.log(z.first.a); + } +} diff --git a/internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json b/internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json new file mode 100644 index 00000000000..ae5be89bac7 --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a.ts/webapp/manifest.json @@ -0,0 +1,66 @@ +{ + "_version": "1.12.0", + "sap.app": { + "id": "application.a.ts", + "type": "application", + "i18n": "i18n/i18n.properties", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { + "version": "1.0.0" + } + }, + "sap.ui": { + "technology": "UI5", + "icons": {}, + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "rootView": { + "viewName": "application.a.ts.view.App", + "type": "XML", + "async": true, + "id": "app" + }, + "handleValidation": true, + "contentDensities": { + "compact": true, + "cozy": true + }, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { + "bundleName": "application.a.ts.i18n.i18n" + } + } + }, + "routing": { + "config": { + "routerClass": "sap.m.routing.Router", + "viewType": "XML", + "viewPath": "application.a.ts.view", + "controlId": "app", + "controlAggregation": "pages", + "async": true + }, + "routes": [ + { + "pattern": "", + "name": "main", + "target": "main" + } + ], + "targets": { + "main": { + "viewId": "main", + "viewName": "Main" + } + } + } + } +} diff --git a/internal/e2e-tests/fixtures/application.a/package.json b/internal/e2e-tests/fixtures/application.a/package.json new file mode 100644 index 00000000000..4c8a19f1593 --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/package.json @@ -0,0 +1,15 @@ +{ + "name": "application.a", + "version": "1.0.0", + "description": "UI5 Application: application.a", + "license": "Apache-2.0", + "dependencies": { + "chart.js": "4.5.1" + }, + "devDependencies": { + "@openui5/types": "1.115.1", + "ui5-task-zipper": "3.6.0", + "ui5-tooling-modules": "3.35.0", + "ui5-tooling-stringreplace": "3.6.0" + } +} diff --git a/internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml b/internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml new file mode 100644 index 00000000000..b217eb59ec7 --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/ui5-task-zipper.yaml @@ -0,0 +1,10 @@ +specVersion: "5.0" +metadata: + name: application.a +type: application +builder: + customTasks: + - name: ui5-task-zipper + afterTask: generateVersionInfo + configuration: + archiveName: "webapp" diff --git a/internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml b/internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml new file mode 100644 index 00000000000..943ea39c81a --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/ui5-tooling-modules.yaml @@ -0,0 +1,12 @@ +specVersion: "5.0" +metadata: + name: application.a +type: application +server: + customMiddleware: + - name: ui5-tooling-modules-middleware + afterMiddleware: compression +builder: + customTasks: + - name: ui5-tooling-modules-task + afterTask: replaceVersion diff --git a/internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml b/internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml new file mode 100644 index 00000000000..36fad93ceee --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/ui5-tooling-stringreplace.yaml @@ -0,0 +1,14 @@ +specVersion: "5.0" +metadata: + name: application.a +type: application +builder: + customTasks: + - name: ui5-tooling-stringreplace-task + afterTask: replaceVersion + configuration: + files: + - "**/*.js" + replace: + - placeholder: ${PLACEHOLDER_TEXT} + value: "'INSERTED_TEXT'" diff --git a/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js b/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js new file mode 100644 index 00000000000..8b0befdce30 --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/webapp/controller/Test.controller.js @@ -0,0 +1,16 @@ +sap.ui.define([], () => { + return Controller.extend("application.a.controller.Test",{ + onInit() { + const z = { + first: { + a: 1, + b: 2, + c: 3 + }, + second: "test" + }; + console.log(z.first.a); + } + }); +}); + diff --git a/internal/e2e-tests/fixtures/application.a/webapp/manifest.json b/internal/e2e-tests/fixtures/application.a/webapp/manifest.json new file mode 100644 index 00000000000..58eb693c97f --- /dev/null +++ b/internal/e2e-tests/fixtures/application.a/webapp/manifest.json @@ -0,0 +1,66 @@ +{ + "_version": "1.12.0", + "sap.app": { + "id": "application.a", + "type": "application", + "i18n": "i18n/i18n.properties", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { + "version": "1.0.0" + } + }, + "sap.ui": { + "technology": "UI5", + "icons": {}, + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "rootView": { + "viewName": "application.a.view.App", + "type": "XML", + "async": true, + "id": "app" + }, + "handleValidation": true, + "contentDensities": { + "compact": true, + "cozy": true + }, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { + "bundleName": "application.a.i18n.i18n" + } + } + }, + "routing": { + "config": { + "routerClass": "sap.m.routing.Router", + "viewType": "XML", + "viewPath": "application.a.view", + "controlId": "app", + "controlAggregation": "pages", + "async": true + }, + "routes": [ + { + "pattern": "", + "name": "main", + "target": "main" + } + ], + "targets": { + "main": { + "viewId": "main", + "viewName": "Main" + } + } + } + } +} diff --git a/internal/e2e-tests/package.json b/internal/e2e-tests/package.json new file mode 100644 index 00000000000..1c556aa69ba --- /dev/null +++ b/internal/e2e-tests/package.json @@ -0,0 +1,17 @@ +{ + "name": "@ui5-internal/e2e-tests", + "private": true, + "license": "Apache-2.0", + "type": "module", + "engines": { + "node": "^22.20.0 || >=24.0.0", + "npm": ">= 8" + }, + "scripts": { + "unit": "node --test 'test/**/*.js'", + "unit-watch": "node --test --watch 'test/**/*.js'" + }, + "dependencies": { + "adm-zip": "^0.5.17" + } +} diff --git a/internal/e2e-tests/test/build.js b/internal/e2e-tests/test/build.js new file mode 100644 index 00000000000..d7450661aac --- /dev/null +++ b/internal/e2e-tests/test/build.js @@ -0,0 +1,227 @@ +// Test running "ui5 build" +// with fixtures (under ../fixtures) +// and by using node's child_process module and the ui5.cjs under ../../../packages/cli/bin/ui5.cjs. + +import { execFile } from "node:child_process"; +import {test, describe} from "node:test"; +import {fileURLToPath} from "node:url"; +import path from "node:path"; +import fs from "node:fs/promises"; +import AdmZip from "adm-zip"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ui5CliPath = path.resolve(__dirname, "../../../packages/cli/bin/ui5.cjs"); + +class FixtureHelper { + constructor(fixtureName) { + this.fixtureName = fixtureName; + this.originFixturePath = path.resolve(__dirname, "../fixtures", fixtureName); + this.tmpPath = path.resolve(__dirname, "../tmp", fixtureName); + this.dotUi5Path = path.resolve(this.tmpPath, ".ui5"); + this.distPath = path.resolve(this.tmpPath, "dist"); + } + + async init() { + // Clean up previous runs + await fs.rm(this.tmpPath, {recursive: true, force: true}); + // Copy source files to temp location + await fs.cp(this.originFixturePath, this.tmpPath, {recursive: true}); + // Install node_modules + await this._installNodeModules(); + } + + async prepareForNextRun() { + // Delete everything from the tmp/ folder except .ui5, dist & node_modules folders + const entries = await fs.readdir(this.tmpPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === ".ui5" || entry.name === "dist" || entry.name === "node_modules") { + continue; + } + const entryPath = path.resolve(this.tmpPath, entry.name); + await fs.rm(entryPath, { recursive: true, force: true }); + } + // Copy source files to temp location + await fs.cp(this.originFixturePath, this.tmpPath, {recursive: true}); + } + + async build(assert, ui5YamlName) { + await new Promise((resolve, reject) => { + execFile("node", [ui5CliPath, "build", "--config", ui5YamlName, "--dest", this.distPath], async (error, stdout, stderr) => { + if (error) { + assert.fail(error); + reject(error); + return; + } + resolve(); + }); + }); + } + + async _installNodeModules() { + await new Promise((resolve, reject) => { + execFile("npm", ["install"], { cwd: this.tmpPath }, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +describe("ui5 build", () => { + test("ui5-tooling-transpile", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a.ts"); + await fixtureHelper.init(); + process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; + process.chdir(fixtureHelper.tmpPath); + const ui5YamlName = "ui5-tooling-transpile.yaml"; + + // #1 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: no TS syntax is left in the preload (transpile + preload tasks succeeeded) + const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); + assert.ok(componentPreload.includes("application/a/ts/controller/Test.controller"), "Component-preload.js should contain the TS resource transpiled to JS"); + assert.ok(!componentPreload.includes("randomTSType"), "Component-preload.js should NOT contain any TS syntax"); + const componentPreloadMap = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js.map"), "utf-8"); + assert.ok(componentPreloadMap.includes("randomTSType"), "Component-preload.js.map should contain the TS type information"); + + // -------------------------------------------------------------------------------------------- + + // Modify source files + await fixtureHelper.prepareForNextRun(); + const fileToModify = path.resolve(fixtureHelper.tmpPath, "webapp/controller/Test.controller.ts"); + const fileContent = await fs.readFile(fileToModify, "utf-8"); + const modifiedContent = fileContent.replace("second: \"test\"", "second: \"test_2\""); + await fs.writeFile(fileToModify, modifiedContent, "utf-8"); + + // #2 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the modified content is reflected in the new build output (transpile + preload tasks succeeeded) + const newComponentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); + assert.ok(newComponentPreload.includes("second:\"test_2\""), "Component-preload.js should contain the updated content from the modified source file"); + }); + + test("ui5-task-zipper", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a"); + await fixtureHelper.init(); + process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; + process.chdir(fixtureHelper.tmpPath); + const ui5YamlName = "ui5-task-zipper.yaml"; + + // #1 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the zip file is created in the dist folder + const zipFilePath = path.resolve(fixtureHelper.distPath, "webapp.zip"); + const zipFileExists = await fs.access(zipFilePath).then(() => true).catch(() => false); + assert.ok(zipFileExists, "The zip file should be created in the dist folder"); + + // Check the archive content + const zip = new AdmZip(zipFilePath); + const zipEntries = zip.getEntries(); + assert.ok(zipEntries.length > 0, "The zip file should contain entries"); + + // Check that the zip file contains the expected source file + const testControllerEntry = zipEntries.find(entry => entry.entryName === "controller/Test.controller.js"); + assert.ok(testControllerEntry, "The zip file should contain the expected source file"); + + // -------------------------------------------------------------------------------------------- + + // Delete a source file + await fixtureHelper.prepareForNextRun(); + await fs.rm(path.resolve(fixtureHelper.tmpPath, "webapp/controller/Test.controller.js")); + + // #2 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the zip file is updated and does not contain the deleted file + const newZipFileExists = await fs.access(zipFilePath).then(() => true).catch(() => false); + assert.ok(newZipFileExists, "The zip file should be created in the dist folder after the second build"); + + // Check the archive content + const zip2 = new AdmZip(zipFilePath); + const zipEntries2 = zip2.getEntries(); + assert.ok(zipEntries2.length > 0, "The zip file should contain entries after the second build"); + + // Check that the zip file does NOT contain the expected source file anymore + const deletedTestControllerEntry = zipEntries2.find(entry => entry.entryName === "controller/Test.controller.js"); + assert.ok(!deletedTestControllerEntry, "The zip file should NOT contain the deleted source file"); + }); + + test("ui5-tooling-modules", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a"); + await fixtureHelper.init(); + process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; + process.chdir(fixtureHelper.tmpPath); + const ui5YamlName = "ui5-tooling-modules.yaml"; + + // #1 Build (no thirdparty module yet -> just checking that the build succeeds) + await fixtureHelper.build(assert, ui5YamlName); + + // -------------------------------------------------------------------------------------------- + + // Add a new source file with a third party import + await fixtureHelper.prepareForNextRun(); + const newControllerPath = path.resolve(fixtureHelper.tmpPath, "webapp/controller/New.controller.js"); + const newControllerContent = +`sap.ui.define(["chart.js"], (chartJS) => { + return Controller.extend("application.a.controller.New",{ + onInit() { + console.log(chartJS); + } + }); +});`; + await fs.writeFile(newControllerPath, newControllerContent, "utf-8"); + + // #2 Build + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the dist contains the new controller and the third party import + const newComponentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); + assert.ok(newComponentPreload.includes("sap.ui.predefine(\"application/a/controller/New.controller\", [\"application/a/thirdparty/chart.js\"]"), "Component-preload.js should contain the 'New' controller and chart.js"); + }); + + test("ui5-tooling-stringreplace", async ({assert}) => { + const fixtureHelper = new FixtureHelper("application.a"); + await fixtureHelper.init(); + process.env.UI5_DATA_DIR = `${fixtureHelper.dotUi5Path}`; + process.chdir(fixtureHelper.tmpPath); + const ui5YamlName = "ui5-tooling-stringreplace.yaml"; + + // #1 Build (no string replacing yet -> just checking that the build succeeds) + await fixtureHelper.build(assert, ui5YamlName); + + // -------------------------------------------------------------------------------------------- + + + // Add a new source file with a placeholder string + await fixtureHelper.prepareForNextRun(); + const newControllerPath = path.resolve(fixtureHelper.tmpPath, "webapp/controller/New.controller.js"); + const newControllerContent = +`sap.ui.define([], () => { + return Controller.extend("application.a.controller.New",{ + onInit() { + console.log(\${PLACEHOLDER_TEXT}); + } + }); +});`; + await fs.writeFile(newControllerPath, newControllerContent, "utf-8"); + + // #2 Build + // FIXME: Currently failing here for IB (https://github.com/UI5/cli/pull/1267), April 02 2026 - aa3a2c1c04f7a5cd27650335cde37a798baacf2a + // Error message: + // ("Minification failed with error: Unexpected token punc «{», expected punc «,» in file /resources/application/a/controller/New.controller.js (line 4, col 16, pos 114)") + // + // -> Probably, the string replacement doesn't get executed as very first middleware (minify happens earlier unexpectedly) + await fixtureHelper.build(assert, ui5YamlName); + + // Test: the placeholder in the source file is replaced in the dist output + const componentPreload = await fs.readFile(path.resolve(fixtureHelper.distPath, "Component-preload.js"), "utf-8"); + assert.ok(componentPreload.includes("console.log(\"INSERTED_TEXT\")"), "The placeholder should get replaced with the expected text in the component preload"); + }); +}); diff --git a/internal/e2e-tests/test/version.js b/internal/e2e-tests/test/version.js new file mode 100644 index 00000000000..30c2eb9e22e --- /dev/null +++ b/internal/e2e-tests/test/version.js @@ -0,0 +1,31 @@ +// Test running "ui5 --version" +// without any fixtures or additional setup. +// and by using node's child_process module and the ui5.cjs under ../../../packages/cli/bin/ui5.cjs. + +import { execFile } from "node:child_process"; +import {test, describe} from "node:test"; +import {fileURLToPath} from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe("ui5 version", () => { + test("output the version of the UI5 CLI", ({assert}) => { + const ui5Path = path.resolve(__dirname, "../../../packages/cli/bin/ui5.cjs"); + execFile("node", [ui5Path, "--version"], (error, stdout, stderr) => { + if (error) { + assert.fail(error); + return; + } + if (stderr) { + assert.fail(new Error(stderr)); + return; + } + // Test: the expected CLI version output is printed + // e.g. "5.0.0 (from /path/to/ui5/cli)" + const outPattern = /\d+\..* \(from .*\)/; + assert.ok(stdout.match(outPattern)); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index e849d341f04..6a71dcff1b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,17 @@ "npm": ">= 8" } }, + "internal/e2e-tests": { + "name": "@ui5-internal/e2e-tests", + "license": "Apache-2.0", + "dependencies": { + "adm-zip": "^0.5.17" + }, + "engines": { + "node": "^22.20.0 || >=24.0.0", + "npm": ">= 8" + } + }, "internal/shrinkwrap-extractor": { "name": "@ui5/shrinkwrap-extractor", "version": "1.0.0", @@ -5736,6 +5747,10 @@ "resolved": "internal/benchmark", "link": true }, + "node_modules/@ui5-internal/e2e-tests": { + "resolved": "internal/e2e-tests", + "link": true + }, "node_modules/@ui5/builder": { "resolved": "packages/builder", "link": true @@ -6149,6 +6164,15 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",