From 7a6cc062ea1bdb13d2797c65dae04c6a1c719792 Mon Sep 17 00:00:00 2001 From: Jimmy Joseph Date: Mon, 13 Oct 2025 10:09:30 +0200 Subject: [PATCH 1/2] generate test project for manual testing --- .../integration/adaptation-editor/.gitignore | 1 + .../adaptation-editor/src/global-setup.ts | 18 +- .../adaptation-editor/src/project/builder.ts | 64 +++- .../test-project-generator.ts | 302 ++++++++++++++++++ .../adaptation-editor/tsconfig.json | 5 +- 5 files changed, 372 insertions(+), 18 deletions(-) create mode 100644 tests/integration/adaptation-editor/test-project-generator.ts diff --git a/tests/integration/adaptation-editor/.gitignore b/tests/integration/adaptation-editor/.gitignore index 591a02d97f9..3a816b9d494 100644 --- a/tests/integration/adaptation-editor/.gitignore +++ b/tests/integration/adaptation-editor/.gitignore @@ -3,3 +3,4 @@ node_modules dist blob-report versions.json +manual-test 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..cefa7151354 --- /dev/null +++ b/tests/integration/adaptation-editor/test-project-generator.ts @@ -0,0 +1,302 @@ +import { randomUUID } from 'crypto'; +import { generateAdpProject, generateUi5Project } from './src/project/builder'; +import { ADP_FIORI_ELEMENTS_V2 } from './src/project/projects'; +import { rm, stat, symlink, mkdir, readdir } from 'fs/promises'; +import { join } from 'path'; +import globalSetup from './src/global-setup'; +import { getPortPromise } from 'portfinder'; +import { existsSync } from 'fs'; + +/** + * Interface for test project configurations. + */ +interface TestProjectConfig { + projectConfig: any; + isAdp?: boolean; +} + +/** + * Map of test file names to their corresponding project configurations. + */ +const testConfigMap: Record = { + 'list-report-v2': { + projectConfig: ADP_FIORI_ELEMENTS_V2, + isAdp: true + }, + 'object-page-v2': { + projectConfig: { + ...ADP_FIORI_ELEMENTS_V2, + baseApp: { + ...ADP_FIORI_ELEMENTS_V2.baseApp, + userParams: { + navigationProperty: 'toFirstAssociatedEntity', + qualifier: 'tableSection' + } + } + }, + isAdp: true + }, + 'object-page-v2a': { + projectConfig: { + ...ADP_FIORI_ELEMENTS_V2, + baseApp: { + ...ADP_FIORI_ELEMENTS_V2.baseApp, + userParams: { + navigationProperty: 'toFirstAssociatedEntity', + variantManagement: false, + qualifier: 'tableSection' + } + } + }, + isAdp: true + }, + 'object-page-v2b': { + projectConfig: { + ...ADP_FIORI_ELEMENTS_V2, + baseApp: { + ...ADP_FIORI_ELEMENTS_V2.baseApp, + userParams: { + navigationProperty: 'toFirstAssociatedEntity', + qualifier: 'tableSection', + analyticalTable: true + } + } + }, + isAdp: true + } + // Add more test file mappings as needed +}; + +/** + * 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'); +let abapServerInstance: any; +/** + * Starts the mock ABAP backend server. + * If a server is already running, it will reuse the existing server if it's using + * the same folder, otherwise it will stop the existing server and start a new one. + * + * @param folderName - The folder name to use for the server + * @param port - The port to use for the server (default: 3050) + * @returns Promise that resolves when the server is started with the port it's running on + */ +export async function startAbapServer(folderName: string, port: number = 3050): Promise { + abapServerInstance = await globalSetup(port, folderName); + console.log(`Started ABAP server with PID ${process.pid} using folder: ${folderName} on port: ${port}`); +} + +/** + * Stops the mock ABAP backend server. + */ +export async function stopAbapServer(): Promise { + if (abapServerInstance) { + (await abapServerInstance).close(); + abapServerInstance = null; + console.log('Mock ABAP backend stopped'); + } +} + +/** + * Stops all servers. + */ +export async function stopAllServers(): Promise { + await stopAbapServer(); +} + +/** + * Delete a project + * + * @param folderPath - Path to the project to delete + */ +async function deleteProject(folderPath: string): Promise { + if (existsSync(folderPath)) { + try { + console.log(`Deleting existing project at ${folderPath}`); + await rm(folderPath, { recursive: true }); + } catch (error) { + console.error(`Error deleting project: ${error}`); + throw error; + } + } +} + +/** + * 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; ui5Port?: number; abapPort?: number }> { + // Extract just the filename if a full path is provided + const fileName = testFileName.includes('/') + ? testFileName.substring(testFileName.lastIndexOf('/') + 1) + : testFileName; + + // Remove the '#file:' prefix if present + const normalizedFileName = fileName.startsWith('#file:') ? fileName.substring(6) : fileName; + + // Remove the '.spec.ts' suffix if present + const testName = normalizedFileName.endsWith('.spec.ts') + ? normalizedFileName.substring(0, normalizedFileName.length - 8) + : normalizedFileName; + + 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 = ''; + + // if (!forceRegenerate) { + // console.log(`Using existing project at ${fullFolderPath}`); + + // // Find the actual project path (it includes a unique worker ID) + // const items = await readdir(fullFolderPath); + // const adpProject = items.find( + // (item) => item.startsWith(config.projectConfig.id) && existsSync(join(fullFolderPath, item, 'webapp')) + // ); + + // if (adpProject) { + // projectPath = join(fullFolderPath, adpProject); + // } else { + // console.log('Could not find valid project in the folder, regenerating...'); + // await deleteProject(fullFolderPath); + // } + // } + + // Start the ABAP server if requested + let abapPort = 3050; + if (startServers) { + try { + abapPort = await getPortPromise({ port: abapPort, stopPort: 3050 + 1000 }); + await startAbapServer(folderPath, abapPort); + + // Update backendUrl with the actual port the server is running on + backendUrl = `http://localhost:${abapPort}`; + } catch (error) { + console.error(`Failed to start ABAP server: ${error}`); + } + } + + // 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) { + 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, abapPort }; +} + +/** + * Command line interface for the script. + * Usage: node test-project-generator.js #file:test-file-name.spec.ts [--start-servers] [--port=3000] [--force] + */ +if (require.main === module) { + // This will run only when the script is executed directly + const testFileName = process.argv[2]; + const startServersFlag = process.argv.includes('--start-servers'); + //const forceRegenerateFlag = process.argv.includes('--force'); + + 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, + startServersFlag, + //forceRegenerateFlag + ) + .then(({ projectPath, ui5Port }) => { + console.log(`Project at: ${projectPath}`); + if (ui5Port) { + console.log( + `Adaptation Editor URL: http://localhost:${ui5Port}/adaptation-editor.html?fiori-tools-rta-mode=true#app-preview` + ); + } + + if (!startServersFlag) { + process.exit(0); + } else { + console.log('Press Ctrl+C to stop the servers and exit'); + + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('Shutting down servers...'); + await stopAllServers(); + process.exit(0); + }); + } + }) + .catch(async (error) => { + console.error(`Failed to generate project: ${error.message}`); + if (startServersFlag) { + await stopAllServers(); + } + process.exit(1); + }); +} diff --git a/tests/integration/adaptation-editor/tsconfig.json b/tests/integration/adaptation-editor/tsconfig.json index 640f50bd6a3..c36df63ae4e 100644 --- a/tests/integration/adaptation-editor/tsconfig.json +++ b/tests/integration/adaptation-editor/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../../../tsconfig.json", "include": [ - "src", - "src/**/*.json" + "src/**/*.ts", + "src/**/*.json", + "test-project-generator.ts" ], "compilerOptions": { "rootDir": "src", From 4b44f82ab9ad44a0f39dbfb6ca64354934f17204 Mon Sep 17 00:00:00 2001 From: Jimmy Joseph Date: Tue, 14 Oct 2025 08:38:56 +0200 Subject: [PATCH 2/2] fix: split generate script into generate and server --- tests/integration/adaptation-editor/server.ts | 180 ++++++++++++++ .../test-project-generator.ts | 235 ++++-------------- .../adaptation-editor/test-project-map.json | 76 ++++++ .../adaptation-editor/tsconfig.json | 3 +- 4 files changed, 304 insertions(+), 190 deletions(-) create mode 100644 tests/integration/adaptation-editor/server.ts create mode 100644 tests/integration/adaptation-editor/test-project-map.json 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/test-project-generator.ts b/tests/integration/adaptation-editor/test-project-generator.ts index cefa7151354..3ea43155ad8 100644 --- a/tests/integration/adaptation-editor/test-project-generator.ts +++ b/tests/integration/adaptation-editor/test-project-generator.ts @@ -1,11 +1,10 @@ import { randomUUID } from 'crypto'; import { generateAdpProject, generateUi5Project } from './src/project/builder'; -import { ADP_FIORI_ELEMENTS_V2 } from './src/project/projects'; -import { rm, stat, symlink, mkdir, readdir } from 'fs/promises'; -import { join } from 'path'; -import globalSetup from './src/global-setup'; +import { rm, stat, symlink, mkdir } from 'fs/promises'; +import { join } from 'node:path'; import { getPortPromise } from 'portfinder'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'node:fs'; +import readline from 'node:readline'; /** * Interface for test project configurations. @@ -15,58 +14,6 @@ interface TestProjectConfig { isAdp?: boolean; } -/** - * Map of test file names to their corresponding project configurations. - */ -const testConfigMap: Record = { - 'list-report-v2': { - projectConfig: ADP_FIORI_ELEMENTS_V2, - isAdp: true - }, - 'object-page-v2': { - projectConfig: { - ...ADP_FIORI_ELEMENTS_V2, - baseApp: { - ...ADP_FIORI_ELEMENTS_V2.baseApp, - userParams: { - navigationProperty: 'toFirstAssociatedEntity', - qualifier: 'tableSection' - } - } - }, - isAdp: true - }, - 'object-page-v2a': { - projectConfig: { - ...ADP_FIORI_ELEMENTS_V2, - baseApp: { - ...ADP_FIORI_ELEMENTS_V2.baseApp, - userParams: { - navigationProperty: 'toFirstAssociatedEntity', - variantManagement: false, - qualifier: 'tableSection' - } - } - }, - isAdp: true - }, - 'object-page-v2b': { - projectConfig: { - ...ADP_FIORI_ELEMENTS_V2, - baseApp: { - ...ADP_FIORI_ELEMENTS_V2.baseApp, - userParams: { - navigationProperty: 'toFirstAssociatedEntity', - qualifier: 'tableSection', - analyticalTable: true - } - } - }, - isAdp: true - } - // Add more test file mappings as needed -}; - /** * Default UI5 version to use if not provided. */ @@ -84,55 +31,6 @@ const DEFAULT_LIVERELOAD_PORT = 35729; // Avoid installing npm packages every time, but use symlink instead const PACKAGE_ROOT = join(__dirname, '..', '..', 'fixtures', 'projects', 'mock'); -let abapServerInstance: any; -/** - * Starts the mock ABAP backend server. - * If a server is already running, it will reuse the existing server if it's using - * the same folder, otherwise it will stop the existing server and start a new one. - * - * @param folderName - The folder name to use for the server - * @param port - The port to use for the server (default: 3050) - * @returns Promise that resolves when the server is started with the port it's running on - */ -export async function startAbapServer(folderName: string, port: number = 3050): Promise { - abapServerInstance = await globalSetup(port, folderName); - console.log(`Started ABAP server with PID ${process.pid} using folder: ${folderName} on port: ${port}`); -} - -/** - * Stops the mock ABAP backend server. - */ -export async function stopAbapServer(): Promise { - if (abapServerInstance) { - (await abapServerInstance).close(); - abapServerInstance = null; - console.log('Mock ABAP backend stopped'); - } -} - -/** - * Stops all servers. - */ -export async function stopAllServers(): Promise { - await stopAbapServer(); -} - -/** - * Delete a project - * - * @param folderPath - Path to the project to delete - */ -async function deleteProject(folderPath: string): Promise { - if (existsSync(folderPath)) { - try { - console.log(`Deleting existing project at ${folderPath}`); - await rm(folderPath, { recursive: true }); - } catch (error) { - console.error(`Error deleting project: ${error}`); - throw error; - } - } -} /** * Generates a test project based on the specified test file name. @@ -151,23 +49,23 @@ export async function generateTestProject( testFileName: string, ui5Version: string = DEFAULT_UI5_VERSION, backendUrl: string = DEFAULT_BACKEND_URL, - livereloadPort: number = DEFAULT_LIVERELOAD_PORT, - startServers: boolean = false, + livereloadPort: number = DEFAULT_LIVERELOAD_PORT + // startServers: boolean = false, //forceRegenerate: boolean = false -): Promise<{ projectPath: string; ui5Port?: number; abapPort?: number }> { +): 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 '#file:' prefix if present - const normalizedFileName = fileName.startsWith('#file:') ? fileName.substring(6) : fileName; - // Remove the '.spec.ts' suffix if present - const testName = normalizedFileName.endsWith('.spec.ts') - ? normalizedFileName.substring(0, normalizedFileName.length - 8) - : normalizedFileName; + 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) { @@ -179,43 +77,23 @@ export async function generateTestProject( let projectPath = ''; - // if (!forceRegenerate) { - // console.log(`Using existing project at ${fullFolderPath}`); - - // // Find the actual project path (it includes a unique worker ID) - // const items = await readdir(fullFolderPath); - // const adpProject = items.find( - // (item) => item.startsWith(config.projectConfig.id) && existsSync(join(fullFolderPath, item, 'webapp')) - // ); - - // if (adpProject) { - // projectPath = join(fullFolderPath, adpProject); - // } else { - // console.log('Could not find valid project in the folder, regenerating...'); - // await deleteProject(fullFolderPath); - // } - // } - - // Start the ABAP server if requested - let abapPort = 3050; - if (startServers) { - try { - abapPort = await getPortPromise({ port: abapPort, stopPort: 3050 + 1000 }); - await startAbapServer(folderPath, abapPort); - - // Update backendUrl with the actual port the server is running on - backendUrl = `http://localhost:${abapPort}`; - } catch (error) { - console.error(`Failed to start ABAP server: ${error}`); - } - } - // 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( @@ -245,58 +123,37 @@ export async function generateTestProject( } } - return { projectPath, abapPort }; + return { projectPath }; } /** - * Command line interface for the script. - * Usage: node test-project-generator.js #file:test-file-name.spec.ts [--start-servers] [--port=3000] [--force] + * 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) { - // This will run only when the script is executed directly + // argv[2] expect on file name with or without extension const testFileName = process.argv[2]; - const startServersFlag = process.argv.includes('--start-servers'); - //const forceRegenerateFlag = process.argv.includes('--force'); - 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, - startServersFlag, - //forceRegenerateFlag - ) - .then(({ projectPath, ui5Port }) => { - console.log(`Project at: ${projectPath}`); - if (ui5Port) { - console.log( - `Adaptation Editor URL: http://localhost:${ui5Port}/adaptation-editor.html?fiori-tools-rta-mode=true#app-preview` - ); - } - - if (!startServersFlag) { - process.exit(0); - } else { - console.log('Press Ctrl+C to stop the servers and exit'); - - // Handle graceful shutdown - process.on('SIGINT', async () => { - console.log('Shutting down servers...'); - await stopAllServers(); - process.exit(0); - }); - } - }) - .catch(async (error) => { - console.error(`Failed to generate project: ${error.message}`); - if (startServersFlag) { - await stopAllServers(); - } - 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 c36df63ae4e..1bff3efdb30 100644 --- a/tests/integration/adaptation-editor/tsconfig.json +++ b/tests/integration/adaptation-editor/tsconfig.json @@ -3,7 +3,8 @@ "include": [ "src/**/*.ts", "src/**/*.json", - "test-project-generator.ts" + "test-project-generator.ts", + "server.ts" ], "compilerOptions": { "rootDir": "src",