diff --git a/tests/integration/adaptation-editor/.gitignore b/tests/integration/adaptation-editor/.gitignore index 06400ceed9e..fe3f0b923ad 100644 --- a/tests/integration/adaptation-editor/.gitignore +++ b/tests/integration/adaptation-editor/.gitignore @@ -4,3 +4,4 @@ dist blob-report versions.json test-project-map.json +manual-test diff --git a/tests/integration/adaptation-editor/server.ts b/tests/integration/adaptation-editor/server.ts new file mode 100644 index 00000000000..5ec83ac1c2c --- /dev/null +++ b/tests/integration/adaptation-editor/server.ts @@ -0,0 +1,180 @@ +import globalSetup from './src/global-setup'; +import type { Server } from 'http'; +import { readFile, writeFile, readdir } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'node:path'; +import { getPortPromise } from 'portfinder'; +import { YamlDocument, YAMLMap } from '@sap-ux/yaml'; + +let abapServer: Server | null = null; + +/** + * Start ABAP mock server for given folder and port. + */ +export async function startAbapServer(folderName: string, port = 3050): Promise { + if (abapServer) { + return port; + } + abapServer = await globalSetup(port, folderName); + console.log(`ABAP server started (folder=${folderName}, port=${port})`); + return port; +} + +/** + * Stop ABAP mock server if running. + */ +export async function stopAbapServer(): Promise { + if (abapServer) { + abapServer.close(); + abapServer = null; + console.log('ABAP server stopped'); + } +} + +/** + * Convenience to stop all servers (expandable). + */ +export async function stopAllServers(): Promise { + await stopAbapServer(); +} + +/* +CLI: start UI5 server for a generated project based on test name: + node server.js +Behavior: + - looks into manual-test/ for a project folder that starts with "fiori" + - reads ui5.yaml inside that folder to extract a desired port (http://localhost:PORT or port: PORT) + - tries to use that port; if occupied, picks a free port and updates ui5.yaml replacing occurrences of the original port + - starts `npx ui5 serve --config=ui5.yaml --port=PORT` in that project folder +*/ +if (require.main === module) { + (async () => { + const testNameArg = process.argv[2]; + if (!testNameArg) { + console.error('Usage: node server.js '); + process.exit(1); + } + const testName = testNameArg.endsWith('.spec.ts') ? testNameArg.slice(0, -8) : testNameArg; + const baseFolder = join(__dirname, 'manual-test', testName); + + if (!existsSync(baseFolder)) { + console.error(`Project folder not found: ${baseFolder}`); + process.exit(2); + } + + // find project folder starting with 'fiori' + const entries = await readdir(baseFolder, { withFileTypes: true }); + const fioriCandidate = entries + .filter((e) => e.isDirectory() && e.name.startsWith('fiori')) + .map((e) => e.name)[0]; + const adpCandidate = entries.filter((e) => e.isDirectory() && e.name.startsWith('adp')).map((e) => e.name)[0]; + if (!fioriCandidate) { + console.error(`Base project not found in ${baseFolder}`); + process.exit(3); + } + if (!adpCandidate) { + console.error(`Adaptation project not found in ${baseFolder}`); + process.exit(3); + } + + const projectFolder = join(baseFolder, fioriCandidate); + const ui5YamlPath = join(projectFolder, 'ui5.yaml'); + if (!existsSync(ui5YamlPath)) { + console.error(`ui5.yaml not found in project folder: ${projectFolder}`); + process.exit(4); + } + + const adpProjectFolder = join(baseFolder, adpCandidate); + const adpUi5YamlPath = join(adpProjectFolder, 'ui5.yaml'); + if (!existsSync(adpUi5YamlPath)) { + console.error(`ui5.yaml not found in project folder: ${adpProjectFolder}`); + process.exit(4); + } + + let yamlText = await readFile(ui5YamlPath, 'utf-8'); + let adpYamlText = await readFile(adpUi5YamlPath, 'utf-8'); + + // discover port from ui5.yaml using YamlDocument + let desiredPort: number | undefined; + try { + const doc = await YamlDocument.newInstance(yamlText); + //const cms = doc.findItem({ path: 'server.customMiddleware' }) as any[]; + const middlewareList = doc.getSequence({ path: 'server.customMiddleware' }); + const backendProxyMiddleware = doc.findItem( + middlewareList, + (item: any) => item.name === 'backend-proxy-middleware' + ); + if (!backendProxyMiddleware) { + throw new Error('Could not find backend-proxy-middleware'); + } + const backendConfig = doc.getMap({ + start: backendProxyMiddleware as YAMLMap, + path: 'configuration' + }); + const proxyMiddlewareConfig = backendConfig.toJSON() as { url: string }; + if (typeof proxyMiddlewareConfig.url === 'string') { + const m = proxyMiddlewareConfig.url.match(/localhost:(\d{3,5})/); + if (m) { + desiredPort = Number(m[1]); + } + } + } catch { + desiredPort = undefined; + } + + // if no port found, pick default 3050 + if (!desiredPort) { + desiredPort = 3050; + } + + // try to reserve desired port + let portToUse = await getPortPromise({ port: desiredPort, stopPort: desiredPort + 1000 }); + if (portToUse !== desiredPort) { + console.log(`Desired port ${desiredPort} not available, will use ${portToUse} and update ui5.yaml`); + // Parse YAML and update backend url in the backend-proxy-middleware configuration + try { + const doc = await YamlDocument.newInstance(yamlText); + // set backend url using path-safe API + doc.setIn({ + path: 'server.customMiddleware.4.configuration.backend.url', + value: `http://localhost:${portToUse}` + }); + yamlText = doc.toString(); + await writeFile(ui5YamlPath, yamlText, 'utf-8'); + + // adaptation + const adpDoc = await YamlDocument.newInstance(adpYamlText); + adpDoc.setIn({ + path: 'server.customMiddleware.4.configuration.url', + value: `http://localhost:${portToUse}` + }); + adpDoc.setIn({ + path: 'server.customMiddleware.4.configuration.backend.url', + value: `http://localhost:${portToUse}` + }); + adpDoc.setIn({ + path: 'server.customMiddleware.2.configuration.adp.target.url', + value: `http://localhost:${portToUse}` + }); + adpYamlText = adpDoc.toString(); + await writeFile(adpUi5YamlPath, adpYamlText, 'utf-8'); + console.log(`Updated ui5.yaml backend URL to use port ${portToUse}`); + } catch (err) { + console.error('Failed to update ui5.yaml:', err); + process.exit(6); + } + } else { + console.log(`Using desired port ${desiredPort}`); + portToUse = desiredPort; + } + process.on('SIGINT', async () => { + console.log('Shutting down server...'); + await stopAllServers(); + process.exit(0); + }); + startAbapServer(join('manual-test', testName), portToUse).catch((err) => { + console.error('Failed to start ABAP server:', err); + process.exit(5); + }); + })(); +} diff --git a/tests/integration/adaptation-editor/src/global-setup.ts b/tests/integration/adaptation-editor/src/global-setup.ts index 5b5be96eb63..d1c8105c426 100644 --- a/tests/integration/adaptation-editor/src/global-setup.ts +++ b/tests/integration/adaptation-editor/src/global-setup.ts @@ -6,6 +6,7 @@ import fs from 'node:fs'; import express from 'express'; import ZipFile from 'adm-zip'; import type { ManifestNamespace } from '@sap-ux/project-access'; +import type { Server } from 'http'; interface Change { changeType: string; @@ -16,15 +17,18 @@ interface Change { /** * Global setup. * - * It fetches maintained UI5 versions and add them to `process.env` variable. + * It sets up a mock ABAP backend server and returns the server instance. + * + * @param port - The port to run the server on (default: 3050) + * @param folder - for creating a copy of the fixtures folder (default: 'fixtures-copy') + * @returns The server instance */ -async function globalSetup(): Promise { +async function globalSetup(port: number = 3050, folder = 'fixtures-copy'): Promise { const app = express(); - const port = 3050; const mapping: Record> = {}; // Define the path to the static content - const staticPath = path.join(__dirname, '..', 'fixtures-copy'); + const staticPath = path.join(__dirname, '..', folder); const mergedManifestCache = new Map(); @@ -100,7 +104,7 @@ async function globalSetup(): Promise { const baseAppDirectory = `${variant.reference}`; const manifestText = await readFile( - join(__dirname, '..', 'fixtures-copy', `${variant.reference}`, 'webapp', 'manifest.json'), + join(__dirname, '..', folder, `${variant.reference}`, 'webapp', 'manifest.json'), 'utf-8' ); @@ -291,9 +295,11 @@ async function globalSetup(): Promise { }); // Start the server - app.listen(port, () => { + const server = app.listen(Number(port) || 3050, () => { console.log(`Mock ABAP backend is running on http://localhost:${port}`); }); + + return server; } export default globalSetup; diff --git a/tests/integration/adaptation-editor/src/project/builder.ts b/tests/integration/adaptation-editor/src/project/builder.ts index 965a31bddae..73046369266 100644 --- a/tests/integration/adaptation-editor/src/project/builder.ts +++ b/tests/integration/adaptation-editor/src/project/builder.ts @@ -14,6 +14,10 @@ export interface ProjectParameters { userParams?: Record; } +export interface TestParams { + port: number; +} + export const ADAPTATION_EDITOR_PATH = '/adaptation-editor.html'; /** @@ -103,20 +107,29 @@ export function createV2Manifest(userParameters: ProjectParameters, workerId: st * @param userParameters - The project parameters provided by the user. * @param ui5Version - The UI5 version to be used. * @param workerId - The unique worker ID for the project. + * @param testParams - optional object to pass parameters for manual test project generation. * @returns A string representation of the YAML file content. */ export async function createYamlFile( userParameters: ProjectParameters, ui5Version: string, - workerId: string + workerId: string, + testParams?: TestParams ): Promise { const { id, mainServiceUri } = getProjectParametersWithDefaults(userParameters); + const template = await readFile(join(__dirname, 'templates', 'ui5.yaml'), 'utf-8'); const document = await YamlDocument.newInstance(template); document.setIn({ path: 'metadata.name', value: id + '.' + workerId }); document.setIn({ path: 'server.customMiddleware.0.configuration.services.urlPath', value: mainServiceUri }); document.setIn({ path: 'server.customMiddleware.3.configuration.version', value: ui5Version }); + if (testParams?.port) { + document.setIn({ + path: 'server.customMiddleware.4.configuration.url', + value: `http://localhost:${testParams.port}` + }); + } return document.toString(); } @@ -148,9 +161,11 @@ export function createComponent(userParameters: ProjectParameters, workerId: str * Creates a package.json file for the project. * * @param id - The project ID. + * @param isAdpProject - Whether the project is an ADP project. + * @param testParams - Optional test parameters for manual test project generation. * @returns A string representation of the package.json file content. */ -export function createPackageJson(id: string): string { +export function createPackageJson(id: string, isAdpProject = false, testParams?: TestParams): string { return `{ "name": "${id}", "version": "0.0.1", @@ -158,6 +173,13 @@ export function createPackageJson(id: string): string { "devDependencies": { "@sap-ux/ui5-middleware-fe-mockserver": "2.1.112", "@ui5/cli": "3" + }${ + isAdpProject && testParams + ? `, + "scripts": { + "start-editor": "ui5 serve --config=ui5.yaml --open adaptation-editor.html" + }` + : '' } } `; @@ -169,16 +191,20 @@ export function createPackageJson(id: string): string { * @param projectConfig - The project configuration. * @param workerId - The unique worker ID for the project. * @param ui5Version - The UI5 version to be used. + * @param folder - The folder to create the project in (default: 'fixtures-copy') + * @param testParams - additional options for manual test project generation * @returns The root path of the generated project. */ export async function generateUi5Project( projectConfig: typeof FIORI_ELEMENTS_V2, workerId: string, - ui5Version: string + ui5Version: string, + folder = 'fixtures-copy', + testParams?: TestParams ): Promise { const { id } = getProjectParametersWithDefaults(projectConfig); - const root = join(__dirname, '..', '..', 'fixtures-copy', `${projectConfig.id}.${workerId}`); - const yamlContent = await createYamlFile(projectConfig, ui5Version, workerId); + const root = join(__dirname, '..', '..', folder, `${projectConfig.id}.${workerId}`); + const yamlContent = await createYamlFile(projectConfig, ui5Version, workerId, testParams); const manifestContent = JSON.stringify(createV2Manifest(projectConfig, workerId), undefined, 2); if (!existsSync(root)) { @@ -274,6 +300,7 @@ function getAdpProjectParametersWithDefaults(parameters: AdpProjectParameters): * @param backendUrl - The backend URL for the ADP project. * @param mainServiceUri - The main service URI for the ADP project. * @param livereloadPort - The livereload port for the ADP project. + * @param abapPort - Port for starting backend server. * @returns A string representation of the YAML file content. */ async function createAdpYamlFile( @@ -281,7 +308,8 @@ async function createAdpYamlFile( ui5Version: string, backendUrl: string, mainServiceUri: string, - livereloadPort: number + livereloadPort: number, + abapPort?: number ): Promise { const { id } = getAdpProjectParametersWithDefaults(userParameters); const template = await readFile(join(__dirname, 'templates', 'adp.yaml'), 'utf-8'); @@ -296,6 +324,17 @@ async function createAdpYamlFile( }); document.setIn({ path: 'server.customMiddleware.2.configuration.adp.target.url', value: backendUrl }); document.setIn({ path: 'server.customMiddleware.3.configuration.version', value: ui5Version }); + if (abapPort) { + document.setIn({ path: 'server.customMiddleware.4.configuration.url', value: `http://localhost:${abapPort}` }); + document.setIn({ + path: 'server.customMiddleware.4.configuration.backend.url', + value: `http://localhost:${abapPort}` + }); + document.setIn({ + path: 'server.customMiddleware.2.configuration.adp.target.url', + value: `http://localhost:${abapPort}` + }); + } return document.toString(); } @@ -329,6 +368,8 @@ export async function createAppDescriptorVariant( * @param ui5Version - The UI5 version to be used. * @param backendUrl - The backend URL for the ADP project. * @param livereloadPort - The livereload port for the ADP project. + * @param folder - The folder to create the project in (default: 'fixtures-copy') + * @param testParams - Additional options for manual test project generation. * @returns The root path of the generated ADP project. */ export async function generateAdpProject( @@ -336,16 +377,19 @@ export async function generateAdpProject( workerId: string, ui5Version: string, backendUrl: string, - livereloadPort: number + livereloadPort: number, + folder = 'fixtures-copy', + testParams?: TestParams ): Promise { const { id } = getAdpProjectParametersWithDefaults(projectConfig); - const root = join(__dirname, '..', '..', 'fixtures-copy', `${projectConfig.id}.${workerId}`); + const root = join(__dirname, '..', '..', folder, `${projectConfig.id}.${workerId}`); const yamlContent = await createAdpYamlFile( projectConfig, ui5Version, backendUrl, projectConfig.baseApp.mainServiceUri, - livereloadPort + livereloadPort, + testParams?.port ); const appDescriptorVariant = JSON.stringify( await createAppDescriptorVariant(projectConfig, projectConfig.baseApp.id + '.' + workerId), @@ -370,7 +414,7 @@ export async function generateAdpProject( await Promise.all([ writeFile(join(root, 'ui5.yaml'), yamlContent), - writeFile(join(root, 'package.json'), createPackageJson(id + '.' + workerId)), + writeFile(join(root, 'package.json'), createPackageJson(id + '.' + workerId, true, testParams)), writeFile(join(root, 'webapp', 'manifest.appdescr_variant'), appDescriptorVariant), writeFile(join(root, 'service.cds'), await readFile(join(__dirname, 'templates', 'service.cds'), 'utf-8')), writeFile( diff --git a/tests/integration/adaptation-editor/test-project-generator.ts b/tests/integration/adaptation-editor/test-project-generator.ts new file mode 100644 index 00000000000..3ea43155ad8 --- /dev/null +++ b/tests/integration/adaptation-editor/test-project-generator.ts @@ -0,0 +1,159 @@ +import { randomUUID } from 'crypto'; +import { generateAdpProject, generateUi5Project } from './src/project/builder'; +import { rm, stat, symlink, mkdir } from 'fs/promises'; +import { join } from 'node:path'; +import { getPortPromise } from 'portfinder'; +import { existsSync, readFileSync } from 'node:fs'; +import readline from 'node:readline'; + +/** + * Interface for test project configurations. + */ +interface TestProjectConfig { + projectConfig: any; + isAdp?: boolean; +} + +/** + * Default UI5 version to use if not provided. + */ +const DEFAULT_UI5_VERSION = '1.120.0'; + +/** + * Default backend URL for ADP projects. + */ +const DEFAULT_BACKEND_URL = 'http://localhost:3050'; + +/** + * Default livereload port for ADP projects. + */ +const DEFAULT_LIVERELOAD_PORT = 35729; + +// Avoid installing npm packages every time, but use symlink instead +const PACKAGE_ROOT = join(__dirname, '..', '..', 'fixtures', 'projects', 'mock'); + +/** + * Generates a test project based on the specified test file name. + * + * @param testFileName - The name of the test file to use for configuration + * @param ui5Version - Optional UI5 version to use + * @param backendUrl - Optional backend URL for ADP projects + * @param livereloadPort - Optional livereload port for ADP projects + * @param startServers - Whether to automatically start the servers (ABAP and UI5) + * @param ui5Port - Starting port for the UI5 server + * @param forceRegenerate - Whether to force regeneration of the project even if it exists + * @returns Promise resolving to an object containing the project path and server info + * @throws Error if the specified test file is not found in the configuration map + */ +export async function generateTestProject( + testFileName: string, + ui5Version: string = DEFAULT_UI5_VERSION, + backendUrl: string = DEFAULT_BACKEND_URL, + livereloadPort: number = DEFAULT_LIVERELOAD_PORT + // startServers: boolean = false, + //forceRegenerate: boolean = false +): Promise<{ projectPath: string }> { + // Extract just the filename if a full path is provided + const fileName = testFileName.includes('/') + ? testFileName.substring(testFileName.lastIndexOf('/') + 1) + : testFileName; + + // Remove the '.spec.ts' suffix if present + const testName = fileName.endsWith('.spec.ts') ? fileName.substring(0, fileName.length - 8) : fileName; + + // Get the configuration by reading #test-project-map.json + // load mapping from JSON file placed next to this script + const testConfigMap: Record = JSON.parse( + readFileSync(join(__dirname, 'test-project-map.json'), 'utf-8') + ); + const config = testConfigMap[testName]; + + if (!config) { + throw new Error(`No configuration found for test file: ${testName}`); + } + + const folderPath = join('manual-test', testName); + const fullFolderPath = join(__dirname, folderPath); + + let projectPath = ''; + + // Generate the project if it doesn't exist or we need to regenerate + if (!projectPath) { + // Generate a unique worker ID for the project + const workerId = randomUUID().substring(0, 8); + + if (config.isAdp) { + const abapPort = await getPortPromise({ port: 3050, stopPort: 3050 + 1000 }); + // If the target folder already exists, remove it completely to start clean + if (existsSync(fullFolderPath)) { + console.log(`WARNING: existing folder will be removed: ${fullFolderPath}`); + const ok = await promptYesNo('This will delete the existing project state. Continue and remove it? (y/N): '); + if (!ok) { + throw new Error('Aborted by user — existing project state preserved.'); + } + console.log(`Removing existing folder ${fullFolderPath} to recreate fresh project`); + await rm(fullFolderPath, { recursive: true, force: true }); + } + await mkdir(fullFolderPath, { recursive: true }); + await generateUi5Project(config.projectConfig.baseApp, workerId, ui5Version, folderPath); + projectPath = await generateAdpProject( + config.projectConfig, + workerId, + ui5Version, + backendUrl, + livereloadPort, + folderPath, + { port: abapPort } + ); + const targetPath = join(projectPath, 'node_modules'); + try { + await stat(targetPath); + await rm(targetPath, { recursive: true }); + } catch (error) { + if (error?.code !== 'ENOENT') { + console.log(error); + } + } finally { + // type required for windows + const nmFolder = join(PACKAGE_ROOT, 'node_modules'); + await symlink(nmFolder, targetPath, 'junction'); + } + } else { + projectPath = await generateUi5Project(config.projectConfig, workerId, ui5Version, folderPath); + } + } + + return { projectPath }; +} + +/** + * Ask a yes/no question on the terminal. Returns true only if user answers 'y' or 'Y'. + * If stdin is not a TTY, returns false to avoid destructive actions in non-interactive environments. + */ +function promptYesNo(question: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY) { + console.error('Non-interactive terminal detected — cannot confirm destructive action.'); + return resolve(false); + } + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + const normalized = (answer || '').trim().toLowerCase(); + resolve(normalized === 'y' || normalized === 'yes'); + }); + }); +} + +if (require.main === module) { + // argv[2] expect on file name with or without extension + const testFileName = process.argv[2]; + if (!testFileName) { + console.error('Please provide a test file name. Example: #file:object-page-v2a.spec.ts'); + process.exit(1); + } + + generateTestProject(testFileName, DEFAULT_UI5_VERSION, DEFAULT_BACKEND_URL, DEFAULT_LIVERELOAD_PORT).then( + ({ projectPath }) => console.log(`Project Generated under folder: "${projectPath}"`) + ); +} diff --git a/tests/integration/adaptation-editor/test-project-map.json b/tests/integration/adaptation-editor/test-project-map.json new file mode 100644 index 00000000000..016c93602e3 --- /dev/null +++ b/tests/integration/adaptation-editor/test-project-map.json @@ -0,0 +1,76 @@ +{ + "list-report-v2": { + "projectConfig": { + "type": "generated", + "kind": "adp", + "baseApp": { + "type": "generated", + "kind": "ui5", + "id": "fiori.elements.v2", + "mainServiceUri": "/sap/opu/odata/sap/SERVICE/", + "entitySet": "RootEntity" + }, + "id": "adp.fiori.elements.v2" + }, + "isAdp": true + }, + "object-page-v2": { + "projectConfig": { + "type": "generated", + "kind": "adp", + "baseApp": { + "type": "generated", + "kind": "ui5", + "id": "fiori.elements.v2", + "mainServiceUri": "/sap/opu/odata/sap/SERVICE/", + "entitySet": "RootEntity", + "userParams": { + "navigationProperty": "toFirstAssociatedEntity", + "qualifier": "tableSection" + } + }, + "id": "adp.fiori.elements.v2" + }, + "isAdp": true + }, + "object-page-v2a": { + "projectConfig": { + "type": "generated", + "kind": "adp", + "baseApp": { + "type": "generated", + "kind": "ui5", + "id": "fiori.elements.v2", + "mainServiceUri": "/sap/opu/odata/sap/SERVICE/", + "entitySet": "RootEntity", + "userParams": { + "navigationProperty": "toFirstAssociatedEntity", + "variantManagement": false, + "qualifier": "tableSection" + } + }, + "id": "adp.fiori.elements.v2" + }, + "isAdp": true + }, + "object-page-v2b": { + "projectConfig": { + "type": "generated", + "kind": "adp", + "baseApp": { + "type": "generated", + "kind": "ui5", + "id": "fiori.elements.v2", + "mainServiceUri": "/sap/opu/odata/sap/SERVICE/", + "entitySet": "RootEntity", + "userParams": { + "navigationProperty": "toFirstAssociatedEntity", + "qualifier": "tableSection", + "analyticalTable": true + } + }, + "id": "adp.fiori.elements.v2" + }, + "isAdp": true + } +} \ No newline at end of file diff --git a/tests/integration/adaptation-editor/tsconfig.json b/tests/integration/adaptation-editor/tsconfig.json index f80b191cd65..617f91c6ac3 100644 --- a/tests/integration/adaptation-editor/tsconfig.json +++ b/tests/integration/adaptation-editor/tsconfig.json @@ -1,8 +1,10 @@ { "extends": "../../../tsconfig.json", "include": [ - "src/**/*.ts", - "src/**/*.json" + "src/**/*.ts/**/*.ts", + "src/**/*.json" , + "test-project-generator.ts", + "server.ts" ], "compilerOptions": { "rootDir": "src",