From e7372ce5328b19dc5b3b0fdbfb27706eb3d3857e Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 5 Aug 2025 17:44:11 +0300 Subject: [PATCH 001/111] feat: start transferring cf business logic --- packages/adp-tooling/package.json | 5 +- packages/adp-tooling/src/cf/fdc.ts | 562 ++++++++++++++++++++ packages/adp-tooling/src/cf/utils.ts | 198 +++++++ packages/adp-tooling/src/source/manifest.ts | 17 + packages/adp-tooling/src/types.ts | 168 ++++++ pnpm-lock.yaml | 31 +- 6 files changed, 960 insertions(+), 21 deletions(-) create mode 100644 packages/adp-tooling/src/cf/fdc.ts create mode 100644 packages/adp-tooling/src/cf/utils.ts diff --git a/packages/adp-tooling/package.json b/packages/adp-tooling/package.json index a6bf64ca0a6..a4e68dd418f 100644 --- a/packages/adp-tooling/package.json +++ b/packages/adp-tooling/package.json @@ -36,6 +36,7 @@ ], "dependencies": { "@sap-devx/yeoman-ui-types": "1.16.9", + "@sap/cf-tools": "3.2.2", "@sap-ux/axios-extension": "workspace:*", "@sap-ux/btp-utils": "workspace:*", "@sap-ux/inquirer-common": "workspace:*", @@ -57,7 +58,8 @@ "mem-fs-editor": "9.4.0", "prompts": "2.4.2", "sanitize-filename": "1.6.3", - "uuid": "10.0.0" + "uuid": "10.0.0", + "axios": "1.8.2" }, "devDependencies": { "@sap-ux/store": "workspace:*", @@ -70,6 +72,7 @@ "@types/prompts": "2.4.4", "@types/supertest": "2.0.12", "@types/uuid": "10.0.0", + "adm-zip": "0.5.16", "dotenv": "16.3.1", "express": "4.21.2", "nock": "13.4.0", diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts new file mode 100644 index 00000000000..58e9dc9717c --- /dev/null +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -0,0 +1,562 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import axios from 'axios'; +import type * as AdmZip from 'adm-zip'; +import CFLocal = require('@sap/cf-tools/out/src/cf-local'); + +import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; + +import { YamlUtils, HTML5RepoUtils, CFUtils } from './'; +import { Messages } from '../i18n/messages'; +import type { + Config, + CFConfig, + Space, + Organization, + HttpResponse, + CFApp, + RequestArguments, + Credentials, + ServiceKeys, + BusinessSeviceResource, + AppParams +} from '../types'; +import { isAppStudio } from '@sap-ux/btp-utils'; +import { getApplicationType, isSupportedAppTypeForAdp } from '../source'; + +export default class FDCService { + public html5RepoRuntimeGuid: string; + public manifests: any[] = []; + private CF_HOME = 'CF_HOME'; + private WIN32 = 'win32'; + private HOMEDRIVE = 'HOMEDRIVE'; + private HOMEPATH = 'HOMEPATH'; + private TARGET = 'Target'; + private ACCESS_TOKEN = 'AccessToken'; + private BEARER_SPACE = 'bearer '; + private ORGANIZATION_FIELDS = 'OrganizationFields'; + private SPACE_FIELDS = 'SpaceFields'; + private CF_FOLDER_NAME = '.cf'; + private CONFIG_JSON_FILE = 'config.json'; + private API_CF = 'api.cf.'; + private OK = 'OK'; + private HTML5_APPS_REPO = 'html5-apps-repo'; + private MTA_YAML_FILE = 'mta.yaml'; + private cfConfig: CFConfig; + private vscode: any; + private logger: ToolsLogger; + + constructor(logger: ToolsLogger, vscode: any) { + this.vscode = vscode; + this.logger = logger; + } + + public async isCfInstalled(): Promise { + let isInstalled = true; + try { + await CFUtils.checkForCf(); + } catch (error) { + isInstalled = false; + } + + return isInstalled; + } + + public loadConfig(): void { + let cfHome = process.env[this.CF_HOME]; + if (!cfHome) { + cfHome = path.join(this.getHomedir(), this.CF_FOLDER_NAME); + } + + const configFileLocation = path.join(cfHome, this.CONFIG_JSON_FILE); + + let config = {} as Config; + try { + const configAsString = fs.readFileSync(configFileLocation, 'utf-8'); + config = JSON.parse(configAsString); + } catch (e) { + this.logger?.error(Messages.CANNOT_RECIEVE_TOKEN_ERROR_MSG); + } + + const API_CF = this.API_CF; + if (config) { + const result = {} as CFConfig; + if (config[this.TARGET]) { + const apiCfIndex = config[this.TARGET].indexOf(API_CF); + result.url = config[this.TARGET].substring(apiCfIndex + API_CF.length); + } + + if (config[this.ACCESS_TOKEN]) { + result.token = config[this.ACCESS_TOKEN].substring(this.BEARER_SPACE.length); + } + + if (config[this.ORGANIZATION_FIELDS]) { + result.org = { + name: config[this.ORGANIZATION_FIELDS].Name, + guid: config[this.ORGANIZATION_FIELDS].GUID + }; + } + + if (config[this.SPACE_FIELDS]) { + result.space = { + name: config[this.SPACE_FIELDS].Name, + guid: config[this.SPACE_FIELDS].GUID + }; + } + + this.cfConfig = result; + YamlUtils.spaceGuid = this.cfConfig?.space?.guid; + } + } + + public async isLoggedIn(): Promise { + let isLogged = false; + let orgs; + await CFUtils.getAuthToken(); + this.loadConfig(); + if (this.cfConfig) { + try { + orgs = await CFLocal.cfGetAvailableOrgs(); + this.logger?.log(`Available organizations: ${JSON.stringify(orgs)}`); + if (orgs.length > 0) { + isLogged = true; + } + } catch (e) { + this.logger?.error(`Error occured while trying to check if it is logged in: ${e?.message}`); + isLogged = false; + } + } + + return isLogged; + } + + public async isExternalLoginEnabled(): Promise { + const commands = await this.vscode.commands.getCommands(); + + return commands.includes('cf.login'); + } + + public async isLoggedInToDifferentSource(organizacion: string, space: string, apiurl: string): Promise { + const isLoggedIn = await this.isLoggedIn(); + const cfConfig = this.getConfig(); + const isLoggedToDifferentSource = + isLoggedIn && + (cfConfig.org.name !== organizacion || cfConfig.space.name !== space || cfConfig.url !== apiurl); + + return isLoggedToDifferentSource; + } + + public async login(username: string, password: string, apiEndpoint: string): Promise { + let isSuccessful = false; + const loginResponse = await CFLocal.cfLogin(apiEndpoint, username, password); + if (loginResponse === this.OK) { + isSuccessful = true; + } else { + throw new Error(Messages.LOGIN_FAILED_MSG); + } + + return isSuccessful; + } + + public async getOrganizations(): Promise { + let organizations: Organization[] = []; + try { + organizations = await CFLocal.cfGetAvailableOrgs(); + this.logger?.log(`Available organizations: ${JSON.stringify(organizations)}`); + } catch (error) { + this.logger?.error(Messages.CANNOT_GET_ORGANIZATIONS); + } + + return organizations; + } + + public async getSpaces(spaceGuid: string): Promise { + let spaces: Space[] = []; + if (spaceGuid) { + try { + spaces = await CFLocal.cfGetAvailableSpaces(spaceGuid); + this.logger?.log(`Available spaces: ${JSON.stringify(spaces)} for space guid: ${spaceGuid}.`); + } catch (error) { + this.logger?.error(Messages.CANNOT_GET_SPACES); + } + } else { + this.logger?.error(Messages.INVALID_GUID_MSG); + } + + return spaces; + } + + public async setOrgSpace(orgName: string, spaceName: string): Promise { + if (!orgName || !spaceName) { + throw new Error(Messages.MISSING_ORG_OR_SPACE_NAME); + } + + await CFLocal.cfSetOrgSpace(orgName, spaceName); + this.loadConfig(); + } + + public async getServices(projectPath: string): Promise { + const services = await this.readMta(projectPath); + this.logger?.log(`Available services defined in mta.yaml: ${JSON.stringify(services)}`); + return services; + } + + public async getBaseApps(credentials: Credentials[], includeInvalid = false): Promise { + const appHostIds = this.getAppHostIds(credentials); + this.logger?.log(`App Host Ids: ${JSON.stringify(appHostIds)}`); + const discoveryApps = await Promise.all( + Array.from(appHostIds).map(async (appHostId: string) => { + try { + const response = await this.getFDCApps(appHostId); + if (response.status === 200) { + const results = (response.data as any)?.['results'] as CFApp[]; + results.forEach((result) => (result.appHostId = appHostId)); // store appHostId in order to know by which appHostId was the app selected + return results; + } + throw new Error( + Messages.FAILED_TO_CONNECT_TO_FDC( + appHostId, + Messages.HTTP_STATUS_MESSAGE(response.status.toString(), response.statusText) + ) + ); + } catch (error) { + return [{ appHostId: appHostId, messages: [error.message] }]; + } + }) + ).then((results) => results.flat()); + + const validatedApps = await this.getValidatedApps(discoveryApps, credentials); + return includeInvalid ? validatedApps : validatedApps.filter((app) => !app.messages?.length); + } + + public hasApprouter(projectName: string, moduleNames: string[]): boolean { + return moduleNames.some( + (name) => + name === `${projectName.toLowerCase()}-destination-content` || + name === `${projectName.toLowerCase()}-approuter` + ); + } + + public getManifestByBaseAppId(appId: string) { + return this.manifests.find((appManifest) => { + return appManifest['sap.app'].id === appId; + }); + } + + public getApprouterType(): string { + return YamlUtils.getRouterType(); + } + + public getModuleNames(mtaProjectPath: string): string[] { + YamlUtils.loadYamlContent(path.join(mtaProjectPath, this.MTA_YAML_FILE)); + return YamlUtils.yamlContent?.modules?.map((module: { name: any }) => module.name) || []; + } + + public formatDiscovery(app: any): string { + return `${app.title} (${app.appId} ${app.appVersion})`; + } + + public getConfig(): CFConfig { + return this.cfConfig; + } + + public async getBusinessServiceKeys(businessService: string): Promise { + const serviceKeys = await CFUtils.getServiceInstanceKeys({ + spaceGuids: [this.getConfig().space.guid], + names: [businessService] + }); + this.logger?.log(`Available service key instance : ${JSON.stringify(serviceKeys?.serviceInstance)}`); + return serviceKeys; + } + + public async validateODataEndpoints(zipEntries: AdmZip.IZipEntry[], credentials: Credentials[]): Promise { + const messages: string[] = []; + let xsApp, manifest; + try { + xsApp = this.extractXSApp(zipEntries); + this.logger?.log(`ODATA endpoints: ${JSON.stringify(xsApp)}`); + } catch (error) { + messages.push(error.message); + return messages; + } + + try { + manifest = this.extractManifest(zipEntries); + this.logger?.log(`Extracted manifest: ${JSON.stringify(manifest)}`); + } catch (error) { + messages.push(error.message); + return messages; + } + + const dataSources = manifest?.['sap.app']?.dataSources; + const routes = (xsApp as any)?.routes; + if (dataSources && routes) { + const serviceKeyEndpoints = ([] as string[]).concat( + ...credentials.map((item) => (item.endpoints ? Object.keys(item.endpoints) : [])) + ); + messages.push(...this.matchRoutesAndDatasources(dataSources, routes, serviceKeyEndpoints)); + } else if (routes && !dataSources) { + messages.push(Messages.MANIFEST_MISSING_DATASOURCES); + } else if (!routes && dataSources) { + messages.push(Messages.XSAPP_MISSING_ROUTES); + } + return messages; + } + + private extractXSApp(zipEntries: AdmZip.IZipEntry[]) { + let xsApp; + zipEntries.forEach((item) => { + if (item.entryName.endsWith('xs-app.json')) { + try { + xsApp = JSON.parse(item.getData().toString('utf8')); + } catch (error) { + throw new Error(Messages.FAILED_TO_PARSE_XS_APP_JSON_IN_APP_ZIP(error.message)); + } + } + }); + return xsApp; + } + + private extractManifest(zipEntries: AdmZip.IZipEntry[]): Manifest | undefined { + let manifest: Manifest | undefined; + zipEntries.forEach((item) => { + if (item.entryName.endsWith('manifest.json')) { + try { + manifest = JSON.parse(item.getData().toString('utf8')); + } catch (error) { + throw new Error(Messages.FAILED_TO_PARSE_MANIFEST_JSON_IN_APP_ZIP(error.message)); + } + } + }); + return manifest; + } + + private matchRoutesAndDatasources(dataSources: any, routes: any, serviceKeyEndpoints: any): string[] { + const messages: string[] = []; + routes.forEach((route: any) => { + if (route.endpoint && !serviceKeyEndpoints.includes(route.endpoint)) { + messages.push(Messages.MANIFEST_DATASOURCE_NOT_MATCH(route.endpoint)); + } + }); + + Object.keys(dataSources).forEach((dataSourceName) => { + if ( + !routes.some((route: any) => + dataSources[dataSourceName].uri?.match(this.normalizeRouteRegex(route.source)) + ) + ) { + messages.push(Messages.SERVICE_KEY_ENDPOINT_NOT_MATCH(dataSourceName)); + } + }); + return messages; + } + + private getAppHostIds(credentials: Credentials[]): Set { + const appHostIds: string[] = []; + credentials.forEach((credential) => { + const appHostId = credential[this.HTML5_APPS_REPO]?.app_host_id; + if (appHostId) { + appHostIds.push(appHostId.split(',').map((item: any) => item.trim())); // there might be multiple appHostIds separated by comma + } + }); + + // appHostIds is now an array of arrays of strings (from split) + // Flatten the array and create a Set + return new Set(appHostIds.flat()); + } + + private async filterServices(businessServices: BusinessSeviceResource[]): Promise { + const serviceLabels = businessServices.map((service) => service.label).filter((label) => label); + if (serviceLabels.length > 0) { + const url = `/v3/service_offerings?names=${serviceLabels.join(',')}`; + const json = await CFUtils.requestCfApi(url); + this.logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); + + const businessServiceNames = new Set(businessServices.map((service) => service.label)); + const result: string[] = []; + json.resources.forEach((resource: any) => { + if (businessServiceNames.has(resource.name)) { + const sapService = resource?.['broker_catalog']?.metadata?.sapservice; + if (sapService && ['v2', 'v4'].includes(sapService.odataversion)) { + result.push(businessServices?.find((service) => resource.name === service.label)?.name ?? ''); + } else { + this.logger?.log(`Service '${resource.name}' doesn't support V2/V4 Odata and will be ignored`); + } + } + }); + + if (result.length > 0) { + return result; + } + } + throw new Error(Messages.FAILED_TO_FIND_BUSINESS_SERVICES); + } + + public normalizeRouteRegex(value: string) { + return new RegExp(value.replace('^/', '^(/)*').replace('/(.*)$', '(/)*(.*)$')); + } + + public getFDCRequestArguments(): RequestArguments { + const cfConfig = this.getConfig(); + const fdcUrl = 'https://ui5-flexibility-design-and-configuration.'; + const cfApiEndpoint = `https://api.cf.${cfConfig.url}`; + const endpointParts = /https:\/\/api\.cf(?:\.([^-.]*)(-\d+)?(\.hana\.ondemand\.com)|(.*))/.exec(cfApiEndpoint); + const options: any = { + withCredentials: true, + headers: { + 'Content-Type': 'application/json' + } + }; + // Public cloud + let url = `${fdcUrl}cert.cfapps.${endpointParts?.[1]}.hana.ondemand.com`; + if (!endpointParts?.[3]) { + // Private cloud - if hana.ondemand.com missing as a part of CF api endpotint + url = `${fdcUrl}sapui5flex.cfapps${endpointParts?.[4]}`; + if (endpointParts?.[4]?.endsWith('.cn')) { + // China has a special URL pattern + const parts = endpointParts?.[4]?.split('.'); + parts.splice(2, 0, 'apps'); + url = `${fdcUrl}sapui5flex${parts.join('.')}`; + } + } + if (!isAppStudio() || !endpointParts?.[3]) { + // Adding authorization token for none BAS enviourment and + // for private cloud as a temporary solution until enablement of cert auth + options.headers['Authorization'] = `Bearer ${cfConfig.token}`; + } + return { + url: url, + options + }; + } + + private async getFDCApps(appHostId: string): Promise { + const requestArguments = this.getFDCRequestArguments(); + this.logger?.log(`App Host: ${appHostId}, request arguments: ${JSON.stringify(requestArguments)}`); + const url = `${requestArguments.url}/api/business-service/discovery?appHostId=${appHostId}`; + + try { + const isLoggedIn = await this.isLoggedIn(); + if (!isLoggedIn) { + await CFLocal.cfGetAvailableOrgs(); + this.loadConfig(); + } + const response = await axios.get(url, requestArguments.options); + this.logger?.log( + `Getting FDC apps. Request url: ${url} response status: ${ + response.status + }, response data: ${JSON.stringify(response.data)}` + ); + return response; + } catch (error) { + this.logger?.error( + `Getting FDC apps. Request url: ${url}, response status: ${error?.response?.status}, message: ${ + error.message || error + }` + ); + throw new Error(Messages.FAILED_TO_GET_FDC_APP(url, error.message || error)); + } + } + + private async getValidatedApps(discoveryApps: any[], credentials: any): Promise { + const validatedApps: any[] = []; + await Promise.all( + discoveryApps.map(async (app) => { + if (!(app.messages && app.messages.length)) { + const messages = await this.validateSelectedApp(app, credentials); + app.messages = messages; + } + validatedApps.push(app); + }) + ); + return validatedApps; + } + + private async validateSelectedApp(appParams: AppParams, credentials: any): Promise { + try { + const { entries, serviceInstanceGuid, manifest } = await HTML5RepoUtils.downloadAppContent( + this.cfConfig.space.guid, + appParams + ); + this.manifests.push(manifest); + const messages = await this.validateSmartTemplateApplication(manifest); + this.html5RepoRuntimeGuid = serviceInstanceGuid; + if (messages?.length === 0) { + return this.validateODataEndpoints(entries, credentials); + } else { + return messages; + } + } catch (error) { + return [error.message]; + } + } + + public async validateSmartTemplateApplication(manifest: Manifest): Promise { + const messages: string[] = []; + const appType = getApplicationType(manifest); + + if (isSupportedAppTypeForAdp(appType)) { + if (manifest['sap.ui5'] && manifest['sap.ui5'].flexEnabled === false) { + return messages.concat(Messages.I18N_KEY_APPLICATION_DO_NOT_SUPPORT_ADAPTATION); + } + } else { + return messages.concat(Messages.ADAPTATIONPROJECTPLUGIN_SMARTTEMPLATE_PROJECT_CHECK); + } + return messages; + } + + private async readMta(projectPath: string): Promise { + if (!projectPath) { + throw new Error(Messages.NO_PROJECT_PATH_ERR_MSG); + } + + const mtaYamlPath = path.resolve(projectPath, this.MTA_YAML_FILE); + return this.getResources([mtaYamlPath]); + } + + private async getResources(files: string[]): Promise { + let finalList: string[] = []; + + await Promise.all( + files.map(async (file) => { + const servicesList = this.getServicesForFile(file); + const oDataFilteredServices = await this.filterServices(servicesList); + finalList = finalList.concat(oDataFilteredServices); + }) + ); + + return finalList; + } + + private getServicesForFile(file: string): BusinessSeviceResource[] { + const serviceNames: BusinessSeviceResource[] = []; + YamlUtils.loadYamlContent(file); + const parsed = YamlUtils.yamlContent; + if (parsed && parsed.resources && Array.isArray(parsed.resources)) { + parsed.resources.forEach((resource: any) => { + const name = resource?.['parameters']?.['service-name'] || resource.name; + const label = resource?.['parameters']?.service; + if (name) { + serviceNames.push({ name, label }); + if (!label) { + this.logger?.log(`Service '${name}' will be ignored without 'service' parameter`); + } + } + }); + } + return serviceNames; + } + + private getHomedir() { + let homedir = os.homedir(); + const homeDrive = process.env?.[this.HOMEDRIVE]; + const homePath = process.env?.[this.HOMEPATH]; + if (process.platform === this.WIN32 && typeof homeDrive === 'string' && typeof homePath === 'string') { + homedir = path.join(homeDrive, homePath); + } + + return homedir; + } +} diff --git a/packages/adp-tooling/src/cf/utils.ts b/packages/adp-tooling/src/cf/utils.ts new file mode 100644 index 00000000000..b1ca4f80346 --- /dev/null +++ b/packages/adp-tooling/src/cf/utils.ts @@ -0,0 +1,198 @@ +import fs from 'fs'; +import path from 'path'; +import CFLocal = require('@sap/cf-tools/out/src/cf-local'); +import CFToolsCli = require('@sap/cf-tools/out/src/cli'); +import { eFilters } from '@sap/cf-tools/out/src/types'; + +import { YamlUtils } from './'; +import { Messages } from '../i18n/messages'; +import type { ToolsLogger } from '@sap-ux/logger'; +import type { GetServiceInstanceParams, ServiceKeys, ServiceInstance } from '../types'; + +export default class CFUtils { + private static ENV = { env: { 'CF_COLOR': 'false' } }; + private static CREATE_SERVICE_KEY = 'create-service-key'; + + public static async getServiceInstanceKeys( + serviceInstanceQuery: GetServiceInstanceParams, + logger: ToolsLogger + ): Promise { + try { + const serviceInstances = await this.getServiceInstance(serviceInstanceQuery); + if (serviceInstances?.length > 0) { + // we can use any instance in the list to connect to HTML5 Repo + logger?.log(`Use '${serviceInstances[0].name}' HTML5 Repo instance`); + return { + credentials: await this.getOrCreateServiceKeys(serviceInstances[0], logger), + serviceInstance: serviceInstances[0] + }; + } + return null; + } catch (error) { + const errorMessage = Messages.FAILED_TO_GET_SERVICE_INSTANCE_KEYS(error.message); + logger?.error(errorMessage); + throw new Error(errorMessage); + } + } + + public static async createService( + spaceGuid: string, + plan: string, + serviceInstanceName: string, + logger: ToolsLogger, + tags: string[] = [], + securityFilePath: string | null = null, + serviceName: string | null = null + ): Promise { + try { + if (!serviceName) { + const json = await this.requestCfApi(`/v3/service_offerings?per_page=1000&space_guids=${spaceGuid}`); + serviceName = json.resources.find( + (resource: any) => resource.tags && tags.every((tag) => resource.tags.includes(tag)) + ).name; + } + + logger?.log( + `Creating service instance '${serviceInstanceName}' of service '${serviceName}' with '${plan}' plan` + ); + const commandParameters: string[] = ['create-service', serviceName ?? '', plan, serviceInstanceName]; + if (securityFilePath) { + let xsSecurity = null; + try { + const filePath = path.resolve(__dirname, '../templates/cf/xs-security.json'); + const xsContent = fs.readFileSync(filePath, 'utf-8'); + xsSecurity = JSON.parse(xsContent); + xsSecurity.xsappname = YamlUtils.getProjectNameForXsSecurity(); + } catch (err) { + throw new Error('xs-security.json could not be parsed.'); + } + + commandParameters.push('-c'); + commandParameters.push(JSON.stringify(xsSecurity)); + } + + const query = await CFToolsCli.Cli.execute(commandParameters); + if (query.exitCode !== 0) { + throw new Error(query.stderr); + } + } catch (error) { + logger?.error(Messages.FAILED_TO_CREATE_SERVICE_INSTANCE(serviceInstanceName, spaceGuid, error.message)); + throw new Error(Messages.FAILED_TO_CREATE_SERVICE_INSTANCE(serviceInstanceName, spaceGuid, error.message)); + } + } + + public static async requestCfApi(url: string) { + try { + const response = await CFToolsCli.Cli.execute(['curl', url], this.ENV); + if (response.exitCode === 0) { + try { + return JSON.parse(response.stdout); + } catch (error) { + throw new Error(Messages.FAILED_TO_PARSE_RESPONSE_FROM_CF(error.message)); + } + } + throw new Error(response.stderr); + } catch (error) { + // log error: CFUtils.ts=>requestCfApi(params) + throw new Error(Messages.REQUEST_TO_CF_API_FAILED(error.message)); + } + } + + public static async getAuthToken(): Promise { + const response = await CFToolsCli.Cli.execute(['oauth-token'], this.ENV); + if (response.exitCode === 0) { + return response.stdout; + } + return response.stderr; + } + + public static async checkForCf(): Promise { + try { + const response = await CFToolsCli.Cli.execute(['version'], this.ENV); + if (response.exitCode !== 0) { + throw new Error(response.stderr); + } + } catch (error) { + // log error: CFUtils.ts=>checkForCf + throw new Error(Messages.CLOUD_FOUNDRY_NOT_INSTALLED); + } + } + + public static async cFLogout(): Promise { + await CFToolsCli.Cli.execute(['logout']); + } + + private static async getServiceInstance(params: GetServiceInstanceParams): Promise { + const PARAM_MAP: Map = new Map([ + ['spaceGuids', 'space_guids'], + ['planNames', 'service_plan_names'], + ['names', 'names'] + ]); + const parameters = Object.entries(params) + .filter(([_, value]) => value?.length > 0) + .map(([key, value]) => `${PARAM_MAP.get(key)}=${value.join(',')}`); + const uriParameters = parameters.length > 0 ? `?${parameters.join('&')}` : ''; + const uri = `/v3/service_instances` + uriParameters; + try { + const json = await this.requestCfApi(uri); + if (json && json.resources && Array.isArray(json.resources)) { + return json.resources.map((service: any) => ({ + name: service.name, + guid: service.guid + })); + } + throw new Error(Messages.NO_VALID_JSON_FOR_SERVICE_INSTANCE); + } catch (error) { + // log error: CFUtils.ts=>getServiceInstance with uriParameters + throw new Error(Messages.FAILED_TO_GET_SERVICE_INSTANCE(uriParameters, error.message)); + } + } + + private static async getOrCreateServiceKeys(serviceInstance: ServiceInstance, logger: ToolsLogger) { + try { + const credentials = await this.getServiceKeys(serviceInstance.guid); + if (credentials?.length > 0) { + return credentials; + } else { + const serviceKeyName = serviceInstance.name + '_key'; + logger?.log(`Creating service key '${serviceKeyName}' for service instance '${serviceInstance.name}'`); + await this.createServiceKey(serviceInstance.name, serviceKeyName); + return this.getServiceKeys(serviceInstance.guid); + } + } catch (error) { + // log error: CFUtils.ts=>getOrCreateServiceKeys with param + throw new Error(Messages.FAILED_TO_GET_OR_CREATE_SERVICE_KEYS(serviceInstance.name, error.message)); + } + } + + private static async getServiceKeys(serviceInstanceGuid: string): Promise { + try { + return await CFLocal.cfGetInstanceCredentials({ + filters: [ + { + value: serviceInstanceGuid, + key: eFilters.service_instance_guid + } + ] + }); + } catch (error) { + // log error: CFUtils.ts=>getServiceKeys for guid + throw new Error(Messages.FAILED_TO_GET_INSTANCE_CREDENTIALS(serviceInstanceGuid, error.message)); + } + } + + private static async createServiceKey(serviceInstanceName: string, serviceKeyName: any) { + try { + const cliResult = await CFToolsCli.Cli.execute( + [this.CREATE_SERVICE_KEY, serviceInstanceName, serviceKeyName], + this.ENV + ); + if (cliResult.exitCode !== 0) { + throw new Error(cliResult.stderr); + } + } catch (error) { + // log error: CFUtils.ts=>createServiceKey for serviceInstanceName + throw new Error(Messages.FAILED_TO_CREATE_SERVICE_KEY_FOR_INSTANCE(serviceInstanceName, error.message)); + } + } +} diff --git a/packages/adp-tooling/src/source/manifest.ts b/packages/adp-tooling/src/source/manifest.ts index c31d683abce..57d1917eede 100644 --- a/packages/adp-tooling/src/source/manifest.ts +++ b/packages/adp-tooling/src/source/manifest.ts @@ -68,6 +68,23 @@ export function getApplicationType(manifest?: Manifest): ApplicationType { return ApplicationType.FREE_STYLE; } +/** + * Checks if the application type is supported for ADAPTATION PROJECT. + * + * @param {ApplicationType} appType - The application type to check. + * @returns {boolean} True if the application type is supported for ADAPTATION PROJECT, false otherwise. + */ +export function isSupportedAppTypeForAdp(appType: ApplicationType): boolean { + if ( + appType === ApplicationType.FIORI_ELEMENTS || + appType === ApplicationType.FIORI_ELEMENTS_OVP || + appType === ApplicationType.FREE_STYLE + ) { + return true; + } + return false; +} + /** * Checks if views are loaded synchronously or asynchronously in the UI5 settings of the manifest. * Sets the isAppSync property based on the loading method. diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 2cb6aa066bf..0806b9e85bf 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -798,3 +798,171 @@ export interface InboundChange { }; }; } + +export interface Uaa { + clientid: string; + clientsecret: string; + url: string; +} + +export interface AppParams { + appName: string; + appVersion: string; + appHostId: string; +} + +export interface AppParamsExtended extends AppParams { + spaceGuid: string; +} + +export interface CFParameters { + org: string; + space: string; + html5RepoRuntime: string; +} + +export interface Credentials { + [key: string]: any; + + uaa: Uaa; + uri: string; + endpoints: any; +} + +export interface ServiceKeys { + credentials: Credentials[]; + serviceInstance: ServiceInstance; +} + +export interface HTML5Content { + // entries: AdmZip.IZipEntry[]; + entries: any[]; // TODO: replace with AdmZip.IZipEntry[] + serviceInstanceGuid: string; + manifest: any; +} + +export interface ServiceInstance { + name: string; + guid: string; +} + +export interface GetServiceInstanceParams { + spaceGuids?: string[]; + planNames?: string[]; + names: string[]; +} + +export interface BusinessSeviceResource { + name: string; + label: string; +} + +export interface AppParams { + appName: string; + appVersion: string; + appHostId: string; +} + +export interface Resource { + name: string; + type: string; + parameters: any; +} + +export interface Yaml { + '_schema-version': string; + 'ID': string; + 'version': string; + resources?: any[]; + modules?: MTAModule[]; +} + +export interface MTAModule { + name: string; + parameters: any; + path: string; + requires: MTARequire[]; + type: string; +} + +export interface MTARequire { + name: string; +} + +export interface ODataTargetSource { + dataSourceName: string; + uri: string; +} + +export interface CfAdpConfig extends AdpConfig { + cfSpace: string; + cfOrganization: string; + cfApiUrl: string; +} + +/** Old ADP config file types */ +export interface AdpConfig { + sourceSystem?: string; + componentname: string; + appvariant: string; + layer: string; + isOVPApp: boolean; + isFioriElement: boolean; + environment: string; + ui5Version: string; +} + +export interface Organization { + guid: string; + name: string; +} + +export interface Space { + guid: string; + name: string; +} + +export interface CFConfig { + org: Organization; + space: Space; + token: string; + url: string; +} + +export interface ConfigGeneric { + [key: string]: any; +} + +export interface Config { + AccessToken: string; + AuthorizationEndpoint: string; + OrganizationFields: Organization; + Target: string; + SpaceFields: Space; +} + +export interface HttpResponse { + statusText: string; + status: number; + data: string; +} + +export interface CFApp { + appId: string; + appName: string; + appVersion: string; + serviceName: string; + title: string; + appHostId: string; + messages?: string[]; +} + +export interface RequestArguments { + url: string; + options: { + headers: { + 'Content-Type': string; + Authorization?: string; + }; + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96b694f3639..184ac2343ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -626,9 +626,15 @@ importers: '@sap-ux/ui5-info': specifier: workspace:* version: link:../ui5-info + '@sap/cf-tools': + specifier: 3.2.2 + version: 3.2.2 adm-zip: specifier: 0.5.10 version: 0.5.10 + axios: + specifier: 1.8.2 + version: 1.8.2 ejs: specifier: 3.1.10 version: 3.1.10 @@ -12173,7 +12179,6 @@ packages: proxy-from-env: 1.1.0 transitivePeerDependencies: - debug - dev: false /axios@1.8.2: resolution: {integrity: sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==} @@ -12184,16 +12189,6 @@ packages: transitivePeerDependencies: - debug - /axios@1.9.0: - resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} - dependencies: - follow-redirects: 1.15.6 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - dev: true - /b4a@1.6.6: resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} @@ -15349,6 +15344,7 @@ packages: /esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} + hasBin: true /esquery@1.5.0: resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} @@ -20437,7 +20433,7 @@ packages: '@yarnpkg/lockfile': 1.1.0 '@yarnpkg/parsers': 3.0.2 '@zkochan/js-yaml': 0.0.7 - axios: 1.9.0 + axios: 1.11.0 chalk: 4.1.2 cli-cursor: 3.1.0 cli-spinners: 2.6.1 @@ -21805,14 +21801,9 @@ packages: /punycode@1.4.1: resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} - /punycode@2.3.0: - resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} - engines: {node: '>=6'} - /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - dev: true /pupa@2.1.1: resolution: {integrity: sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==} @@ -24569,7 +24560,7 @@ packages: engines: {node: '>=6'} dependencies: psl: 1.9.0 - punycode: 2.3.0 + punycode: 2.3.1 universalify: 0.2.0 url-parse: 1.5.10 @@ -24587,7 +24578,7 @@ packages: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} dependencies: - punycode: 2.3.0 + punycode: 2.3.1 /tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} @@ -25432,7 +25423,7 @@ packages: /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: - punycode: 2.3.0 + punycode: 2.3.1 /url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} From 4e1f565b3a15a98f9a28f973aa518746e7e7d159 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 6 Aug 2025 14:36:38 +0300 Subject: [PATCH 002/111] feat: add cf specific logic --- packages/adp-tooling/package.json | 20 +- packages/adp-tooling/src/cf/fdc.ts | 115 +++-- packages/adp-tooling/src/cf/html5-repo.ts | 150 ++++++ packages/adp-tooling/src/cf/utils.ts | 51 +- packages/adp-tooling/src/cf/yaml.ts | 442 ++++++++++++++++++ .../src/translations/adp-tooling.i18n.json | 5 +- pnpm-lock.yaml | 58 ++- 7 files changed, 758 insertions(+), 83 deletions(-) create mode 100644 packages/adp-tooling/src/cf/html5-repo.ts create mode 100644 packages/adp-tooling/src/cf/yaml.ts diff --git a/packages/adp-tooling/package.json b/packages/adp-tooling/package.json index a4e68dd418f..3136c2441c7 100644 --- a/packages/adp-tooling/package.json +++ b/packages/adp-tooling/package.json @@ -36,30 +36,31 @@ ], "dependencies": { "@sap-devx/yeoman-ui-types": "1.16.9", - "@sap/cf-tools": "3.2.2", "@sap-ux/axios-extension": "workspace:*", "@sap-ux/btp-utils": "workspace:*", + "@sap-ux/i18n": "workspace:*", "@sap-ux/inquirer-common": "workspace:*", "@sap-ux/logger": "workspace:*", + "@sap-ux/nodejs-utils": "workspace:*", + "@sap-ux/odata-service-writer": "workspace:*", "@sap-ux/project-access": "workspace:*", "@sap-ux/project-input-validator": "workspace:*", + "@sap-ux/store": "workspace:*", "@sap-ux/system-access": "workspace:*", "@sap-ux/ui5-config": "workspace:*", "@sap-ux/ui5-info": "workspace:*", - "@sap-ux/odata-service-writer": "workspace:*", - "@sap-ux/nodejs-utils": "workspace:*", - "@sap-ux/i18n": "workspace:*", - "@sap-ux/store": "workspace:*", + "@sap/cf-tools": "0.8.1", "adm-zip": "0.5.10", + "axios": "1.8.2", "ejs": "3.1.10", "i18next": "25.3.0", "inquirer": "8.2.6", + "js-yaml": "4.1.0", "mem-fs": "2.1.0", "mem-fs-editor": "9.4.0", "prompts": "2.4.2", "sanitize-filename": "1.6.3", - "uuid": "10.0.0", - "axios": "1.8.2" + "uuid": "10.0.0" }, "devDependencies": { "@sap-ux/store": "workspace:*", @@ -67,18 +68,19 @@ "@types/ejs": "3.1.2", "@types/express": "4.17.21", "@types/inquirer": "8.2.6", + "@types/js-yaml": "4.0.9", "@types/mem-fs": "1.1.2", "@types/mem-fs-editor": "7.0.1", "@types/prompts": "2.4.4", "@types/supertest": "2.0.12", "@types/uuid": "10.0.0", "adm-zip": "0.5.16", + "cross-env": "^7.0.3", "dotenv": "16.3.1", "express": "4.21.2", "nock": "13.4.0", "rimraf": "5.0.5", - "supertest": "7.1.4", - "cross-env": "^7.0.3" + "supertest": "7.1.4" }, "engines": { "node": ">=20.x" diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 58e9dc9717c..d5347afdbe6 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -8,8 +8,9 @@ import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import { YamlUtils, HTML5RepoUtils, CFUtils } from './'; -import { Messages } from '../i18n/messages'; +import YamlUtils from './yaml'; +import HTML5RepoUtils from './html5-repo'; +import CFUtils from './utils'; import type { Config, CFConfig, @@ -25,6 +26,7 @@ import type { } from '../types'; import { isAppStudio } from '@sap-ux/btp-utils'; import { getApplicationType, isSupportedAppTypeForAdp } from '../source'; +import { t } from '../i18n'; export default class FDCService { public html5RepoRuntimeGuid: string; @@ -75,34 +77,34 @@ export default class FDCService { let config = {} as Config; try { const configAsString = fs.readFileSync(configFileLocation, 'utf-8'); - config = JSON.parse(configAsString); + config = JSON.parse(configAsString) as Config; } catch (e) { - this.logger?.error(Messages.CANNOT_RECIEVE_TOKEN_ERROR_MSG); + this.logger?.error('Cannot receive token from config.json'); } const API_CF = this.API_CF; if (config) { const result = {} as CFConfig; - if (config[this.TARGET]) { - const apiCfIndex = config[this.TARGET].indexOf(API_CF); - result.url = config[this.TARGET].substring(apiCfIndex + API_CF.length); + if (config.Target) { + const apiCfIndex = config.Target.indexOf(API_CF); + result.url = config.Target.substring(apiCfIndex + API_CF.length); } - if (config[this.ACCESS_TOKEN]) { - result.token = config[this.ACCESS_TOKEN].substring(this.BEARER_SPACE.length); + if (config.AccessToken) { + result.token = config.AccessToken.substring(this.BEARER_SPACE.length); } - if (config[this.ORGANIZATION_FIELDS]) { + if (config.OrganizationFields) { result.org = { - name: config[this.ORGANIZATION_FIELDS].Name, - guid: config[this.ORGANIZATION_FIELDS].GUID + name: config.OrganizationFields.name, + guid: config.OrganizationFields.guid }; } - if (config[this.SPACE_FIELDS]) { + if (config.SpaceFields) { result.space = { - name: config[this.SPACE_FIELDS].Name, - guid: config[this.SPACE_FIELDS].GUID + name: config.SpaceFields.name, + guid: config.SpaceFields.guid }; } @@ -154,7 +156,7 @@ export default class FDCService { if (loginResponse === this.OK) { isSuccessful = true; } else { - throw new Error(Messages.LOGIN_FAILED_MSG); + throw new Error('Login failed'); } return isSuccessful; @@ -166,7 +168,7 @@ export default class FDCService { organizations = await CFLocal.cfGetAvailableOrgs(); this.logger?.log(`Available organizations: ${JSON.stringify(organizations)}`); } catch (error) { - this.logger?.error(Messages.CANNOT_GET_ORGANIZATIONS); + this.logger?.error('Cannot get organizations'); } return organizations; @@ -179,10 +181,10 @@ export default class FDCService { spaces = await CFLocal.cfGetAvailableSpaces(spaceGuid); this.logger?.log(`Available spaces: ${JSON.stringify(spaces)} for space guid: ${spaceGuid}.`); } catch (error) { - this.logger?.error(Messages.CANNOT_GET_SPACES); + this.logger?.error('Cannot get spaces'); } } else { - this.logger?.error(Messages.INVALID_GUID_MSG); + this.logger?.error('Invalid GUID'); } return spaces; @@ -190,7 +192,7 @@ export default class FDCService { public async setOrgSpace(orgName: string, spaceName: string): Promise { if (!orgName || !spaceName) { - throw new Error(Messages.MISSING_ORG_OR_SPACE_NAME); + throw new Error('Organization or space name is not provided.'); } await CFLocal.cfSetOrgSpace(orgName, spaceName); @@ -216,10 +218,9 @@ export default class FDCService { return results; } throw new Error( - Messages.FAILED_TO_CONNECT_TO_FDC( - appHostId, - Messages.HTTP_STATUS_MESSAGE(response.status.toString(), response.statusText) - ) + `Failed to connect to Flexibility Design and Configuration service for app_host_id ${appHostId}. Reason: HTTP status code ${response.status.toString()}: ${ + response.statusText + }` ); } catch (error) { return [{ appHostId: appHostId, messages: [error.message] }]; @@ -262,11 +263,14 @@ export default class FDCService { return this.cfConfig; } - public async getBusinessServiceKeys(businessService: string): Promise { - const serviceKeys = await CFUtils.getServiceInstanceKeys({ - spaceGuids: [this.getConfig().space.guid], - names: [businessService] - }); + public async getBusinessServiceKeys(businessService: string): Promise { + const serviceKeys = await CFUtils.getServiceInstanceKeys( + { + spaceGuids: [this.getConfig().space.guid], + names: [businessService] + }, + this.logger + ); this.logger?.log(`Available service key instance : ${JSON.stringify(serviceKeys?.serviceInstance)}`); return serviceKeys; } @@ -298,9 +302,9 @@ export default class FDCService { ); messages.push(...this.matchRoutesAndDatasources(dataSources, routes, serviceKeyEndpoints)); } else if (routes && !dataSources) { - messages.push(Messages.MANIFEST_MISSING_DATASOURCES); + messages.push("Base app manifest.json doesn't contain data sources specified in xs-app.json"); } else if (!routes && dataSources) { - messages.push(Messages.XSAPP_MISSING_ROUTES); + messages.push("Base app xs-app.json doesn't contain data sources routes specified in manifest.json"); } return messages; } @@ -311,8 +315,8 @@ export default class FDCService { if (item.entryName.endsWith('xs-app.json')) { try { xsApp = JSON.parse(item.getData().toString('utf8')); - } catch (error) { - throw new Error(Messages.FAILED_TO_PARSE_XS_APP_JSON_IN_APP_ZIP(error.message)); + } catch (e) { + throw new Error(`Failed to parse xs-app.json. Reason: ${e.message}`); } } }); @@ -325,8 +329,8 @@ export default class FDCService { if (item.entryName.endsWith('manifest.json')) { try { manifest = JSON.parse(item.getData().toString('utf8')); - } catch (error) { - throw new Error(Messages.FAILED_TO_PARSE_MANIFEST_JSON_IN_APP_ZIP(error.message)); + } catch (e) { + throw new Error(`Failed to parse manifest.json. Reason: ${e.message}`); } } }); @@ -337,7 +341,7 @@ export default class FDCService { const messages: string[] = []; routes.forEach((route: any) => { if (route.endpoint && !serviceKeyEndpoints.includes(route.endpoint)) { - messages.push(Messages.MANIFEST_DATASOURCE_NOT_MATCH(route.endpoint)); + messages.push(`Route endpoint '${route.endpoint}' doesn't match a corresponding OData endpoint`); } }); @@ -347,7 +351,9 @@ export default class FDCService { dataSources[dataSourceName].uri?.match(this.normalizeRouteRegex(route.source)) ) ) { - messages.push(Messages.SERVICE_KEY_ENDPOINT_NOT_MATCH(dataSourceName)); + messages.push( + `Data source '${dataSourceName}' doesn't match a corresponding route in xs-app.json routes` + ); } }); return messages; @@ -391,7 +397,13 @@ export default class FDCService { return result; } } - throw new Error(Messages.FAILED_TO_FIND_BUSINESS_SERVICES); + throw new Error(`No business services found, please specify the business services in resource section of mts.yaml: + - name: + type: org.cloudfoundry.-service + parameters: + service: + service-name: + service-plan: `); } public normalizeRouteRegex(value: string) { @@ -450,13 +462,17 @@ export default class FDCService { }, response data: ${JSON.stringify(response.data)}` ); return response; - } catch (error) { + } catch (e) { this.logger?.error( - `Getting FDC apps. Request url: ${url}, response status: ${error?.response?.status}, message: ${ - error.message || error + `Getting FDC apps. Request url: ${url}, response status: ${e?.response?.status}, message: ${ + e.message || e + }` + ); + throw new Error( + `Failed to get application from Flexibility Design and Configuration service ${url}. Reason: ${ + e.message || e }` ); - throw new Error(Messages.FAILED_TO_GET_FDC_APP(url, error.message || error)); } } @@ -478,7 +494,8 @@ export default class FDCService { try { const { entries, serviceInstanceGuid, manifest } = await HTML5RepoUtils.downloadAppContent( this.cfConfig.space.guid, - appParams + appParams, + this.logger ); this.manifests.push(manifest); const messages = await this.validateSmartTemplateApplication(manifest); @@ -488,8 +505,8 @@ export default class FDCService { } else { return messages; } - } catch (error) { - return [error.message]; + } catch (e) { + return [e.message]; } } @@ -499,17 +516,19 @@ export default class FDCService { if (isSupportedAppTypeForAdp(appType)) { if (manifest['sap.ui5'] && manifest['sap.ui5'].flexEnabled === false) { - return messages.concat(Messages.I18N_KEY_APPLICATION_DO_NOT_SUPPORT_ADAPTATION); + return messages.concat(t('error.appDoesNotSupportFlexibility')); } } else { - return messages.concat(Messages.ADAPTATIONPROJECTPLUGIN_SMARTTEMPLATE_PROJECT_CHECK); + return messages.concat( + "Select a different application. Adaptation project doesn't support the selected application." + ); } return messages; } private async readMta(projectPath: string): Promise { if (!projectPath) { - throw new Error(Messages.NO_PROJECT_PATH_ERR_MSG); + throw new Error('Project path is missing.'); } const mtaYamlPath = path.resolve(projectPath, this.MTA_YAML_FILE); diff --git a/packages/adp-tooling/src/cf/html5-repo.ts b/packages/adp-tooling/src/cf/html5-repo.ts new file mode 100644 index 00000000000..6bc5448a523 --- /dev/null +++ b/packages/adp-tooling/src/cf/html5-repo.ts @@ -0,0 +1,150 @@ +import axios from 'axios'; +import AdmZip from 'adm-zip'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import CFUtils from './utils'; +import type { HTML5Content, ServiceKeys, Uaa, AppParams } from '../types'; + +export default class HTML5RepoUtils { + /** + * Get HTML5 repo credentials. + * + * @param {string} spaceGuid space guid + * @param {ToolsLogger} logger logger to log messages + * @returns {Promise} credentials json object + */ + public static async getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLogger): Promise { + const INSTANCE_NAME = 'html5-apps-repo-runtime'; + try { + let serviceKeys = await CFUtils.getServiceInstanceKeys( + { + spaceGuids: [spaceGuid], + planNames: ['app-runtime'], + names: [INSTANCE_NAME] + }, + logger + ); + if (serviceKeys === null || serviceKeys?.credentials === null || serviceKeys?.credentials?.length === 0) { + await CFUtils.createService(spaceGuid, 'app-runtime', INSTANCE_NAME, logger, ['html5-apps-repo-rt']); + serviceKeys = await CFUtils.getServiceInstanceKeys({ names: [INSTANCE_NAME] }, logger); + if ( + serviceKeys === null || + serviceKeys?.credentials === null || + serviceKeys?.credentials?.length === 0 + ) { + throw new Error('Cannot find HTML5 Repo runtime in current space'); + } + } + return serviceKeys; + } catch (e) { + // log error: HTML5RepoUtils.ts=>getHtml5RepoCredentials(spaceGuid) + throw new Error( + `Failed to get credentials from HTML5 repository for space ${spaceGuid}. Reason: ${e.message}` + ); + } + } + + /** + * Download base app manifest.json and xs-app.json from HTML5 repository. + * + * @param {string} spaceGuid current space guid + * @param {AppParams} parameters appName, appVersion, appHostId + * @param {ToolsLogger} logger logger to log messages + * @returns {Promise} manifest.json and xs-app.json + */ + public static async downloadAppContent( + spaceGuid: string, + parameters: AppParams, + logger: ToolsLogger + ): Promise { + const { appHostId, appName, appVersion } = parameters; + const appNameVersion = `${appName}-${appVersion}`; + try { + const htmlRepoCredentials = await this.getHtml5RepoCredentials(spaceGuid, logger); + if ( + htmlRepoCredentials?.credentials && + htmlRepoCredentials?.credentials.length && + htmlRepoCredentials?.credentials[0]?.uaa + ) { + const token = await this.getToken(htmlRepoCredentials.credentials[0].uaa); + const uri = `${htmlRepoCredentials.credentials[0].uri}/applications/content/${appNameVersion}?pathSuffixFilter=manifest.json,xs-app.json`; + const zip = await this.downloadZip(token, appHostId, uri); + let admZip; + try { + admZip = new AdmZip(zip); + } catch (e) { + throw new Error(`Failed to parse zip content from HTML5 repository. Reason: ${e.message}`); + } + if (!(admZip && admZip.getEntries().length)) { + throw new Error('No zip content was parsed from HTML5 repository'); + } + const zipEntry = admZip.getEntries().find((zipEntry) => zipEntry.entryName === 'manifest.json'); + if (!zipEntry) { + throw new Error('Failed to find manifest.json in the application content from HTML5 repository'); + } + + try { + const manifest = JSON.parse(zipEntry.getData().toString('utf8')); + return { + entries: admZip.getEntries(), + serviceInstanceGuid: htmlRepoCredentials.serviceInstance.guid, + manifest: manifest + }; + } catch (error) { + throw new Error('Failed to parse manifest.json.'); + } + } else { + throw new Error('No UAA credentials found for HTML5 repository'); + } + } catch (e) { + // log error: HTML5RepoUtils.ts=>downloadAppContent(params) + throw new Error( + `Failed to download the application content from HTML5 repository for space ${spaceGuid} and app ${appName} (${appHostId}). Reason: ${e.message}` + ); + } + } + + /** + * Download zip from HTML5 repository. + * + * @param {string} token html5 reposiotry token + * @param {string} appHostId appHostId where content is stored + * @param {string} uri url with parameters + * @returns {Promise} file buffer content + */ + public static async downloadZip(token: string, appHostId: string, uri: string): Promise { + try { + const response = await axios.get(uri, { + responseType: 'arraybuffer', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token, + 'x-app-host-id': appHostId + } + }); + return response.data; + } catch (e) { + // log error: HTML5RepoUtils.ts=>downloadZip(params) + throw new Error(`Failed to download zip from HTML5 repository. Reason: ${e.message}`); + } + } + + public static async getToken(uaa: Uaa): Promise { + const auth = Buffer.from(`${uaa.clientid}:${uaa.clientsecret}`); + const options = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Basic ' + auth.toString('base64') + } + }; + const uri = `${uaa.url}/oauth/token?grant_type=client_credentials`; + try { + const response = await axios.get(uri, options); + return response.data['access_token']; + } catch (e) { + // log error: HTML5RepoUtils.ts=>getToken(params) + throw new Error(`Failed to get the OAuth token from HTML5 repository. Reason: ${e.message}`); + } + } +} diff --git a/packages/adp-tooling/src/cf/utils.ts b/packages/adp-tooling/src/cf/utils.ts index b1ca4f80346..ac16964c042 100644 --- a/packages/adp-tooling/src/cf/utils.ts +++ b/packages/adp-tooling/src/cf/utils.ts @@ -4,8 +4,7 @@ import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import CFToolsCli = require('@sap/cf-tools/out/src/cli'); import { eFilters } from '@sap/cf-tools/out/src/types'; -import { YamlUtils } from './'; -import { Messages } from '../i18n/messages'; +import YamlUtils from './yaml'; import type { ToolsLogger } from '@sap-ux/logger'; import type { GetServiceInstanceParams, ServiceKeys, ServiceInstance } from '../types'; @@ -28,8 +27,9 @@ export default class CFUtils { }; } return null; - } catch (error) { - const errorMessage = Messages.FAILED_TO_GET_SERVICE_INSTANCE_KEYS(error.message); + } catch (e) { + // const errorMessage = Messages.FAILED_TO_GET_SERVICE_INSTANCE_KEYS(error.message); + const errorMessage = `Failed to get service instance keys. Reason: ${e.message}`; logger?.error(errorMessage); throw new Error(errorMessage); } @@ -75,9 +75,11 @@ export default class CFUtils { if (query.exitCode !== 0) { throw new Error(query.stderr); } - } catch (error) { - logger?.error(Messages.FAILED_TO_CREATE_SERVICE_INSTANCE(serviceInstanceName, spaceGuid, error.message)); - throw new Error(Messages.FAILED_TO_CREATE_SERVICE_INSTANCE(serviceInstanceName, spaceGuid, error.message)); + } catch (e) { + // const errorMessage = Messages.FAILED_TO_CREATE_SERVICE_INSTANCE(serviceInstanceName, spaceGuid, e.message); + const errorMessage = `Cannot create a service instance '${serviceInstanceName}' in space '${spaceGuid}'. Reason: ${e.message}`; + logger?.error(errorMessage); + throw new Error(errorMessage); } } @@ -87,14 +89,14 @@ export default class CFUtils { if (response.exitCode === 0) { try { return JSON.parse(response.stdout); - } catch (error) { - throw new Error(Messages.FAILED_TO_PARSE_RESPONSE_FROM_CF(error.message)); + } catch (e) { + throw new Error(`Failed to parse response from request CF API: ${e.message}`); } } throw new Error(response.stderr); - } catch (error) { + } catch (e) { // log error: CFUtils.ts=>requestCfApi(params) - throw new Error(Messages.REQUEST_TO_CF_API_FAILED(error.message)); + throw new Error(`Request to CF API failed. Reason: ${e.message}`); } } @@ -114,7 +116,7 @@ export default class CFUtils { } } catch (error) { // log error: CFUtils.ts=>checkForCf - throw new Error(Messages.CLOUD_FOUNDRY_NOT_INSTALLED); + throw new Error('Cloud Foundry is not installed in your space.'); } } @@ -141,10 +143,10 @@ export default class CFUtils { guid: service.guid })); } - throw new Error(Messages.NO_VALID_JSON_FOR_SERVICE_INSTANCE); - } catch (error) { + throw new Error('No valid JSON for service instance'); + } catch (e) { // log error: CFUtils.ts=>getServiceInstance with uriParameters - throw new Error(Messages.FAILED_TO_GET_SERVICE_INSTANCE(uriParameters, error.message)); + throw new Error(`Failed to get service instance with params ${uriParameters}. Reason: ${e.message}`); } } @@ -159,9 +161,11 @@ export default class CFUtils { await this.createServiceKey(serviceInstance.name, serviceKeyName); return this.getServiceKeys(serviceInstance.guid); } - } catch (error) { + } catch (e) { // log error: CFUtils.ts=>getOrCreateServiceKeys with param - throw new Error(Messages.FAILED_TO_GET_OR_CREATE_SERVICE_KEYS(serviceInstance.name, error.message)); + throw new Error( + `Failed to get or create service keys for instance name ${serviceInstance.name}. Reason: ${e.message}` + ); } } @@ -171,13 +175,16 @@ export default class CFUtils { filters: [ { value: serviceInstanceGuid, + // key: eFilters.service_instance_guid key: eFilters.service_instance_guid } ] }); - } catch (error) { + } catch (e) { // log error: CFUtils.ts=>getServiceKeys for guid - throw new Error(Messages.FAILED_TO_GET_INSTANCE_CREDENTIALS(serviceInstanceGuid, error.message)); + throw new Error( + `Failed to get service instance credentials from CFLocal for guid ${serviceInstanceGuid}. Reason: ${e.message}` + ); } } @@ -190,9 +197,11 @@ export default class CFUtils { if (cliResult.exitCode !== 0) { throw new Error(cliResult.stderr); } - } catch (error) { + } catch (e) { // log error: CFUtils.ts=>createServiceKey for serviceInstanceName - throw new Error(Messages.FAILED_TO_CREATE_SERVICE_KEY_FOR_INSTANCE(serviceInstanceName, error.message)); + throw new Error( + `Failed to create service key for instance name ${serviceInstanceName}. Reason: ${e.message}` + ); } } } diff --git a/packages/adp-tooling/src/cf/yaml.ts b/packages/adp-tooling/src/cf/yaml.ts new file mode 100644 index 00000000000..6fcaeb6dced --- /dev/null +++ b/packages/adp-tooling/src/cf/yaml.ts @@ -0,0 +1,442 @@ +import fs from 'fs'; +import * as path from 'path'; +import yaml from 'js-yaml'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import CFUtils from './utils'; +import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../types'; + +export default class YamlUtils { + public static timestamp: string; + public static yamlContent: Yaml; + public static spaceGuid: string; + private static STANDALONE_APPROUTER = 'Standalone Approuter'; + private static APPROUTER_MANAGED = 'Approuter Managed by SAP Cloud Platform'; + private static yamlPath: string; + private static MTA_YAML_FILE = 'mta.yaml'; + private static UI5_YAML_FILE = 'ui5.yaml'; + private static HTML5_APPS_REPO = 'html5-apps-repo'; + private static SAP_APPLICATION_CONTENT = 'com.sap.application.content'; + private static CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; + + public static isMtaProject(selectedPath: string): boolean { + return fs.existsSync(path.join(selectedPath, this.MTA_YAML_FILE)); + } + + public static loadYamlContent(file: string): void { + const parsed = this.parseMtaFile(file); + this.yamlContent = parsed as Yaml; + this.yamlPath = file; + } + + public static async adjustMtaYaml( + projectPath: string, + moduleName: string, + appRouterType: string, + businessSolutionName: string, + businessService: string, + logger: ToolsLogger + ): Promise { + this.setTimestamp(); + + const defaultYaml = { + ID: projectPath.split(path.sep).pop(), + version: '0.0.1', + modules: [] as any[], + resources: [] as any[], + '_schema-version': '3.2' + }; + + if (!appRouterType) { + appRouterType = this.getRouterType(); + } + + const yamlContent = Object.assign(defaultYaml, this.yamlContent); + const projectName = yamlContent.ID.toLowerCase(); + const businesServices = yamlContent.resources.map((resource: { name: string }) => resource.name); + const initialServices = yamlContent.resources.map( + (resource: { parameters: { service: string } }) => resource.parameters.service + ); + if (appRouterType === this.STANDALONE_APPROUTER) { + this.adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businesServices, businessService); + } else if (appRouterType === this.APPROUTER_MANAGED) { + this.adjustMtaYamlManagedApprouter(yamlContent, projectName, businessSolutionName, businessService); + } + this.adjustMtaYamlUDeployer(yamlContent, projectName, moduleName); + this.adjustMtaYamlResources(yamlContent, projectName, appRouterType === this.APPROUTER_MANAGED); + this.adjustMtaYamlOwnModule(yamlContent, moduleName); + // should go last since it sorts the modules (workaround, should be removed after fixed in deployment module) + this.adjustMtaYamlFlpModule(yamlContent, projectName, businessService); + + const updatedYamlContent = yaml.dump(yamlContent); + await this.createServices(yamlContent.resources, initialServices, logger); + return fs.writeFile(this.yamlPath, updatedYamlContent, 'utf-8', this.writeFileCallback); + } + + public static getRouterType(): string { + const filterd: MTAModule[] | undefined = this.yamlContent?.modules?.filter( + (module: { name: string }) => + module.name.includes('destination-content') || module.name.includes('approuter') + ); + const routerType = filterd?.pop(); + if (routerType?.name.includes('approuter')) { + return this.STANDALONE_APPROUTER; + } else { + return this.APPROUTER_MANAGED; + } + } + + public static getProjectName(): string { + return this.yamlContent.ID; + } + + public static getProjectNameForXsSecurity(): string { + return `${this.getProjectName().toLowerCase().replace(/\./g, '_')}_${this.timestamp}`; + } + + private static setTimestamp(): void { + this.timestamp = Date.now().toString(); + } + + public static getSAPCloudService(): string { + const modules = this.yamlContent?.modules?.filter((module: { name: string }) => + module.name.includes('destination-content') + ); + const destinations = modules?.[0]?.parameters?.content?.instance?.destinations; + let sapCloudService = destinations?.find((destination: { Name: string }) => + destination.Name.includes('html_repo_host') + ); + sapCloudService = sapCloudService?.['sap.cloud.service'].replace(/_/g, '.'); + + return sapCloudService; + } + + public static async getAppParamsFromUI5Yaml(projectPath: string): Promise { + const ui5YamlPath = path.join(projectPath, this.UI5_YAML_FILE); + const parsedMtaFile = this.parseMtaFile(ui5YamlPath) as any; + + const appConfiguration = parsedMtaFile?.builder?.customTasks[0]?.configuration; + const appParams: AppParamsExtended = { + appHostId: appConfiguration?.appHostId, + appName: appConfiguration?.appName, + appVersion: appConfiguration?.appVersion, + spaceGuid: appConfiguration?.space + }; + + return Promise.resolve(appParams); + } + + private static parseMtaFile(file: string) { + if (!fs.existsSync(file)) { + throw new Error(`Could not find file ${file}`); + } + + const content = fs.readFileSync(file, 'utf-8'); + let parsed; + try { + parsed = yaml.load(content); + + return parsed; + } catch (e) { + throw new Error(`Error parsing file ${file}`); + } + } + + private static async createServices( + resources: any[], + initialServices: string[], + logger: ToolsLogger + ): Promise { + const excludeServices = initialServices.concat(['portal', this.HTML5_APPS_REPO]); + const xsSecurityPath = this.yamlPath.replace('mta.yaml', 'xs-security.json'); + for (const resource of resources) { + if (!excludeServices.includes(resource.parameters.service)) { + if (resource.parameters.service === 'xsuaa') { + await CFUtils.createService( + this.spaceGuid, + resource.parameters['service-plan'], + resource.parameters['service-name'], + logger, + [], + xsSecurityPath, + resource.parameters.service + ); + } else { + await CFUtils.createService( + this.spaceGuid, + resource.parameters['service-plan'], + resource.parameters['service-name'], + logger, + [], + '', + resource.parameters.service + ); + } + } + } + } + + private static writeFileCallback(error: any): void { + if (error) { + throw new Error('Cannot save mta.yaml file.'); + } + } + + private static adjustMtaYamlStandaloneApprouter( + yamlContent: any, + projectName: string, + resourceNames: ConcatArray, + businessService: string + ): void { + const appRouterName = `${projectName}-approuter`; + let appRouter = yamlContent.modules.find((module: { name: string }) => module.name === appRouterName); + if (appRouter == null) { + appRouter = { + name: appRouterName, + type: 'approuter.nodejs', + path: appRouterName, + requires: [], + parameters: { + 'disk-quota': '256M', + 'memory': '256M' + } + }; + yamlContent.modules.push(appRouter); + } + const requires = [ + `${projectName}_html_repo_runtime`, + `${projectName}_uaa`, + `portal_resources_${projectName}` + ].concat(businessService); + requires.forEach((name) => { + if (appRouter.requires.every((existing: { name: string }) => existing.name !== name)) { + appRouter.requires.push({ name }); + } + }); + } + + private static adjustMtaYamlManagedApprouter( + yamlContent: any, + projectName: string, + businessSolution: any, + businessService: string + ): void { + const appRouterName = `${projectName}-destination-content`; + let appRouter = yamlContent.modules.find((module: { name: string }) => module.name === appRouterName); + if (appRouter == null) { + businessSolution = businessSolution.split('.').join('_'); + appRouter = { + name: appRouterName, + type: this.SAP_APPLICATION_CONTENT, + requires: [ + { + name: `${projectName}_uaa`, + parameters: { + 'service-key': { + name: `${projectName}-uaa-key` + } + } + }, + { + name: `${projectName}_html_repo_host`, + parameters: { + 'service-key': { + name: `${projectName}-html_repo_host-key` + } + } + }, + { + name: `${projectName}-destination`, + parameters: { + 'content-target': true + } + }, + { + name: `${businessService}`, + parameters: { + 'service-key': { + name: `${businessService}-key` + } + } + } + ], + 'build-parameters': { + 'no-source': true + }, + parameters: { + content: { + instance: { + destinations: [ + { + Name: `${businessSolution}-${projectName}-html_repo_host`, + ServiceInstanceName: `${projectName}-html5_app_host`, + ServiceKeyName: `${projectName}-html_repo_host-key`, + 'sap.cloud.service': businessSolution.replace(/_/g, '.') + }, + { + Name: `${businessSolution}-uaa-${projectName}`, + ServiceInstanceName: `${projectName}-xsuaa`, + ServiceKeyName: `${projectName}_uaa-key`, + Authentication: 'OAuth2UserTokenExchange', + 'sap.cloud.service': businessSolution.replace(/_/g, '.') + }, + { + Name: `${businessService}-service_instance_name`, + Authentication: 'OAuth2UserTokenExchange', + ServiceInstanceName: `${businessService}`, + ServiceKeyName: `${businessService}-key` + } + ], + existing_destinations_policy: 'update' + } + } + } + }; + yamlContent.modules.push(appRouter); + } + } + + private static adjustMtaYamlUDeployer(yamlContent: any, projectName: string, moduleName: string): void { + const uiDeployerName = `${projectName}_ui_deployer`; + let uiDeployer = yamlContent.modules.find((module: { name: string }) => module.name === uiDeployerName); + if (uiDeployer == null) { + uiDeployer = { + name: uiDeployerName, + type: this.SAP_APPLICATION_CONTENT, + path: '.', + requires: [], + 'build-parameters': { + 'build-result': 'resources', + requires: [] + } + }; + yamlContent.modules.push(uiDeployer); + } + const htmlRepoHostName = `${projectName}_html_repo_host`; + if (uiDeployer.requires.every((req: { name: string }) => req.name !== htmlRepoHostName)) { + uiDeployer.requires.push({ + name: htmlRepoHostName, + parameters: { + 'content-target': true + } + }); + } + if (uiDeployer['build-parameters'].requires.every((require: { name: any }) => require.name !== moduleName)) { + uiDeployer['build-parameters'].requires.push({ + artifacts: [`${moduleName}.zip`], + name: moduleName, + 'target-path': 'resources/' + }); + } + } + + private static adjustMtaYamlResources(yamlContent: any, projectName: string, isManagedAppRouter: boolean): void { + const resources: Resource[] = [ + { + name: `${projectName}_html_repo_host`, + type: this.CF_MANAGED_SERVICE, + parameters: { + service: this.HTML5_APPS_REPO, + 'service-plan': 'app-host', + 'service-name': `${projectName}-html5_app_host` + } + }, + { + name: `${projectName}_uaa`, + type: this.CF_MANAGED_SERVICE, + parameters: { + service: 'xsuaa', + path: './xs-security.json', + 'service-plan': 'application', + 'service-name': `${YamlUtils.getProjectNameForXsSecurity()}-xsuaa` + } + } + ]; + + if (isManagedAppRouter) { + resources.push({ + name: `${projectName}-destination`, + type: this.CF_MANAGED_SERVICE, + parameters: { + service: 'destination', + 'service-name': `${projectName}-destination`, + 'service-plan': 'lite', + config: { + HTML5Runtime_enabled: true, + version: '1.0.0' + } + } + }); + } else { + resources.push( + { + name: `portal_resources_${projectName}`, + type: this.CF_MANAGED_SERVICE, + parameters: { + service: 'portal', + 'service-plan': 'standard' + } + }, + { + name: `${projectName}_html_repo_runtime`, + type: this.CF_MANAGED_SERVICE, + parameters: { + service: this.HTML5_APPS_REPO, + 'service-plan': 'app-runtime' + } + } + ); + } + + resources.forEach((resource) => { + if (yamlContent.resources.every((existing: { name: string }) => existing.name !== resource.name)) { + yamlContent.resources.push(resource); + } + }); + } + + private static adjustMtaYamlOwnModule(yamlContent: any, moduleName: string): void { + let module = yamlContent.modules.find((module: { name: string }) => module.name === moduleName); + if (module == null) { + module = { + name: moduleName, + type: 'html5', + path: moduleName, + 'build-parameters': { + builder: 'custom', + commands: ['npm install', 'npm run build'], + 'supported-platforms': [] + } + }; + yamlContent.modules.push(module); + } + } + + private static addModuleIfNotExists(requires: { name: any }[], name: any): void { + if (requires.every((require) => require.name !== name)) { + requires.push({ name }); + } + } + + private static adjustMtaYamlFlpModule( + yamlContent: { modules: any[] }, + projectName: any, + businessService: string + ): void { + yamlContent.modules.forEach((module, index) => { + if (module.type === this.SAP_APPLICATION_CONTENT && module.requires) { + const portalResources = module.requires.find( + (require: { name: string }) => require.name === `portal_resources_${projectName}` + ); + if (portalResources?.['parameters']?.['service-key']?.['name'] === 'content-deploy-key') { + this.addModuleIfNotExists(module.requires, `${projectName}_html_repo_host`); + this.addModuleIfNotExists(module.requires, `${projectName}_ui_deployer`); + this.addModuleIfNotExists(module.requires, businessService); + // move flp module to last position + yamlContent.modules.push(yamlContent.modules.splice(index, 1)[0]); + } + } + }); + } +} diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index b008b5998af..6a369f61910 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -74,6 +74,9 @@ "ui5VersionDoesNotExistGeneric": "An error occurred when validating the SAPUI5 version: {{error}}. Please select a different version.", "ui5VersionNotDetectedError": "The SAPUI5 version of the selected system cannot be determined. You will be able to create and edit adaptation projects using the newest version but it will not be usable on this system until the system`s SAPUI5 version is upgraded to version 1.71 or higher." }, + "error": { + "appDoesNotSupportFlexibility": "The selected application does not support flexibility because it has (`flexEnabled=false`). SAPUI5 Adaptation Project only supports applications that support flexibility. Please select a different application." + }, "choices": { "true": "true", "false": "false", @@ -82,4 +85,4 @@ "createTemplateFile": "Create local annotation file from template" } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 184ac2343ca..7d56bdfe7da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,8 +627,8 @@ importers: specifier: workspace:* version: link:../ui5-info '@sap/cf-tools': - specifier: 3.2.2 - version: 3.2.2 + specifier: 0.8.1 + version: 0.8.1 adm-zip: specifier: 0.5.10 version: 0.5.10 @@ -644,6 +644,9 @@ importers: inquirer: specifier: 8.2.6 version: 8.2.6 + js-yaml: + specifier: 4.1.0 + version: 4.1.0 mem-fs: specifier: 2.1.0 version: 2.1.0 @@ -672,6 +675,9 @@ importers: '@types/inquirer': specifier: 8.2.6 version: 8.2.6 + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 '@types/mem-fs': specifier: 1.1.2 version: 1.1.2 @@ -8838,6 +8844,16 @@ packages: antlr4: 4.9.3 dev: true + /@sap/cf-tools@0.8.1: + resolution: {integrity: sha512-g5tAs7gGmY2fCJRmbmYDMFmVVj36j2mYUUTHzZYVXfZVuhbCfciPySEEV5HIpyQtdCfTTM/NbOFuanZLy78qag==} + dependencies: + comment-json: 4.1.0 + fs-extra: 9.1.0 + lodash: 4.17.21 + properties-reader: 2.2.0 + url: 0.11.0 + dev: false + /@sap/cf-tools@3.2.2: resolution: {integrity: sha512-hlz3KiHbKrQJdydacVbpBSufoiNDU7YEE5MnvTUOm0FabI+L+fp+b5RZqwSsurUtTm8RJzrHSUVXTqztyW13oA==} dependencies: @@ -12139,7 +12155,6 @@ packages: /at-least-node@1.0.0: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} - dev: true /autoprefixer@10.4.7(postcss@8.5.1): resolution: {integrity: sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA==} @@ -13393,6 +13408,17 @@ packages: resolution: {integrity: sha512-sRPT+umqkz90UA8M1yqYfnHlZA7fF6nSphDtxeywPZ49ysjxDQybzk13CL+mXekDRG92skbcqCLVovuCusNmFw==} engines: {node: ^12.20.0 || >=14} + /comment-json@4.1.0: + resolution: {integrity: sha512-WEghmVYaNq9NlWbrkzQTSsya9ycLyxJxpTQfZEan6a5Jomnjw18zS3Podf8q1Zf9BvonvQd/+Z7Z39L7KKzzdQ==} + engines: {node: '>= 6'} + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + has-own-prop: 2.0.0 + repeat-string: 1.6.1 + dev: false + /comment-json@4.2.3: resolution: {integrity: sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==} engines: {node: '>= 6'} @@ -16093,7 +16119,6 @@ packages: graceful-fs: 4.2.11 jsonfile: 6.1.0 universalify: 2.0.1 - dev: true /fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} @@ -18594,6 +18619,7 @@ packages: /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true dependencies: argparse: 2.0.1 @@ -21752,6 +21778,13 @@ packages: engines: {node: '>= 8'} dev: true + /properties-reader@2.2.0: + resolution: {integrity: sha512-CgVcr8MwGoBKK24r9TwHfZkLLaNFHQ6y4wgT9w/XzdpacOOi5ciH4hcuLechSDAwXsfrGQtI2JTutY2djOx2Ow==} + engines: {node: '>=10'} + dependencies: + mkdirp: 1.0.4 + dev: false + /properties-reader@2.3.0: resolution: {integrity: sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==} engines: {node: '>=14'} @@ -21798,6 +21831,10 @@ packages: end-of-stream: 1.4.4 once: 1.4.0 + /punycode@1.3.2: + resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} + dev: false + /punycode@1.4.1: resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} @@ -21883,6 +21920,12 @@ packages: strict-uri-encode: 2.0.0 dev: true + /querystring@0.2.0: + resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} + engines: {node: '>=0.4.x'} + deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. + dev: false + /querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -25453,6 +25496,13 @@ packages: engines: {node: '>= 4'} dev: true + /url@0.11.0: + resolution: {integrity: sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==} + dependencies: + punycode: 1.3.2 + querystring: 0.2.0 + dev: false + /url@0.11.4: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} From 2d9ff42ea679eb0e628c3b7250d4d593df0f7a33 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 6 Aug 2025 15:38:03 +0300 Subject: [PATCH 003/111] feat: add cf specific logic --- packages/adp-tooling/src/cf/fdc.ts | 4 ---- packages/adp-tooling/src/cf/index.ts | 4 ++++ packages/adp-tooling/src/index.ts | 1 + packages/generator-adp/src/app/index.ts | 6 ++++++ 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 packages/adp-tooling/src/cf/index.ts diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index d5347afdbe6..2ce143e193e 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -35,11 +35,7 @@ export default class FDCService { private WIN32 = 'win32'; private HOMEDRIVE = 'HOMEDRIVE'; private HOMEPATH = 'HOMEPATH'; - private TARGET = 'Target'; - private ACCESS_TOKEN = 'AccessToken'; private BEARER_SPACE = 'bearer '; - private ORGANIZATION_FIELDS = 'OrganizationFields'; - private SPACE_FIELDS = 'SpaceFields'; private CF_FOLDER_NAME = '.cf'; private CONFIG_JSON_FILE = 'config.json'; private API_CF = 'api.cf.'; diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts new file mode 100644 index 00000000000..ad882e27669 --- /dev/null +++ b/packages/adp-tooling/src/cf/index.ts @@ -0,0 +1,4 @@ +export * from './yaml'; +export * from './utils'; +export * from './html5-repo'; +export { default as FDCService } from './fdc'; diff --git a/packages/adp-tooling/src/index.ts b/packages/adp-tooling/src/index.ts index 7eca8fe25ee..804f77e66af 100644 --- a/packages/adp-tooling/src/index.ts +++ b/packages/adp-tooling/src/index.ts @@ -5,6 +5,7 @@ export * from './abap'; export * from './source'; export * from './ui5'; export * from './base/cf'; +export * from './cf'; export * from './base/helper'; export * from './base/constants'; export * from './base/project-builder'; diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 7fc2751f5aa..601d71933ee 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -39,6 +39,7 @@ import { getDefaultNamespace, getDefaultProjectName } from './questions/helper/d import { type AdpGeneratorOptions, type AttributePromptOptions, type JsonInput } from './types'; import { getWizardPages, updateFlpWizardSteps, updateWizardSteps, getDeployPage } from '../utils/steps'; import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderChoice } from '../utils/workspace'; +import { FDCService } from '@sap-ux/adp-tooling'; const generatorTitle = 'Adaptation Project'; @@ -115,6 +116,7 @@ export default class extends Generator { * Base application inbounds, if the base application is an FLP app. */ private baseAppInbounds?: ManifestNamespace.Inbound; + private readonly fdcService: FDCService; /** * Creates an instance of the generator. @@ -127,6 +129,7 @@ export default class extends Generator { this.appWizard = opts.appWizard ?? AppWizard.create(opts); this.shouldInstallDeps = opts.shouldInstallDeps ?? true; this.toolsLogger = new ToolsLogger(); + this.fdcService = new FDCService(this.logger, opts.vscode); this.vscode = opts.vscode; this.options = opts; @@ -179,6 +182,9 @@ export default class extends Generator { return; } + const isCfInstalled = await this.fdcService.isCfInstalled(); + this.logger.info(`isCfInstalled: ${isCfInstalled}`); + const configQuestions = this.prompter.getPrompts({ appValidationCli: { hide: !this.isCli }, systemValidationCli: { hide: !this.isCli } From bf521abb5701f1bb154e2032a1fbb03630c36389 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 10:02:36 +0300 Subject: [PATCH 004/111] feat: add cf specific logic --- packages/adp-tooling/src/cf/index.ts | 2 +- packages/generator-adp/src/app/index.ts | 21 ++++++- .../src/app/questions/helper/validators.ts | 18 +++++- .../src/app/questions/target-env.ts | 55 +++++++++++++++++++ packages/generator-adp/src/app/types.ts | 18 ++++++ packages/generator-adp/src/utils/steps.ts | 4 +- 6 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 packages/generator-adp/src/app/questions/target-env.ts diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index ad882e27669..bfb1d9b80a4 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -1,4 +1,4 @@ -export * from './yaml'; export * from './utils'; export * from './html5-repo'; +export { default as YamlUtils } from './yaml'; export { default as FDCService } from './fdc'; diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 601d71933ee..d0e348e8066 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -15,7 +15,8 @@ import { type UI5Version, SourceManifest, isCFEnvironment, - getBaseAppInbounds + getBaseAppInbounds, + YamlUtils } from '@sap-ux/adp-tooling'; import { ToolsLogger } from '@sap-ux/logger'; import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; @@ -36,10 +37,12 @@ import { getFirstArgAsString, parseJsonInput } from '../utils/parse-json-input'; import { addDeployGen, addExtProjectGen, addFlpGen } from '../utils/subgenHelpers'; import { cacheClear, cacheGet, cachePut, initCache } from '../utils/appWizardCache'; import { getDefaultNamespace, getDefaultProjectName } from './questions/helper/default-values'; +import type { TargetEnv, TargetEnvAnswers } from './types'; import { type AdpGeneratorOptions, type AttributePromptOptions, type JsonInput } from './types'; import { getWizardPages, updateFlpWizardSteps, updateWizardSteps, getDeployPage } from '../utils/steps'; import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderChoice } from '../utils/workspace'; import { FDCService } from '@sap-ux/adp-tooling'; +import { getTargetEnvPrompt } from './questions/target-env'; const generatorTitle = 'Adaptation Project'; @@ -117,6 +120,9 @@ export default class extends Generator { */ private baseAppInbounds?: ManifestNamespace.Inbound; private readonly fdcService: FDCService; + private readonly isMtaYamlFound: boolean; + private readonly isExtensionInstalled: boolean; + private targetEnv: TargetEnv; /** * Creates an instance of the generator. @@ -130,6 +136,8 @@ export default class extends Generator { this.shouldInstallDeps = opts.shouldInstallDeps ?? true; this.toolsLogger = new ToolsLogger(); this.fdcService = new FDCService(this.logger, opts.vscode); + this.isMtaYamlFound = YamlUtils.isMtaProject(process.cwd()); + this.isExtensionInstalled = !!this.vscode?.extensions?.getExtension('SAPSE.sap-ux-adp-tooling'); this.vscode = opts.vscode; this.options = opts; @@ -163,7 +171,7 @@ export default class extends Generator { this.systemLookup = new SystemLookup(this.logger); if (!this.jsonInput) { - this.prompts.splice(0, 0, getWizardPages()); + this.prompts.splice(0, 0, getWizardPages(this.isExtensionInstalled)); this.prompter = this._getOrCreatePrompter(); } @@ -185,6 +193,15 @@ export default class extends Generator { const isCfInstalled = await this.fdcService.isCfInstalled(); this.logger.info(`isCfInstalled: ${isCfInstalled}`); + if (this.isExtensionInstalled) { + const targetEnvAnswers = await this.prompt([ + getTargetEnvPrompt(this.appWizard, isCfInstalled, this.fdcService) + ]); + this.targetEnv = targetEnvAnswers.targetEnv; + this.logger.info(`Target environment: ${this.targetEnv}`); + this.prompts.splice(1, 1, getWizardPages(false)); + } + const configQuestions = this.prompter.getPrompts({ appValidationCli: { hide: !this.isCli }, systemValidationCli: { hide: !this.isCli } diff --git a/packages/generator-adp/src/app/questions/helper/validators.ts b/packages/generator-adp/src/app/questions/helper/validators.ts index 4983d7ad2f3..ef8c6766391 100644 --- a/packages/generator-adp/src/app/questions/helper/validators.ts +++ b/packages/generator-adp/src/app/questions/helper/validators.ts @@ -1,9 +1,10 @@ -import type { SystemLookup } from '@sap-ux/adp-tooling'; +import type { FDCService, SystemLookup } from '@sap-ux/adp-tooling'; import { validateNamespaceAdp, validateProjectName } from '@sap-ux/project-input-validator'; import { t } from '../../../utils/i18n'; import { isString } from '../../../utils/type-guards'; import { resolveNodeModuleGenerator } from '../../extension-project'; +import { isAppStudio } from '@sap-ux/btp-utils'; interface JsonInputParams { projectName: string; @@ -75,3 +76,18 @@ export async function validateJsonInput( throw new Error(t('error.systemNotFound', { system })); } } + +export async function validateEnvironment(value: string, label: string, fdcService: FDCService) { + if (!value) { + return t('error.selectCannotBeEmptyError', { value: label }); + } + + if (value === 'CF' && !isAppStudio()) { + const isExternalLoginEnabled = await fdcService.isExternalLoginEnabled(); + if (!isExternalLoginEnabled) { + return 'CF Login cannot be detected as extension in current installation of VSCode, please refer to documentation (link not yet available) in order to install it.'; + } + } + + return true; +} diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts new file mode 100644 index 00000000000..e7b2ecce317 --- /dev/null +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -0,0 +1,55 @@ +import type { FDCService } from '@sap-ux/adp-tooling'; +import { MessageType } from '@sap-devx/yeoman-ui-types'; +import type { AppWizard } from '@sap-devx/yeoman-ui-types'; +import type { ListQuestion } from '@sap-ux/inquirer-common'; + +import { validateEnvironment } from './helper/validators'; +import { TargetEnv, type TargetEnvAnswers, type TargetEnvQuestion } from '../types'; + +type EnvironmentChoice = { name: string; value: TargetEnv }; + +/** + * Returns the target environment prompt. + * + * @param {AppWizard} appWizard - The app wizard instance. + * @param {boolean} isCfInstalled - Whether Cloud Foundry is installed. + * @param {FDCService} fdcService - The FDC service instance. + * @returns {object[]} The target environment prompt. + */ +export function getTargetEnvPrompt( + appWizard: AppWizard, + isCfInstalled: boolean, + fdcService: FDCService +): TargetEnvQuestion { + return { + type: 'list', + name: 'targetEnv', + message: 'Select environment', + choices: () => getEnvironments(appWizard, isCfInstalled), + default: () => getEnvironments(appWizard, isCfInstalled)[0]?.name, + guiOptions: { + mandatory: true, + hint: 'Select the target environment for your Adaptation Project.' + }, + validate: (value: string) => validateEnvironment(value, 'Target environment', fdcService) + } as ListQuestion; +} + +/** + * Returns the environments. + * + * @param {AppWizard} appWizard - The app wizard instance. + * @param {boolean} isCfInstalled - Whether Cloud Foundry is installed. + * @returns {object[]} The environments. + */ +export function getEnvironments(appWizard: AppWizard, isCfInstalled: boolean): EnvironmentChoice[] { + const choices: EnvironmentChoice[] = [{ name: 'ABAP', value: TargetEnv.ABAP }]; + + if (isCfInstalled) { + choices.push({ name: 'Cloud Foundry', value: TargetEnv.CF }); + } else { + appWizard.showInformation('Cloud Foundry is not installed in your space.', MessageType.prompt); + } + + return choices; +} diff --git a/packages/generator-adp/src/app/types.ts b/packages/generator-adp/src/app/types.ts index 9e5c2bbd6fb..7f8112b1a0a 100644 --- a/packages/generator-adp/src/app/types.ts +++ b/packages/generator-adp/src/app/types.ts @@ -162,6 +162,24 @@ export type AttributePromptOptions = Partial<{ [attributePromptNames.addFlpConfig]: AddFlpConfigPromptOptions; }>; +export enum targetEnvPromptNames { + targetEnv = 'targetEnv' +} + +export const TargetEnv = { ABAP: 'ABAP', CF: 'CF' } as const; + +export type TargetEnv = (typeof TargetEnv)[keyof typeof TargetEnv]; + +export type TargetEnvAnswers = { targetEnv: TargetEnv }; + +export type TargetEnvQuestion = YUIQuestion; + +// export type TargetEnvPromptOptions = Partial<{ +// [targetEnvPromptNames.targetEnv]: { +// hide?: boolean; +// }; +// }>; + export interface ExtensionProjectData { destination: { name: string; diff --git a/packages/generator-adp/src/utils/steps.ts b/packages/generator-adp/src/utils/steps.ts index a579d6ac0a5..ba3ca7d1b3e 100644 --- a/packages/generator-adp/src/utils/steps.ts +++ b/packages/generator-adp/src/utils/steps.ts @@ -6,10 +6,12 @@ import { GeneratorTypes } from '../types'; /** * Returns the list of base wizard pages used in the Adaptation Project. * + * @param {boolean} isExtensionInstalled - Whether the extension is installed. * @returns {IPrompt[]} The list of static wizard steps to show initially. */ -export function getWizardPages(): IPrompt[] { +export function getWizardPages(isExtensionInstalled: boolean): IPrompt[] { return [ + ...(isExtensionInstalled ? [{ name: 'Target environment', description: '' }] : []), { name: t('yuiNavSteps.configurationName'), description: t('yuiNavSteps.configurationDescr') From a4aff7352bdb7731a1cedca8c24b6b030c6e8c6f Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 10:22:50 +0300 Subject: [PATCH 005/111] feat: add cf specific logic --- packages/generator-adp/src/app/index.ts | 18 ++++++++++-------- packages/generator-adp/src/utils/steps.ts | 6 +++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index d0e348e8066..ad6fd4a2b1a 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -37,12 +37,14 @@ import { getFirstArgAsString, parseJsonInput } from '../utils/parse-json-input'; import { addDeployGen, addExtProjectGen, addFlpGen } from '../utils/subgenHelpers'; import { cacheClear, cacheGet, cachePut, initCache } from '../utils/appWizardCache'; import { getDefaultNamespace, getDefaultProjectName } from './questions/helper/default-values'; -import type { TargetEnv, TargetEnvAnswers } from './types'; +import type { TargetEnvAnswers } from './types'; +import { TargetEnv } from './types'; import { type AdpGeneratorOptions, type AttributePromptOptions, type JsonInput } from './types'; import { getWizardPages, updateFlpWizardSteps, updateWizardSteps, getDeployPage } from '../utils/steps'; import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderChoice } from '../utils/workspace'; import { FDCService } from '@sap-ux/adp-tooling'; import { getTargetEnvPrompt } from './questions/target-env'; +import { isAppStudio } from '@sap-ux/btp-utils'; const generatorTitle = 'Adaptation Project'; @@ -121,7 +123,6 @@ export default class extends Generator { private baseAppInbounds?: ManifestNamespace.Inbound; private readonly fdcService: FDCService; private readonly isMtaYamlFound: boolean; - private readonly isExtensionInstalled: boolean; private targetEnv: TargetEnv; /** @@ -137,7 +138,6 @@ export default class extends Generator { this.toolsLogger = new ToolsLogger(); this.fdcService = new FDCService(this.logger, opts.vscode); this.isMtaYamlFound = YamlUtils.isMtaProject(process.cwd()); - this.isExtensionInstalled = !!this.vscode?.extensions?.getExtension('SAPSE.sap-ux-adp-tooling'); this.vscode = opts.vscode; this.options = opts; @@ -171,7 +171,7 @@ export default class extends Generator { this.systemLookup = new SystemLookup(this.logger); if (!this.jsonInput) { - this.prompts.splice(0, 0, getWizardPages(this.isExtensionInstalled)); + this.prompts.splice(0, 0, getWizardPages()); this.prompter = this._getOrCreatePrompter(); } @@ -190,16 +190,18 @@ export default class extends Generator { return; } - const isCfInstalled = await this.fdcService.isCfInstalled(); - this.logger.info(`isCfInstalled: ${isCfInstalled}`); + if (isAppStudio()) { + const isCfInstalled = await this.fdcService.isCfInstalled(); + this.logger.info(`isCfInstalled: ${isCfInstalled}`); - if (this.isExtensionInstalled) { const targetEnvAnswers = await this.prompt([ getTargetEnvPrompt(this.appWizard, isCfInstalled, this.fdcService) ]); this.targetEnv = targetEnvAnswers.targetEnv; this.logger.info(`Target environment: ${this.targetEnv}`); - this.prompts.splice(1, 1, getWizardPages(false)); + // this.prompts.splice(1, 1, getWizardPages()); // TODO: Add ABAP or CF pages accordingly + } else { + this.targetEnv = TargetEnv.ABAP; } const configQuestions = this.prompter.getPrompts({ diff --git a/packages/generator-adp/src/utils/steps.ts b/packages/generator-adp/src/utils/steps.ts index ba3ca7d1b3e..c8728edefaa 100644 --- a/packages/generator-adp/src/utils/steps.ts +++ b/packages/generator-adp/src/utils/steps.ts @@ -2,16 +2,16 @@ import type { Prompts as YeomanUiSteps, IPrompt } from '@sap-devx/yeoman-ui-type import { t } from './i18n'; import { GeneratorTypes } from '../types'; +import { isAppStudio } from '@sap-ux/btp-utils'; /** * Returns the list of base wizard pages used in the Adaptation Project. * - * @param {boolean} isExtensionInstalled - Whether the extension is installed. * @returns {IPrompt[]} The list of static wizard steps to show initially. */ -export function getWizardPages(isExtensionInstalled: boolean): IPrompt[] { +export function getWizardPages(): IPrompt[] { return [ - ...(isExtensionInstalled ? [{ name: 'Target environment', description: '' }] : []), + ...(isAppStudio() ? [{ name: 'Target environment', description: '' }] : []), { name: t('yuiNavSteps.configurationName'), description: t('yuiNavSteps.configurationDescr') From 3c657d91aaa8b711bbcefd3b198eb6954e30d764 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 10:42:13 +0300 Subject: [PATCH 006/111] feat: add cf specific logic --- packages/generator-adp/src/app/index.ts | 158 +++++++++++++--------- packages/generator-adp/src/utils/steps.ts | 33 +++++ 2 files changed, 125 insertions(+), 66 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index ad6fd4a2b1a..7a47a69f762 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -40,7 +40,13 @@ import { getDefaultNamespace, getDefaultProjectName } from './questions/helper/d import type { TargetEnvAnswers } from './types'; import { TargetEnv } from './types'; import { type AdpGeneratorOptions, type AttributePromptOptions, type JsonInput } from './types'; -import { getWizardPages, updateFlpWizardSteps, updateWizardSteps, getDeployPage } from '../utils/steps'; +import { + getWizardPages, + updateFlpWizardSteps, + updateWizardSteps, + getDeployPage, + getUIPageLabels +} from '../utils/steps'; import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderChoice } from '../utils/workspace'; import { FDCService } from '@sap-ux/adp-tooling'; import { getTargetEnvPrompt } from './questions/target-env'; @@ -124,6 +130,8 @@ export default class extends Generator { private readonly fdcService: FDCService; private readonly isMtaYamlFound: boolean; private targetEnv: TargetEnv; + private isCfEnv = false; + private isCFLoggedIn = false; /** * Creates an instance of the generator. @@ -198,80 +206,98 @@ export default class extends Generator { getTargetEnvPrompt(this.appWizard, isCfInstalled, this.fdcService) ]); this.targetEnv = targetEnvAnswers.targetEnv; + this.isCfEnv = this.targetEnv === TargetEnv.CF; this.logger.info(`Target environment: ${this.targetEnv}`); - // this.prompts.splice(1, 1, getWizardPages()); // TODO: Add ABAP or CF pages accordingly + this.prompts.splice(1, 1, getUIPageLabels()); } else { this.targetEnv = TargetEnv.ABAP; } - const configQuestions = this.prompter.getPrompts({ - appValidationCli: { hide: !this.isCli }, - systemValidationCli: { hide: !this.isCli } - }); - this.configAnswers = await this.prompt(configQuestions); - this.shouldCreateExtProject = !!this.configAnswers.shouldCreateExtProject; - - this.logger.info(`System: ${this.configAnswers.system}`); - this.logger.info(`Application: ${JSON.stringify(this.configAnswers.application, null, 2)}`); - - const { ui5Versions, systemVersion } = this.prompter.ui5; - const promptConfig = { - ui5Versions, - isVersionDetected: !!systemVersion, - isCloudProject: this.prompter.isCloud, - layer: this.layer, - prompts: this.prompts - }; - const defaultFolder = getDefaultTargetFolder(this.options.vscode) ?? process.cwd(); - if (this.prompter.isCloud) { - this.baseAppInbounds = await getBaseAppInbounds(this.configAnswers.application.id, this.prompter.provider); - } - const options: AttributePromptOptions = { - targetFolder: { default: defaultFolder, hide: this.shouldCreateExtProject }, - ui5ValidationCli: { hide: !this.isCli }, - enableTypeScript: { hide: this.shouldCreateExtProject }, - addFlpConfig: { hasBaseAppInbounds: !!this.baseAppInbounds, hide: this.shouldCreateExtProject }, - addDeployConfig: { hide: this.shouldCreateExtProject || !this.isCustomerBase } - }; - const attributesQuestions = getPrompts(this.destinationPath(), promptConfig, options); - - this.attributeAnswers = await this.prompt(attributesQuestions); - - // Steps need to be updated here to be available after back navigation in Yeoman UI. - this._updateWizardStepsAfterNavigation(); - - this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); - - if (this.attributeAnswers.addDeployConfig) { - const client = (await this.systemLookup.getSystemByName(this.configAnswers.system))?.Client; - addDeployGen( - { - projectName: this.attributeAnswers.projectName, - targetFolder: this.attributeAnswers.targetFolder, - connectedSystem: this.configAnswers.system, - client - }, - this.composeWith.bind(this), - this.logger, - this.appWizard - ); - } + if (!this.isCfEnv) { + const configQuestions = this.prompter.getPrompts({ + appValidationCli: { hide: !this.isCli }, + systemValidationCli: { hide: !this.isCli } + }); + this.configAnswers = await this.prompt(configQuestions); + this.shouldCreateExtProject = !!this.configAnswers.shouldCreateExtProject; - if (this.attributeAnswers?.addFlpConfig) { - addFlpGen( - { - vscode: this.vscode, - projectRootPath: this._getProjectPath(), - inbounds: this.baseAppInbounds, - layer: this.layer - }, - this.composeWith.bind(this), - this.logger, - this.appWizard - ); + this.logger.info(`System: ${this.configAnswers.system}`); + this.logger.info(`Application: ${JSON.stringify(this.configAnswers.application, null, 2)}`); + + const { ui5Versions, systemVersion } = this.prompter.ui5; + const promptConfig = { + ui5Versions, + isVersionDetected: !!systemVersion, + isCloudProject: this.prompter.isCloud, + layer: this.layer, + prompts: this.prompts + }; + const defaultFolder = getDefaultTargetFolder(this.options.vscode) ?? process.cwd(); + if (this.prompter.isCloud) { + this.baseAppInbounds = await getBaseAppInbounds( + this.configAnswers.application.id, + this.prompter.provider + ); + } + const options: AttributePromptOptions = { + targetFolder: { default: defaultFolder, hide: this.shouldCreateExtProject }, + ui5ValidationCli: { hide: !this.isCli }, + enableTypeScript: { hide: this.shouldCreateExtProject }, + addFlpConfig: { hasBaseAppInbounds: !!this.baseAppInbounds, hide: this.shouldCreateExtProject }, + addDeployConfig: { hide: this.shouldCreateExtProject || !this.isCustomerBase } + }; + const attributesQuestions = getPrompts(this.destinationPath(), promptConfig, options); + + this.attributeAnswers = await this.prompt(attributesQuestions); + + // Steps need to be updated here to be available after back navigation in Yeoman UI. + this._updateWizardStepsAfterNavigation(); + + this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); + + if (this.attributeAnswers.addDeployConfig) { + const client = (await this.systemLookup.getSystemByName(this.configAnswers.system))?.Client; + addDeployGen( + { + projectName: this.attributeAnswers.projectName, + targetFolder: this.attributeAnswers.targetFolder, + connectedSystem: this.configAnswers.system, + client + }, + this.composeWith.bind(this), + this.logger, + this.appWizard + ); + } + + if (this.attributeAnswers?.addFlpConfig) { + addFlpGen( + { + vscode: this.vscode, + projectRootPath: this._getProjectPath(), + inbounds: this.baseAppInbounds, + layer: this.layer + }, + this.composeWith.bind(this), + this.logger, + this.appWizard + ); + } + } else { + this.isCFLoggedIn = await this.fdcService.isLoggedIn(); + + this._setCFLoginPageDescription(this.isCFLoggedIn); } } + private _setCFLoginPageDescription(isLoggedIn: boolean): void { + const pageLabel = { + name: 'Login to Cloud Foundry', + description: isLoggedIn ? '' : 'Provide credentials.' + }; + this.prompts.splice(1, 1, [pageLabel]); + } + async writing(): Promise { try { if (this.jsonInput) { diff --git a/packages/generator-adp/src/utils/steps.ts b/packages/generator-adp/src/utils/steps.ts index c8728edefaa..b6eee8ac00d 100644 --- a/packages/generator-adp/src/utils/steps.ts +++ b/packages/generator-adp/src/utils/steps.ts @@ -23,6 +23,39 @@ export function getWizardPages(): IPrompt[] { ]; } +/** + * Returns the UI page labels. + * + * @returns {IPrompt[]} The UI page labels. + */ +export function getUIPageLabels(): IPrompt[] { + let prompts; + if (!isAppStudio()) { + prompts = [ + { + name: t('yuiNavSteps.configurationName'), + description: t('yuiNavSteps.configurationDescr') + }, + { + name: t('yuiNavSteps.projectAttributesName'), + description: t('yuiNavSteps.projectAttributesDescr') + } + ]; + } else { + prompts = [ + { name: 'Login to Cloud Foundry', description: 'Provide credentials.' }, + { name: 'Project path', description: 'Provide path to MTA project.' }, + { + name: t('yuiNavSteps.projectAttributesName'), + description: t('yuiNavSteps.projectAttributesDescr') + }, + { name: 'Application Details', description: 'Setup application details.' } + ]; + } + + return prompts; +} + /** * Returns the FLP configuration page step. * From 4070f28cc1de2a7f6ae96d2c8bb087c5b7c20bca Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 10:49:44 +0300 Subject: [PATCH 007/111] feat: add cf specific logic --- packages/generator-adp/src/app/index.ts | 2 +- packages/generator-adp/src/utils/steps.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 7a47a69f762..a6f687202c1 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -208,7 +208,7 @@ export default class extends Generator { this.targetEnv = targetEnvAnswers.targetEnv; this.isCfEnv = this.targetEnv === TargetEnv.CF; this.logger.info(`Target environment: ${this.targetEnv}`); - this.prompts.splice(1, 1, getUIPageLabels()); + this.prompts.splice(1, 1, getUIPageLabels(this.isCfEnv)); } else { this.targetEnv = TargetEnv.ABAP; } diff --git a/packages/generator-adp/src/utils/steps.ts b/packages/generator-adp/src/utils/steps.ts index b6eee8ac00d..c139e38d398 100644 --- a/packages/generator-adp/src/utils/steps.ts +++ b/packages/generator-adp/src/utils/steps.ts @@ -26,11 +26,12 @@ export function getWizardPages(): IPrompt[] { /** * Returns the UI page labels. * + * @param {boolean} isCFEnv - Whether the target environment is Cloud Foundry. * @returns {IPrompt[]} The UI page labels. */ -export function getUIPageLabels(): IPrompt[] { +export function getUIPageLabels(isCFEnv: boolean): IPrompt[] { let prompts; - if (!isAppStudio()) { + if (!isCFEnv) { prompts = [ { name: t('yuiNavSteps.configurationName'), From 5f62087e7a1afb0ac0fed6ca4396ca54a36a5fbb Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 11:16:47 +0300 Subject: [PATCH 008/111] feat: add cf specific logic --- packages/generator-adp/src/app/index.ts | 10 +++ .../src/app/questions/cf-login.ts | 88 +++++++++++++++++++ packages/generator-adp/src/app/types.ts | 20 +++-- 3 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 packages/generator-adp/src/app/questions/cf-login.ts diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index a6f687202c1..0355610a090 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -2,6 +2,8 @@ import { join } from 'path'; import Generator from 'yeoman-generator'; import { AppWizard, MessageType, Prompts as YeomanUiSteps, type IPrompt } from '@sap-devx/yeoman-ui-types'; +import type { CFConfig } from '@sap-ux/adp-tooling'; +import { getPrompts as getCFLoginPrompts } from './questions/cf-login'; import { FlexLayer, SystemLookup, @@ -132,6 +134,7 @@ export default class extends Generator { private targetEnv: TargetEnv; private isCfEnv = false; private isCFLoggedIn = false; + private cfConfig: CFConfig; /** * Creates an instance of the generator. @@ -287,6 +290,13 @@ export default class extends Generator { this.isCFLoggedIn = await this.fdcService.isLoggedIn(); this._setCFLoginPageDescription(this.isCFLoggedIn); + + await this.prompt(getCFLoginPrompts(this.vscode, this.fdcService, this.isCFLoggedIn)); + this.cfConfig = this.fdcService.getConfig(); + + this.logger.log(`Project organization information: ${JSON.stringify(this.cfConfig.org, null, 2)}`); + this.logger.log(`Project space information: ${JSON.stringify(this.cfConfig.space, null, 2)}`); + this.logger.log(`Project apiUrl information: ${JSON.stringify(this.cfConfig.url, null, 2)}`); } } diff --git a/packages/generator-adp/src/app/questions/cf-login.ts b/packages/generator-adp/src/app/questions/cf-login.ts new file mode 100644 index 00000000000..261b9e253c5 --- /dev/null +++ b/packages/generator-adp/src/app/questions/cf-login.ts @@ -0,0 +1,88 @@ +import type { FDCService } from '@sap-ux/adp-tooling'; +import { type InputQuestion, type YUIQuestion } from '@sap-ux/inquirer-common'; + +import CFUtils from '@sap-ux/adp-tooling/src/cf/utils'; +import { type CFConfig } from '@sap-ux/adp-tooling'; +import { type CFLoginQuestion, type CFLoginAnswers, cfLoginPromptNames } from '../types'; + +/** + * Returns the list of CF-login prompts. + * + * @param {any} vscode - The VS Code instance. + * @param {FDCService} fdcService - The FDC service instance. + * @param {boolean} isCFLoggedIn - Whether the user is logged in. + * @returns {CFLoginQuestion[]} The list of CF-login prompts. + */ +export function getPrompts(vscode: any, fdcService: FDCService, isCFLoggedIn: boolean): CFLoginQuestion[] { + const cfConfig = fdcService.getConfig(); + + if (isCFLoggedIn) { + return [ + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInMainMessage, 'You are currently logged in:'), + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedApiEndpointMessage, `CF API Endpoint: ${cfConfig.url}`), + getLoggedInInfoPrompt( + cfLoginPromptNames.cfLoggedInOrganizationMessage, + `Organization: ${cfConfig.org.name}` + ), + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInSpaceMessage, `Space: ${cfConfig.space.name}`), + getLoggedInInfoPrompt( + cfLoginPromptNames.cfLoggedInEndingMessage, + 'You can proceed with the project creation.' + ) + ]; + } + + let isCFLoginSuccessful = false; + + const externalLoginPrompt: InputQuestion = { + type: 'input', + name: cfLoginPromptNames.cfExternalLogin, + message: 'Please complete your CF login using the form on the right side.', + guiOptions: { type: 'label' }, + validate: async (): Promise => { + // loop until both org & space appear in the refreshed config + let result = ''; + let cfg: CFConfig = { org: { guid: '', name: '' }, space: { guid: '', name: '' }, token: '', url: '' }; + while (!cfg.org.name && !cfg.space.name) { + result = (await vscode?.commands.executeCommand('cf.login', 'side')) as string; + fdcService.loadConfig(); + cfg = fdcService.getConfig(); + if (result !== 'OK' || !result) { + await CFUtils.cFLogout(); + return 'Login failed.'; + } + } + isCFLoginSuccessful = true; + // update outer state so the generator can reuse the token later + Object.assign(cfConfig, cfg); + return true; + } + }; + + const successLabelPrompt: InputQuestion = { + type: 'input', + name: 'cfExternalLoginSuccessMessage', + message: 'Login successful', + guiOptions: { type: 'label' }, + when: (): boolean => isCFLoginSuccessful + }; + + return [externalLoginPrompt, successLabelPrompt]; +} + +/** + * Returns the logged-in information prompt. + * + * @param {keyof CFLoginAnswers} name - The name of the prompt. + * @param {string} message - The message to display. + * @returns {YUIQuestion} The logged-in information prompt. + */ +function getLoggedInInfoPrompt(name: keyof CFLoginAnswers, message: string): YUIQuestion { + return { + type: 'input', + name, + message, + guiOptions: { type: 'label' }, + store: false + } as YUIQuestion; +} diff --git a/packages/generator-adp/src/app/types.ts b/packages/generator-adp/src/app/types.ts index 7f8112b1a0a..f1ad51c9a78 100644 --- a/packages/generator-adp/src/app/types.ts +++ b/packages/generator-adp/src/app/types.ts @@ -174,11 +174,21 @@ export type TargetEnvAnswers = { targetEnv: TargetEnv }; export type TargetEnvQuestion = YUIQuestion; -// export type TargetEnvPromptOptions = Partial<{ -// [targetEnvPromptNames.targetEnv]: { -// hide?: boolean; -// }; -// }>; +export enum cfLoginPromptNames { + cfLoggedInMainMessage = 'cfLoggedInMainMessage', + cfLoggedApiEndpointMessage = 'cfLoggedApiEndpointMessage', + cfLoggedInOrganizationMessage = 'cfLoggedInOrganizationMessage', + cfLoggedInSpaceMessage = 'cfLoggedInSpaceMessage', + cfLoggedInEndingMessage = 'cfLoggedInEndingMessage', + cfExternalLogin = 'cfExternalLogin', + cfExternalLoginSuccessMessage = 'cfExternalLoginSuccessMessage' +} + +export type CFLoginAnswers = { + [K in cfLoginPromptNames]?: string; +}; + +export type CFLoginQuestion = YUIQuestion; export interface ExtensionProjectData { destination: { From e7c2858841d28448c1839d2e4115b60f1353b3f0 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 11:19:19 +0300 Subject: [PATCH 009/111] feat: add cf specific logic --- packages/adp-tooling/src/cf/index.ts | 2 +- packages/generator-adp/src/app/questions/cf-login.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index bfb1d9b80a4..ac9d15b867c 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -1,4 +1,4 @@ -export * from './utils'; export * from './html5-repo'; +export { default as CFUtils } from './utils'; export { default as YamlUtils } from './yaml'; export { default as FDCService } from './fdc'; diff --git a/packages/generator-adp/src/app/questions/cf-login.ts b/packages/generator-adp/src/app/questions/cf-login.ts index 261b9e253c5..c6dc372c03e 100644 --- a/packages/generator-adp/src/app/questions/cf-login.ts +++ b/packages/generator-adp/src/app/questions/cf-login.ts @@ -1,7 +1,7 @@ import type { FDCService } from '@sap-ux/adp-tooling'; import { type InputQuestion, type YUIQuestion } from '@sap-ux/inquirer-common'; -import CFUtils from '@sap-ux/adp-tooling/src/cf/utils'; +import { CFUtils } from '@sap-ux/adp-tooling'; import { type CFConfig } from '@sap-ux/adp-tooling'; import { type CFLoginQuestion, type CFLoginAnswers, cfLoginPromptNames } from '../types'; From 80186d626e78852a238c8d04106b988fe9dfcdf4 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 11:26:17 +0300 Subject: [PATCH 010/111] feat: add cf specific logic --- packages/generator-adp/src/app/questions/cf-login.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/generator-adp/src/app/questions/cf-login.ts b/packages/generator-adp/src/app/questions/cf-login.ts index c6dc372c03e..f6f265af562 100644 --- a/packages/generator-adp/src/app/questions/cf-login.ts +++ b/packages/generator-adp/src/app/questions/cf-login.ts @@ -2,7 +2,6 @@ import type { FDCService } from '@sap-ux/adp-tooling'; import { type InputQuestion, type YUIQuestion } from '@sap-ux/inquirer-common'; import { CFUtils } from '@sap-ux/adp-tooling'; -import { type CFConfig } from '@sap-ux/adp-tooling'; import { type CFLoginQuestion, type CFLoginAnswers, cfLoginPromptNames } from '../types'; /** @@ -42,19 +41,16 @@ export function getPrompts(vscode: any, fdcService: FDCService, isCFLoggedIn: bo validate: async (): Promise => { // loop until both org & space appear in the refreshed config let result = ''; - let cfg: CFConfig = { org: { guid: '', name: '' }, space: { guid: '', name: '' }, token: '', url: '' }; + const cfg = fdcService.getConfig(); while (!cfg.org.name && !cfg.space.name) { result = (await vscode?.commands.executeCommand('cf.login', 'side')) as string; fdcService.loadConfig(); - cfg = fdcService.getConfig(); if (result !== 'OK' || !result) { await CFUtils.cFLogout(); return 'Login failed.'; } } isCFLoginSuccessful = true; - // update outer state so the generator can reuse the token later - Object.assign(cfConfig, cfg); return true; } }; From 7fa6652497beb1daa8659cc7775195e048e66d5c Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 12:26:25 +0300 Subject: [PATCH 011/111] feat: add cf specific logic --- packages/generator-adp/src/app/index.ts | 4 ++-- packages/generator-adp/src/utils/steps.ts | 26 ++++++----------------- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 0355610a090..62d6e92d80c 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -47,7 +47,7 @@ import { updateFlpWizardSteps, updateWizardSteps, getDeployPage, - getUIPageLabels + updateCfWizardSteps } from '../utils/steps'; import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderChoice } from '../utils/workspace'; import { FDCService } from '@sap-ux/adp-tooling'; @@ -211,7 +211,7 @@ export default class extends Generator { this.targetEnv = targetEnvAnswers.targetEnv; this.isCfEnv = this.targetEnv === TargetEnv.CF; this.logger.info(`Target environment: ${this.targetEnv}`); - this.prompts.splice(1, 1, getUIPageLabels(this.isCfEnv)); + updateCfWizardSteps(this.isCfEnv, this.prompts); } else { this.targetEnv = TargetEnv.ABAP; } diff --git a/packages/generator-adp/src/utils/steps.ts b/packages/generator-adp/src/utils/steps.ts index c139e38d398..2daa026d789 100644 --- a/packages/generator-adp/src/utils/steps.ts +++ b/packages/generator-adp/src/utils/steps.ts @@ -24,26 +24,14 @@ export function getWizardPages(): IPrompt[] { } /** - * Returns the UI page labels. + * Updates the CF wizard steps. * * @param {boolean} isCFEnv - Whether the target environment is Cloud Foundry. - * @returns {IPrompt[]} The UI page labels. + * @param {YeomanUiSteps} prompts - The Yeoman UI Prompts container object. */ -export function getUIPageLabels(isCFEnv: boolean): IPrompt[] { - let prompts; - if (!isCFEnv) { - prompts = [ - { - name: t('yuiNavSteps.configurationName'), - description: t('yuiNavSteps.configurationDescr') - }, - { - name: t('yuiNavSteps.projectAttributesName'), - description: t('yuiNavSteps.projectAttributesDescr') - } - ]; - } else { - prompts = [ +export function updateCfWizardSteps(isCFEnv: boolean, prompts: YeomanUiSteps): void { + if (isCFEnv) { + prompts.splice(1, 1, [ { name: 'Login to Cloud Foundry', description: 'Provide credentials.' }, { name: 'Project path', description: 'Provide path to MTA project.' }, { @@ -51,10 +39,8 @@ export function getUIPageLabels(isCFEnv: boolean): IPrompt[] { description: t('yuiNavSteps.projectAttributesDescr') }, { name: 'Application Details', description: 'Setup application details.' } - ]; + ]); } - - return prompts; } /** From 10949d767c98b716709fdbf9061dcc5bf12d54ca Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 15:35:00 +0300 Subject: [PATCH 012/111] feat: add cf specific logic --- packages/generator-adp/src/app/index.ts | 19 +++++- .../src/app/questions/cf-login.ts | 32 +++++----- .../src/app/questions/helper/validators.ts | 58 ++++++++++++++++++- .../src/app/questions/target-env.ts | 40 ++++++++++++- packages/generator-adp/src/app/types.ts | 2 + 5 files changed, 131 insertions(+), 20 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 62d6e92d80c..0b8d2a8f3cb 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import { join } from 'path'; import Generator from 'yeoman-generator'; import { AppWizard, MessageType, Prompts as YeomanUiSteps, type IPrompt } from '@sap-devx/yeoman-ui-types'; @@ -51,7 +52,7 @@ import { } from '../utils/steps'; import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderChoice } from '../utils/workspace'; import { FDCService } from '@sap-ux/adp-tooling'; -import { getTargetEnvPrompt } from './questions/target-env'; +import { getTargetEnvPrompt, promptUserForProjectPath } from './questions/target-env'; import { isAppStudio } from '@sap-ux/btp-utils'; const generatorTitle = 'Adaptation Project'; @@ -135,6 +136,8 @@ export default class extends Generator { private isCfEnv = false; private isCFLoggedIn = false; private cfConfig: CFConfig; + private projectLocation: string; + private cfProjectDestinationPath: string; /** * Creates an instance of the generator. @@ -297,6 +300,20 @@ export default class extends Generator { this.logger.log(`Project organization information: ${JSON.stringify(this.cfConfig.org, null, 2)}`); this.logger.log(`Project space information: ${JSON.stringify(this.cfConfig.space, null, 2)}`); this.logger.log(`Project apiUrl information: ${JSON.stringify(this.cfConfig.url, null, 2)}`); + + if (!this.isMtaYamlFound) { + const projectPathAnswers = await this.prompt( + promptUserForProjectPath(this.fdcService, this.isCFLoggedIn, this.vscode) + ); + this.projectLocation = projectPathAnswers.projectLocation; + this.projectLocation = fs.realpathSync(this.projectLocation, 'utf-8'); + this.cfProjectDestinationPath = this.destinationRoot(this.projectLocation); + this.logger.log(`Project path information: ${this.projectLocation}`); + } else { + this.cfProjectDestinationPath = this.destinationRoot(process.cwd()); + YamlUtils.loadYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); + this.logger.log(`Project path information: ${this.cfProjectDestinationPath}`); + } } } diff --git a/packages/generator-adp/src/app/questions/cf-login.ts b/packages/generator-adp/src/app/questions/cf-login.ts index f6f265af562..1fdac553cc1 100644 --- a/packages/generator-adp/src/app/questions/cf-login.ts +++ b/packages/generator-adp/src/app/questions/cf-login.ts @@ -1,4 +1,4 @@ -import type { FDCService } from '@sap-ux/adp-tooling'; +import type { CFConfig, FDCService } from '@sap-ux/adp-tooling'; import { type InputQuestion, type YUIQuestion } from '@sap-ux/inquirer-common'; import { CFUtils } from '@sap-ux/adp-tooling'; @@ -16,19 +16,7 @@ export function getPrompts(vscode: any, fdcService: FDCService, isCFLoggedIn: bo const cfConfig = fdcService.getConfig(); if (isCFLoggedIn) { - return [ - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInMainMessage, 'You are currently logged in:'), - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedApiEndpointMessage, `CF API Endpoint: ${cfConfig.url}`), - getLoggedInInfoPrompt( - cfLoginPromptNames.cfLoggedInOrganizationMessage, - `Organization: ${cfConfig.org.name}` - ), - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInSpaceMessage, `Space: ${cfConfig.space.name}`), - getLoggedInInfoPrompt( - cfLoginPromptNames.cfLoggedInEndingMessage, - 'You can proceed with the project creation.' - ) - ]; + return getLoggedInPrompts(cfConfig); } let isCFLoginSuccessful = false; @@ -66,6 +54,22 @@ export function getPrompts(vscode: any, fdcService: FDCService, isCFLoggedIn: bo return [externalLoginPrompt, successLabelPrompt]; } +/** + * Returns the logged-in information prompts. + * + * @param {CFConfig} cfConfig - The CF config. + * @returns {CFLoginQuestion[]} The logged-in information prompts. + */ +export function getLoggedInPrompts(cfConfig: CFConfig): CFLoginQuestion[] { + return [ + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInMainMessage, 'You are currently logged in:'), + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedApiEndpointMessage, `CF API Endpoint: ${cfConfig.url}`), + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInOrganizationMessage, `Organization: ${cfConfig.org.name}`), + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInSpaceMessage, `Space: ${cfConfig.space.name}`), + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInEndingMessage, 'You can proceed with the project creation.') + ]; +} + /** * Returns the logged-in information prompt. * diff --git a/packages/generator-adp/src/app/questions/helper/validators.ts b/packages/generator-adp/src/app/questions/helper/validators.ts index ef8c6766391..f91c518b1f1 100644 --- a/packages/generator-adp/src/app/questions/helper/validators.ts +++ b/packages/generator-adp/src/app/questions/helper/validators.ts @@ -1,4 +1,6 @@ -import type { FDCService, SystemLookup } from '@sap-ux/adp-tooling'; +import fs from 'fs'; + +import { YamlUtils, type FDCService, type SystemLookup } from '@sap-ux/adp-tooling'; import { validateNamespaceAdp, validateProjectName } from '@sap-ux/project-input-validator'; import { t } from '../../../utils/i18n'; @@ -77,7 +79,19 @@ export async function validateJsonInput( } } -export async function validateEnvironment(value: string, label: string, fdcService: FDCService) { +/** + * Validates the environment. + * + * @param {string} value - The value to validate. + * @param {string} label - The label to validate. + * @param {FDCService} fdcService - The FDC service instance. + * @returns {Promise} Returns true if the environment is valid, otherwise returns an error message. + */ +export async function validateEnvironment( + value: string, + label: string, + fdcService: FDCService +): Promise { if (!value) { return t('error.selectCannotBeEmptyError', { value: label }); } @@ -91,3 +105,43 @@ export async function validateEnvironment(value: string, label: string, fdcServi return true; } + +/** + * Validates the project path. + * + * @param {string} projectPath - The path to the project. + * @param {FDCService} fdcService - The FDC service instance. + * @returns {Promise} Returns true if the project path is valid, otherwise returns an error message. + */ +export async function validateProjectPath(projectPath: string, fdcService: FDCService): Promise { + if (!projectPath) { + return 'Input cannot be empty'; + } + + try { + fs.realpathSync(projectPath, 'utf-8'); + } catch (e) { + return 'The project does not exist.'; + } + + if (!fs.existsSync(projectPath)) { + return 'The project does not exist.'; + } + + if (!YamlUtils.isMtaProject(projectPath)) { + return 'Provide the path to the MTA project where you want to have your Adaptation Project created'; + } + + let services: string[]; + try { + services = await fdcService.getServices(projectPath); + } catch (err) { + services = []; + } + + if (services.length < 1) { + return 'No adaptable business service found in the MTA'; + } + + return true; +} diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index e7b2ecce317..8e1677d52fd 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -1,9 +1,12 @@ -import type { FDCService } from '@sap-ux/adp-tooling'; import { MessageType } from '@sap-devx/yeoman-ui-types'; import type { AppWizard } from '@sap-devx/yeoman-ui-types'; -import type { ListQuestion } from '@sap-ux/inquirer-common'; -import { validateEnvironment } from './helper/validators'; +import type { FDCService } from '@sap-ux/adp-tooling'; +import { getDefaultTargetFolder } from '@sap-ux/fiori-generator-shared'; +import type { InputQuestion, ListQuestion, YUIQuestion } from '@sap-ux/inquirer-common'; + +import type { ProjectLocationAnswers } from '../types'; +import { validateEnvironment, validateProjectPath } from './helper/validators'; import { TargetEnv, type TargetEnvAnswers, type TargetEnvQuestion } from '../types'; type EnvironmentChoice = { name: string; value: TargetEnv }; @@ -53,3 +56,34 @@ export function getEnvironments(appWizard: AppWizard, isCfInstalled: boolean): E return choices; } + +/** + * Returns the project path prompt. + * + * @param {FDCService} fdcService - The FDC service instance. + * @param {boolean} isCFLoggedIn - Whether the user is logged in to Cloud Foundry. + * @param {any} vscode - The VSCode instance. + * @returns {YUIQuestion[]} The project path prompt. + */ +export function promptUserForProjectPath( + fdcService: FDCService, + isCFLoggedIn: boolean, + vscode: any +): YUIQuestion[] { + return [ + { + type: 'input', + name: 'projectLocation', + guiOptions: { + type: 'folder-browser', + mandatory: true, + hint: 'Select the path to the root of your project' + }, + message: 'Specify the path to the project root', + validate: (value: string) => validateProjectPath(value, fdcService), + when: () => isCFLoggedIn, + default: () => getDefaultTargetFolder(vscode), + store: false + } as InputQuestion + ]; +} diff --git a/packages/generator-adp/src/app/types.ts b/packages/generator-adp/src/app/types.ts index f1ad51c9a78..9df17cff5b9 100644 --- a/packages/generator-adp/src/app/types.ts +++ b/packages/generator-adp/src/app/types.ts @@ -174,6 +174,8 @@ export type TargetEnvAnswers = { targetEnv: TargetEnv }; export type TargetEnvQuestion = YUIQuestion; +export type ProjectLocationAnswers = { projectLocation: string }; + export enum cfLoginPromptNames { cfLoggedInMainMessage = 'cfLoggedInMainMessage', cfLoggedApiEndpointMessage = 'cfLoggedApiEndpointMessage', From b3499e30b05a5b953192b74db95b833dc7986614 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 16:52:03 +0300 Subject: [PATCH 013/111] feat: adjust login validation --- .../src/app/questions/cf-login.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/generator-adp/src/app/questions/cf-login.ts b/packages/generator-adp/src/app/questions/cf-login.ts index 1fdac553cc1..886c937c2c2 100644 --- a/packages/generator-adp/src/app/questions/cf-login.ts +++ b/packages/generator-adp/src/app/questions/cf-login.ts @@ -29,15 +29,32 @@ export function getPrompts(vscode: any, fdcService: FDCService, isCFLoggedIn: bo validate: async (): Promise => { // loop until both org & space appear in the refreshed config let result = ''; - const cfg = fdcService.getConfig(); - while (!cfg.org.name && !cfg.space.name) { + let cfg = fdcService.getConfig(); + let retryCount = 0; + const maxRetries = 10; + + while (!cfg.org?.name && !cfg.space?.name && retryCount < maxRetries) { result = (await vscode?.commands.executeCommand('cf.login', 'side')) as string; - fdcService.loadConfig(); + if (result !== 'OK' || !result) { await CFUtils.cFLogout(); return 'Login failed.'; } + + // Add a small delay to allow the config file to be written + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Reload config and retry + fdcService.loadConfig(); + cfg = fdcService.getConfig(); + retryCount++; + } + + if (!cfg.org?.name && !cfg.space?.name) { + await CFUtils.cFLogout(); + return 'Login succeeded but configuration could not be loaded. Please try again.'; } + isCFLoginSuccessful = true; return true; } From fe5217daed3b88c9681eb3f8eadb19529ddc8370 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 17:10:01 +0300 Subject: [PATCH 014/111] feat: correct cf config types --- packages/adp-tooling/src/cf/fdc.ts | 16 ++++++++-------- packages/adp-tooling/src/types.ts | 8 ++++---- .../generator-adp/src/app/questions/cf-login.ts | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 2ce143e193e..b7315f5024c 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -92,20 +92,20 @@ export default class FDCService { if (config.OrganizationFields) { result.org = { - name: config.OrganizationFields.name, - guid: config.OrganizationFields.guid + Name: config.OrganizationFields.Name, + GUID: config.OrganizationFields.GUID }; } if (config.SpaceFields) { result.space = { - name: config.SpaceFields.name, - guid: config.SpaceFields.guid + Name: config.SpaceFields.Name, + GUID: config.SpaceFields.GUID }; } this.cfConfig = result; - YamlUtils.spaceGuid = this.cfConfig?.space?.guid; + YamlUtils.spaceGuid = this.cfConfig?.space?.GUID; } } @@ -141,7 +141,7 @@ export default class FDCService { const cfConfig = this.getConfig(); const isLoggedToDifferentSource = isLoggedIn && - (cfConfig.org.name !== organizacion || cfConfig.space.name !== space || cfConfig.url !== apiurl); + (cfConfig.org.Name !== organizacion || cfConfig.space.Name !== space || cfConfig.url !== apiurl); return isLoggedToDifferentSource; } @@ -262,7 +262,7 @@ export default class FDCService { public async getBusinessServiceKeys(businessService: string): Promise { const serviceKeys = await CFUtils.getServiceInstanceKeys( { - spaceGuids: [this.getConfig().space.guid], + spaceGuids: [this.getConfig().space.GUID], names: [businessService] }, this.logger @@ -489,7 +489,7 @@ export default class FDCService { private async validateSelectedApp(appParams: AppParams, credentials: any): Promise { try { const { entries, serviceInstanceGuid, manifest } = await HTML5RepoUtils.downloadAppContent( - this.cfConfig.space.guid, + this.cfConfig.space.GUID, appParams, this.logger ); diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 0806b9e85bf..ea19dd16d45 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -913,13 +913,13 @@ export interface AdpConfig { } export interface Organization { - guid: string; - name: string; + GUID: string; + Name: string; } export interface Space { - guid: string; - name: string; + GUID: string; + Name: string; } export interface CFConfig { diff --git a/packages/generator-adp/src/app/questions/cf-login.ts b/packages/generator-adp/src/app/questions/cf-login.ts index 886c937c2c2..6e5c9f65e81 100644 --- a/packages/generator-adp/src/app/questions/cf-login.ts +++ b/packages/generator-adp/src/app/questions/cf-login.ts @@ -33,7 +33,7 @@ export function getPrompts(vscode: any, fdcService: FDCService, isCFLoggedIn: bo let retryCount = 0; const maxRetries = 10; - while (!cfg.org?.name && !cfg.space?.name && retryCount < maxRetries) { + while (!cfg.org?.Name && !cfg.space?.Name && retryCount < maxRetries) { result = (await vscode?.commands.executeCommand('cf.login', 'side')) as string; if (result !== 'OK' || !result) { @@ -50,7 +50,7 @@ export function getPrompts(vscode: any, fdcService: FDCService, isCFLoggedIn: bo retryCount++; } - if (!cfg.org?.name && !cfg.space?.name) { + if (!cfg.org?.Name && !cfg.space?.Name) { await CFUtils.cFLogout(); return 'Login succeeded but configuration could not be loaded. Please try again.'; } @@ -81,8 +81,8 @@ export function getLoggedInPrompts(cfConfig: CFConfig): CFLoginQuestion[] { return [ getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInMainMessage, 'You are currently logged in:'), getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedApiEndpointMessage, `CF API Endpoint: ${cfConfig.url}`), - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInOrganizationMessage, `Organization: ${cfConfig.org.name}`), - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInSpaceMessage, `Space: ${cfConfig.space.name}`), + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInOrganizationMessage, `Organization: ${cfConfig.org.Name}`), + getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInSpaceMessage, `Space: ${cfConfig.space.Name}`), getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInEndingMessage, 'You can proceed with the project creation.') ]; } From e481120022b0517ff2d3d1b01da3f43091610526 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 7 Aug 2025 17:46:29 +0300 Subject: [PATCH 015/111] fix: login problem --- packages/generator-adp/src/app/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 0b8d2a8f3cb..3884a011e8c 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -296,6 +296,7 @@ export default class extends Generator { await this.prompt(getCFLoginPrompts(this.vscode, this.fdcService, this.isCFLoggedIn)); this.cfConfig = this.fdcService.getConfig(); + this.isCFLoggedIn = await this.fdcService.isLoggedIn(); this.logger.log(`Project organization information: ${JSON.stringify(this.cfConfig.org, null, 2)}`); this.logger.log(`Project space information: ${JSON.stringify(this.cfConfig.space, null, 2)}`); From 79cb04ee3715d26961b2700647948d9c989bdd85 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 8 Aug 2025 11:37:46 +0300 Subject: [PATCH 016/111] feat: add attribute prompts for cf --- packages/generator-adp/src/app/index.ts | 24 +++++++++++++++++++ .../src/app/questions/attributes.ts | 14 ++++++++--- .../src/adp/validators.ts | 12 +++++++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 3884a011e8c..8a09917d3c9 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -315,6 +315,30 @@ export default class extends Generator { YamlUtils.loadYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); this.logger.log(`Project path information: ${this.cfProjectDestinationPath}`); } + + const options: AttributePromptOptions = { + targetFolder: { default: this.cfProjectDestinationPath, hide: true }, + ui5ValidationCli: { hide: true }, + enableTypeScript: { hide: true }, + addFlpConfig: { hide: true }, + addDeployConfig: { hide: true } + }; + const attributesQuestions = getPrompts( + this.destinationPath(), + { + ui5Versions: [], + isVersionDetected: false, + isCloudProject: false, + layer: this.layer, + prompts: this.prompts, + isCfEnv: true + }, + options + ); + + this.attributeAnswers = await this.prompt(attributesQuestions); + + this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); } } diff --git a/packages/generator-adp/src/app/questions/attributes.ts b/packages/generator-adp/src/app/questions/attributes.ts index 7bd636ff8b6..0df7215ad32 100644 --- a/packages/generator-adp/src/app/questions/attributes.ts +++ b/packages/generator-adp/src/app/questions/attributes.ts @@ -33,6 +33,7 @@ interface Config { ui5Versions: string[]; isVersionDetected: boolean; prompts: YeomanUiSteps; + isCfEnv?: boolean; } /** @@ -44,13 +45,14 @@ interface Config { * @returns {AttributesQuestion[]} An array of prompt objects for basic info input. */ export function getPrompts(path: string, config: Config, promptOptions?: AttributePromptOptions): AttributesQuestion[] { - const { isVersionDetected, ui5Versions, isCloudProject, layer, prompts } = config; + const { isVersionDetected, ui5Versions, isCloudProject, layer, prompts, isCfEnv = false } = config; const isCustomerBase = layer === FlexLayer.CUSTOMER_BASE; const keyedPrompts: Record = { [attributePromptNames.projectName]: getProjectNamePrompt( path, isCustomerBase, + isCfEnv, promptOptions?.[attributePromptNames.projectName] ), [attributePromptNames.title]: getApplicationTitlePrompt(promptOptions?.[attributePromptNames.title]), @@ -90,10 +92,16 @@ export function getPrompts(path: string, config: Config, promptOptions?: Attribu * * @param {string} path - The base project path. * @param {boolean} isCustomerBase - Whether the layer is CUSTOMER_BASE. + * @param {boolean} isCfEnv - Whether the project is in a CF environment. * @param {ProjectNamePromptOptions} [_] - Optional prompt options. * @returns {AttributesQuestion} The prompt configuration for project name. */ -function getProjectNamePrompt(path: string, isCustomerBase: boolean, _?: ProjectNamePromptOptions): AttributesQuestion { +function getProjectNamePrompt( + path: string, + isCustomerBase: boolean, + isCfEnv: boolean, + _?: ProjectNamePromptOptions +): AttributesQuestion { return { type: 'input', name: attributePromptNames.projectName, @@ -105,7 +113,7 @@ function getProjectNamePrompt(path: string, isCustomerBase: boolean, _?: Project hint: getProjectNameTooltip(isCustomerBase) }, validate: (value: string, answers: AttributesAnswers) => - validateProjectName(value, answers.targetFolder || path, isCustomerBase), + validateProjectName(value, answers.targetFolder || path, isCustomerBase, isCfEnv), store: false } as InputQuestion; } diff --git a/packages/project-input-validator/src/adp/validators.ts b/packages/project-input-validator/src/adp/validators.ts index 2a47f9db94b..efd608f2478 100644 --- a/packages/project-input-validator/src/adp/validators.ts +++ b/packages/project-input-validator/src/adp/validators.ts @@ -49,9 +49,15 @@ export function isDataSourceURI(uri: string): boolean { * @param {string} value - The project name. * @param {string} destinationPath - The project directory. * @param {boolean} isCustomerBase - Whether the layer is customer base. + * @param {boolean} isCfEnv - Whether the project is in a CF environment. * @returns {string | boolean} If value is valid returns true otherwise error message. */ -export function validateProjectName(value: string, destinationPath: string, isCustomerBase: boolean): boolean | string { +export function validateProjectName( + value: string, + destinationPath: string, + isCustomerBase: boolean, + isCfEnv: boolean +): boolean | string { const validationResult = validateEmptyString(value); if (typeof validationResult === 'string') { return validationResult; @@ -61,6 +67,10 @@ export function validateProjectName(value: string, destinationPath: string, isCu return t('adp.projectNameUppercaseError'); } + if (isCfEnv) { + return validateDuplicateProjectName(value, destinationPath); + } + if (!isCustomerBase) { return validateProjectNameInternal(value); } else { From 8258a9414839c4d6cc7be69da0341fba315d2ffa Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 8 Aug 2025 13:50:53 +0300 Subject: [PATCH 017/111] fix: build error --- packages/generator-adp/src/app/questions/helper/validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/generator-adp/src/app/questions/helper/validators.ts b/packages/generator-adp/src/app/questions/helper/validators.ts index f91c518b1f1..51c83b816d8 100644 --- a/packages/generator-adp/src/app/questions/helper/validators.ts +++ b/packages/generator-adp/src/app/questions/helper/validators.ts @@ -63,7 +63,7 @@ export async function validateJsonInput( isCustomerBase: boolean, { projectName, targetFolder, namespace, system }: JsonInputParams ): Promise { - let validationResult = validateProjectName(projectName, targetFolder, isCustomerBase); + let validationResult = validateProjectName(projectName, targetFolder, isCustomerBase, false); if (isString(validationResult)) { throw new Error(validationResult); } From 7882cba5a4196d9db39362c95d96157fd5b59cea Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 8 Aug 2025 15:54:14 +0300 Subject: [PATCH 018/111] feat: add business services and app prompts for cf --- packages/generator-adp/src/app/index.ts | 19 +- .../src/app/questions/cf-services.ts | 341 ++++++++++++++++++ packages/generator-adp/src/app/types.ts | 44 +++ 3 files changed, 401 insertions(+), 3 deletions(-) create mode 100644 packages/generator-adp/src/app/questions/cf-services.ts diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 8a09917d3c9..3a53ae7a152 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -33,6 +33,7 @@ import { EventName } from '../telemetryEvents'; import { setHeaderTitle } from '../utils/opts'; import AdpGeneratorLogger from '../utils/logger'; import { getPrompts } from './questions/attributes'; +import { getPrompts as getCFServicesPrompts } from './questions/cf-services'; import { ConfigPrompter } from './questions/configuration'; import { validateJsonInput } from './questions/helper/validators'; import { getPackageInfo, installDependencies } from '../utils/deps'; @@ -40,7 +41,7 @@ import { getFirstArgAsString, parseJsonInput } from '../utils/parse-json-input'; import { addDeployGen, addExtProjectGen, addFlpGen } from '../utils/subgenHelpers'; import { cacheClear, cacheGet, cachePut, initCache } from '../utils/appWizardCache'; import { getDefaultNamespace, getDefaultProjectName } from './questions/helper/default-values'; -import type { TargetEnvAnswers } from './types'; +import type { TargetEnvAnswers, CfServicesAnswers } from './types'; import { TargetEnv } from './types'; import { type AdpGeneratorOptions, type AttributePromptOptions, type JsonInput } from './types'; import { @@ -138,6 +139,7 @@ export default class extends Generator { private cfConfig: CFConfig; private projectLocation: string; private cfProjectDestinationPath: string; + private cfServicesAnswers: CfServicesAnswers; /** * Creates an instance of the generator. @@ -317,12 +319,14 @@ export default class extends Generator { } const options: AttributePromptOptions = { - targetFolder: { default: this.cfProjectDestinationPath, hide: true }, + targetFolder: { hide: true }, + ui5Version: { hide: true }, ui5ValidationCli: { hide: true }, enableTypeScript: { hide: true }, addFlpConfig: { hide: true }, addDeployConfig: { hide: true } }; + const attributesQuestions = getPrompts( this.destinationPath(), { @@ -337,8 +341,17 @@ export default class extends Generator { ); this.attributeAnswers = await this.prompt(attributesQuestions); - this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); + + const cfServicesQuestions = await getCFServicesPrompts({ + isCFLoggedIn: this.isCFLoggedIn, + fdcService: this.fdcService, + mtaProjectPath: this.cfProjectDestinationPath, + isInternalUsage: isInternalFeaturesSettingEnabled(), + logger: this.logger + }); + this.cfServicesAnswers = await this.prompt(cfServicesQuestions); + this.logger.info(`CF Services Answers: ${JSON.stringify(this.cfServicesAnswers, null, 2)}`); } } diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts new file mode 100644 index 00000000000..1d253625c45 --- /dev/null +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -0,0 +1,341 @@ +import type { ToolsLogger } from '@sap-ux/logger'; +import type { CFApp, FDCService, ServiceKeys } from '@sap-ux/adp-tooling'; +import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; + +import { cfServicesPromptNames } from '../types'; +import type { CfServicesAnswers, CFServicesQuestion, CfServicesPromptOptions } from '../types'; + +export interface CFServicesPromptsConfig { + fdcService: FDCService; + isInternalUsage?: boolean; + logger?: { log: (msg: string) => void; error: (msg: string) => void }; +} + +const MANAGED_APPROUTER = 'Managed HTML5 Application Runtime'; +const STANDALONE_APPROUTER = 'Standalone HTML5 Application Runtime'; + +const getBaseAppChoices = (apps: CFApp[], fdcService: FDCService): { name: string; value: CFApp }[] => { + return apps.map((result: CFApp) => ({ + name: fdcService.formatDiscovery?.(result) ?? `${result.title} (${result.appId}, ${result.appVersion})`, + value: result + })); +}; + +/** + * Prompter for CF services. + */ +export class CFServicesPrompter { + private readonly fdcService: FDCService; + private readonly isInternalUsage: boolean; + private readonly logger?: { log: (msg: string) => void; error: (msg: string) => void }; + + /** + * Whether the user is logged in to Cloud Foundry. + */ + private isCFLoggedIn = false; + /** + * Whether to show the solution name prompt. + */ + private showSolutionNamePrompt = false; + /** + * The type of approuter to use. + */ + private approuter: string | undefined; + /** + * The business services available. + */ + private businessServices: string[] = []; + /** + * The name of the cached business service. + */ + private cachedServiceName: string | undefined; + /** + * The keys of the business service. + */ + private businessServiceKeys: ServiceKeys | null = null; + /** + * The base apps available. + */ + private apps: CFApp[] = []; + /** + * The error message when choosing a base app. + */ + private baseAppOnChoiceError: string | null = null; + + /** + * @param {FDCService} fdcService - FDC service instance. + * @param {ToolsLogger} logger - Logger instance. + * @param {boolean} [isInternalUsage] - Internal usage flag. + */ + constructor(fdcService: FDCService, logger: ToolsLogger, isInternalUsage: boolean = false) { + this.fdcService = fdcService; + this.isInternalUsage = isInternalUsage; + this.logger = logger; + } + + /** + * Public API: returns prompts for CF application sources. + * + * @param {string} mtaProjectPath - The path to the MTA project. + * @param {boolean} isCFLoggedIn - Whether the user is logged in to Cloud Foundry. + * @returns {Promise} The prompts for CF application sources. + */ + /** + * Builds the CF services prompts, keyed and hide-filtered like attributes.ts. + * + * @param {string} mtaProjectPath - MTA project path + * @param {boolean} isCFLoggedIn - Whether user is logged in to CF + * @param {CfServicesPromptOptions} [promptOptions] - Optional per-prompt visibility controls + * @returns {Promise} CF services questions + */ + public async getPrompts( + mtaProjectPath: string, + isCFLoggedIn: boolean, + promptOptions?: CfServicesPromptOptions + ): Promise { + this.isCFLoggedIn = isCFLoggedIn; + if (this.isCFLoggedIn) { + this.businessServices = await this.fdcService.getServices(mtaProjectPath); + } + + const keyedPrompts: Record = { + [cfServicesPromptNames.approuter]: this.getAppRouterPrompt(mtaProjectPath), + [cfServicesPromptNames.businessService]: this.getBusinessServicesPrompt(), + [cfServicesPromptNames.businessSolutionName]: this.getBusinessSolutionNamePrompt(), + [cfServicesPromptNames.baseApp]: this.getBaseAppPrompt() + }; + + const questions = Object.entries(keyedPrompts) + .filter(([promptName]) => { + const options = promptOptions?.[promptName as cfServicesPromptNames]; + return !(options && 'hide' in options && (options as { hide?: boolean }).hide); + }) + .map(([, question]) => question); + + return questions; + } + + /** + * Prompt for business solution name. + * + * @returns {CFServicesQuestion} Prompt for business solution name. + */ + private getBusinessSolutionNamePrompt(): CFServicesQuestion { + return { + type: 'input', + name: cfServicesPromptNames.businessSolutionName, + message: 'Enter a unique name for the business solution of the project', + when: (answers: CfServicesAnswers) => + this.isCFLoggedIn && + answers.approuter === MANAGED_APPROUTER && + this.showSolutionNamePrompt && + answers.businessService + ? true + : false, + validate: (value: string) => this.validateBusinessSolutionName(value), + guiOptions: { + mandatory: true, + hint: 'Business solution name must consist of at least two segments and they should be separated by period.' + }, + store: false + } as InputQuestion; + } + + /** + * Prompt for approuter. + * + * @param {string} mtaProjectPath - MTA project path. + * @returns {CFServicesQuestion} Prompt for approuter. + */ + private getAppRouterPrompt(mtaProjectPath: string): CFServicesQuestion { + const mtaProjectName = + mtaProjectPath.indexOf('/') > -1 ? mtaProjectPath.split('/').pop() : mtaProjectPath.split('\\').pop(); + const options = [ + { + name: MANAGED_APPROUTER, + value: MANAGED_APPROUTER + } + ]; + if (this.isInternalUsage) { + options.push({ + name: STANDALONE_APPROUTER, + value: STANDALONE_APPROUTER + }); + } + + return { + type: 'list', + name: cfServicesPromptNames.approuter, + message: 'Select your HTML5 application runtime', + choices: options, + when: () => { + const modules = this.fdcService.getModuleNames(mtaProjectPath); + const hasRouter = this.fdcService.hasApprouter(mtaProjectName as string, modules); + if (hasRouter) { + // keep behavior even if getApprouterType is not declared in typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.approuter = this.fdcService.getApprouterType?.(); + } + + if (this.isCFLoggedIn && !hasRouter) { + this.showSolutionNamePrompt = true; + return true; + } else { + return false; + } + }, + validate: async (value: string) => { + this.isCFLoggedIn = await this.fdcService.isLoggedIn(); + if (!this.isCFLoggedIn) { + return 'You are not logged in to Cloud Foundry.'; + } + + return this.validateEmptySelect(value, 'Approuter'); + }, + guiOptions: { + hint: 'Select the HTML5 application runtime that you want to use' + } + } as ListQuestion; + } + + /** + * Prompt for base application. + * + * @returns {CFServicesQuestion} Prompt for base application. + */ + private getBaseAppPrompt(): CFServicesQuestion { + return { + type: 'list', + name: cfServicesPromptNames.baseApp, + message: 'Select base application', + choices: async (answers: CfServicesAnswers): Promise => { + try { + this.baseAppOnChoiceError = null; + if (this.cachedServiceName != answers.businessService) { + this.cachedServiceName = answers.businessService; + this.businessServiceKeys = await this.fdcService.getBusinessServiceKeys( + answers.businessService ?? '' + ); + if (!this.businessServiceKeys) { + return []; + } + this.apps = await this.fdcService.getBaseApps(this.businessServiceKeys.credentials); + this.logger?.log(`Available applications: ${JSON.stringify(this.apps)}`); + } + return getBaseAppChoices(this.apps, this.fdcService); + } catch (e) { + // log error: baseApp => choices + /* the error will be shown by the validation functionality */ + this.baseAppOnChoiceError = e instanceof Error ? e.message : 'Unknown error'; + this.logger?.error(`Failed to get base apps: ${e.message}`); + return []; + } + }, + validate: async (value: string) => { + if (!value) { + return 'Base application has to be selected'; + } + if (this.baseAppOnChoiceError !== null) { + return this.baseAppOnChoiceError; + } + return true; + }, + when: (answers: any) => this.isCFLoggedIn && answers.businessService, + guiOptions: { + hint: 'Select the base application you want to use' + } + } as ListQuestion; + } + + /** + * Prompt for business services. + * + * @returns {CFServicesQuestion} Prompt for business services. + */ + private getBusinessServicesPrompt(): CFServicesQuestion { + return { + type: 'list', + name: cfServicesPromptNames.businessService, + message: 'Select business service', + choices: this.businessServices, + default: (_answers?: any) => (this.businessServices.length === 1 ? this.businessServices[0] ?? '' : ''), + when: (answers: CfServicesAnswers) => { + return this.isCFLoggedIn && (this.approuter || answers.approuter); + }, + validate: async (value: string) => { + if (!value) { + return 'Business service has to be selected'; + } + this.businessServiceKeys = await this.fdcService.getBusinessServiceKeys(value); + if (this.businessServiceKeys === null) { + return 'The service chosen does not exist in cockpit or the user is not member of the needed space.'; + } + + return true; + }, + guiOptions: { + mandatory: true, + hint: 'Select the business service you want to use' + } + } as ListQuestion; + } + + /** + * Validate empty select. + * + * @param {string} value - Value to validate. + * @param {string} label - Label to validate. + * @returns {string | true} Validation result. + */ + private validateEmptySelect(value: string, label: string): string | true { + if (!value) { + return `${label} has to be selected`; + } + return true; + } + + /** + * Validate business solution name. + * + * @param {string} value - Value to validate. + * @returns {string | boolean} Validation result. + */ + private validateBusinessSolutionName(value: string): string | boolean { + if (!value) { + return 'Value cannot be empty'; + } + const parts = String(value) + .split('.') + .filter((p) => p.length > 0); + if (parts.length < 2) { + return 'Business solution name must consist of at least two segments and they should be separated by period.'; + } + return true; + } +} + +/** + * @param {object} param0 - Configuration object containing FDC service, internal usage flag, MTA project path, CF login status, and logger. + * @param {FDCService} param0.fdcService - FDC service instance. + * @param {boolean} [param0.isInternalUsage] - Internal usage flag. + * @param {string} param0.mtaProjectPath - MTA project path. + * @param {boolean} param0.isCFLoggedIn - CF login status. + * @param {ToolsLogger} param0.logger - Logger instance. + * @returns {Promise} CF services questions. + */ +export async function getPrompts({ + fdcService, + isInternalUsage, + mtaProjectPath, + isCFLoggedIn, + logger +}: { + fdcService: FDCService; + isInternalUsage?: boolean; + mtaProjectPath: string; + isCFLoggedIn: boolean; + logger: ToolsLogger; +}): Promise { + const prompter = new CFServicesPrompter(fdcService, logger, isInternalUsage); + return prompter.getPrompts(mtaProjectPath, isCFLoggedIn); +} diff --git a/packages/generator-adp/src/app/types.ts b/packages/generator-adp/src/app/types.ts index 9df17cff5b9..b012d0e6ee9 100644 --- a/packages/generator-adp/src/app/types.ts +++ b/packages/generator-adp/src/app/types.ts @@ -135,6 +135,7 @@ export interface TargetFolderPromptOptions { export interface UI5VersionPromptOptions { default?: string; + hide?: boolean; } export interface EnableTypeScriptPromptOptions { @@ -223,3 +224,46 @@ export interface JsonInput { projectName?: string; namespace?: string; } + +/** + * CF services (application sources) prompts + */ +export enum cfServicesPromptNames { + approuter = 'approuter', + businessService = 'businessService', + businessSolutionName = 'businessSolutionName', + baseApp = 'baseApp' +} + +export type CfServicesAnswers = { + [cfServicesPromptNames.approuter]?: string; + [cfServicesPromptNames.businessService]?: string; + [cfServicesPromptNames.businessSolutionName]?: string; + // Base app object returned by discovery (shape provided by FDC service) + [cfServicesPromptNames.baseApp]?: unknown; +}; + +export type CFServicesQuestion = YUIQuestion; + +export interface ApprouterPromptOptions { + hide?: boolean; +} + +export interface BusinessServicePromptOptions { + hide?: boolean; +} + +export interface BusinessSolutionNamePromptOptions { + hide?: boolean; +} + +export interface BaseAppPromptOptions { + hide?: boolean; +} + +export type CfServicesPromptOptions = Partial<{ + [cfServicesPromptNames.approuter]: ApprouterPromptOptions; + [cfServicesPromptNames.businessService]: BusinessServicePromptOptions; + [cfServicesPromptNames.businessSolutionName]: BusinessSolutionNamePromptOptions; + [cfServicesPromptNames.baseApp]: BaseAppPromptOptions; +}>; From 44c1a507b66ece70bcfee345a79d4ed11e1ab475 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 8 Aug 2025 16:53:51 +0300 Subject: [PATCH 019/111] refactor: slightly improve code --- .../src/app/questions/cf-services.ts | 80 ++++++++++--------- packages/generator-adp/src/app/types.ts | 7 ++ 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 1d253625c45..8f477b8c814 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -2,18 +2,16 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { CFApp, FDCService, ServiceKeys } from '@sap-ux/adp-tooling'; import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; -import { cfServicesPromptNames } from '../types'; +import { cfServicesPromptNames, AppRouterType } from '../types'; import type { CfServicesAnswers, CFServicesQuestion, CfServicesPromptOptions } from '../types'; -export interface CFServicesPromptsConfig { - fdcService: FDCService; - isInternalUsage?: boolean; - logger?: { log: (msg: string) => void; error: (msg: string) => void }; -} - -const MANAGED_APPROUTER = 'Managed HTML5 Application Runtime'; -const STANDALONE_APPROUTER = 'Standalone HTML5 Application Runtime'; - +/** + * Get the choices for the base app. + * + * @param {CFApp[]} apps - The apps to get the choices for. + * @param {FDCService} fdcService - The FDC service instance. + * @returns {Array<{ name: string; value: CFApp }>} The choices for the base app. + */ const getBaseAppChoices = (apps: CFApp[], fdcService: FDCService): { name: string; value: CFApp }[] => { return apps.map((result: CFApp) => ({ name: fdcService.formatDiscovery?.(result) ?? `${result.title} (${result.appId}, ${result.appVersion})`, @@ -21,6 +19,28 @@ const getBaseAppChoices = (apps: CFApp[], fdcService: FDCService): { name: strin })); }; +/** + * Get the choices for the approuter. + * + * @param {boolean} isInternalUsage - Whether the user is using internal features. + * @returns {Array<{ name: AppRouterType; value: AppRouterType }>} The choices for the approuter. + */ +const getAppRouterChoices = (isInternalUsage: boolean): { name: AppRouterType; value: AppRouterType }[] => { + const options: { name: AppRouterType; value: AppRouterType }[] = [ + { + name: AppRouterType.MANAGED, + value: AppRouterType.MANAGED + } + ]; + if (isInternalUsage) { + options.push({ + name: AppRouterType.STANDALONE, + value: AppRouterType.STANDALONE + }); + } + return options; +}; + /** * Prompter for CF services. */ @@ -63,37 +83,31 @@ export class CFServicesPrompter { private baseAppOnChoiceError: string | null = null; /** + * Constructor for CFServicesPrompter. + * * @param {FDCService} fdcService - FDC service instance. + * @param {boolean} isCFLoggedIn - Whether the user is logged in to Cloud Foundry. * @param {ToolsLogger} logger - Logger instance. * @param {boolean} [isInternalUsage] - Internal usage flag. */ - constructor(fdcService: FDCService, logger: ToolsLogger, isInternalUsage: boolean = false) { + constructor(fdcService: FDCService, isCFLoggedIn: boolean, logger: ToolsLogger, isInternalUsage: boolean = false) { this.fdcService = fdcService; this.isInternalUsage = isInternalUsage; this.logger = logger; + this.isCFLoggedIn = isCFLoggedIn; } - /** - * Public API: returns prompts for CF application sources. - * - * @param {string} mtaProjectPath - The path to the MTA project. - * @param {boolean} isCFLoggedIn - Whether the user is logged in to Cloud Foundry. - * @returns {Promise} The prompts for CF application sources. - */ /** * Builds the CF services prompts, keyed and hide-filtered like attributes.ts. * * @param {string} mtaProjectPath - MTA project path - * @param {boolean} isCFLoggedIn - Whether user is logged in to CF * @param {CfServicesPromptOptions} [promptOptions] - Optional per-prompt visibility controls * @returns {Promise} CF services questions */ public async getPrompts( mtaProjectPath: string, - isCFLoggedIn: boolean, promptOptions?: CfServicesPromptOptions ): Promise { - this.isCFLoggedIn = isCFLoggedIn; if (this.isCFLoggedIn) { this.businessServices = await this.fdcService.getServices(mtaProjectPath); } @@ -108,9 +122,9 @@ export class CFServicesPrompter { const questions = Object.entries(keyedPrompts) .filter(([promptName]) => { const options = promptOptions?.[promptName as cfServicesPromptNames]; - return !(options && 'hide' in options && (options as { hide?: boolean }).hide); + return !(options && 'hide' in options && options.hide); }) - .map(([, question]) => question); + .map(([_, question]) => question); return questions; } @@ -127,7 +141,7 @@ export class CFServicesPrompter { message: 'Enter a unique name for the business solution of the project', when: (answers: CfServicesAnswers) => this.isCFLoggedIn && - answers.approuter === MANAGED_APPROUTER && + answers.approuter === AppRouterType.MANAGED && this.showSolutionNamePrompt && answers.businessService ? true @@ -150,24 +164,12 @@ export class CFServicesPrompter { private getAppRouterPrompt(mtaProjectPath: string): CFServicesQuestion { const mtaProjectName = mtaProjectPath.indexOf('/') > -1 ? mtaProjectPath.split('/').pop() : mtaProjectPath.split('\\').pop(); - const options = [ - { - name: MANAGED_APPROUTER, - value: MANAGED_APPROUTER - } - ]; - if (this.isInternalUsage) { - options.push({ - name: STANDALONE_APPROUTER, - value: STANDALONE_APPROUTER - }); - } return { type: 'list', name: cfServicesPromptNames.approuter, message: 'Select your HTML5 application runtime', - choices: options, + choices: getAppRouterChoices(this.isInternalUsage), when: () => { const modules = this.fdcService.getModuleNames(mtaProjectPath); const hasRouter = this.fdcService.hasApprouter(mtaProjectName as string, modules); @@ -336,6 +338,6 @@ export async function getPrompts({ isCFLoggedIn: boolean; logger: ToolsLogger; }): Promise { - const prompter = new CFServicesPrompter(fdcService, logger, isInternalUsage); - return prompter.getPrompts(mtaProjectPath, isCFLoggedIn); + const prompter = new CFServicesPrompter(fdcService, isCFLoggedIn, logger, isInternalUsage); + return prompter.getPrompts(mtaProjectPath); } diff --git a/packages/generator-adp/src/app/types.ts b/packages/generator-adp/src/app/types.ts index b012d0e6ee9..585268d4545 100644 --- a/packages/generator-adp/src/app/types.ts +++ b/packages/generator-adp/src/app/types.ts @@ -235,6 +235,13 @@ export enum cfServicesPromptNames { baseApp = 'baseApp' } +export const AppRouterType = { + MANAGED: 'Managed HTML5 Application Runtime', + STANDALONE: 'Standalone HTML5 Application Runtime' +} as const; + +export type AppRouterType = (typeof AppRouterType)[keyof typeof AppRouterType]; + export type CfServicesAnswers = { [cfServicesPromptNames.approuter]?: string; [cfServicesPromptNames.businessService]?: string; From efda0c22c72004d8984525f40fbf78836ad78aa9 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 11 Aug 2025 09:31:43 +0300 Subject: [PATCH 020/111] feat: prevent writing for cf --- packages/generator-adp/src/app/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 3a53ae7a152..662b727bb88 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -365,6 +365,12 @@ export default class extends Generator { async writing(): Promise { try { + if (this.isCfEnv) { + // Will be removed once CF project generation is supported. + this.vscode.showInformationMessage('CF project generation is not supported yet.'); + return; + } + if (this.jsonInput) { await this._initFromJson(); } @@ -410,7 +416,7 @@ export default class extends Generator { } async install(): Promise { - if (!this.shouldInstallDeps || this.shouldCreateExtProject) { + if (!this.shouldInstallDeps || this.shouldCreateExtProject || this.isCfEnv) { return; } @@ -422,7 +428,7 @@ export default class extends Generator { } async end(): Promise { - if (this.shouldCreateExtProject) { + if (this.shouldCreateExtProject || this.isCfEnv) { return; } From 6b7ea86e7feb6b4f2c02bf87c5106cc7ae02e80c Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 11 Aug 2025 11:18:34 +0300 Subject: [PATCH 021/111] refactor: slight improvements --- packages/generator-adp/src/app/index.ts | 10 +++--- .../src/app/questions/target-env.ts | 34 +++++++++---------- packages/generator-adp/src/utils/steps.ts | 4 ++- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 662b727bb88..4fac34a4c43 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -53,7 +53,7 @@ import { } from '../utils/steps'; import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderChoice } from '../utils/workspace'; import { FDCService } from '@sap-ux/adp-tooling'; -import { getTargetEnvPrompt, promptUserForProjectPath } from './questions/target-env'; +import { getTargetEnvPrompt, getProjectPathPrompt } from './questions/target-env'; import { isAppStudio } from '@sap-ux/btp-utils'; const generatorTitle = 'Adaptation Project'; @@ -305,10 +305,10 @@ export default class extends Generator { this.logger.log(`Project apiUrl information: ${JSON.stringify(this.cfConfig.url, null, 2)}`); if (!this.isMtaYamlFound) { - const projectPathAnswers = await this.prompt( - promptUserForProjectPath(this.fdcService, this.isCFLoggedIn, this.vscode) - ); - this.projectLocation = projectPathAnswers.projectLocation; + const pathAnswers = await this.prompt([ + getProjectPathPrompt(this.fdcService, this.isCFLoggedIn, this.vscode) + ]); + this.projectLocation = pathAnswers.projectLocation; this.projectLocation = fs.realpathSync(this.projectLocation, 'utf-8'); this.cfProjectDestinationPath = this.destinationRoot(this.projectLocation); this.logger.log(`Project path information: ${this.projectLocation}`); diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index 8e1677d52fd..ab6e7b9c81e 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -65,25 +65,23 @@ export function getEnvironments(appWizard: AppWizard, isCfInstalled: boolean): E * @param {any} vscode - The VSCode instance. * @returns {YUIQuestion[]} The project path prompt. */ -export function promptUserForProjectPath( +export function getProjectPathPrompt( fdcService: FDCService, isCFLoggedIn: boolean, vscode: any -): YUIQuestion[] { - return [ - { - type: 'input', - name: 'projectLocation', - guiOptions: { - type: 'folder-browser', - mandatory: true, - hint: 'Select the path to the root of your project' - }, - message: 'Specify the path to the project root', - validate: (value: string) => validateProjectPath(value, fdcService), - when: () => isCFLoggedIn, - default: () => getDefaultTargetFolder(vscode), - store: false - } as InputQuestion - ]; +): YUIQuestion { + return { + type: 'input', + name: 'projectLocation', + guiOptions: { + type: 'folder-browser', + mandatory: true, + hint: 'Select the path to the root of your project' + }, + message: 'Specify the path to the project root', + validate: (value: string) => validateProjectPath(value, fdcService), + when: () => isCFLoggedIn, + default: () => getDefaultTargetFolder(vscode), + store: false + } as InputQuestion; } diff --git a/packages/generator-adp/src/utils/steps.ts b/packages/generator-adp/src/utils/steps.ts index 2daa026d789..e10d8e2fba1 100644 --- a/packages/generator-adp/src/utils/steps.ts +++ b/packages/generator-adp/src/utils/steps.ts @@ -31,7 +31,9 @@ export function getWizardPages(): IPrompt[] { */ export function updateCfWizardSteps(isCFEnv: boolean, prompts: YeomanUiSteps): void { if (isCFEnv) { - prompts.splice(1, 1, [ + // Replace all pages starting from index 1 (after "Target environment") + // This prevents "Project Attributes" from being pushed to the end + prompts.splice(1, prompts['items'].length - 1, [ { name: 'Login to Cloud Foundry', description: 'Provide credentials.' }, { name: 'Project path', description: 'Provide path to MTA project.' }, { From 0473a8a5477d2595d3cca1c8c62d1190d5ff4f02 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 11 Aug 2025 11:19:08 +0300 Subject: [PATCH 022/111] refactor: slight improvements --- packages/generator-adp/src/app/questions/target-env.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index ab6e7b9c81e..8891c22b603 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -80,7 +80,6 @@ export function getProjectPathPrompt( }, message: 'Specify the path to the project root', validate: (value: string) => validateProjectPath(value, fdcService), - when: () => isCFLoggedIn, default: () => getDefaultTargetFolder(vscode), store: false } as InputQuestion; From 9ceaa65ec267d98fca399dc0782fa9b1f280de32 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 11 Aug 2025 11:42:18 +0300 Subject: [PATCH 023/111] refactor: slight improvements --- packages/generator-adp/src/app/index.ts | 2 +- .../src/app/questions/cf-services.ts | 70 +++---------------- .../src/app/questions/helper/choices.ts | 39 ++++++++++- .../src/app/questions/helper/validators.ts | 19 +++++ 4 files changed, 67 insertions(+), 63 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 4fac34a4c43..467ac9175e3 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -367,7 +367,7 @@ export default class extends Generator { try { if (this.isCfEnv) { // Will be removed once CF project generation is supported. - this.vscode.showInformationMessage('CF project generation is not supported yet.'); + this.vscode.window.showInformationMessage('CF project generation is not supported yet.'); return; } diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 8f477b8c814..57c7d5e32fe 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -4,42 +4,8 @@ import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; import { cfServicesPromptNames, AppRouterType } from '../types'; import type { CfServicesAnswers, CFServicesQuestion, CfServicesPromptOptions } from '../types'; - -/** - * Get the choices for the base app. - * - * @param {CFApp[]} apps - The apps to get the choices for. - * @param {FDCService} fdcService - The FDC service instance. - * @returns {Array<{ name: string; value: CFApp }>} The choices for the base app. - */ -const getBaseAppChoices = (apps: CFApp[], fdcService: FDCService): { name: string; value: CFApp }[] => { - return apps.map((result: CFApp) => ({ - name: fdcService.formatDiscovery?.(result) ?? `${result.title} (${result.appId}, ${result.appVersion})`, - value: result - })); -}; - -/** - * Get the choices for the approuter. - * - * @param {boolean} isInternalUsage - Whether the user is using internal features. - * @returns {Array<{ name: AppRouterType; value: AppRouterType }>} The choices for the approuter. - */ -const getAppRouterChoices = (isInternalUsage: boolean): { name: AppRouterType; value: AppRouterType }[] => { - const options: { name: AppRouterType; value: AppRouterType }[] = [ - { - name: AppRouterType.MANAGED, - value: AppRouterType.MANAGED - } - ]; - if (isInternalUsage) { - options.push({ - name: AppRouterType.STANDALONE, - value: AppRouterType.STANDALONE - }); - } - return options; -}; +import { getAppRouterChoices, getCFAppChoices } from './helper/choices'; +import { validateBusinessSolutionName } from './helper/validators'; /** * Prompter for CF services. @@ -146,7 +112,7 @@ export class CFServicesPrompter { answers.businessService ? true : false, - validate: (value: string) => this.validateBusinessSolutionName(value), + validate: (value: string) => validateBusinessSolutionName(value), guiOptions: { mandatory: true, hint: 'Business solution name must consist of at least two segments and they should be separated by period.' @@ -162,9 +128,6 @@ export class CFServicesPrompter { * @returns {CFServicesQuestion} Prompt for approuter. */ private getAppRouterPrompt(mtaProjectPath: string): CFServicesQuestion { - const mtaProjectName = - mtaProjectPath.indexOf('/') > -1 ? mtaProjectPath.split('/').pop() : mtaProjectPath.split('\\').pop(); - return { type: 'list', name: cfServicesPromptNames.approuter, @@ -172,7 +135,11 @@ export class CFServicesPrompter { choices: getAppRouterChoices(this.isInternalUsage), when: () => { const modules = this.fdcService.getModuleNames(mtaProjectPath); - const hasRouter = this.fdcService.hasApprouter(mtaProjectName as string, modules); + const mtaProjectName = + (mtaProjectPath.indexOf('/') > -1 + ? mtaProjectPath.split('/').pop() + : mtaProjectPath.split('\\').pop()) ?? ''; + const hasRouter = this.fdcService.hasApprouter(mtaProjectName, modules); if (hasRouter) { // keep behavior even if getApprouterType is not declared in typing // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -224,7 +191,7 @@ export class CFServicesPrompter { this.apps = await this.fdcService.getBaseApps(this.businessServiceKeys.credentials); this.logger?.log(`Available applications: ${JSON.stringify(this.apps)}`); } - return getBaseAppChoices(this.apps, this.fdcService); + return getCFAppChoices(this.apps, this.fdcService); } catch (e) { // log error: baseApp => choices /* the error will be shown by the validation functionality */ @@ -295,25 +262,6 @@ export class CFServicesPrompter { } return true; } - - /** - * Validate business solution name. - * - * @param {string} value - Value to validate. - * @returns {string | boolean} Validation result. - */ - private validateBusinessSolutionName(value: string): string | boolean { - if (!value) { - return 'Value cannot be empty'; - } - const parts = String(value) - .split('.') - .filter((p) => p.length > 0); - if (parts.length < 2) { - return 'Business solution name must consist of at least two segments and they should be separated by period.'; - } - return true; - } } /** diff --git a/packages/generator-adp/src/app/questions/helper/choices.ts b/packages/generator-adp/src/app/questions/helper/choices.ts index c47a8ac852d..de664a6b61a 100644 --- a/packages/generator-adp/src/app/questions/helper/choices.ts +++ b/packages/generator-adp/src/app/questions/helper/choices.ts @@ -1,4 +1,5 @@ -import type { SourceApplication } from '@sap-ux/adp-tooling'; +import type { CFApp, FDCService, SourceApplication } from '@sap-ux/adp-tooling'; +import { AppRouterType } from '../../types'; interface Choice { name: string; @@ -25,3 +26,39 @@ export const getApplicationChoices = (apps: SourceApplication[]): Choice[] => { }) : apps; }; + +/** + * Get the choices for the base app. + * + * @param {CFApp[]} apps - The apps to get the choices for. + * @param {FDCService} fdcService - The FDC service instance. + * @returns {Array<{ name: string; value: CFApp }>} The choices for the base app. + */ +export const getCFAppChoices = (apps: CFApp[], fdcService: FDCService): { name: string; value: CFApp }[] => { + return apps.map((result: CFApp) => ({ + name: fdcService.formatDiscovery?.(result) ?? `${result.title} (${result.appId}, ${result.appVersion})`, + value: result + })); +}; + +/** + * Get the choices for the approuter. + * + * @param {boolean} isInternalUsage - Whether the user is using internal features. + * @returns {Array<{ name: AppRouterType; value: AppRouterType }>} The choices for the approuter. + */ +export const getAppRouterChoices = (isInternalUsage: boolean): { name: AppRouterType; value: AppRouterType }[] => { + const options: { name: AppRouterType; value: AppRouterType }[] = [ + { + name: AppRouterType.MANAGED, + value: AppRouterType.MANAGED + } + ]; + if (isInternalUsage) { + options.push({ + name: AppRouterType.STANDALONE, + value: AppRouterType.STANDALONE + }); + } + return options; +}; diff --git a/packages/generator-adp/src/app/questions/helper/validators.ts b/packages/generator-adp/src/app/questions/helper/validators.ts index 51c83b816d8..f6d0b83c6fc 100644 --- a/packages/generator-adp/src/app/questions/helper/validators.ts +++ b/packages/generator-adp/src/app/questions/helper/validators.ts @@ -145,3 +145,22 @@ export async function validateProjectPath(projectPath: string, fdcService: FDCSe return true; } + +/** + * Validate business solution name. + * + * @param {string} value - Value to validate. + * @returns {string | boolean} Validation result. + */ +export function validateBusinessSolutionName(value: string): string | boolean { + if (!value) { + return 'Value cannot be empty'; + } + const parts = String(value) + .split('.') + .filter((p) => p.length > 0); + if (parts.length < 2) { + return 'Business solution name must consist of at least two segments and they should be separated by period.'; + } + return true; +} From 1d66a234d81348a30b8a8012c7b14efa27461bee Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 11 Aug 2025 12:03:18 +0300 Subject: [PATCH 024/111] refactor: remove redundant code --- packages/generator-adp/src/app/index.ts | 34 +++++++------------ .../src/app/questions/target-env.ts | 7 +--- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 467ac9175e3..0ccf68c044c 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -294,8 +294,6 @@ export default class extends Generator { } else { this.isCFLoggedIn = await this.fdcService.isLoggedIn(); - this._setCFLoginPageDescription(this.isCFLoggedIn); - await this.prompt(getCFLoginPrompts(this.vscode, this.fdcService, this.isCFLoggedIn)); this.cfConfig = this.fdcService.getConfig(); this.isCFLoggedIn = await this.fdcService.isLoggedIn(); @@ -304,19 +302,7 @@ export default class extends Generator { this.logger.log(`Project space information: ${JSON.stringify(this.cfConfig.space, null, 2)}`); this.logger.log(`Project apiUrl information: ${JSON.stringify(this.cfConfig.url, null, 2)}`); - if (!this.isMtaYamlFound) { - const pathAnswers = await this.prompt([ - getProjectPathPrompt(this.fdcService, this.isCFLoggedIn, this.vscode) - ]); - this.projectLocation = pathAnswers.projectLocation; - this.projectLocation = fs.realpathSync(this.projectLocation, 'utf-8'); - this.cfProjectDestinationPath = this.destinationRoot(this.projectLocation); - this.logger.log(`Project path information: ${this.projectLocation}`); - } else { - this.cfProjectDestinationPath = this.destinationRoot(process.cwd()); - YamlUtils.loadYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); - this.logger.log(`Project path information: ${this.cfProjectDestinationPath}`); - } + await this.promptForCfProjectPath(); const options: AttributePromptOptions = { targetFolder: { hide: true }, @@ -355,12 +341,18 @@ export default class extends Generator { } } - private _setCFLoginPageDescription(isLoggedIn: boolean): void { - const pageLabel = { - name: 'Login to Cloud Foundry', - description: isLoggedIn ? '' : 'Provide credentials.' - }; - this.prompts.splice(1, 1, [pageLabel]); + private async promptForCfProjectPath(): Promise { + if (!this.isMtaYamlFound) { + const pathAnswers = await this.prompt([getProjectPathPrompt(this.fdcService, this.vscode)]); + this.projectLocation = pathAnswers.projectLocation; + this.projectLocation = fs.realpathSync(this.projectLocation, 'utf-8'); + this.cfProjectDestinationPath = this.destinationRoot(this.projectLocation); + this.logger.log(`Project path information: ${this.projectLocation}`); + } else { + this.cfProjectDestinationPath = this.destinationRoot(process.cwd()); + YamlUtils.loadYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); + this.logger.log(`Project path information: ${this.cfProjectDestinationPath}`); + } } async writing(): Promise { diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index 8891c22b603..7d737c4db5a 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -61,15 +61,10 @@ export function getEnvironments(appWizard: AppWizard, isCfInstalled: boolean): E * Returns the project path prompt. * * @param {FDCService} fdcService - The FDC service instance. - * @param {boolean} isCFLoggedIn - Whether the user is logged in to Cloud Foundry. * @param {any} vscode - The VSCode instance. * @returns {YUIQuestion[]} The project path prompt. */ -export function getProjectPathPrompt( - fdcService: FDCService, - isCFLoggedIn: boolean, - vscode: any -): YUIQuestion { +export function getProjectPathPrompt(fdcService: FDCService, vscode: any): YUIQuestion { return { type: 'input', name: 'projectLocation', From a0288bd10288ed9a7079f2dc05ea6ceb9585cdd9 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 11 Aug 2025 12:16:35 +0300 Subject: [PATCH 025/111] fix: gen private method --- packages/generator-adp/src/app/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 0ccf68c044c..70d1826f56a 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -302,7 +302,7 @@ export default class extends Generator { this.logger.log(`Project space information: ${JSON.stringify(this.cfConfig.space, null, 2)}`); this.logger.log(`Project apiUrl information: ${JSON.stringify(this.cfConfig.url, null, 2)}`); - await this.promptForCfProjectPath(); + await this._promptForCfProjectPath(); const options: AttributePromptOptions = { targetFolder: { hide: true }, @@ -341,7 +341,7 @@ export default class extends Generator { } } - private async promptForCfProjectPath(): Promise { + private async _promptForCfProjectPath(): Promise { if (!this.isMtaYamlFound) { const pathAnswers = await this.prompt([getProjectPathPrompt(this.fdcService, this.vscode)]); this.projectLocation = pathAnswers.projectLocation; From 9bfee8a6ff121705d55b29eb86c8359b72574537 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 13 Aug 2025 16:53:33 +0300 Subject: [PATCH 026/111] feat: refactor and improve code --- packages/adp-tooling/src/cf/fdc.ts | 451 +++++----- packages/adp-tooling/src/cf/html5-repo.ts | 258 +++--- packages/adp-tooling/src/cf/index.ts | 6 +- packages/adp-tooling/src/cf/utils.ts | 404 +++++---- packages/adp-tooling/src/cf/yaml.ts | 775 ++++++++++-------- packages/adp-tooling/src/types.ts | 90 +- packages/generator-adp/src/app/index.ts | 198 +++-- .../src/app/questions/cf-login.ts | 105 --- .../src/app/questions/cf-services.ts | 16 +- .../src/app/questions/helper/validators.ts | 14 +- .../src/app/questions/target-env.ts | 18 +- packages/generator-adp/src/utils/steps.ts | 9 +- 12 files changed, 1302 insertions(+), 1042 deletions(-) delete mode 100644 packages/generator-adp/src/app/questions/cf-login.ts diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index b7315f5024c..7e7e2e30410 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -5,12 +5,10 @@ import axios from 'axios'; import type * as AdmZip from 'adm-zip'; import CFLocal = require('@sap/cf-tools/out/src/cf-local'); +import { isAppStudio } from '@sap-ux/btp-utils'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import YamlUtils from './yaml'; -import HTML5RepoUtils from './html5-repo'; -import CFUtils from './utils'; import type { Config, CFConfig, @@ -22,19 +20,258 @@ import type { Credentials, ServiceKeys, BusinessSeviceResource, - AppParams + AppParams, + CFServiceOffering, + CFAPIResponse } from '../types'; -import { isAppStudio } from '@sap-ux/btp-utils'; -import { getApplicationType, isSupportedAppTypeForAdp } from '../source'; import { t } from '../i18n'; +import { downloadAppContent } from './html5-repo'; +import { YamlUtils, getRouterType } from './yaml'; +import { getApplicationType, isSupportedAppTypeForAdp } from '../source'; +import { checkForCf, getAuthToken, getServiceInstanceKeys, requestCfApi } from './utils'; + +const HOMEDRIVE = 'HOMEDRIVE'; +const HOMEPATH = 'HOMEPATH'; +const WIN32 = 'win32'; + +/** + * Validate the smart template application. + * + * @param {Manifest} manifest - The manifest. + * @returns {Promise} The messages. + */ +async function validateSmartTemplateApplication(manifest: Manifest): Promise { + const messages: string[] = []; + const appType = getApplicationType(manifest); + + if (isSupportedAppTypeForAdp(appType)) { + if (manifest['sap.ui5'] && manifest['sap.ui5'].flexEnabled === false) { + return messages.concat(t('error.appDoesNotSupportFlexibility')); + } + } else { + return messages.concat( + "Select a different application. Adaptation project doesn't support the selected application." + ); + } + return messages; +} + +/** + * Get the home directory. + * + * @returns {string} The home directory. + */ +function getHomedir() { + let homedir = os.homedir(); + const homeDrive = process.env?.[HOMEDRIVE]; + const homePath = process.env?.[HOMEPATH]; + if (process.platform === WIN32 && typeof homeDrive === 'string' && typeof homePath === 'string') { + homedir = path.join(homeDrive, homePath); + } + + return homedir; +} + +/** + * Check if CF is installed. + * + * @returns {Promise} True if CF is installed, false otherwise. + */ +export async function isCfInstalled(): Promise { + let isInstalled = true; + try { + await checkForCf(); + } catch (error) { + isInstalled = false; + } + + return isInstalled; +} + +/** + * Get the business service keys. + * + * @param {string} businessService - The business service. + * @param {CFConfig} config - The CF config. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The service keys. + */ +export async function getBusinessServiceKeys( + businessService: string, + config: CFConfig, + logger: ToolsLogger +): Promise { + const serviceKeys = await getServiceInstanceKeys( + { + spaceGuids: [config.space.GUID], + names: [businessService] + }, + logger + ); + logger?.log(`Available service key instance : ${JSON.stringify(serviceKeys?.serviceInstance)}`); + return serviceKeys; +} -export default class FDCService { +/** + * Get the FDC request arguments. + * + * @param {CFConfig} cfConfig - The CF config. + * @returns {RequestArguments} The request arguments. + */ +function getFDCRequestArguments(cfConfig: CFConfig): RequestArguments { + const fdcUrl = 'https://ui5-flexibility-design-and-configuration.'; + const cfApiEndpoint = `https://api.cf.${cfConfig.url}`; + const endpointParts = /https:\/\/api\.cf(?:\.([^-.]*)(-\d+)?(\.hana\.ondemand\.com)|(.*))/.exec(cfApiEndpoint); + const options: any = { + withCredentials: true, + headers: { + 'Content-Type': 'application/json' + } + }; + // Public cloud + let url = `${fdcUrl}cert.cfapps.${endpointParts?.[1]}.hana.ondemand.com`; + if (!endpointParts?.[3]) { + // Private cloud - if hana.ondemand.com missing as a part of CF api endpotint + url = `${fdcUrl}sapui5flex.cfapps${endpointParts?.[4]}`; + if (endpointParts?.[4]?.endsWith('.cn')) { + // China has a special URL pattern + const parts = endpointParts?.[4]?.split('.'); + parts.splice(2, 0, 'apps'); + url = `${fdcUrl}sapui5flex${parts.join('.')}`; + } + } + if (!isAppStudio() || !endpointParts?.[3]) { + // Adding authorization token for none BAS enviourment and + // for private cloud as a temporary solution until enablement of cert auth + options.headers['Authorization'] = `Bearer ${cfConfig.token}`; + } + return { + url: url, + options + }; +} + +/** + * Normalize the route regex. + * + * @param {string} value - The value. + * @returns {RegExp} The normalized route regex. + */ +function normalizeRouteRegex(value: string): RegExp { + return new RegExp(value.replace('^/', '^(/)*').replace('/(.*)$', '(/)*(.*)$')); +} + +/** + * Match the routes and data sources. + * + * @param {any} dataSources - The data sources. + * @param {any} routes - The routes. + * @param {any} serviceKeyEndpoints - The service key endpoints. + * @returns {string[]} The messages. + */ +function matchRoutesAndDatasources(dataSources: any, routes: any, serviceKeyEndpoints: any): string[] { + const messages: string[] = []; + routes.forEach((route: any) => { + if (route.endpoint && !serviceKeyEndpoints.includes(route.endpoint)) { + messages.push(`Route endpoint '${route.endpoint}' doesn't match a corresponding OData endpoint`); + } + }); + + Object.keys(dataSources).forEach((dataSourceName) => { + if (!routes.some((route: any) => dataSources[dataSourceName].uri?.match(normalizeRouteRegex(route.source)))) { + messages.push(`Data source '${dataSourceName}' doesn't match a corresponding route in xs-app.json routes`); + } + }); + return messages; +} + +/** + * Extract the xs-app.json from the zip entries. + * + * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. + * @returns {any} The xs-app.json. + */ +function extractXSApp(zipEntries: AdmZip.IZipEntry[]): any { + let xsApp; + zipEntries.forEach((item) => { + if (item.entryName.endsWith('xs-app.json')) { + try { + xsApp = JSON.parse(item.getData().toString('utf8')); + } catch (e) { + throw new Error(`Failed to parse xs-app.json. Reason: ${e.message}`); + } + } + }); + return xsApp; +} + +/** + * Extract the manifest.json from the zip entries. + * + * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. + * @returns {Manifest | undefined} The manifest. + */ +function extractManifest(zipEntries: AdmZip.IZipEntry[]): Manifest | undefined { + let manifest: Manifest | undefined; + zipEntries.forEach((item) => { + if (item.entryName.endsWith('manifest.json')) { + try { + manifest = JSON.parse(item.getData().toString('utf8')); + } catch (e) { + throw new Error(`Failed to parse manifest.json. Reason: ${e.message}`); + } + } + }); + return manifest; +} + +/** + * Get the app host ids. + * + * @param {Credentials[]} credentials - The credentials. + * @returns {Set} The app host ids. + */ +function getAppHostIds(credentials: Credentials[]): Set { + const appHostIds: string[] = []; + credentials.forEach((credential) => { + const appHostId = credential['html5-apps-repo']?.app_host_id; + if (appHostId) { + appHostIds.push(appHostId.split(',').map((item: any) => item.trim())); // there might be multiple appHostIds separated by comma + } + }); + + // appHostIds is now an array of arrays of strings (from split) + // Flatten the array and create a Set + return new Set(appHostIds.flat()); +} + +/** + * Get the spaces. + * + * @param {string} spaceGuid - The space guid. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The spaces. + */ +export async function getSpaces(spaceGuid: string, logger: ToolsLogger): Promise { + let spaces: Space[] = []; + if (spaceGuid) { + try { + spaces = await CFLocal.cfGetAvailableSpaces(spaceGuid); + logger?.log(`Available spaces: ${JSON.stringify(spaces)} for space guid: ${spaceGuid}.`); + } catch (error) { + logger?.error('Cannot get spaces'); + } + } else { + logger?.error('Invalid GUID'); + } + + return spaces; +} + +export class FDCService { public html5RepoRuntimeGuid: string; public manifests: any[] = []; private CF_HOME = 'CF_HOME'; - private WIN32 = 'win32'; - private HOMEDRIVE = 'HOMEDRIVE'; - private HOMEPATH = 'HOMEPATH'; private BEARER_SPACE = 'bearer '; private CF_FOLDER_NAME = '.cf'; private CONFIG_JSON_FILE = 'config.json'; @@ -51,21 +288,10 @@ export default class FDCService { this.logger = logger; } - public async isCfInstalled(): Promise { - let isInstalled = true; - try { - await CFUtils.checkForCf(); - } catch (error) { - isInstalled = false; - } - - return isInstalled; - } - public loadConfig(): void { let cfHome = process.env[this.CF_HOME]; if (!cfHome) { - cfHome = path.join(this.getHomedir(), this.CF_FOLDER_NAME); + cfHome = path.join(getHomedir(), this.CF_FOLDER_NAME); } const configFileLocation = path.join(cfHome, this.CONFIG_JSON_FILE); @@ -112,7 +338,7 @@ export default class FDCService { public async isLoggedIn(): Promise { let isLogged = false; let orgs; - await CFUtils.getAuthToken(); + await getAuthToken(); this.loadConfig(); if (this.cfConfig) { try { @@ -170,22 +396,6 @@ export default class FDCService { return organizations; } - public async getSpaces(spaceGuid: string): Promise { - let spaces: Space[] = []; - if (spaceGuid) { - try { - spaces = await CFLocal.cfGetAvailableSpaces(spaceGuid); - this.logger?.log(`Available spaces: ${JSON.stringify(spaces)} for space guid: ${spaceGuid}.`); - } catch (error) { - this.logger?.error('Cannot get spaces'); - } - } else { - this.logger?.error('Invalid GUID'); - } - - return spaces; - } - public async setOrgSpace(orgName: string, spaceName: string): Promise { if (!orgName || !spaceName) { throw new Error('Organization or space name is not provided.'); @@ -202,7 +412,7 @@ export default class FDCService { } public async getBaseApps(credentials: Credentials[], includeInvalid = false): Promise { - const appHostIds = this.getAppHostIds(credentials); + const appHostIds = getAppHostIds(credentials); this.logger?.log(`App Host Ids: ${JSON.stringify(appHostIds)}`); const discoveryApps = await Promise.all( Array.from(appHostIds).map(async (appHostId: string) => { @@ -243,7 +453,7 @@ export default class FDCService { } public getApprouterType(): string { - return YamlUtils.getRouterType(); + return getRouterType(YamlUtils.yamlContent); } public getModuleNames(mtaProjectPath: string): string[] { @@ -259,23 +469,11 @@ export default class FDCService { return this.cfConfig; } - public async getBusinessServiceKeys(businessService: string): Promise { - const serviceKeys = await CFUtils.getServiceInstanceKeys( - { - spaceGuids: [this.getConfig().space.GUID], - names: [businessService] - }, - this.logger - ); - this.logger?.log(`Available service key instance : ${JSON.stringify(serviceKeys?.serviceInstance)}`); - return serviceKeys; - } - public async validateODataEndpoints(zipEntries: AdmZip.IZipEntry[], credentials: Credentials[]): Promise { const messages: string[] = []; let xsApp, manifest; try { - xsApp = this.extractXSApp(zipEntries); + xsApp = extractXSApp(zipEntries); this.logger?.log(`ODATA endpoints: ${JSON.stringify(xsApp)}`); } catch (error) { messages.push(error.message); @@ -283,7 +481,7 @@ export default class FDCService { } try { - manifest = this.extractManifest(zipEntries); + manifest = extractManifest(zipEntries); this.logger?.log(`Extracted manifest: ${JSON.stringify(manifest)}`); } catch (error) { messages.push(error.message); @@ -296,7 +494,7 @@ export default class FDCService { const serviceKeyEndpoints = ([] as string[]).concat( ...credentials.map((item) => (item.endpoints ? Object.keys(item.endpoints) : [])) ); - messages.push(...this.matchRoutesAndDatasources(dataSources, routes, serviceKeyEndpoints)); + messages.push(...matchRoutesAndDatasources(dataSources, routes, serviceKeyEndpoints)); } else if (routes && !dataSources) { messages.push("Base app manifest.json doesn't contain data sources specified in xs-app.json"); } else if (!routes && dataSources) { @@ -305,83 +503,19 @@ export default class FDCService { return messages; } - private extractXSApp(zipEntries: AdmZip.IZipEntry[]) { - let xsApp; - zipEntries.forEach((item) => { - if (item.entryName.endsWith('xs-app.json')) { - try { - xsApp = JSON.parse(item.getData().toString('utf8')); - } catch (e) { - throw new Error(`Failed to parse xs-app.json. Reason: ${e.message}`); - } - } - }); - return xsApp; - } - - private extractManifest(zipEntries: AdmZip.IZipEntry[]): Manifest | undefined { - let manifest: Manifest | undefined; - zipEntries.forEach((item) => { - if (item.entryName.endsWith('manifest.json')) { - try { - manifest = JSON.parse(item.getData().toString('utf8')); - } catch (e) { - throw new Error(`Failed to parse manifest.json. Reason: ${e.message}`); - } - } - }); - return manifest; - } - - private matchRoutesAndDatasources(dataSources: any, routes: any, serviceKeyEndpoints: any): string[] { - const messages: string[] = []; - routes.forEach((route: any) => { - if (route.endpoint && !serviceKeyEndpoints.includes(route.endpoint)) { - messages.push(`Route endpoint '${route.endpoint}' doesn't match a corresponding OData endpoint`); - } - }); - - Object.keys(dataSources).forEach((dataSourceName) => { - if ( - !routes.some((route: any) => - dataSources[dataSourceName].uri?.match(this.normalizeRouteRegex(route.source)) - ) - ) { - messages.push( - `Data source '${dataSourceName}' doesn't match a corresponding route in xs-app.json routes` - ); - } - }); - return messages; - } - - private getAppHostIds(credentials: Credentials[]): Set { - const appHostIds: string[] = []; - credentials.forEach((credential) => { - const appHostId = credential[this.HTML5_APPS_REPO]?.app_host_id; - if (appHostId) { - appHostIds.push(appHostId.split(',').map((item: any) => item.trim())); // there might be multiple appHostIds separated by comma - } - }); - - // appHostIds is now an array of arrays of strings (from split) - // Flatten the array and create a Set - return new Set(appHostIds.flat()); - } - private async filterServices(businessServices: BusinessSeviceResource[]): Promise { const serviceLabels = businessServices.map((service) => service.label).filter((label) => label); if (serviceLabels.length > 0) { const url = `/v3/service_offerings?names=${serviceLabels.join(',')}`; - const json = await CFUtils.requestCfApi(url); + const json = await requestCfApi>(url); this.logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); const businessServiceNames = new Set(businessServices.map((service) => service.label)); const result: string[] = []; - json.resources.forEach((resource: any) => { + json?.resources?.forEach((resource: CFServiceOffering) => { if (businessServiceNames.has(resource.name)) { const sapService = resource?.['broker_catalog']?.metadata?.sapservice; - if (sapService && ['v2', 'v4'].includes(sapService.odataversion)) { + if (sapService && ['v2', 'v4'].includes(sapService?.odataversion ?? '')) { result.push(businessServices?.find((service) => resource.name === service.label)?.name ?? ''); } else { this.logger?.log(`Service '${resource.name}' doesn't support V2/V4 Odata and will be ignored`); @@ -402,46 +536,8 @@ export default class FDCService { service-plan: `); } - public normalizeRouteRegex(value: string) { - return new RegExp(value.replace('^/', '^(/)*').replace('/(.*)$', '(/)*(.*)$')); - } - - public getFDCRequestArguments(): RequestArguments { - const cfConfig = this.getConfig(); - const fdcUrl = 'https://ui5-flexibility-design-and-configuration.'; - const cfApiEndpoint = `https://api.cf.${cfConfig.url}`; - const endpointParts = /https:\/\/api\.cf(?:\.([^-.]*)(-\d+)?(\.hana\.ondemand\.com)|(.*))/.exec(cfApiEndpoint); - const options: any = { - withCredentials: true, - headers: { - 'Content-Type': 'application/json' - } - }; - // Public cloud - let url = `${fdcUrl}cert.cfapps.${endpointParts?.[1]}.hana.ondemand.com`; - if (!endpointParts?.[3]) { - // Private cloud - if hana.ondemand.com missing as a part of CF api endpotint - url = `${fdcUrl}sapui5flex.cfapps${endpointParts?.[4]}`; - if (endpointParts?.[4]?.endsWith('.cn')) { - // China has a special URL pattern - const parts = endpointParts?.[4]?.split('.'); - parts.splice(2, 0, 'apps'); - url = `${fdcUrl}sapui5flex${parts.join('.')}`; - } - } - if (!isAppStudio() || !endpointParts?.[3]) { - // Adding authorization token for none BAS enviourment and - // for private cloud as a temporary solution until enablement of cert auth - options.headers['Authorization'] = `Bearer ${cfConfig.token}`; - } - return { - url: url, - options - }; - } - private async getFDCApps(appHostId: string): Promise { - const requestArguments = this.getFDCRequestArguments(); + const requestArguments = getFDCRequestArguments(this.cfConfig); this.logger?.log(`App Host: ${appHostId}, request arguments: ${JSON.stringify(requestArguments)}`); const url = `${requestArguments.url}/api/business-service/discovery?appHostId=${appHostId}`; @@ -488,13 +584,13 @@ export default class FDCService { private async validateSelectedApp(appParams: AppParams, credentials: any): Promise { try { - const { entries, serviceInstanceGuid, manifest } = await HTML5RepoUtils.downloadAppContent( + const { entries, serviceInstanceGuid, manifest } = await downloadAppContent( this.cfConfig.space.GUID, appParams, this.logger ); this.manifests.push(manifest); - const messages = await this.validateSmartTemplateApplication(manifest); + const messages = await validateSmartTemplateApplication(manifest); this.html5RepoRuntimeGuid = serviceInstanceGuid; if (messages?.length === 0) { return this.validateODataEndpoints(entries, credentials); @@ -506,22 +602,6 @@ export default class FDCService { } } - public async validateSmartTemplateApplication(manifest: Manifest): Promise { - const messages: string[] = []; - const appType = getApplicationType(manifest); - - if (isSupportedAppTypeForAdp(appType)) { - if (manifest['sap.ui5'] && manifest['sap.ui5'].flexEnabled === false) { - return messages.concat(t('error.appDoesNotSupportFlexibility')); - } - } else { - return messages.concat( - "Select a different application. Adaptation project doesn't support the selected application." - ); - } - return messages; - } - private async readMta(projectPath: string): Promise { if (!projectPath) { throw new Error('Project path is missing.'); @@ -563,15 +643,4 @@ export default class FDCService { } return serviceNames; } - - private getHomedir() { - let homedir = os.homedir(); - const homeDrive = process.env?.[this.HOMEDRIVE]; - const homePath = process.env?.[this.HOMEPATH]; - if (process.platform === this.WIN32 && typeof homeDrive === 'string' && typeof homePath === 'string') { - homedir = path.join(homeDrive, homePath); - } - - return homedir; - } } diff --git a/packages/adp-tooling/src/cf/html5-repo.ts b/packages/adp-tooling/src/cf/html5-repo.ts index 6bc5448a523..be12c8a70fd 100644 --- a/packages/adp-tooling/src/cf/html5-repo.ts +++ b/packages/adp-tooling/src/cf/html5-repo.ts @@ -3,148 +3,146 @@ import AdmZip from 'adm-zip'; import type { ToolsLogger } from '@sap-ux/logger'; -import CFUtils from './utils'; +import { getServiceInstanceKeys, createService } from './utils'; import type { HTML5Content, ServiceKeys, Uaa, AppParams } from '../types'; -export default class HTML5RepoUtils { - /** - * Get HTML5 repo credentials. - * - * @param {string} spaceGuid space guid - * @param {ToolsLogger} logger logger to log messages - * @returns {Promise} credentials json object - */ - public static async getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLogger): Promise { - const INSTANCE_NAME = 'html5-apps-repo-runtime'; - try { - let serviceKeys = await CFUtils.getServiceInstanceKeys( - { - spaceGuids: [spaceGuid], - planNames: ['app-runtime'], - names: [INSTANCE_NAME] - }, - logger - ); - if (serviceKeys === null || serviceKeys?.credentials === null || serviceKeys?.credentials?.length === 0) { - await CFUtils.createService(spaceGuid, 'app-runtime', INSTANCE_NAME, logger, ['html5-apps-repo-rt']); - serviceKeys = await CFUtils.getServiceInstanceKeys({ names: [INSTANCE_NAME] }, logger); - if ( - serviceKeys === null || - serviceKeys?.credentials === null || - serviceKeys?.credentials?.length === 0 - ) { - throw new Error('Cannot find HTML5 Repo runtime in current space'); - } - } - return serviceKeys; - } catch (e) { - // log error: HTML5RepoUtils.ts=>getHtml5RepoCredentials(spaceGuid) - throw new Error( - `Failed to get credentials from HTML5 repository for space ${spaceGuid}. Reason: ${e.message}` - ); +/** + * Get the OAuth token from HTML5 repository. + * + * @param {Uaa} uaa UAA credentials + * @returns {Promise} OAuth token + */ +export async function getToken(uaa: Uaa): Promise { + const auth = Buffer.from(`${uaa.clientid}:${uaa.clientsecret}`); + const options = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Basic ' + auth.toString('base64') } + }; + const uri = `${uaa.url}/oauth/token?grant_type=client_credentials`; + try { + const response = await axios.get(uri, options); + return response.data['access_token']; + } catch (e) { + // log error: HTML5RepoUtils.ts=>getToken(params) + throw new Error(`Failed to get the OAuth token from HTML5 repository. Reason: ${e.message}`); } +} - /** - * Download base app manifest.json and xs-app.json from HTML5 repository. - * - * @param {string} spaceGuid current space guid - * @param {AppParams} parameters appName, appVersion, appHostId - * @param {ToolsLogger} logger logger to log messages - * @returns {Promise} manifest.json and xs-app.json - */ - public static async downloadAppContent( - spaceGuid: string, - parameters: AppParams, - logger: ToolsLogger - ): Promise { - const { appHostId, appName, appVersion } = parameters; - const appNameVersion = `${appName}-${appVersion}`; - try { - const htmlRepoCredentials = await this.getHtml5RepoCredentials(spaceGuid, logger); - if ( - htmlRepoCredentials?.credentials && - htmlRepoCredentials?.credentials.length && - htmlRepoCredentials?.credentials[0]?.uaa - ) { - const token = await this.getToken(htmlRepoCredentials.credentials[0].uaa); - const uri = `${htmlRepoCredentials.credentials[0].uri}/applications/content/${appNameVersion}?pathSuffixFilter=manifest.json,xs-app.json`; - const zip = await this.downloadZip(token, appHostId, uri); - let admZip; - try { - admZip = new AdmZip(zip); - } catch (e) { - throw new Error(`Failed to parse zip content from HTML5 repository. Reason: ${e.message}`); - } - if (!(admZip && admZip.getEntries().length)) { - throw new Error('No zip content was parsed from HTML5 repository'); - } - const zipEntry = admZip.getEntries().find((zipEntry) => zipEntry.entryName === 'manifest.json'); - if (!zipEntry) { - throw new Error('Failed to find manifest.json in the application content from HTML5 repository'); - } - - try { - const manifest = JSON.parse(zipEntry.getData().toString('utf8')); - return { - entries: admZip.getEntries(), - serviceInstanceGuid: htmlRepoCredentials.serviceInstance.guid, - manifest: manifest - }; - } catch (error) { - throw new Error('Failed to parse manifest.json.'); - } - } else { - throw new Error('No UAA credentials found for HTML5 repository'); +/** + * Download zip from HTML5 repository. + * + * @param {string} token html5 reposiotry token + * @param {string} appHostId appHostId where content is stored + * @param {string} uri url with parameters + * @returns {Promise} file buffer content + */ +export async function downloadZip(token: string, appHostId: string, uri: string): Promise { + try { + const response = await axios.get(uri, { + responseType: 'arraybuffer', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token, + 'x-app-host-id': appHostId } - } catch (e) { - // log error: HTML5RepoUtils.ts=>downloadAppContent(params) - throw new Error( - `Failed to download the application content from HTML5 repository for space ${spaceGuid} and app ${appName} (${appHostId}). Reason: ${e.message}` - ); - } + }); + return response.data; + } catch (e) { + // log error: HTML5RepoUtils.ts=>downloadZip(params) + throw new Error(`Failed to download zip from HTML5 repository. Reason: ${e.message}`); } +} - /** - * Download zip from HTML5 repository. - * - * @param {string} token html5 reposiotry token - * @param {string} appHostId appHostId where content is stored - * @param {string} uri url with parameters - * @returns {Promise} file buffer content - */ - public static async downloadZip(token: string, appHostId: string, uri: string): Promise { - try { - const response = await axios.get(uri, { - responseType: 'arraybuffer', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + token, - 'x-app-host-id': appHostId - } - }); - return response.data; - } catch (e) { - // log error: HTML5RepoUtils.ts=>downloadZip(params) - throw new Error(`Failed to download zip from HTML5 repository. Reason: ${e.message}`); +/** + * Get HTML5 repo credentials. + * + * @param {string} spaceGuid space guid + * @param {ToolsLogger} logger logger to log messages + * @returns {Promise} credentials json object + */ +export async function getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLogger): Promise { + const INSTANCE_NAME = 'html5-apps-repo-runtime'; + try { + let serviceKeys = await getServiceInstanceKeys( + { + spaceGuids: [spaceGuid], + planNames: ['app-runtime'], + names: [INSTANCE_NAME] + }, + logger + ); + if (serviceKeys === null || serviceKeys?.credentials === null || serviceKeys?.credentials?.length === 0) { + await createService(spaceGuid, 'app-runtime', INSTANCE_NAME, logger, ['html5-apps-repo-rt']); + serviceKeys = await getServiceInstanceKeys({ names: [INSTANCE_NAME] }, logger); + if (serviceKeys === null || serviceKeys?.credentials === null || serviceKeys?.credentials?.length === 0) { + throw new Error('Cannot find HTML5 Repo runtime in current space'); + } } + return serviceKeys; + } catch (e) { + // log error: HTML5RepoUtils.ts=>getHtml5RepoCredentials(spaceGuid) + throw new Error(`Failed to get credentials from HTML5 repository for space ${spaceGuid}. Reason: ${e.message}`); } +} - public static async getToken(uaa: Uaa): Promise { - const auth = Buffer.from(`${uaa.clientid}:${uaa.clientsecret}`); - const options = { - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Basic ' + auth.toString('base64') +/** + * Download base app manifest.json and xs-app.json from HTML5 repository. + * + * @param {string} spaceGuid current space guid + * @param {AppParams} parameters appName, appVersion, appHostId + * @param {ToolsLogger} logger logger to log messages + * @returns {Promise} manifest.json and xs-app.json + */ +export async function downloadAppContent( + spaceGuid: string, + parameters: AppParams, + logger: ToolsLogger +): Promise { + const { appHostId, appName, appVersion } = parameters; + const appNameVersion = `${appName}-${appVersion}`; + try { + const htmlRepoCredentials = await getHtml5RepoCredentials(spaceGuid, logger); + if ( + htmlRepoCredentials?.credentials && + htmlRepoCredentials?.credentials.length && + htmlRepoCredentials?.credentials[0]?.uaa + ) { + const token = await getToken(htmlRepoCredentials.credentials[0].uaa); + const uri = `${htmlRepoCredentials.credentials[0].uri}/applications/content/${appNameVersion}?pathSuffixFilter=manifest.json,xs-app.json`; + const zip = await downloadZip(token, appHostId, uri); + let admZip; + try { + admZip = new AdmZip(zip); + } catch (e) { + throw new Error(`Failed to parse zip content from HTML5 repository. Reason: ${e.message}`); + } + if (!(admZip && admZip.getEntries().length)) { + throw new Error('No zip content was parsed from HTML5 repository'); + } + const zipEntry = admZip.getEntries().find((zipEntry) => zipEntry.entryName === 'manifest.json'); + if (!zipEntry) { + throw new Error('Failed to find manifest.json in the application content from HTML5 repository'); + } + + try { + const manifest = JSON.parse(zipEntry.getData().toString('utf8')); + return { + entries: admZip.getEntries(), + serviceInstanceGuid: htmlRepoCredentials.serviceInstance.guid, + manifest: manifest + }; + } catch (error) { + throw new Error('Failed to parse manifest.json.'); } - }; - const uri = `${uaa.url}/oauth/token?grant_type=client_credentials`; - try { - const response = await axios.get(uri, options); - return response.data['access_token']; - } catch (e) { - // log error: HTML5RepoUtils.ts=>getToken(params) - throw new Error(`Failed to get the OAuth token from HTML5 repository. Reason: ${e.message}`); + } else { + throw new Error('No UAA credentials found for HTML5 repository'); } + } catch (e) { + // log error: HTML5RepoUtils.ts=>downloadAppContent(params) + throw new Error( + `Failed to download the application content from HTML5 repository for space ${spaceGuid} and app ${appName} (${appHostId}). Reason: ${e.message}` + ); } } diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index ac9d15b867c..21c4b6afb50 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -1,4 +1,4 @@ export * from './html5-repo'; -export { default as CFUtils } from './utils'; -export { default as YamlUtils } from './yaml'; -export { default as FDCService } from './fdc'; +export * from './utils'; +export * from './yaml'; +export * from './fdc'; diff --git a/packages/adp-tooling/src/cf/utils.ts b/packages/adp-tooling/src/cf/utils.ts index ac16964c042..f87576dd4ee 100644 --- a/packages/adp-tooling/src/cf/utils.ts +++ b/packages/adp-tooling/src/cf/utils.ts @@ -4,204 +4,270 @@ import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import CFToolsCli = require('@sap/cf-tools/out/src/cli'); import { eFilters } from '@sap/cf-tools/out/src/types'; -import YamlUtils from './yaml'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { GetServiceInstanceParams, ServiceKeys, ServiceInstance } from '../types'; - -export default class CFUtils { - private static ENV = { env: { 'CF_COLOR': 'false' } }; - private static CREATE_SERVICE_KEY = 'create-service-key'; - - public static async getServiceInstanceKeys( - serviceInstanceQuery: GetServiceInstanceParams, - logger: ToolsLogger - ): Promise { - try { - const serviceInstances = await this.getServiceInstance(serviceInstanceQuery); - if (serviceInstances?.length > 0) { - // we can use any instance in the list to connect to HTML5 Repo - logger?.log(`Use '${serviceInstances[0].name}' HTML5 Repo instance`); - return { - credentials: await this.getOrCreateServiceKeys(serviceInstances[0], logger), - serviceInstance: serviceInstances[0] - }; - } - return null; - } catch (e) { - // const errorMessage = Messages.FAILED_TO_GET_SERVICE_INSTANCE_KEYS(error.message); - const errorMessage = `Failed to get service instance keys. Reason: ${e.message}`; - logger?.error(errorMessage); - throw new Error(errorMessage); + +import type { + GetServiceInstanceParams, + ServiceKeys, + ServiceInstance, + Credentials, + CFAPIResponse, + CFServiceInstance, + CFServiceOffering +} from '../types'; +import { YamlUtils } from './yaml'; + +const ENV = { env: { 'CF_COLOR': 'false' } }; +const CREATE_SERVICE_KEY = 'create-service-key'; + +/** + * Gets the service instance keys. + * + * @param {GetServiceInstanceParams} serviceInstanceQuery - The service instance query. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The service instance keys. + */ +export async function getServiceInstanceKeys( + serviceInstanceQuery: GetServiceInstanceParams, + logger: ToolsLogger +): Promise { + try { + const serviceInstances = await getServiceInstance(serviceInstanceQuery); + if (serviceInstances?.length > 0) { + // we can use any instance in the list to connect to HTML5 Repo + logger?.log(`Use '${serviceInstances[0].name}' HTML5 Repo instance`); + return { + credentials: await getOrCreateServiceKeys(serviceInstances[0], logger), + serviceInstance: serviceInstances[0] + }; } + return null; + } catch (e) { + const errorMessage = `Failed to get service instance keys. Reason: ${e.message}`; + logger?.error(errorMessage); + throw new Error(errorMessage); } +} - public static async createService( - spaceGuid: string, - plan: string, - serviceInstanceName: string, - logger: ToolsLogger, - tags: string[] = [], - securityFilePath: string | null = null, - serviceName: string | null = null - ): Promise { - try { - if (!serviceName) { - const json = await this.requestCfApi(`/v3/service_offerings?per_page=1000&space_guids=${spaceGuid}`); - serviceName = json.resources.find( - (resource: any) => resource.tags && tags.every((tag) => resource.tags.includes(tag)) - ).name; - } - - logger?.log( - `Creating service instance '${serviceInstanceName}' of service '${serviceName}' with '${plan}' plan` +/** + * Creates a service. + * + * @param {string} spaceGuid - The space GUID. + * @param {string} plan - The plan. + * @param {string} serviceInstanceName - The service instance name. + * @param {ToolsLogger} logger - The logger. + * @param {string[]} tags - The tags. + * @param {string | null} securityFilePath - The security file path. + * @param {string | null} serviceName - The service name. + */ +export async function createService( + spaceGuid: string, + plan: string, + serviceInstanceName: string, + logger: ToolsLogger, + tags: string[] = [], + securityFilePath: string | null = null, + serviceName: string | undefined = undefined +): Promise { + try { + if (!serviceName) { + const json: CFAPIResponse = await requestCfApi>( + `/v3/service_offerings?per_page=1000&space_guids=${spaceGuid}` ); - const commandParameters: string[] = ['create-service', serviceName ?? '', plan, serviceInstanceName]; - if (securityFilePath) { - let xsSecurity = null; - try { - const filePath = path.resolve(__dirname, '../templates/cf/xs-security.json'); - const xsContent = fs.readFileSync(filePath, 'utf-8'); - xsSecurity = JSON.parse(xsContent); - xsSecurity.xsappname = YamlUtils.getProjectNameForXsSecurity(); - } catch (err) { - throw new Error('xs-security.json could not be parsed.'); - } + const serviceOffering = json?.resources?.find( + (resource: CFServiceOffering) => resource.tags && tags.every((tag) => resource.tags?.includes(tag)) + ); + serviceName = serviceOffering?.name; + } + logger?.log( + `Creating service instance '${serviceInstanceName}' of service '${serviceName}' with '${plan}' plan` + ); - commandParameters.push('-c'); - commandParameters.push(JSON.stringify(xsSecurity)); + const commandParameters: string[] = ['create-service', serviceName ?? '', plan, serviceInstanceName]; + if (securityFilePath) { + let xsSecurity = null; + try { + // TODO: replace with the path to the xs-security.json file from the templates + const filePath = path.resolve(__dirname, '../templates/cf/xs-security.json'); + const xsContent = fs.readFileSync(filePath, 'utf-8'); + xsSecurity = JSON.parse(xsContent); + xsSecurity.xsappname = YamlUtils.getProjectNameForXsSecurity(); + } catch (err) { + throw new Error('xs-security.json could not be parsed.'); } - const query = await CFToolsCli.Cli.execute(commandParameters); - if (query.exitCode !== 0) { - throw new Error(query.stderr); - } - } catch (e) { - // const errorMessage = Messages.FAILED_TO_CREATE_SERVICE_INSTANCE(serviceInstanceName, spaceGuid, e.message); - const errorMessage = `Cannot create a service instance '${serviceInstanceName}' in space '${spaceGuid}'. Reason: ${e.message}`; - logger?.error(errorMessage); - throw new Error(errorMessage); + commandParameters.push('-c'); + commandParameters.push(JSON.stringify(xsSecurity)); } - } - public static async requestCfApi(url: string) { - try { - const response = await CFToolsCli.Cli.execute(['curl', url], this.ENV); - if (response.exitCode === 0) { - try { - return JSON.parse(response.stdout); - } catch (e) { - throw new Error(`Failed to parse response from request CF API: ${e.message}`); - } - } - throw new Error(response.stderr); - } catch (e) { - // log error: CFUtils.ts=>requestCfApi(params) - throw new Error(`Request to CF API failed. Reason: ${e.message}`); + const query = await CFToolsCli.Cli.execute(commandParameters); + if (query.exitCode !== 0) { + throw new Error(query.stderr); } + } catch (e) { + // const errorMessage = Messages.FAILED_TO_CREATE_SERVICE_INSTANCE(serviceInstanceName, spaceGuid, e.message); + const errorMessage = `Cannot create a service instance '${serviceInstanceName}' in space '${spaceGuid}'. Reason: ${e.message}`; + logger?.error(errorMessage); + throw new Error(errorMessage); } +} - public static async getAuthToken(): Promise { - const response = await CFToolsCli.Cli.execute(['oauth-token'], this.ENV); +/** + * Requests the CF API. + * + * @param {string} url - The URL to request. + * @returns {Promise} The response from the CF API. + * @template T - The type of the response. + */ +export async function requestCfApi(url: string): Promise { + try { + const response = await CFToolsCli.Cli.execute(['curl', url], ENV); if (response.exitCode === 0) { - return response.stdout; - } - return response.stderr; - } - - public static async checkForCf(): Promise { - try { - const response = await CFToolsCli.Cli.execute(['version'], this.ENV); - if (response.exitCode !== 0) { - throw new Error(response.stderr); + try { + return JSON.parse(response.stdout); + } catch (e) { + throw new Error(`Failed to parse response from request CF API: ${e.message}`); } - } catch (error) { - // log error: CFUtils.ts=>checkForCf - throw new Error('Cloud Foundry is not installed in your space.'); } + throw new Error(response.stderr); + } catch (e) { + // log error: CFUtils.ts=>requestCfApi(params) + throw new Error(`Request to CF API failed. Reason: ${e.message}`); } +} - public static async cFLogout(): Promise { - await CFToolsCli.Cli.execute(['logout']); +/** + * Gets the authentication token. + * + * @returns {Promise} The authentication token. + */ +export async function getAuthToken(): Promise { + const response = await CFToolsCli.Cli.execute(['oauth-token'], ENV); + if (response.exitCode === 0) { + return response.stdout; } + return response.stderr; +} - private static async getServiceInstance(params: GetServiceInstanceParams): Promise { - const PARAM_MAP: Map = new Map([ - ['spaceGuids', 'space_guids'], - ['planNames', 'service_plan_names'], - ['names', 'names'] - ]); - const parameters = Object.entries(params) - .filter(([_, value]) => value?.length > 0) - .map(([key, value]) => `${PARAM_MAP.get(key)}=${value.join(',')}`); - const uriParameters = parameters.length > 0 ? `?${parameters.join('&')}` : ''; - const uri = `/v3/service_instances` + uriParameters; - try { - const json = await this.requestCfApi(uri); - if (json && json.resources && Array.isArray(json.resources)) { - return json.resources.map((service: any) => ({ - name: service.name, - guid: service.guid - })); - } - throw new Error('No valid JSON for service instance'); - } catch (e) { - // log error: CFUtils.ts=>getServiceInstance with uriParameters - throw new Error(`Failed to get service instance with params ${uriParameters}. Reason: ${e.message}`); +/** + * Checks if Cloud Foundry is installed. + */ +export async function checkForCf(): Promise { + try { + const response = await CFToolsCli.Cli.execute(['version'], ENV); + if (response.exitCode !== 0) { + throw new Error(response.stderr); } + } catch (error) { + // log error: CFUtils.ts=>checkForCf + throw new Error('Cloud Foundry is not installed in your space.'); } +} - private static async getOrCreateServiceKeys(serviceInstance: ServiceInstance, logger: ToolsLogger) { - try { - const credentials = await this.getServiceKeys(serviceInstance.guid); - if (credentials?.length > 0) { - return credentials; - } else { - const serviceKeyName = serviceInstance.name + '_key'; - logger?.log(`Creating service key '${serviceKeyName}' for service instance '${serviceInstance.name}'`); - await this.createServiceKey(serviceInstance.name, serviceKeyName); - return this.getServiceKeys(serviceInstance.guid); - } - } catch (e) { - // log error: CFUtils.ts=>getOrCreateServiceKeys with param - throw new Error( - `Failed to get or create service keys for instance name ${serviceInstance.name}. Reason: ${e.message}` - ); +/** + * Logs out from Cloud Foundry. + */ +export async function cFLogout(): Promise { + await CFToolsCli.Cli.execute(['logout']); +} + +/** + * Gets the service instance. + * + * @param {GetServiceInstanceParams} params - The service instance parameters. + * @returns {Promise} The service instance. + */ +async function getServiceInstance(params: GetServiceInstanceParams): Promise { + const PARAM_MAP: Map = new Map([ + ['spaceGuids', 'space_guids'], + ['planNames', 'service_plan_names'], + ['names', 'names'] + ]); + const parameters = Object.entries(params) + .filter(([_, value]) => value?.length > 0) + .map(([key, value]) => `${PARAM_MAP.get(key)}=${value.join(',')}`); + const uriParameters = parameters.length > 0 ? `?${parameters.join('&')}` : ''; + const uri = `/v3/service_instances` + uriParameters; + try { + const json = await requestCfApi>(uri); + if (json?.resources && Array.isArray(json.resources)) { + return json.resources.map((service: CFServiceInstance) => ({ + name: service.name, + guid: service.guid + })); } + throw new Error('No valid JSON for service instance'); + } catch (e) { + // log error: CFUtils.ts=>getServiceInstance with uriParameters + throw new Error(`Failed to get service instance with params ${uriParameters}. Reason: ${e.message}`); } +} - private static async getServiceKeys(serviceInstanceGuid: string): Promise { - try { - return await CFLocal.cfGetInstanceCredentials({ - filters: [ - { - value: serviceInstanceGuid, - // key: eFilters.service_instance_guid - key: eFilters.service_instance_guid - } - ] - }); - } catch (e) { - // log error: CFUtils.ts=>getServiceKeys for guid - throw new Error( - `Failed to get service instance credentials from CFLocal for guid ${serviceInstanceGuid}. Reason: ${e.message}` - ); +/** + * Gets the service instance keys. + * + * @param {ServiceInstance} serviceInstance - The service instance. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The service instance keys. + */ +async function getOrCreateServiceKeys(serviceInstance: ServiceInstance, logger: ToolsLogger): Promise { + try { + const credentials = await getServiceKeys(serviceInstance.guid); + if (credentials?.length > 0) { + return credentials; + } else { + const serviceKeyName = serviceInstance.name + '_key'; + logger?.log(`Creating service key '${serviceKeyName}' for service instance '${serviceInstance.name}'`); + await createServiceKey(serviceInstance.name, serviceKeyName); + return getServiceKeys(serviceInstance.guid); } + } catch (e) { + // log error: CFUtils.ts=>getOrCreateServiceKeys with param + throw new Error( + `Failed to get or create service keys for instance name ${serviceInstance.name}. Reason: ${e.message}` + ); } +} - private static async createServiceKey(serviceInstanceName: string, serviceKeyName: any) { - try { - const cliResult = await CFToolsCli.Cli.execute( - [this.CREATE_SERVICE_KEY, serviceInstanceName, serviceKeyName], - this.ENV - ); - if (cliResult.exitCode !== 0) { - throw new Error(cliResult.stderr); - } - } catch (e) { - // log error: CFUtils.ts=>createServiceKey for serviceInstanceName - throw new Error( - `Failed to create service key for instance name ${serviceInstanceName}. Reason: ${e.message}` - ); +/** + * Gets the service instance credentials. + * + * @param {string} serviceInstanceGuid - The service instance GUID. + * @returns {Promise} The service instance credentials. + */ +async function getServiceKeys(serviceInstanceGuid: string): Promise { + try { + return await CFLocal.cfGetInstanceCredentials({ + filters: [ + { + value: serviceInstanceGuid, + // key: eFilters.service_instance_guid + key: eFilters.service_instance_guid + } + ] + }); + } catch (e) { + // log error: CFUtils.ts=>getServiceKeys for guid + throw new Error( + `Failed to get service instance credentials from CFLocal for guid ${serviceInstanceGuid}. Reason: ${e.message}` + ); + } +} + +/** + * Creates a service key. + * + * @param {string} serviceInstanceName - The service instance name. + * @param {string} serviceKeyName - The service key name. + */ +async function createServiceKey(serviceInstanceName: string, serviceKeyName: string): Promise { + try { + const cliResult = await CFToolsCli.Cli.execute([CREATE_SERVICE_KEY, serviceInstanceName, serviceKeyName], ENV); + if (cliResult.exitCode !== 0) { + throw new Error(cliResult.stderr); } + } catch (e) { + // log error: CFUtils.ts=>createServiceKey for serviceInstanceName + throw new Error(`Failed to create service key for instance name ${serviceInstanceName}. Reason: ${e.message}`); } } diff --git a/packages/adp-tooling/src/cf/yaml.ts b/packages/adp-tooling/src/cf/yaml.ts index 6fcaeb6dced..2dde12ed203 100644 --- a/packages/adp-tooling/src/cf/yaml.ts +++ b/packages/adp-tooling/src/cf/yaml.ts @@ -4,28 +4,434 @@ import yaml from 'js-yaml'; import type { ToolsLogger } from '@sap-ux/logger'; -import CFUtils from './utils'; import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../types'; +import { createService } from './utils'; + +const CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; +const HTML5_APPS_REPO = 'html5-apps-repo'; +const SAP_APPLICATION_CONTENT = 'com.sap.application.content'; + +/** + * Checks if the selected path is a MTA project. + * + * @param {string} selectedPath - The selected path. + * @returns {boolean} True if the selected path is a MTA project, false otherwise. + */ +export function isMtaProject(selectedPath: string): boolean { + return fs.existsSync(path.join(selectedPath, 'mta.yaml')); +} + +/** + * Gets the SAP Cloud Service. + * + * @param {Yaml} yamlContent - The YAML content. + * @returns {string} The SAP Cloud Service. + */ +export function getSAPCloudService(yamlContent: Yaml): string { + const modules = yamlContent?.modules?.filter((module: { name: string }) => + module.name.includes('destination-content') + ); + const destinations = modules?.[0]?.parameters?.content?.instance?.destinations; + let sapCloudService = destinations?.find((destination: { Name: string }) => + destination.Name.includes('html_repo_host') + ); + sapCloudService = sapCloudService?.['sap.cloud.service'].replace(/_/g, '.'); + + return sapCloudService; +} + +/** + * Parses the MTA file. + * + * @param {string} file - The file to parse. + * @returns {Yaml} The parsed YAML content. + */ +export function parseMtaFile(file: string): Yaml { + if (!fs.existsSync(file)) { + throw new Error(`Could not find file ${file}`); + } + + const content = fs.readFileSync(file, 'utf-8'); + let parsed: Yaml; + try { + parsed = yaml.load(content) as Yaml; + + return parsed; + } catch (e) { + throw new Error(`Error parsing file ${file}`); + } +} + +/** + * Gets the router type. + * + * @param {Yaml} yamlContent - The YAML content. + * @returns {string} The router type. + */ +export function getRouterType(yamlContent: Yaml): string { + const filterd: MTAModule[] | undefined = yamlContent?.modules?.filter( + (module: { name: string }) => module.name.includes('destination-content') || module.name.includes('approuter') + ); + const routerType = filterd?.pop(); + if (routerType?.name.includes('approuter')) { + return 'Standalone Approuter'; + } else { + return 'Approuter Managed by SAP Cloud Platform'; + } +} + +/** + * Gets the app params from the UI5 YAML file. + * + * @param {string} projectPath - The project path. + * @returns {Promise} The app params. + */ +export function getAppParamsFromUI5Yaml(projectPath: string): AppParamsExtended { + const ui5YamlPath = path.join(projectPath, 'ui5.yaml'); + const parsedMtaFile = parseMtaFile(ui5YamlPath) as any; + + const appConfiguration = parsedMtaFile?.builder?.customTasks[0]?.configuration; + const appParams: AppParamsExtended = { + appHostId: appConfiguration?.appHostId, + appName: appConfiguration?.appName, + appVersion: appConfiguration?.appVersion, + spaceGuid: appConfiguration?.space + }; + + return appParams; +} + +/** + * Adjusts the MTA YAML for a standalone approuter. + * + * @param {any} yamlContent - The YAML content. + * @param {string} projectName - The project name. + * @param {ConcatArray} resourceNames - The resource names. + * @param {string} businessService - The business service. + */ +function adjustMtaYamlStandaloneApprouter( + yamlContent: any, + projectName: string, + resourceNames: ConcatArray, + businessService: string +): void { + const appRouterName = `${projectName}-approuter`; + let appRouter = yamlContent.modules.find((module: { name: string }) => module.name === appRouterName); + if (appRouter == null) { + appRouter = { + name: appRouterName, + type: 'approuter.nodejs', + path: appRouterName, + requires: [], + parameters: { + 'disk-quota': '256M', + 'memory': '256M' + } + }; + yamlContent.modules.push(appRouter); + } + const requires = [ + `${projectName}_html_repo_runtime`, + `${projectName}_uaa`, + `portal_resources_${projectName}` + ].concat(businessService); + requires.forEach((name) => { + if (appRouter.requires.every((existing: { name: string }) => existing.name !== name)) { + appRouter.requires.push({ name }); + } + }); +} + +/** + * Adjusts the MTA YAML for a managed approuter. + * + * @param {any} yamlContent - The YAML content. + * @param {string} projectName - The project name. + * @param {any} businessSolution - The business solution. + * @param {string} businessService - The business service. + */ +function adjustMtaYamlManagedApprouter( + yamlContent: any, + projectName: string, + businessSolution: any, + businessService: string +): void { + const appRouterName = `${projectName}-destination-content`; + let appRouter = yamlContent.modules.find((module: { name: string }) => module.name === appRouterName); + if (appRouter == null) { + businessSolution = businessSolution.split('.').join('_'); + appRouter = { + name: appRouterName, + type: SAP_APPLICATION_CONTENT, + requires: [ + { + name: `${projectName}_uaa`, + parameters: { + 'service-key': { + name: `${projectName}-uaa-key` + } + } + }, + { + name: `${projectName}_html_repo_host`, + parameters: { + 'service-key': { + name: `${projectName}-html_repo_host-key` + } + } + }, + { + name: `${projectName}-destination`, + parameters: { + 'content-target': true + } + }, + { + name: `${businessService}`, + parameters: { + 'service-key': { + name: `${businessService}-key` + } + } + } + ], + 'build-parameters': { + 'no-source': true + }, + parameters: { + content: { + instance: { + destinations: [ + { + Name: `${businessSolution}-${projectName}-html_repo_host`, + ServiceInstanceName: `${projectName}-html5_app_host`, + ServiceKeyName: `${projectName}-html_repo_host-key`, + 'sap.cloud.service': businessSolution.replace(/_/g, '.') + }, + { + Name: `${businessSolution}-uaa-${projectName}`, + ServiceInstanceName: `${projectName}-xsuaa`, + ServiceKeyName: `${projectName}_uaa-key`, + Authentication: 'OAuth2UserTokenExchange', + 'sap.cloud.service': businessSolution.replace(/_/g, '.') + }, + { + Name: `${businessService}-service_instance_name`, + Authentication: 'OAuth2UserTokenExchange', + ServiceInstanceName: `${businessService}`, + ServiceKeyName: `${businessService}-key` + } + ], + existing_destinations_policy: 'update' + } + } + } + }; + yamlContent.modules.push(appRouter); + } +} -export default class YamlUtils { +/** + * Adjusts the MTA YAML for a UI deployer. + * + * @param {any} yamlContent - The YAML content. + * @param {string} projectName - The project name. + * @param {string} moduleName - The module name. + */ +function adjustMtaYamlUDeployer(yamlContent: any, projectName: string, moduleName: string): void { + const uiDeployerName = `${projectName}_ui_deployer`; + let uiDeployer = yamlContent.modules.find((module: { name: string }) => module.name === uiDeployerName); + if (uiDeployer == null) { + uiDeployer = { + name: uiDeployerName, + type: SAP_APPLICATION_CONTENT, + path: '.', + requires: [], + 'build-parameters': { + 'build-result': 'resources', + requires: [] + } + }; + yamlContent.modules.push(uiDeployer); + } + const htmlRepoHostName = `${projectName}_html_repo_host`; + if (uiDeployer.requires.every((req: { name: string }) => req.name !== htmlRepoHostName)) { + uiDeployer.requires.push({ + name: htmlRepoHostName, + parameters: { + 'content-target': true + } + }); + } + if (uiDeployer['build-parameters'].requires.every((require: { name: any }) => require.name !== moduleName)) { + uiDeployer['build-parameters'].requires.push({ + artifacts: [`${moduleName}.zip`], + name: moduleName, + 'target-path': 'resources/' + }); + } +} + +/** + * Adjusts the MTA YAML for resources. + * + * @param {any} yamlContent - The YAML content. + * @param {string} projectName - The project name. + * @param {boolean} isManagedAppRouter - Whether the approuter is managed. + * @param {string} projectNameForXsSecurity - The project name for XS security. + */ +function adjustMtaYamlResources( + yamlContent: any, + projectName: string, + isManagedAppRouter: boolean, + projectNameForXsSecurity: string +): void { + const resources: Resource[] = [ + { + name: `${projectName}_html_repo_host`, + type: CF_MANAGED_SERVICE, + parameters: { + service: HTML5_APPS_REPO, + 'service-plan': 'app-host', + 'service-name': `${projectName}-html5_app_host` + } + }, + { + name: `${projectName}_uaa`, + type: CF_MANAGED_SERVICE, + parameters: { + service: 'xsuaa', + path: './xs-security.json', + 'service-plan': 'application', + 'service-name': `${projectNameForXsSecurity}-xsuaa` + } + } + ]; + + if (isManagedAppRouter) { + resources.push({ + name: `${projectName}-destination`, + type: CF_MANAGED_SERVICE, + parameters: { + service: 'destination', + 'service-name': `${projectName}-destination`, + 'service-plan': 'lite', + config: { + HTML5Runtime_enabled: true, + version: '1.0.0' + } + } + }); + } else { + resources.push( + { + name: `portal_resources_${projectName}`, + type: CF_MANAGED_SERVICE, + parameters: { + service: 'portal', + 'service-plan': 'standard' + } + }, + { + name: `${projectName}_html_repo_runtime`, + type: CF_MANAGED_SERVICE, + parameters: { + service: HTML5_APPS_REPO, + 'service-plan': 'app-runtime' + } + } + ); + } + + resources.forEach((resource) => { + if (yamlContent.resources.every((existing: { name: string }) => existing.name !== resource.name)) { + yamlContent.resources.push(resource); + } + }); +} + +/** + * Adjusts the MTA YAML for the own module. + * + * @param {any} yamlContent - The YAML content. + * @param {string} moduleName - The module name. + */ +function adjustMtaYamlOwnModule(yamlContent: any, moduleName: string): void { + let module = yamlContent.modules.find((module: { name: string }) => module.name === moduleName); + if (module == null) { + module = { + name: moduleName, + type: 'html5', + path: moduleName, + 'build-parameters': { + builder: 'custom', + commands: ['npm install', 'npm run build'], + 'supported-platforms': [] + } + }; + yamlContent.modules.push(module); + } +} + +/** + * Adds a module if it does not exist. + * + * @param {any[]} requires - The requires. + * @param {any} name - The name. + */ +function addModuleIfNotExists(requires: { name: any }[], name: any): void { + if (requires.every((require) => require.name !== name)) { + requires.push({ name }); + } +} + +/** + * Adjusts the MTA YAML for the FLP module. + * + * @param {any} yamlContent - The YAML content. + * @param {any} yamlContent.modules - The modules. + * @param {string} projectName - The project name. + * @param {string} businessService - The business service. + */ +function adjustMtaYamlFlpModule(yamlContent: { modules: any[] }, projectName: any, businessService: string): void { + yamlContent.modules.forEach((module, index) => { + if (module.type === SAP_APPLICATION_CONTENT && module.requires) { + const portalResources = module.requires.find( + (require: { name: string }) => require.name === `portal_resources_${projectName}` + ); + if (portalResources?.['parameters']?.['service-key']?.['name'] === 'content-deploy-key') { + addModuleIfNotExists(module.requires, `${projectName}_html_repo_host`); + addModuleIfNotExists(module.requires, `${projectName}_ui_deployer`); + addModuleIfNotExists(module.requires, businessService); + // move flp module to last position + yamlContent.modules.push(yamlContent.modules.splice(index, 1)[0]); + } + } + }); +} + +/** + * Writes the file callback. + * + * @param {any} error - The error. + */ +function writeFileCallback(error: any): void { + if (error) { + throw new Error('Cannot save mta.yaml file.'); + } +} + +export class YamlUtils { public static timestamp: string; public static yamlContent: Yaml; public static spaceGuid: string; private static STANDALONE_APPROUTER = 'Standalone Approuter'; private static APPROUTER_MANAGED = 'Approuter Managed by SAP Cloud Platform'; private static yamlPath: string; - private static MTA_YAML_FILE = 'mta.yaml'; - private static UI5_YAML_FILE = 'ui5.yaml'; private static HTML5_APPS_REPO = 'html5-apps-repo'; - private static SAP_APPLICATION_CONTENT = 'com.sap.application.content'; - private static CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; - - public static isMtaProject(selectedPath: string): boolean { - return fs.existsSync(path.join(selectedPath, this.MTA_YAML_FILE)); - } public static loadYamlContent(file: string): void { - const parsed = this.parseMtaFile(file); + const parsed = parseMtaFile(file); this.yamlContent = parsed as Yaml; this.yamlPath = file; } @@ -49,42 +455,34 @@ export default class YamlUtils { }; if (!appRouterType) { - appRouterType = this.getRouterType(); + appRouterType = getRouterType(this.yamlContent); } const yamlContent = Object.assign(defaultYaml, this.yamlContent); const projectName = yamlContent.ID.toLowerCase(); - const businesServices = yamlContent.resources.map((resource: { name: string }) => resource.name); + const businessServices = yamlContent.resources.map((resource: { name: string }) => resource.name); const initialServices = yamlContent.resources.map( (resource: { parameters: { service: string } }) => resource.parameters.service ); if (appRouterType === this.STANDALONE_APPROUTER) { - this.adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businesServices, businessService); + adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businessServices, businessService); } else if (appRouterType === this.APPROUTER_MANAGED) { - this.adjustMtaYamlManagedApprouter(yamlContent, projectName, businessSolutionName, businessService); + adjustMtaYamlManagedApprouter(yamlContent, projectName, businessSolutionName, businessService); } - this.adjustMtaYamlUDeployer(yamlContent, projectName, moduleName); - this.adjustMtaYamlResources(yamlContent, projectName, appRouterType === this.APPROUTER_MANAGED); - this.adjustMtaYamlOwnModule(yamlContent, moduleName); + adjustMtaYamlUDeployer(yamlContent, projectName, moduleName); + adjustMtaYamlResources( + yamlContent, + projectName, + appRouterType === this.APPROUTER_MANAGED, + this.getProjectNameForXsSecurity() + ); + adjustMtaYamlOwnModule(yamlContent, moduleName); // should go last since it sorts the modules (workaround, should be removed after fixed in deployment module) - this.adjustMtaYamlFlpModule(yamlContent, projectName, businessService); + adjustMtaYamlFlpModule(yamlContent, projectName, businessService); const updatedYamlContent = yaml.dump(yamlContent); await this.createServices(yamlContent.resources, initialServices, logger); - return fs.writeFile(this.yamlPath, updatedYamlContent, 'utf-8', this.writeFileCallback); - } - - public static getRouterType(): string { - const filterd: MTAModule[] | undefined = this.yamlContent?.modules?.filter( - (module: { name: string }) => - module.name.includes('destination-content') || module.name.includes('approuter') - ); - const routerType = filterd?.pop(); - if (routerType?.name.includes('approuter')) { - return this.STANDALONE_APPROUTER; - } else { - return this.APPROUTER_MANAGED; - } + return fs.writeFile(this.yamlPath, updatedYamlContent, 'utf-8', writeFileCallback); } public static getProjectName(): string { @@ -99,50 +497,6 @@ export default class YamlUtils { this.timestamp = Date.now().toString(); } - public static getSAPCloudService(): string { - const modules = this.yamlContent?.modules?.filter((module: { name: string }) => - module.name.includes('destination-content') - ); - const destinations = modules?.[0]?.parameters?.content?.instance?.destinations; - let sapCloudService = destinations?.find((destination: { Name: string }) => - destination.Name.includes('html_repo_host') - ); - sapCloudService = sapCloudService?.['sap.cloud.service'].replace(/_/g, '.'); - - return sapCloudService; - } - - public static async getAppParamsFromUI5Yaml(projectPath: string): Promise { - const ui5YamlPath = path.join(projectPath, this.UI5_YAML_FILE); - const parsedMtaFile = this.parseMtaFile(ui5YamlPath) as any; - - const appConfiguration = parsedMtaFile?.builder?.customTasks[0]?.configuration; - const appParams: AppParamsExtended = { - appHostId: appConfiguration?.appHostId, - appName: appConfiguration?.appName, - appVersion: appConfiguration?.appVersion, - spaceGuid: appConfiguration?.space - }; - - return Promise.resolve(appParams); - } - - private static parseMtaFile(file: string) { - if (!fs.existsSync(file)) { - throw new Error(`Could not find file ${file}`); - } - - const content = fs.readFileSync(file, 'utf-8'); - let parsed; - try { - parsed = yaml.load(content); - - return parsed; - } catch (e) { - throw new Error(`Error parsing file ${file}`); - } - } - private static async createServices( resources: any[], initialServices: string[], @@ -153,7 +507,7 @@ export default class YamlUtils { for (const resource of resources) { if (!excludeServices.includes(resource.parameters.service)) { if (resource.parameters.service === 'xsuaa') { - await CFUtils.createService( + await createService( this.spaceGuid, resource.parameters['service-plan'], resource.parameters['service-name'], @@ -163,7 +517,7 @@ export default class YamlUtils { resource.parameters.service ); } else { - await CFUtils.createService( + await createService( this.spaceGuid, resource.parameters['service-plan'], resource.parameters['service-name'], @@ -176,267 +530,4 @@ export default class YamlUtils { } } } - - private static writeFileCallback(error: any): void { - if (error) { - throw new Error('Cannot save mta.yaml file.'); - } - } - - private static adjustMtaYamlStandaloneApprouter( - yamlContent: any, - projectName: string, - resourceNames: ConcatArray, - businessService: string - ): void { - const appRouterName = `${projectName}-approuter`; - let appRouter = yamlContent.modules.find((module: { name: string }) => module.name === appRouterName); - if (appRouter == null) { - appRouter = { - name: appRouterName, - type: 'approuter.nodejs', - path: appRouterName, - requires: [], - parameters: { - 'disk-quota': '256M', - 'memory': '256M' - } - }; - yamlContent.modules.push(appRouter); - } - const requires = [ - `${projectName}_html_repo_runtime`, - `${projectName}_uaa`, - `portal_resources_${projectName}` - ].concat(businessService); - requires.forEach((name) => { - if (appRouter.requires.every((existing: { name: string }) => existing.name !== name)) { - appRouter.requires.push({ name }); - } - }); - } - - private static adjustMtaYamlManagedApprouter( - yamlContent: any, - projectName: string, - businessSolution: any, - businessService: string - ): void { - const appRouterName = `${projectName}-destination-content`; - let appRouter = yamlContent.modules.find((module: { name: string }) => module.name === appRouterName); - if (appRouter == null) { - businessSolution = businessSolution.split('.').join('_'); - appRouter = { - name: appRouterName, - type: this.SAP_APPLICATION_CONTENT, - requires: [ - { - name: `${projectName}_uaa`, - parameters: { - 'service-key': { - name: `${projectName}-uaa-key` - } - } - }, - { - name: `${projectName}_html_repo_host`, - parameters: { - 'service-key': { - name: `${projectName}-html_repo_host-key` - } - } - }, - { - name: `${projectName}-destination`, - parameters: { - 'content-target': true - } - }, - { - name: `${businessService}`, - parameters: { - 'service-key': { - name: `${businessService}-key` - } - } - } - ], - 'build-parameters': { - 'no-source': true - }, - parameters: { - content: { - instance: { - destinations: [ - { - Name: `${businessSolution}-${projectName}-html_repo_host`, - ServiceInstanceName: `${projectName}-html5_app_host`, - ServiceKeyName: `${projectName}-html_repo_host-key`, - 'sap.cloud.service': businessSolution.replace(/_/g, '.') - }, - { - Name: `${businessSolution}-uaa-${projectName}`, - ServiceInstanceName: `${projectName}-xsuaa`, - ServiceKeyName: `${projectName}_uaa-key`, - Authentication: 'OAuth2UserTokenExchange', - 'sap.cloud.service': businessSolution.replace(/_/g, '.') - }, - { - Name: `${businessService}-service_instance_name`, - Authentication: 'OAuth2UserTokenExchange', - ServiceInstanceName: `${businessService}`, - ServiceKeyName: `${businessService}-key` - } - ], - existing_destinations_policy: 'update' - } - } - } - }; - yamlContent.modules.push(appRouter); - } - } - - private static adjustMtaYamlUDeployer(yamlContent: any, projectName: string, moduleName: string): void { - const uiDeployerName = `${projectName}_ui_deployer`; - let uiDeployer = yamlContent.modules.find((module: { name: string }) => module.name === uiDeployerName); - if (uiDeployer == null) { - uiDeployer = { - name: uiDeployerName, - type: this.SAP_APPLICATION_CONTENT, - path: '.', - requires: [], - 'build-parameters': { - 'build-result': 'resources', - requires: [] - } - }; - yamlContent.modules.push(uiDeployer); - } - const htmlRepoHostName = `${projectName}_html_repo_host`; - if (uiDeployer.requires.every((req: { name: string }) => req.name !== htmlRepoHostName)) { - uiDeployer.requires.push({ - name: htmlRepoHostName, - parameters: { - 'content-target': true - } - }); - } - if (uiDeployer['build-parameters'].requires.every((require: { name: any }) => require.name !== moduleName)) { - uiDeployer['build-parameters'].requires.push({ - artifacts: [`${moduleName}.zip`], - name: moduleName, - 'target-path': 'resources/' - }); - } - } - - private static adjustMtaYamlResources(yamlContent: any, projectName: string, isManagedAppRouter: boolean): void { - const resources: Resource[] = [ - { - name: `${projectName}_html_repo_host`, - type: this.CF_MANAGED_SERVICE, - parameters: { - service: this.HTML5_APPS_REPO, - 'service-plan': 'app-host', - 'service-name': `${projectName}-html5_app_host` - } - }, - { - name: `${projectName}_uaa`, - type: this.CF_MANAGED_SERVICE, - parameters: { - service: 'xsuaa', - path: './xs-security.json', - 'service-plan': 'application', - 'service-name': `${YamlUtils.getProjectNameForXsSecurity()}-xsuaa` - } - } - ]; - - if (isManagedAppRouter) { - resources.push({ - name: `${projectName}-destination`, - type: this.CF_MANAGED_SERVICE, - parameters: { - service: 'destination', - 'service-name': `${projectName}-destination`, - 'service-plan': 'lite', - config: { - HTML5Runtime_enabled: true, - version: '1.0.0' - } - } - }); - } else { - resources.push( - { - name: `portal_resources_${projectName}`, - type: this.CF_MANAGED_SERVICE, - parameters: { - service: 'portal', - 'service-plan': 'standard' - } - }, - { - name: `${projectName}_html_repo_runtime`, - type: this.CF_MANAGED_SERVICE, - parameters: { - service: this.HTML5_APPS_REPO, - 'service-plan': 'app-runtime' - } - } - ); - } - - resources.forEach((resource) => { - if (yamlContent.resources.every((existing: { name: string }) => existing.name !== resource.name)) { - yamlContent.resources.push(resource); - } - }); - } - - private static adjustMtaYamlOwnModule(yamlContent: any, moduleName: string): void { - let module = yamlContent.modules.find((module: { name: string }) => module.name === moduleName); - if (module == null) { - module = { - name: moduleName, - type: 'html5', - path: moduleName, - 'build-parameters': { - builder: 'custom', - commands: ['npm install', 'npm run build'], - 'supported-platforms': [] - } - }; - yamlContent.modules.push(module); - } - } - - private static addModuleIfNotExists(requires: { name: any }[], name: any): void { - if (requires.every((require) => require.name !== name)) { - requires.push({ name }); - } - } - - private static adjustMtaYamlFlpModule( - yamlContent: { modules: any[] }, - projectName: any, - businessService: string - ): void { - yamlContent.modules.forEach((module, index) => { - if (module.type === this.SAP_APPLICATION_CONTENT && module.requires) { - const portalResources = module.requires.find( - (require: { name: string }) => require.name === `portal_resources_${projectName}` - ); - if (portalResources?.['parameters']?.['service-key']?.['name'] === 'content-deploy-key') { - this.addModuleIfNotExists(module.requires, `${projectName}_html_repo_host`); - this.addModuleIfNotExists(module.requires, `${projectName}_ui_deployer`); - this.addModuleIfNotExists(module.requires, businessService); - // move flp module to last position - yamlContent.modules.push(yamlContent.modules.splice(index, 1)[0]); - } - } - }); - } } diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index ea19dd16d45..199f9c64e60 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -823,7 +823,6 @@ export interface CFParameters { export interface Credentials { [key: string]: any; - uaa: Uaa; uri: string; endpoints: any; @@ -966,3 +965,92 @@ export interface RequestArguments { }; }; } + +// CF API Response Interfaces +export interface CFAPIResponse { + pagination: CFPagination; + resources: T[]; +} + +export interface CFPagination { + total_results: number; + total_pages: number; + first: CFPaginationLink; + last: CFPaginationLink; + next: CFPaginationLink | null; + previous: CFPaginationLink | null; +} + +export interface CFPaginationLink { + href: string; +} + +export interface CFServiceInstance { + guid: string; + created_at: string; + updated_at: string; + name: string; + tags: string[]; + last_operation: CFLastOperation; + type: string; + maintenance_info: Record; + upgrade_available: boolean; + dashboard_url: string | null; + relationships: CFServiceInstanceRelationships; + metadata: CFMetadata; + links: CFServiceInstanceLinks; +} + +export interface CFLastOperation { + type: string; + state: string; + description: string; + updated_at: string; + created_at: string; +} + +export interface CFServiceInstanceRelationships { + space: CFRelationshipData; + service_plan: CFRelationshipData; +} + +export interface CFRelationshipData { + data: { + guid: string; + }; +} + +export interface CFMetadata { + labels: Record; + annotations: Record; +} + +export interface CFServiceInstanceLinks { + self: CFLink; + space: CFLink; + service_credential_bindings: CFLink; + service_route_bindings: CFLink; + service_plan: CFLink; + parameters: CFLink; + shared_spaces: CFLink; +} + +export interface CFLink { + href: string; +} + +export interface CFServiceOffering { + name: string; + tags?: string[]; + broker_catalog?: { + metadata?: { + sapservice?: { + odataversion?: string; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; +} diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 70d1826f56a..9df800cf663 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -4,7 +4,6 @@ import Generator from 'yeoman-generator'; import { AppWizard, MessageType, Prompts as YeomanUiSteps, type IPrompt } from '@sap-devx/yeoman-ui-types'; import type { CFConfig } from '@sap-ux/adp-tooling'; -import { getPrompts as getCFLoginPrompts } from './questions/cf-login'; import { FlexLayer, SystemLookup, @@ -19,13 +18,21 @@ import { SourceManifest, isCFEnvironment, getBaseAppInbounds, - YamlUtils + YamlUtils, + isMtaProject, + isCfInstalled } from '@sap-ux/adp-tooling'; import { ToolsLogger } from '@sap-ux/logger'; import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; import type { AbapServiceProvider } from '@sap-ux/axios-extension'; import { isInternalFeaturesSettingEnabled } from '@sap-ux/feature-toggle'; -import { TelemetryHelper, getDefaultTargetFolder, isCli, sendTelemetry } from '@sap-ux/fiori-generator-shared'; +import { + TelemetryHelper, + getDefaultTargetFolder, + isCli, + // isExtensionInstalled, + sendTelemetry +} from '@sap-ux/fiori-generator-shared'; import { getFlexLayer } from './layer'; import { initI18n, t } from '../utils/i18n'; @@ -140,7 +147,8 @@ export default class extends Generator { private projectLocation: string; private cfProjectDestinationPath: string; private cfServicesAnswers: CfServicesAnswers; - + private isExtensionInstalled: boolean; + private cfInstalled: boolean; /** * Creates an instance of the generator. * @@ -153,7 +161,9 @@ export default class extends Generator { this.shouldInstallDeps = opts.shouldInstallDeps ?? true; this.toolsLogger = new ToolsLogger(); this.fdcService = new FDCService(this.logger, opts.vscode); - this.isMtaYamlFound = YamlUtils.isMtaProject(process.cwd()); + this.isMtaYamlFound = isMtaProject(process.cwd()) as boolean; + // TODO: Remove this once the PR is ready. + this.isExtensionInstalled = true; // isExtensionInstalled(opts.vscode, 'SAP.adp-ve-bas-ext'); this.vscode = opts.vscode; this.options = opts; @@ -186,8 +196,13 @@ export default class extends Generator { this.isCustomerBase = this.layer === FlexLayer.CUSTOMER_BASE; this.systemLookup = new SystemLookup(this.logger); + this.cfInstalled = await isCfInstalled(); + this.isCFLoggedIn = await this.fdcService.isLoggedIn(); + this.logger.info(`isCfInstalled: ${this.cfInstalled}`); + if (!this.jsonInput) { - this.prompts.splice(0, 0, getWizardPages()); + const shouldShowTargetEnv = isAppStudio() && this.cfInstalled && this.isExtensionInstalled; + this.prompts.splice(0, 0, getWizardPages(shouldShowTargetEnv)); this.prompter = this._getOrCreatePrompter(); } @@ -206,20 +221,7 @@ export default class extends Generator { return; } - if (isAppStudio()) { - const isCfInstalled = await this.fdcService.isCfInstalled(); - this.logger.info(`isCfInstalled: ${isCfInstalled}`); - - const targetEnvAnswers = await this.prompt([ - getTargetEnvPrompt(this.appWizard, isCfInstalled, this.fdcService) - ]); - this.targetEnv = targetEnvAnswers.targetEnv; - this.isCfEnv = this.targetEnv === TargetEnv.CF; - this.logger.info(`Target environment: ${this.targetEnv}`); - updateCfWizardSteps(this.isCfEnv, this.prompts); - } else { - this.targetEnv = TargetEnv.ABAP; - } + await this._determineTargetEnv(); if (!this.isCfEnv) { const configQuestions = this.prompter.getPrompts({ @@ -292,73 +294,14 @@ export default class extends Generator { ); } } else { - this.isCFLoggedIn = await this.fdcService.isLoggedIn(); - - await this.prompt(getCFLoginPrompts(this.vscode, this.fdcService, this.isCFLoggedIn)); - this.cfConfig = this.fdcService.getConfig(); - this.isCFLoggedIn = await this.fdcService.isLoggedIn(); - - this.logger.log(`Project organization information: ${JSON.stringify(this.cfConfig.org, null, 2)}`); - this.logger.log(`Project space information: ${JSON.stringify(this.cfConfig.space, null, 2)}`); - this.logger.log(`Project apiUrl information: ${JSON.stringify(this.cfConfig.url, null, 2)}`); - - await this._promptForCfProjectPath(); - - const options: AttributePromptOptions = { - targetFolder: { hide: true }, - ui5Version: { hide: true }, - ui5ValidationCli: { hide: true }, - enableTypeScript: { hide: true }, - addFlpConfig: { hide: true }, - addDeployConfig: { hide: true } - }; - - const attributesQuestions = getPrompts( - this.destinationPath(), - { - ui5Versions: [], - isVersionDetected: false, - isCloudProject: false, - layer: this.layer, - prompts: this.prompts, - isCfEnv: true - }, - options - ); - - this.attributeAnswers = await this.prompt(attributesQuestions); - this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); - - const cfServicesQuestions = await getCFServicesPrompts({ - isCFLoggedIn: this.isCFLoggedIn, - fdcService: this.fdcService, - mtaProjectPath: this.cfProjectDestinationPath, - isInternalUsage: isInternalFeaturesSettingEnabled(), - logger: this.logger - }); - this.cfServicesAnswers = await this.prompt(cfServicesQuestions); - this.logger.info(`CF Services Answers: ${JSON.stringify(this.cfServicesAnswers, null, 2)}`); - } - } - - private async _promptForCfProjectPath(): Promise { - if (!this.isMtaYamlFound) { - const pathAnswers = await this.prompt([getProjectPathPrompt(this.fdcService, this.vscode)]); - this.projectLocation = pathAnswers.projectLocation; - this.projectLocation = fs.realpathSync(this.projectLocation, 'utf-8'); - this.cfProjectDestinationPath = this.destinationRoot(this.projectLocation); - this.logger.log(`Project path information: ${this.projectLocation}`); - } else { - this.cfProjectDestinationPath = this.destinationRoot(process.cwd()); - YamlUtils.loadYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); - this.logger.log(`Project path information: ${this.cfProjectDestinationPath}`); + await this._promptForCfEnvironment(); } } async writing(): Promise { try { if (this.isCfEnv) { - // Will be removed once CF project generation is supported. + // TODO: Will be removed once CF project generation is supported. this.vscode.window.showInformationMessage('CF project generation is not supported yet.'); return; } @@ -455,6 +398,99 @@ export default class extends Generator { } } + /** + * Prompts the user for the CF project path. + */ + private async _promptForCfProjectPath(): Promise { + if (!this.isMtaYamlFound) { + const pathAnswers = await this.prompt([getProjectPathPrompt(this.fdcService, this.vscode)]); + this.projectLocation = pathAnswers.projectLocation; + this.projectLocation = fs.realpathSync(this.projectLocation, 'utf-8'); + this.cfProjectDestinationPath = this.destinationRoot(this.projectLocation); + this.logger.log(`Project path information: ${this.projectLocation}`); + } else { + this.cfProjectDestinationPath = this.destinationRoot(process.cwd()); + YamlUtils.loadYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); + this.logger.log(`Project path information: ${this.cfProjectDestinationPath}`); + } + } + + /** + * Determines the target environment based on the current context. + * Sets the target environment and updates related state accordingly. + */ + private async _determineTargetEnv(): Promise { + const hasRequiredExtensions = this.isExtensionInstalled && this.cfInstalled; + + if (isAppStudio() && hasRequiredExtensions) { + await this._promptForTargetEnvironment(); + } else { + this.targetEnv = TargetEnv.ABAP; + } + } + + /** + * Prompts the user to select the target environment and updates related state. + */ + private async _promptForTargetEnvironment(): Promise { + const targetEnvAnswers = await this.prompt([ + getTargetEnvPrompt(this.appWizard, this.cfInstalled, this.isCFLoggedIn, this.fdcService) + ]); + + this.targetEnv = targetEnvAnswers.targetEnv; + this.isCfEnv = this.targetEnv === TargetEnv.CF; + this.logger.info(`Target environment: ${this.targetEnv}`); + + updateCfWizardSteps(this.isCfEnv, this.prompts); + + this.cfConfig = this.fdcService.getConfig(); + this.logger.log(`Project organization information: ${JSON.stringify(this.cfConfig.org, null, 2)}`); + this.logger.log(`Project space information: ${JSON.stringify(this.cfConfig.space, null, 2)}`); + this.logger.log(`Project apiUrl information: ${JSON.stringify(this.cfConfig.url, null, 2)}`); + } + + /** + * Prompts the user for the CF environment. + */ + private async _promptForCfEnvironment(): Promise { + await this._promptForCfProjectPath(); + + const options: AttributePromptOptions = { + targetFolder: { hide: true }, + ui5Version: { hide: true }, + ui5ValidationCli: { hide: true }, + enableTypeScript: { hide: true }, + addFlpConfig: { hide: true }, + addDeployConfig: { hide: true } + }; + + const attributesQuestions = getPrompts( + this.destinationPath(), + { + ui5Versions: [], + isVersionDetected: false, + isCloudProject: false, + layer: this.layer, + prompts: this.prompts, + isCfEnv: true + }, + options + ); + + this.attributeAnswers = await this.prompt(attributesQuestions); + this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); + + const cfServicesQuestions = await getCFServicesPrompts({ + isCFLoggedIn: this.isCFLoggedIn, + fdcService: this.fdcService, + mtaProjectPath: this.cfProjectDestinationPath, + isInternalUsage: isInternalFeaturesSettingEnabled(), + logger: this.logger + }); + this.cfServicesAnswers = await this.prompt(cfServicesQuestions); + this.logger.info(`CF Services Answers: ${JSON.stringify(this.cfServicesAnswers, null, 2)}`); + } + /** * Retrieves the ConfigPrompter instance from cache if it exists, otherwise creates a new instance. * diff --git a/packages/generator-adp/src/app/questions/cf-login.ts b/packages/generator-adp/src/app/questions/cf-login.ts deleted file mode 100644 index 6e5c9f65e81..00000000000 --- a/packages/generator-adp/src/app/questions/cf-login.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { CFConfig, FDCService } from '@sap-ux/adp-tooling'; -import { type InputQuestion, type YUIQuestion } from '@sap-ux/inquirer-common'; - -import { CFUtils } from '@sap-ux/adp-tooling'; -import { type CFLoginQuestion, type CFLoginAnswers, cfLoginPromptNames } from '../types'; - -/** - * Returns the list of CF-login prompts. - * - * @param {any} vscode - The VS Code instance. - * @param {FDCService} fdcService - The FDC service instance. - * @param {boolean} isCFLoggedIn - Whether the user is logged in. - * @returns {CFLoginQuestion[]} The list of CF-login prompts. - */ -export function getPrompts(vscode: any, fdcService: FDCService, isCFLoggedIn: boolean): CFLoginQuestion[] { - const cfConfig = fdcService.getConfig(); - - if (isCFLoggedIn) { - return getLoggedInPrompts(cfConfig); - } - - let isCFLoginSuccessful = false; - - const externalLoginPrompt: InputQuestion = { - type: 'input', - name: cfLoginPromptNames.cfExternalLogin, - message: 'Please complete your CF login using the form on the right side.', - guiOptions: { type: 'label' }, - validate: async (): Promise => { - // loop until both org & space appear in the refreshed config - let result = ''; - let cfg = fdcService.getConfig(); - let retryCount = 0; - const maxRetries = 10; - - while (!cfg.org?.Name && !cfg.space?.Name && retryCount < maxRetries) { - result = (await vscode?.commands.executeCommand('cf.login', 'side')) as string; - - if (result !== 'OK' || !result) { - await CFUtils.cFLogout(); - return 'Login failed.'; - } - - // Add a small delay to allow the config file to be written - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Reload config and retry - fdcService.loadConfig(); - cfg = fdcService.getConfig(); - retryCount++; - } - - if (!cfg.org?.Name && !cfg.space?.Name) { - await CFUtils.cFLogout(); - return 'Login succeeded but configuration could not be loaded. Please try again.'; - } - - isCFLoginSuccessful = true; - return true; - } - }; - - const successLabelPrompt: InputQuestion = { - type: 'input', - name: 'cfExternalLoginSuccessMessage', - message: 'Login successful', - guiOptions: { type: 'label' }, - when: (): boolean => isCFLoginSuccessful - }; - - return [externalLoginPrompt, successLabelPrompt]; -} - -/** - * Returns the logged-in information prompts. - * - * @param {CFConfig} cfConfig - The CF config. - * @returns {CFLoginQuestion[]} The logged-in information prompts. - */ -export function getLoggedInPrompts(cfConfig: CFConfig): CFLoginQuestion[] { - return [ - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInMainMessage, 'You are currently logged in:'), - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedApiEndpointMessage, `CF API Endpoint: ${cfConfig.url}`), - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInOrganizationMessage, `Organization: ${cfConfig.org.Name}`), - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInSpaceMessage, `Space: ${cfConfig.space.Name}`), - getLoggedInInfoPrompt(cfLoginPromptNames.cfLoggedInEndingMessage, 'You can proceed with the project creation.') - ]; -} - -/** - * Returns the logged-in information prompt. - * - * @param {keyof CFLoginAnswers} name - The name of the prompt. - * @param {string} message - The message to display. - * @returns {YUIQuestion} The logged-in information prompt. - */ -function getLoggedInInfoPrompt(name: keyof CFLoginAnswers, message: string): YUIQuestion { - return { - type: 'input', - name, - message, - guiOptions: { type: 'label' }, - store: false - } as YUIQuestion; -} diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 57c7d5e32fe..b242bc27cd4 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -1,5 +1,5 @@ import type { ToolsLogger } from '@sap-ux/logger'; -import type { CFApp, FDCService, ServiceKeys } from '@sap-ux/adp-tooling'; +import { getBusinessServiceKeys, type CFApp, type FDCService, type ServiceKeys } from '@sap-ux/adp-tooling'; import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; import { cfServicesPromptNames, AppRouterType } from '../types'; @@ -13,7 +13,7 @@ import { validateBusinessSolutionName } from './helper/validators'; export class CFServicesPrompter { private readonly fdcService: FDCService; private readonly isInternalUsage: boolean; - private readonly logger?: { log: (msg: string) => void; error: (msg: string) => void }; + private readonly logger: ToolsLogger; /** * Whether the user is logged in to Cloud Foundry. @@ -182,8 +182,10 @@ export class CFServicesPrompter { this.baseAppOnChoiceError = null; if (this.cachedServiceName != answers.businessService) { this.cachedServiceName = answers.businessService; - this.businessServiceKeys = await this.fdcService.getBusinessServiceKeys( - answers.businessService ?? '' + this.businessServiceKeys = await getBusinessServiceKeys( + answers.businessService ?? '', + this.fdcService.getConfig(), + this.logger ); if (!this.businessServiceKeys) { return []; @@ -235,7 +237,11 @@ export class CFServicesPrompter { if (!value) { return 'Business service has to be selected'; } - this.businessServiceKeys = await this.fdcService.getBusinessServiceKeys(value); + this.businessServiceKeys = await getBusinessServiceKeys( + value, + this.fdcService.getConfig(), + this.logger + ); if (this.businessServiceKeys === null) { return 'The service chosen does not exist in cockpit or the user is not member of the needed space.'; } diff --git a/packages/generator-adp/src/app/questions/helper/validators.ts b/packages/generator-adp/src/app/questions/helper/validators.ts index f6d0b83c6fc..0bdee0887cb 100644 --- a/packages/generator-adp/src/app/questions/helper/validators.ts +++ b/packages/generator-adp/src/app/questions/helper/validators.ts @@ -1,6 +1,6 @@ import fs from 'fs'; -import { YamlUtils, type FDCService, type SystemLookup } from '@sap-ux/adp-tooling'; +import { isMtaProject, type FDCService, type SystemLookup } from '@sap-ux/adp-tooling'; import { validateNamespaceAdp, validateProjectName } from '@sap-ux/project-input-validator'; import { t } from '../../../utils/i18n'; @@ -83,17 +83,17 @@ export async function validateJsonInput( * Validates the environment. * * @param {string} value - The value to validate. - * @param {string} label - The label to validate. * @param {FDCService} fdcService - The FDC service instance. + * @param {boolean} isCFLoggedIn - Whether Cloud Foundry is logged in. * @returns {Promise} Returns true if the environment is valid, otherwise returns an error message. */ export async function validateEnvironment( value: string, - label: string, - fdcService: FDCService + fdcService: FDCService, + isCFLoggedIn: boolean ): Promise { - if (!value) { - return t('error.selectCannotBeEmptyError', { value: label }); + if (value === 'CF' && !isCFLoggedIn) { + return 'Please login to Cloud Foundry to continue.'; } if (value === 'CF' && !isAppStudio()) { @@ -128,7 +128,7 @@ export async function validateProjectPath(projectPath: string, fdcService: FDCSe return 'The project does not exist.'; } - if (!YamlUtils.isMtaProject(projectPath)) { + if (!isMtaProject(projectPath)) { return 'Provide the path to the MTA project where you want to have your Adaptation Project created'; } diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index 7d737c4db5a..f541ea44d4d 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -1,4 +1,4 @@ -import { MessageType } from '@sap-devx/yeoman-ui-types'; +import { MessageType, Severity } from '@sap-devx/yeoman-ui-types'; import type { AppWizard } from '@sap-devx/yeoman-ui-types'; import type { FDCService } from '@sap-ux/adp-tooling'; @@ -16,14 +16,18 @@ type EnvironmentChoice = { name: string; value: TargetEnv }; * * @param {AppWizard} appWizard - The app wizard instance. * @param {boolean} isCfInstalled - Whether Cloud Foundry is installed. + * @param {boolean} isCFLoggedIn - Whether Cloud Foundry is logged in. * @param {FDCService} fdcService - The FDC service instance. * @returns {object[]} The target environment prompt. */ export function getTargetEnvPrompt( appWizard: AppWizard, isCfInstalled: boolean, + isCFLoggedIn: boolean, fdcService: FDCService ): TargetEnvQuestion { + const cfConfig = fdcService.getConfig(); + return { type: 'list', name: 'targetEnv', @@ -34,7 +38,17 @@ export function getTargetEnvPrompt( mandatory: true, hint: 'Select the target environment for your Adaptation Project.' }, - validate: (value: string) => validateEnvironment(value, 'Target environment', fdcService) + validate: (value: string) => validateEnvironment(value, fdcService, isCFLoggedIn), + additionalMessages: (value: string) => { + if (value === 'CF' && isCFLoggedIn) { + return { + message: `You are logged in to Cloud Foundry: ${cfConfig.url} / ${cfConfig.org?.Name} / ${cfConfig.space?.Name}.`, + severity: Severity.information + }; + } + + return undefined; + } } as ListQuestion; } diff --git a/packages/generator-adp/src/utils/steps.ts b/packages/generator-adp/src/utils/steps.ts index e10d8e2fba1..9fa46021c8b 100644 --- a/packages/generator-adp/src/utils/steps.ts +++ b/packages/generator-adp/src/utils/steps.ts @@ -2,16 +2,16 @@ import type { Prompts as YeomanUiSteps, IPrompt } from '@sap-devx/yeoman-ui-type import { t } from './i18n'; import { GeneratorTypes } from '../types'; -import { isAppStudio } from '@sap-ux/btp-utils'; /** * Returns the list of base wizard pages used in the Adaptation Project. * + * @param {boolean} shouldShowTargetEnv - Whether to show the target environment page. * @returns {IPrompt[]} The list of static wizard steps to show initially. */ -export function getWizardPages(): IPrompt[] { +export function getWizardPages(shouldShowTargetEnv: boolean): IPrompt[] { return [ - ...(isAppStudio() ? [{ name: 'Target environment', description: '' }] : []), + ...(shouldShowTargetEnv ? [{ name: 'Target environment', description: '' }] : []), { name: t('yuiNavSteps.configurationName'), description: t('yuiNavSteps.configurationDescr') @@ -31,10 +31,7 @@ export function getWizardPages(): IPrompt[] { */ export function updateCfWizardSteps(isCFEnv: boolean, prompts: YeomanUiSteps): void { if (isCFEnv) { - // Replace all pages starting from index 1 (after "Target environment") - // This prevents "Project Attributes" from being pushed to the end prompts.splice(1, prompts['items'].length - 1, [ - { name: 'Login to Cloud Foundry', description: 'Provide credentials.' }, { name: 'Project path', description: 'Provide path to MTA project.' }, { name: t('yuiNavSteps.projectAttributesName'), From 0becd56c5ea8bb24e9b1b57e7247e8f8bbbe16a1 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 13 Aug 2025 17:13:05 +0300 Subject: [PATCH 027/111] feat: capitalize page names --- packages/generator-adp/src/utils/steps.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/generator-adp/src/utils/steps.ts b/packages/generator-adp/src/utils/steps.ts index 9fa46021c8b..37eccd7d4b9 100644 --- a/packages/generator-adp/src/utils/steps.ts +++ b/packages/generator-adp/src/utils/steps.ts @@ -11,7 +11,7 @@ import { GeneratorTypes } from '../types'; */ export function getWizardPages(shouldShowTargetEnv: boolean): IPrompt[] { return [ - ...(shouldShowTargetEnv ? [{ name: 'Target environment', description: '' }] : []), + ...(shouldShowTargetEnv ? [{ name: 'Target Environment', description: '' }] : []), { name: t('yuiNavSteps.configurationName'), description: t('yuiNavSteps.configurationDescr') @@ -32,7 +32,7 @@ export function getWizardPages(shouldShowTargetEnv: boolean): IPrompt[] { export function updateCfWizardSteps(isCFEnv: boolean, prompts: YeomanUiSteps): void { if (isCFEnv) { prompts.splice(1, prompts['items'].length - 1, [ - { name: 'Project path', description: 'Provide path to MTA project.' }, + { name: 'Project Path', description: 'Provide path to MTA project.' }, { name: t('yuiNavSteps.projectAttributesName'), description: t('yuiNavSteps.projectAttributesDescr') From b6a8907ade58d76664c4490d34209ae407094863 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 14 Aug 2025 13:46:42 +0300 Subject: [PATCH 028/111] feat: refactor and improve code --- .../src/app/questions/cf-services.ts | 92 +++++++++---------- .../questions/helper/additional-messages.ts | 23 +++++ .../src/app/questions/helper/conditions.ts | 19 ++++ .../src/app/questions/helper/validators.ts | 29 +++--- .../src/app/questions/target-env.ts | 26 ++---- .../src/translations/generator-adp.i18n.json | 24 ++++- 6 files changed, 137 insertions(+), 76 deletions(-) diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index b242bc27cd4..7b3056a1c32 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -1,11 +1,14 @@ import type { ToolsLogger } from '@sap-ux/logger'; -import { getBusinessServiceKeys, type CFApp, type FDCService, type ServiceKeys } from '@sap-ux/adp-tooling'; +import { validateEmptyString } from '@sap-ux/project-input-validator'; import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; +import { getBusinessServiceKeys, type CFApp, type FDCService, type ServiceKeys } from '@sap-ux/adp-tooling'; -import { cfServicesPromptNames, AppRouterType } from '../types'; -import type { CfServicesAnswers, CFServicesQuestion, CfServicesPromptOptions } from '../types'; -import { getAppRouterChoices, getCFAppChoices } from './helper/choices'; +import { t } from '../../utils/i18n'; +import { cfServicesPromptNames } from '../types'; import { validateBusinessSolutionName } from './helper/validators'; +import { getAppRouterChoices, getCFAppChoices } from './helper/choices'; +import { showBusinessSolutionNameQuestion } from './helper/conditions'; +import type { CfServicesAnswers, CFServicesQuestion, CfServicesPromptOptions } from '../types'; /** * Prompter for CF services. @@ -104,18 +107,19 @@ export class CFServicesPrompter { return { type: 'input', name: cfServicesPromptNames.businessSolutionName, - message: 'Enter a unique name for the business solution of the project', + message: t('prompts.businessSolutionNameLabel'), when: (answers: CfServicesAnswers) => - this.isCFLoggedIn && - answers.approuter === AppRouterType.MANAGED && - this.showSolutionNamePrompt && - answers.businessService - ? true - : false, + showBusinessSolutionNameQuestion( + answers, + this.isCFLoggedIn, + this.showSolutionNamePrompt, + answers.businessService + ), validate: (value: string) => validateBusinessSolutionName(value), guiOptions: { mandatory: true, - hint: 'Business solution name must consist of at least two segments and they should be separated by period.' + hint: t('prompts.businessSolutionNameTooltip'), + breadcrumb: true }, store: false } as InputQuestion; @@ -131,7 +135,7 @@ export class CFServicesPrompter { return { type: 'list', name: cfServicesPromptNames.approuter, - message: 'Select your HTML5 application runtime', + message: t('prompts.approuterLabel'), choices: getAppRouterChoices(this.isInternalUsage), when: () => { const modules = this.fdcService.getModuleNames(mtaProjectPath); @@ -156,13 +160,19 @@ export class CFServicesPrompter { validate: async (value: string) => { this.isCFLoggedIn = await this.fdcService.isLoggedIn(); if (!this.isCFLoggedIn) { - return 'You are not logged in to Cloud Foundry.'; + return t('error.cfNotLoggedIn'); + } + + const validationResult = validateEmptyString(value); + if (typeof validationResult === 'string') { + return validationResult; } - return this.validateEmptySelect(value, 'Approuter'); + return true; }, guiOptions: { - hint: 'Select the HTML5 application runtime that you want to use' + hint: t('prompts.approuterTooltip'), + breadcrumb: true } } as ListQuestion; } @@ -176,15 +186,16 @@ export class CFServicesPrompter { return { type: 'list', name: cfServicesPromptNames.baseApp, - message: 'Select base application', + message: t('prompts.baseAppLabel'), choices: async (answers: CfServicesAnswers): Promise => { try { this.baseAppOnChoiceError = null; if (this.cachedServiceName != answers.businessService) { this.cachedServiceName = answers.businessService; + const config = this.fdcService.getConfig(); this.businessServiceKeys = await getBusinessServiceKeys( answers.businessService ?? '', - this.fdcService.getConfig(), + config, this.logger ); if (!this.businessServiceKeys) { @@ -202,10 +213,12 @@ export class CFServicesPrompter { return []; } }, - validate: async (value: string) => { - if (!value) { - return 'Base application has to be selected'; + validate: (value: string) => { + const validationResult = validateEmptyString(value); + if (typeof validationResult === 'string') { + return t('error.baseAppHasToBeSelected'); } + if (this.baseAppOnChoiceError !== null) { return this.baseAppOnChoiceError; } @@ -213,7 +226,8 @@ export class CFServicesPrompter { }, when: (answers: any) => this.isCFLoggedIn && answers.businessService, guiOptions: { - hint: 'Select the base application you want to use' + hint: t('prompts.baseAppTooltip'), + breadcrumb: true } } as ListQuestion; } @@ -227,47 +241,33 @@ export class CFServicesPrompter { return { type: 'list', name: cfServicesPromptNames.businessService, - message: 'Select business service', + message: t('prompts.businessServiceLabel'), choices: this.businessServices, default: (_answers?: any) => (this.businessServices.length === 1 ? this.businessServices[0] ?? '' : ''), when: (answers: CfServicesAnswers) => { return this.isCFLoggedIn && (this.approuter || answers.approuter); }, validate: async (value: string) => { - if (!value) { - return 'Business service has to be selected'; + const validationResult = validateEmptyString(value); + if (typeof validationResult === 'string') { + return t('error.businessServiceHasToBeSelected'); } - this.businessServiceKeys = await getBusinessServiceKeys( - value, - this.fdcService.getConfig(), - this.logger - ); + + const config = this.fdcService.getConfig(); + this.businessServiceKeys = await getBusinessServiceKeys(value, config, this.logger); if (this.businessServiceKeys === null) { - return 'The service chosen does not exist in cockpit or the user is not member of the needed space.'; + return t('error.businessServiceDoesNotExist'); } return true; }, guiOptions: { mandatory: true, - hint: 'Select the business service you want to use' + hint: t('prompts.businessServiceTooltip'), + breadcrumb: true } } as ListQuestion; } - - /** - * Validate empty select. - * - * @param {string} value - Value to validate. - * @param {string} label - Label to validate. - * @returns {string | true} Validation result. - */ - private validateEmptySelect(value: string, label: string): string | true { - if (!value) { - return `${label} has to be selected`; - } - return true; - } } /** diff --git a/packages/generator-adp/src/app/questions/helper/additional-messages.ts b/packages/generator-adp/src/app/questions/helper/additional-messages.ts index ed1807ffc62..ee75ccc4b0b 100644 --- a/packages/generator-adp/src/app/questions/helper/additional-messages.ts +++ b/packages/generator-adp/src/app/questions/helper/additional-messages.ts @@ -127,3 +127,26 @@ export const getVersionAdditionalMessages = (isVersionDetected: boolean): IMessa return undefined; }; + +/** + * Provides additional messages related to the target environment. + * + * @param {string} value - The selected target environment. + * @param {boolean} isCFLoggedIn - Flag indicating whether the user is logged in to Cloud Foundry. + * @param {any} cfConfig - The Cloud Foundry configuration. + * @returns {IMessageSeverity | undefined} Message object or undefined if no message is applicable. + */ +export const getTargetEnvAdditionalMessages = ( + value: string, + isCFLoggedIn: boolean, + cfConfig: any +): IMessageSeverity | undefined => { + if (value === 'CF' && isCFLoggedIn) { + return { + message: `You are logged in to Cloud Foundry: ${cfConfig.url} / ${cfConfig.org?.Name} / ${cfConfig.space?.Name}.`, + severity: Severity.information + }; + } + + return undefined; +}; diff --git a/packages/generator-adp/src/app/questions/helper/conditions.ts b/packages/generator-adp/src/app/questions/helper/conditions.ts index f1445f1e3ce..fb1bda0d7c2 100644 --- a/packages/generator-adp/src/app/questions/helper/conditions.ts +++ b/packages/generator-adp/src/app/questions/helper/conditions.ts @@ -1,5 +1,6 @@ import { isAppStudio } from '@sap-ux/btp-utils'; import type { ConfigAnswers, FlexUISupportedSystem } from '@sap-ux/adp-tooling'; +import { AppRouterType, type CfServicesAnswers } from '../../types'; /** * Determines if a credential question should be shown. @@ -80,3 +81,21 @@ export function showInternalQuestions( ): boolean { return !!answers.system && answers.application && !isCustomerBase && isApplicationSupported; } + +/** + * Determines if the business solution name question should be shown. + * + * @param {CfServicesAnswers} answers - The user-provided answers containing application details. + * @param {boolean} isCFLoggedIn - A flag indicating whether the user is logged in to Cloud Foundry. + * @param {boolean} showSolutionNamePrompt - A flag indicating whether the solution name prompt should be shown. + * @param {string} businessService - The business service to be used. + * @returns {boolean} True if the business solution name question should be shown, otherwise false. + */ +export function showBusinessSolutionNameQuestion( + answers: CfServicesAnswers, + isCFLoggedIn: boolean, + showSolutionNamePrompt: boolean, + businessService: string | undefined +): boolean { + return isCFLoggedIn && answers.approuter === AppRouterType.MANAGED && showSolutionNamePrompt && !!businessService; +} diff --git a/packages/generator-adp/src/app/questions/helper/validators.ts b/packages/generator-adp/src/app/questions/helper/validators.ts index 0bdee0887cb..39f3b35ce85 100644 --- a/packages/generator-adp/src/app/questions/helper/validators.ts +++ b/packages/generator-adp/src/app/questions/helper/validators.ts @@ -1,12 +1,12 @@ import fs from 'fs'; +import { isAppStudio } from '@sap-ux/btp-utils'; import { isMtaProject, type FDCService, type SystemLookup } from '@sap-ux/adp-tooling'; -import { validateNamespaceAdp, validateProjectName } from '@sap-ux/project-input-validator'; +import { validateEmptyString, validateNamespaceAdp, validateProjectName } from '@sap-ux/project-input-validator'; import { t } from '../../../utils/i18n'; import { isString } from '../../../utils/type-guards'; import { resolveNodeModuleGenerator } from '../../extension-project'; -import { isAppStudio } from '@sap-ux/btp-utils'; interface JsonInputParams { projectName: string; @@ -93,13 +93,13 @@ export async function validateEnvironment( isCFLoggedIn: boolean ): Promise { if (value === 'CF' && !isCFLoggedIn) { - return 'Please login to Cloud Foundry to continue.'; + return t('error.cfNotLoggedIn'); } if (value === 'CF' && !isAppStudio()) { const isExternalLoginEnabled = await fdcService.isExternalLoginEnabled(); if (!isExternalLoginEnabled) { - return 'CF Login cannot be detected as extension in current installation of VSCode, please refer to documentation (link not yet available) in order to install it.'; + return t('error.cfLoginCannotBeDetected'); } } @@ -114,22 +114,23 @@ export async function validateEnvironment( * @returns {Promise} Returns true if the project path is valid, otherwise returns an error message. */ export async function validateProjectPath(projectPath: string, fdcService: FDCService): Promise { - if (!projectPath) { - return 'Input cannot be empty'; + const validationResult = validateEmptyString(projectPath); + if (typeof validationResult === 'string') { + return validationResult; } try { fs.realpathSync(projectPath, 'utf-8'); } catch (e) { - return 'The project does not exist.'; + return t('error.projectDoesNotExist'); } if (!fs.existsSync(projectPath)) { - return 'The project does not exist.'; + return t('error.projectDoesNotExist'); } if (!isMtaProject(projectPath)) { - return 'Provide the path to the MTA project where you want to have your Adaptation Project created'; + return t('error.projectDoesNotExistMta'); } let services: string[]; @@ -140,7 +141,7 @@ export async function validateProjectPath(projectPath: string, fdcService: FDCSe } if (services.length < 1) { - return 'No adaptable business service found in the MTA'; + return t('error.noAdaptableBusinessServiceFoundInMta'); } return true; @@ -153,14 +154,16 @@ export async function validateProjectPath(projectPath: string, fdcService: FDCSe * @returns {string | boolean} Validation result. */ export function validateBusinessSolutionName(value: string): string | boolean { - if (!value) { - return 'Value cannot be empty'; + const validationResult = validateEmptyString(value); + if (typeof validationResult === 'string') { + return validationResult; } + const parts = String(value) .split('.') .filter((p) => p.length > 0); if (parts.length < 2) { - return 'Business solution name must consist of at least two segments and they should be separated by period.'; + return t('error.businessSolutionNameInvalid'); } return true; } diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index f541ea44d4d..f3e4514441b 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -1,4 +1,4 @@ -import { MessageType, Severity } from '@sap-devx/yeoman-ui-types'; +import { MessageType } from '@sap-devx/yeoman-ui-types'; import type { AppWizard } from '@sap-devx/yeoman-ui-types'; import type { FDCService } from '@sap-ux/adp-tooling'; @@ -8,6 +8,8 @@ import type { InputQuestion, ListQuestion, YUIQuestion } from '@sap-ux/inquirer- import type { ProjectLocationAnswers } from '../types'; import { validateEnvironment, validateProjectPath } from './helper/validators'; import { TargetEnv, type TargetEnvAnswers, type TargetEnvQuestion } from '../types'; +import { t } from '../../utils/i18n'; +import { getTargetEnvAdditionalMessages } from './helper/additional-messages'; type EnvironmentChoice = { name: string; value: TargetEnv }; @@ -31,24 +33,15 @@ export function getTargetEnvPrompt( return { type: 'list', name: 'targetEnv', - message: 'Select environment', + message: t('prompts.targetEnvironmentLabel'), choices: () => getEnvironments(appWizard, isCfInstalled), default: () => getEnvironments(appWizard, isCfInstalled)[0]?.name, guiOptions: { mandatory: true, - hint: 'Select the target environment for your Adaptation Project.' + hint: t('prompts.targetEnvironmentTooltip') }, validate: (value: string) => validateEnvironment(value, fdcService, isCFLoggedIn), - additionalMessages: (value: string) => { - if (value === 'CF' && isCFLoggedIn) { - return { - message: `You are logged in to Cloud Foundry: ${cfConfig.url} / ${cfConfig.org?.Name} / ${cfConfig.space?.Name}.`, - severity: Severity.information - }; - } - - return undefined; - } + additionalMessages: (value: string) => getTargetEnvAdditionalMessages(value, isCFLoggedIn, cfConfig) } as ListQuestion; } @@ -65,7 +58,7 @@ export function getEnvironments(appWizard: AppWizard, isCfInstalled: boolean): E if (isCfInstalled) { choices.push({ name: 'Cloud Foundry', value: TargetEnv.CF }); } else { - appWizard.showInformation('Cloud Foundry is not installed in your space.', MessageType.prompt); + appWizard.showInformation(t('error.cfNotInstalled'), MessageType.prompt); } return choices; @@ -85,9 +78,10 @@ export function getProjectPathPrompt(fdcService: FDCService, vscode: any): YUIQu guiOptions: { type: 'folder-browser', mandatory: true, - hint: 'Select the path to the root of your project' + hint: t('prompts.projectLocationTooltip'), + breadcrumb: true }, - message: 'Specify the path to the project root', + message: t('prompts.projectLocationLabel'), validate: (value: string) => validateProjectPath(value, fdcService), default: () => getDefaultTargetFolder(vscode), store: false diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index 016c1814008..19e6fd18f4a 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -47,6 +47,18 @@ "ui5VersionTooltip": "Select the SAPUI5 version you want to use to preview your app variant.", "addDeployConfig": "Add Deployment Configuration", "addFlpConfig": "Add SAP Fiori Launchpad Configuration", + "targetEnvironmentLabel": "Environment", + "targetEnvironmentTooltip": "Select the target environment for your Adaptation Project.", + "projectLocationLabel": "Specify the path to the project root", + "projectLocationTooltip": "Select the path to the root of your project", + "businessSolutionNameLabel": "Enter a unique name for the business solution of the project", + "businessSolutionNameTooltip": "Business solution name must consist of at least two segments and they should be separated by period.", + "approuterLabel": "HTML5 Application Runtime", + "approuterTooltip": "Select the HTML5 application runtime that you want to use", + "baseAppLabel": "Base Application", + "baseAppTooltip": "Select the base application you want to use", + "businessServiceLabel": "Business Service", + "businessServiceTooltip": "Select the business service you want to use", "appInfoLabel": "Synchronous views are detected for this application. Therefore, the controller extensions are not supported. Controller extension functionality on these views will be disabled.", "notSupportedAdpOverAdpLabel": "You have selected 'Adaptation Project' as the base. The selected system has an SAPUI5 version lower than 1.90. Therefore, it does not support 'Adaptation Project' as а base for a new adaptation project. You are able to create such а project, but after deployment, it will not work until the SAPUI5 version of the system has been updated.", "isPartiallySupportedAdpOverAdpLabel": "You have selected 'Adaptation Project' as the base. The selected system has an SAPUI5 version lower than 1.96 and for your adaptation project based on an adaptation project to work after deployment, you need to apply SAP Note 756 SP0 on your system.", @@ -77,6 +89,16 @@ "systemNotFound": "System could not be found or is not supported: {{system}}. Please check the system exists.", "applicationNotFound": "The application could not be found or is not supported: {{appName}}. Please check the application exists.", "backendCommunicationError": "An error occurred when communicating with the back end. Please try again later.", - "pleaseProvideAllRequiredData": "Please provide the required data and try again." + "pleaseProvideAllRequiredData": "Please provide the required data and try again.", + "cfNotInstalled": "Cloud Foundry is not installed in your space. Please install it to continue.", + "cfNotLoggedIn": "You are not logged in to Cloud Foundry. Please login to continue.", + "baseAppHasToBeSelected": "Base application has to be selected. Please select a base application.", + "businessServiceHasToBeSelected": "Business service has to be selected. Please select a business service.", + "businessServiceDoesNotExist": "The service chosen does not exist in cockpit or the user is not member of the needed space.", + "businessSolutionNameInvalid": "Business solution name must consist of at least two segments and they should be separated by period.", + "cfLoginCannotBeDetected": "CF Login cannot be detected as extension in current installation of VSCode, please refer to documentation (link not yet available) in order to install it.", + "projectDoesNotExist": "The project does not exist. Please select a suitable MTA project.", + "projectDoesNotExistMta": "Provide the path to the MTA project where you want to have your Adaptation Project created.", + "noAdaptableBusinessServiceFoundInMta": "No adaptable business service found in the MTA." } } From d14bae80def2fc3d553b803505f16655e5b740e8 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 14 Aug 2025 13:54:55 +0300 Subject: [PATCH 029/111] refactor: change texts --- packages/generator-adp/src/app/questions/cf-services.ts | 2 +- packages/generator-adp/src/app/questions/target-env.ts | 2 +- packages/generator-adp/src/translations/generator-adp.i18n.json | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 7b3056a1c32..e712a1bb590 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -119,7 +119,7 @@ export class CFServicesPrompter { guiOptions: { mandatory: true, hint: t('prompts.businessSolutionNameTooltip'), - breadcrumb: true + breadcrumb: t('prompts.businessSolutionBreadcrumb') }, store: false } as InputQuestion; diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index f3e4514441b..c4f1be9a531 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -79,7 +79,7 @@ export function getProjectPathPrompt(fdcService: FDCService, vscode: any): YUIQu type: 'folder-browser', mandatory: true, hint: t('prompts.projectLocationTooltip'), - breadcrumb: true + breadcrumb: t('prompts.projectLocationBreadcrumb') }, message: t('prompts.projectLocationLabel'), validate: (value: string) => validateProjectPath(value, fdcService), diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index 19e6fd18f4a..cbf710da2b2 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -51,7 +51,9 @@ "targetEnvironmentTooltip": "Select the target environment for your Adaptation Project.", "projectLocationLabel": "Specify the path to the project root", "projectLocationTooltip": "Select the path to the root of your project", + "projectLocationBreadcrumb": "Project Path", "businessSolutionNameLabel": "Enter a unique name for the business solution of the project", + "businessSolutionBreadcrumb": "Business Solution", "businessSolutionNameTooltip": "Business solution name must consist of at least two segments and they should be separated by period.", "approuterLabel": "HTML5 Application Runtime", "approuterTooltip": "Select the HTML5 application runtime that you want to use", From 04007f11128e43fb8042678ff7a9ef15fc4353a3 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 14 Aug 2025 14:36:57 +0300 Subject: [PATCH 030/111] refactor: change texts --- packages/generator-adp/src/app/questions/target-env.ts | 5 +++-- .../generator-adp/src/translations/generator-adp.i18n.json | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index c4f1be9a531..6e4e266bbed 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -33,12 +33,13 @@ export function getTargetEnvPrompt( return { type: 'list', name: 'targetEnv', - message: t('prompts.targetEnvironmentLabel'), + message: t('prompts.targetEnvLabel'), choices: () => getEnvironments(appWizard, isCfInstalled), default: () => getEnvironments(appWizard, isCfInstalled)[0]?.name, guiOptions: { mandatory: true, - hint: t('prompts.targetEnvironmentTooltip') + hint: t('prompts.targetEnvTooltip'), + breadcrumb: t('prompts.targetEnvBreadcrumb') }, validate: (value: string) => validateEnvironment(value, fdcService, isCFLoggedIn), additionalMessages: (value: string) => getTargetEnvAdditionalMessages(value, isCFLoggedIn, cfConfig) diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index cbf710da2b2..20412391a71 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -47,8 +47,9 @@ "ui5VersionTooltip": "Select the SAPUI5 version you want to use to preview your app variant.", "addDeployConfig": "Add Deployment Configuration", "addFlpConfig": "Add SAP Fiori Launchpad Configuration", - "targetEnvironmentLabel": "Environment", - "targetEnvironmentTooltip": "Select the target environment for your Adaptation Project.", + "targetEnvLabel": "Environment", + "targetEnvTooltip": "Select the target environment for your Adaptation Project.", + "targetEnvBreadcrumb": "Target Environment", "projectLocationLabel": "Specify the path to the project root", "projectLocationTooltip": "Select the path to the root of your project", "projectLocationBreadcrumb": "Project Path", From b762ce43ffb0c50d10f091449547fcc928cb4e01 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 19 Aug 2025 17:31:57 +0300 Subject: [PATCH 031/111] feat: add cf writer code --- packages/adp-tooling/src/cf/utils.ts | 2 +- packages/adp-tooling/src/cf/yaml.ts | 4 +- packages/adp-tooling/src/index.ts | 1 + packages/adp-tooling/src/types.ts | 111 +++++++++ packages/adp-tooling/src/writer/cf.ts | 232 ++++++++++++++++++ packages/adp-tooling/templates/cf/_gitignore | 6 + .../templates/cf/approuter/package.json | 13 + .../templates/cf/approuter/xs-app.json | 15 ++ .../templates/cf/i18n/i18n.properties | 3 + .../templates/cf/manifest.appdescr_variant | 17 ++ .../adp-tooling/templates/cf/package.json | 30 +++ packages/adp-tooling/templates/cf/ui5.yaml | 18 ++ .../adp-tooling/templates/cf/xs-security.json | 7 + packages/generator-adp/src/app/index.ts | 40 ++- .../src/app/questions/cf-services.ts | 8 +- .../src/app/questions/helper/choices.ts | 2 +- .../src/app/questions/helper/conditions.ts | 4 +- packages/generator-adp/src/app/types.ts | 50 ---- 18 files changed, 500 insertions(+), 63 deletions(-) create mode 100644 packages/adp-tooling/src/writer/cf.ts create mode 100644 packages/adp-tooling/templates/cf/_gitignore create mode 100644 packages/adp-tooling/templates/cf/approuter/package.json create mode 100644 packages/adp-tooling/templates/cf/approuter/xs-app.json create mode 100644 packages/adp-tooling/templates/cf/i18n/i18n.properties create mode 100644 packages/adp-tooling/templates/cf/manifest.appdescr_variant create mode 100644 packages/adp-tooling/templates/cf/package.json create mode 100644 packages/adp-tooling/templates/cf/ui5.yaml create mode 100644 packages/adp-tooling/templates/cf/xs-security.json diff --git a/packages/adp-tooling/src/cf/utils.ts b/packages/adp-tooling/src/cf/utils.ts index f87576dd4ee..1a8fe6c80c2 100644 --- a/packages/adp-tooling/src/cf/utils.ts +++ b/packages/adp-tooling/src/cf/utils.ts @@ -64,7 +64,7 @@ export async function createService( spaceGuid: string, plan: string, serviceInstanceName: string, - logger: ToolsLogger, + logger?: ToolsLogger, tags: string[] = [], securityFilePath: string | null = null, serviceName: string | undefined = undefined diff --git a/packages/adp-tooling/src/cf/yaml.ts b/packages/adp-tooling/src/cf/yaml.ts index 2dde12ed203..f09d1339499 100644 --- a/packages/adp-tooling/src/cf/yaml.ts +++ b/packages/adp-tooling/src/cf/yaml.ts @@ -442,7 +442,7 @@ export class YamlUtils { appRouterType: string, businessSolutionName: string, businessService: string, - logger: ToolsLogger + logger?: ToolsLogger ): Promise { this.setTimestamp(); @@ -500,7 +500,7 @@ export class YamlUtils { private static async createServices( resources: any[], initialServices: string[], - logger: ToolsLogger + logger?: ToolsLogger ): Promise { const excludeServices = initialServices.concat(['portal', this.HTML5_APPS_REPO]); const xsSecurityPath = this.yamlPath.replace('mta.yaml', 'xs-security.json'); diff --git a/packages/adp-tooling/src/index.ts b/packages/adp-tooling/src/index.ts index 804f77e66af..53dd7dfbf21 100644 --- a/packages/adp-tooling/src/index.ts +++ b/packages/adp-tooling/src/index.ts @@ -12,6 +12,7 @@ export * from './base/project-builder'; export * from './base/abap/manifest-service'; export { promptGeneratorInput, PromptDefaults } from './base/prompt'; export * from './preview/adp-preview'; +export * from './writer/cf'; export * from './writer/manifest'; export * from './writer/writer-config'; export { generate, migrate } from './writer'; diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 199f9c64e60..1985b0b7af4 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -4,6 +4,7 @@ import type { Adp, BspApp } from '@sap-ux/ui5-config'; import type { OperationsType } from '@sap-ux/axios-extension'; import type { Editor } from 'mem-fs-editor'; import type { Destination } from '@sap-ux/btp-utils'; +import type { YUIQuestion } from '@sap-ux/inquirer-common'; export interface DescriptorVariant { layer: UI5FlexLayer; @@ -899,6 +900,73 @@ export interface CfAdpConfig extends AdpConfig { cfApiUrl: string; } +/** + * Configuration for CF ADP project generation. + */ +export interface CfAdpWriterConfig { + app: { + id: string; + title: string; + layer: FlexLayer; + namespace: string; + manifest: Manifest; + appType?: ApplicationType; + i18nModels?: ResourceModel[]; + i18nDescription?: string; + }; + baseApp: { + appId: string; + appName: string; + appVersion: string; + appHostId: string; + serviceName: string; + title: string; + }; + cf: { + url: string; + org: Organization; + space: Space; + html5RepoRuntimeGuid: string; + approuter: string; + businessService: string; + businessSolutionName?: string; + }; + project: { + name: string; + path: string; + folder: string; + }; + ui5: { + version: string; + }; + options?: { + addStandaloneApprouter?: boolean; + addSecurity?: boolean; + }; +} + +/** + * Interface for creating CF configuration from batch objects. + */ +export interface CreateCfConfigParams { + attributeAnswers: AttributesAnswers; + cfServicesAnswers: CfServicesAnswers; + cfConfig: CFConfig; + layer: FlexLayer; + manifest: Manifest; + html5RepoRuntimeGuid: string; + projectPath: string; + addStandaloneApprouter?: boolean; + publicVersions: UI5Version; +} + +export const AppRouterType = { + MANAGED: 'Managed HTML5 Application Runtime', + STANDALONE: 'Standalone HTML5 Application Runtime' +} as const; + +export type AppRouterType = (typeof AppRouterType)[keyof typeof AppRouterType]; + /** Old ADP config file types */ export interface AdpConfig { sourceSystem?: string; @@ -956,6 +1024,49 @@ export interface CFApp { messages?: string[]; } +/** + * CF services (application sources) prompts + */ +export enum cfServicesPromptNames { + approuter = 'approuter', + businessService = 'businessService', + businessSolutionName = 'businessSolutionName', + baseApp = 'baseApp' +} + +export type CfServicesAnswers = { + [cfServicesPromptNames.approuter]?: string; + [cfServicesPromptNames.businessService]?: string; + [cfServicesPromptNames.businessSolutionName]?: string; + // Base app object returned by discovery (shape provided by FDC service) + [cfServicesPromptNames.baseApp]?: CFApp; +}; + +export type CFServicesQuestion = YUIQuestion; + +export interface ApprouterPromptOptions { + hide?: boolean; +} + +export interface BusinessServicePromptOptions { + hide?: boolean; +} + +export interface BusinessSolutionNamePromptOptions { + hide?: boolean; +} + +export interface BaseAppPromptOptions { + hide?: boolean; +} + +export type CfServicesPromptOptions = Partial<{ + [cfServicesPromptNames.approuter]: ApprouterPromptOptions; + [cfServicesPromptNames.businessService]: BusinessServicePromptOptions; + [cfServicesPromptNames.businessSolutionName]: BusinessSolutionNamePromptOptions; + [cfServicesPromptNames.baseApp]: BaseAppPromptOptions; +}>; + export interface RequestArguments { url: string; options: { diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts new file mode 100644 index 00000000000..921a8b5573c --- /dev/null +++ b/packages/adp-tooling/src/writer/cf.ts @@ -0,0 +1,232 @@ +import { join } from 'path'; +import fs from 'fs'; +import { create as createStorage } from 'mem-fs'; +import { create, type Editor } from 'mem-fs-editor'; + +import { getApplicationType } from '../source'; +import { getI18nDescription, getI18nModels, writeI18nModels } from './i18n'; +import { + type CfAdpWriterConfig, + type FlexLayer, + ApplicationType, + type CreateCfConfigParams, + AppRouterType, + type DescriptorVariant, + type Content +} from '../types'; +import { YamlUtils } from '../cf/yaml'; +import { fillDescriptorContent } from './manifest'; +import { getLatestVersion } from '../ui5/version-info'; + +const baseTmplPath = join(__dirname, '../../templates'); + +/** + * Create CF configuration from batch objects. + * + * @param {CreateCfConfigParams} params - The configuration parameters containing batch objects. + * @returns {CfAdpWriterConfig} The CF configuration. + */ +export function createCfConfig(params: CreateCfConfigParams): CfAdpWriterConfig { + const baseApp = params.cfServicesAnswers.baseApp; + + if (!baseApp) { + throw new Error('Base app is required for CF project generation'); + } + + const ui5Version = getLatestVersion(params.publicVersions); + + return { + app: { + id: baseApp.appId, + title: params.attributeAnswers.title, + layer: params.layer, + namespace: params.attributeAnswers.namespace, + manifest: params.manifest + }, + baseApp, + cf: { + url: params.cfConfig.url, + org: params.cfConfig.org, + space: params.cfConfig.space, + html5RepoRuntimeGuid: params.html5RepoRuntimeGuid, + approuter: params.cfServicesAnswers.approuter ?? '', + businessService: params.cfServicesAnswers.businessService ?? '', + businessSolutionName: params.cfServicesAnswers.businessSolutionName + }, + project: { + name: params.attributeAnswers.projectName, + path: params.projectPath, + folder: join(params.projectPath, params.attributeAnswers.projectName) + }, + ui5: { + version: ui5Version + }, + options: { + addStandaloneApprouter: params.cfServicesAnswers.approuter === AppRouterType.STANDALONE + } + }; +} + +/** + * Writes the CF adp-project template to the mem-fs-editor instance. + * + * @param {string} basePath - The base path. + * @param {CfAdpWriterConfig} config - The CF writer configuration. + * @param {Editor} fs - The memfs editor instance. + * @returns {Promise} The updated memfs editor instance. + */ +export async function generateCf(basePath: string, config: CfAdpWriterConfig, fs?: Editor): Promise { + if (!fs) { + fs = create(createStorage()); + } + + const fullConfig = setDefaultsCF(config); + + await adjustMtaYaml(basePath, fullConfig); + + if (fullConfig.app.i18nModels) { + writeI18nModels(basePath, fullConfig.app.i18nModels, fs); + } + + await writeCfTemplates(basePath, fullConfig, fs); + + return fs; +} + +/** + * Set default values for CF configuration. + * + * @param {CfAdpWriterConfig} config - The CF configuration provided by the calling middleware. + * @returns {CfAdpWriterConfig} The enhanced configuration with default values. + */ +function setDefaultsCF(config: CfAdpWriterConfig): CfAdpWriterConfig { + const configWithDefaults: CfAdpWriterConfig = { + ...config, + app: { + ...config.app, + appType: config.app.appType ?? getApplicationType(config.app.manifest), + i18nModels: config.app.i18nModels ?? getI18nModels(config.app.manifest, config.app.layer, config.app.id), + i18nDescription: + config.app.i18nDescription ?? getI18nDescription(config.app.layer as FlexLayer, config.app.title) + }, + options: { + addStandaloneApprouter: false, + addSecurity: false, + ...config.options + } + }; + + return configWithDefaults; +} + +/** + * Adjust MTA YAML file for CF project. + * + * @param {string} basePath - The base path. + * @param {CfAdpWriterConfig} config - The CF configuration. + */ +async function adjustMtaYaml(basePath: string, config: CfAdpWriterConfig): Promise { + const { app, cf } = config; + + const mtaYamlPath = join(basePath, 'mta.yaml'); + if (fs.existsSync(mtaYamlPath)) { + YamlUtils.loadYamlContent(mtaYamlPath); + } + + await YamlUtils.adjustMtaYaml(basePath, app.id, cf.approuter, cf.businessSolutionName ?? '', cf.businessService); +} + +/** + * Write CF-specific templates and configuration files. + * + * @param {string} basePath - The base path. + * @param {CfAdpWriterConfig} config - The CF configuration. + * @param {Editor} fs - The memfs editor instance. + */ +async function writeCfTemplates(basePath: string, config: CfAdpWriterConfig, fs: Editor): Promise { + const { app, baseApp, cf, project, ui5, options } = config; + + const variant: DescriptorVariant = { + layer: app.layer, + reference: app.id, + id: app.namespace, + namespace: 'apps/' + app.id + '/appVariants/' + app.namespace + '/', + content: [ + { + changeType: 'appdescr_ui5_setMinUI5Version', + content: { + minUI5Version: ui5.version + } + } + ] + }; + + fillDescriptorContent(variant.content as Content[], app.appType, ui5.version, app.i18nModels); + + fs.writeJSON(join(project.folder, 'webapp', 'manifest.appdescr_variant'), variant); + + fs.copyTpl(join(baseTmplPath, 'cf/package.json'), join(basePath, project.folder, 'package.json'), { + module: project.name + }); + + fs.copyTpl(join(baseTmplPath, 'cf/ui5.yaml'), join(basePath, project.folder, 'ui5.yaml'), { + appHostId: baseApp.appHostId, + appName: baseApp.appName, + appVersion: baseApp.appVersion, + module: project.name, + html5RepoRuntime: cf.html5RepoRuntimeGuid, + org: cf.org.GUID, + space: cf.space.GUID, + sapCloudService: cf.businessSolutionName ?? '' + }); + + const configJson = { + componentname: app.namespace, + appvariant: project.name, + layer: app.layer, + isOVPApp: app.appType === ApplicationType.FIORI_ELEMENTS_OVP, + isFioriElement: app.appType === ApplicationType.FIORI_ELEMENTS, + environment: 'CF', + ui5Version: ui5.version, + cfApiUrl: cf.url, + cfSpace: cf.space.GUID, + cfOrganization: cf.org.GUID + }; + + fs.writeJSON(join(basePath, project.folder, '.adp/config.json'), configJson); + + fs.copyTpl( + join(baseTmplPath, 'cf/i18n/i18n.properties'), + join(basePath, project.folder, 'webapp/i18n/i18n.properties'), + { + module: project.name, + moduleTitle: app.title, + appVariantId: app.namespace, + i18nGuid: config.app.i18nDescription + } + ); + + fs.copy(join(baseTmplPath, 'cf/_gitignore'), join(basePath, project.folder, '.gitignore')); + + if (options?.addStandaloneApprouter) { + fs.copyTpl( + join(baseTmplPath, 'cf/approuter/package.json'), + join(basePath, `${project.name}-approuter/package.json`), + { + projectName: project.name + } + ); + + fs.copyTpl( + join(baseTmplPath, 'cf/approuter/xs-app.json'), + join(basePath, `${project.name}-approuter/xs-app.json`), + {} + ); + } + + if (!fs.exists(join(basePath, 'xs-security.json'))) { + fs.copyTpl(join(baseTmplPath, 'cf/xs-security.json'), join(basePath, 'xs-security.json'), { + projectName: project.name + }); + } +} diff --git a/packages/adp-tooling/templates/cf/_gitignore b/packages/adp-tooling/templates/cf/_gitignore new file mode 100644 index 00000000000..a77f953fb33 --- /dev/null +++ b/packages/adp-tooling/templates/cf/_gitignore @@ -0,0 +1,6 @@ +/node_modules/ +/preview/ +/dist/ +*.tar +*.zip +UIAdaptation*.html diff --git a/packages/adp-tooling/templates/cf/approuter/package.json b/packages/adp-tooling/templates/cf/approuter/package.json new file mode 100644 index 00000000000..11b5f579d80 --- /dev/null +++ b/packages/adp-tooling/templates/cf/approuter/package.json @@ -0,0 +1,13 @@ +{ + "name": "<%= projectName %>-approuter", + "description": "Node.js based application router service for html5-apps", + "engines": { + "node": "^10.0.0" + }, + "dependencies": { + "@sap/approuter": "^7.0.0" + }, + "scripts": { + "start": "node node_modules/@sap/approuter/approuter.js" + } +} diff --git a/packages/adp-tooling/templates/cf/approuter/xs-app.json b/packages/adp-tooling/templates/cf/approuter/xs-app.json new file mode 100644 index 00000000000..03b48044939 --- /dev/null +++ b/packages/adp-tooling/templates/cf/approuter/xs-app.json @@ -0,0 +1,15 @@ +{ + "welcomeFile": "/cp.portal", + "authenticationMethod": "route", + "logout": { + "logoutEndpoint": "/do/logout" + }, + "routes": [ + { + "source": "^(.*)$", + "target": "$1", + "service": "html5-apps-repo-rt", + "authenticationType": "xsuaa" + } + ] +} diff --git a/packages/adp-tooling/templates/cf/i18n/i18n.properties b/packages/adp-tooling/templates/cf/i18n/i18n.properties new file mode 100644 index 00000000000..3131fe013e0 --- /dev/null +++ b/packages/adp-tooling/templates/cf/i18n/i18n.properties @@ -0,0 +1,3 @@ +<%= i18nGuid %> +#XTIT: Application name +<%= appVariantId %>_sap.app.title=<%= moduleTitle %> \ No newline at end of file diff --git a/packages/adp-tooling/templates/cf/manifest.appdescr_variant b/packages/adp-tooling/templates/cf/manifest.appdescr_variant new file mode 100644 index 00000000000..f87bf0fa194 --- /dev/null +++ b/packages/adp-tooling/templates/cf/manifest.appdescr_variant @@ -0,0 +1,17 @@ +{ + "fileName": "manifest", + "layer": "<%= layer %>", + "fileType": "appdescr_variant", + "reference": "<%= appId %>", + "id": "<%= appVariantId %>", + "namespace": "apps/<%= appId %>/appVariants/<%= appVariantId %>/", + "content": [ + { + "changeType": "appdescr_app_setTitle", + "content": {}, + "texts": { + "i18n": "i18n/i18n.properties" + } + } + ] +} \ No newline at end of file diff --git a/packages/adp-tooling/templates/cf/package.json b/packages/adp-tooling/templates/cf/package.json new file mode 100644 index 00000000000..1a40fa16c9e --- /dev/null +++ b/packages/adp-tooling/templates/cf/package.json @@ -0,0 +1,30 @@ +{ + "name": "<%= module.toLowerCase() %>", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip", + "zip": "cd dist && npx bestzip ../<%= module %>.zip *", + "clean": "npx rimraf <%= module %>.zip dist", + "build-ui5": "npm explore @ui5/task-adaptation -- npm run rollup" + }, + "repository": { + "type": "git", + "build": "ui5.js build --verbose --include-task generateCachebusterInfo" + }, + "ui5": { + "dependencies": [ + "@sap/ui5-builder-webide-extension", + "@ui5/task-adaptation" + ] + }, + "devDependencies": { + "@sap/ui5-builder-webide-extension": "1.0.x", + "@sapui5/ts-types": "^1.85.1", + "@ui5/cli": "^3.0.0", + "@ui5/task-adaptation": "^1.0.x", + "bestzip": "2.1.4", + "rimraf": "3.0.2" + } +} diff --git a/packages/adp-tooling/templates/cf/ui5.yaml b/packages/adp-tooling/templates/cf/ui5.yaml new file mode 100644 index 00000000000..8da08cb423d --- /dev/null +++ b/packages/adp-tooling/templates/cf/ui5.yaml @@ -0,0 +1,18 @@ +--- +specVersion: "2.2" +type: application +metadata: + name: <%= module %> +builder: + customTasks: + - name: app-variant-bundler-build + beforeTask: escapeNonAsciiCharacters + configuration: + appHostId: <%= appHostId %> + appName: <%= appName %> + appVersion: <%= appVersion %> + moduleName: <%= module %> + org: <%= org %> + space: <%= space %> + html5RepoRuntime: <%= html5RepoRuntime %> + sapCloudService: <%= sapCloudService %> diff --git a/packages/adp-tooling/templates/cf/xs-security.json b/packages/adp-tooling/templates/cf/xs-security.json new file mode 100644 index 00000000000..2837a33e3f1 --- /dev/null +++ b/packages/adp-tooling/templates/cf/xs-security.json @@ -0,0 +1,7 @@ +{ + "xsappname": "<%= projectName %>", + "tenant-mode": "dedicated", + "description": "Security profile of called application", + "scopes": [], + "role-templates": [] +} diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 9df800cf663..6b0fa91bb62 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -3,7 +3,7 @@ import { join } from 'path'; import Generator from 'yeoman-generator'; import { AppWizard, MessageType, Prompts as YeomanUiSteps, type IPrompt } from '@sap-devx/yeoman-ui-types'; -import type { CFConfig } from '@sap-ux/adp-tooling'; +import type { CFConfig, CfServicesAnswers } from '@sap-ux/adp-tooling'; import { FlexLayer, SystemLookup, @@ -20,7 +20,9 @@ import { getBaseAppInbounds, YamlUtils, isMtaProject, - isCfInstalled + isCfInstalled, + generateCf, + createCfConfig } from '@sap-ux/adp-tooling'; import { ToolsLogger } from '@sap-ux/logger'; import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; @@ -48,7 +50,7 @@ import { getFirstArgAsString, parseJsonInput } from '../utils/parse-json-input'; import { addDeployGen, addExtProjectGen, addFlpGen } from '../utils/subgenHelpers'; import { cacheClear, cacheGet, cachePut, initCache } from '../utils/appWizardCache'; import { getDefaultNamespace, getDefaultProjectName } from './questions/helper/default-values'; -import type { TargetEnvAnswers, CfServicesAnswers } from './types'; +import type { TargetEnvAnswers } from './types'; import { TargetEnv } from './types'; import { type AdpGeneratorOptions, type AttributePromptOptions, type JsonInput } from './types'; import { @@ -301,8 +303,7 @@ export default class extends Generator { async writing(): Promise { try { if (this.isCfEnv) { - // TODO: Will be removed once CF project generation is supported. - this.vscode.window.showInformationMessage('CF project generation is not supported yet.'); + await this._generateAdpProjectArtifactsCF(); return; } @@ -507,6 +508,35 @@ export default class extends Generator { return prompter; } + /** + * Generates the ADP project artifacts for the CF environment. + */ + private async _generateAdpProjectArtifactsCF(): Promise { + const { baseApp } = this.cfServicesAnswers; + + if (!baseApp) { + throw new Error('Base app is required for CF project generation. Please select a base app and try again.'); + } + + const projectPath = this.isMtaYamlFound ? process.cwd() : this.destinationPath(); + const publicVersions = await fetchPublicVersions(this.logger); + + const manifest = this.fdcService.getManifestByBaseAppId(this.cfServicesAnswers.baseApp?.appId ?? ''); + + const cfConfig = createCfConfig({ + attributeAnswers: this.attributeAnswers, + cfServicesAnswers: this.cfServicesAnswers, + cfConfig: this.cfConfig, + layer: this.layer, + manifest, + html5RepoRuntimeGuid: this.fdcService.html5RepoRuntimeGuid, + projectPath, + publicVersions + }); + + await generateCf(projectPath, cfConfig, this.fs); + } + /** * Combines the target folder and project name. * diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index e712a1bb590..2d9b35aee57 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -1,14 +1,18 @@ +import { + type CfServicesAnswers, + type CFServicesQuestion, + type CfServicesPromptOptions, + cfServicesPromptNames +} from '@sap-ux/adp-tooling'; import type { ToolsLogger } from '@sap-ux/logger'; import { validateEmptyString } from '@sap-ux/project-input-validator'; import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; import { getBusinessServiceKeys, type CFApp, type FDCService, type ServiceKeys } from '@sap-ux/adp-tooling'; import { t } from '../../utils/i18n'; -import { cfServicesPromptNames } from '../types'; import { validateBusinessSolutionName } from './helper/validators'; import { getAppRouterChoices, getCFAppChoices } from './helper/choices'; import { showBusinessSolutionNameQuestion } from './helper/conditions'; -import type { CfServicesAnswers, CFServicesQuestion, CfServicesPromptOptions } from '../types'; /** * Prompter for CF services. diff --git a/packages/generator-adp/src/app/questions/helper/choices.ts b/packages/generator-adp/src/app/questions/helper/choices.ts index de664a6b61a..f06a5985266 100644 --- a/packages/generator-adp/src/app/questions/helper/choices.ts +++ b/packages/generator-adp/src/app/questions/helper/choices.ts @@ -1,5 +1,5 @@ +import { AppRouterType } from '@sap-ux/adp-tooling'; import type { CFApp, FDCService, SourceApplication } from '@sap-ux/adp-tooling'; -import { AppRouterType } from '../../types'; interface Choice { name: string; diff --git a/packages/generator-adp/src/app/questions/helper/conditions.ts b/packages/generator-adp/src/app/questions/helper/conditions.ts index fb1bda0d7c2..2d0ff36c23b 100644 --- a/packages/generator-adp/src/app/questions/helper/conditions.ts +++ b/packages/generator-adp/src/app/questions/helper/conditions.ts @@ -1,6 +1,6 @@ import { isAppStudio } from '@sap-ux/btp-utils'; -import type { ConfigAnswers, FlexUISupportedSystem } from '@sap-ux/adp-tooling'; -import { AppRouterType, type CfServicesAnswers } from '../../types'; +import { AppRouterType } from '@sap-ux/adp-tooling'; +import type { ConfigAnswers, FlexUISupportedSystem, CfServicesAnswers } from '@sap-ux/adp-tooling'; /** * Determines if a credential question should be shown. diff --git a/packages/generator-adp/src/app/types.ts b/packages/generator-adp/src/app/types.ts index 585268d4545..1f7d66dc57a 100644 --- a/packages/generator-adp/src/app/types.ts +++ b/packages/generator-adp/src/app/types.ts @@ -224,53 +224,3 @@ export interface JsonInput { projectName?: string; namespace?: string; } - -/** - * CF services (application sources) prompts - */ -export enum cfServicesPromptNames { - approuter = 'approuter', - businessService = 'businessService', - businessSolutionName = 'businessSolutionName', - baseApp = 'baseApp' -} - -export const AppRouterType = { - MANAGED: 'Managed HTML5 Application Runtime', - STANDALONE: 'Standalone HTML5 Application Runtime' -} as const; - -export type AppRouterType = (typeof AppRouterType)[keyof typeof AppRouterType]; - -export type CfServicesAnswers = { - [cfServicesPromptNames.approuter]?: string; - [cfServicesPromptNames.businessService]?: string; - [cfServicesPromptNames.businessSolutionName]?: string; - // Base app object returned by discovery (shape provided by FDC service) - [cfServicesPromptNames.baseApp]?: unknown; -}; - -export type CFServicesQuestion = YUIQuestion; - -export interface ApprouterPromptOptions { - hide?: boolean; -} - -export interface BusinessServicePromptOptions { - hide?: boolean; -} - -export interface BusinessSolutionNamePromptOptions { - hide?: boolean; -} - -export interface BaseAppPromptOptions { - hide?: boolean; -} - -export type CfServicesPromptOptions = Partial<{ - [cfServicesPromptNames.approuter]: ApprouterPromptOptions; - [cfServicesPromptNames.businessService]: BusinessServicePromptOptions; - [cfServicesPromptNames.businessSolutionName]: BusinessSolutionNamePromptOptions; - [cfServicesPromptNames.baseApp]: BaseAppPromptOptions; -}>; From 68b123e2bba0e810c85089e8d266b5e5c5c16718 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 20 Aug 2025 10:02:13 +0300 Subject: [PATCH 032/111] feat: change descr template writing --- packages/adp-tooling/src/writer/cf.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 921a8b5573c..79e34e3bbf4 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -157,13 +157,24 @@ async function writeCfTemplates(basePath: string, config: CfAdpWriterConfig, fs: content: { minUI5Version: ui5.version } + }, + { + changeType: 'appdescr_app_setTitle', + content: {}, + texts: { + i18n: 'i18n/i18n.properties' + } } ] }; fillDescriptorContent(variant.content as Content[], app.appType, ui5.version, app.i18nModels); - fs.writeJSON(join(project.folder, 'webapp', 'manifest.appdescr_variant'), variant); + fs.copyTpl( + join(baseTmplPath, 'project/webapp/manifest.appdescr_variant'), + join(project.folder, 'webapp', 'manifest.appdescr_variant'), + { app: variant } + ); fs.copyTpl(join(baseTmplPath, 'cf/package.json'), join(basePath, project.folder, 'package.json'), { module: project.name From 21bf108faffb9cee23c73db08760f51083f5019a Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 22 Aug 2025 14:23:05 +0300 Subject: [PATCH 033/111] refactor: use enhanced discovery api --- packages/adp-tooling/src/cf/fdc.ts | 110 ++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 34 deletions(-) diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 7e7e2e30410..63369bc763c 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -128,23 +128,32 @@ function getFDCRequestArguments(cfConfig: CFConfig): RequestArguments { 'Content-Type': 'application/json' } }; - // Public cloud - let url = `${fdcUrl}cert.cfapps.${endpointParts?.[1]}.hana.ondemand.com`; - if (!endpointParts?.[3]) { - // Private cloud - if hana.ondemand.com missing as a part of CF api endpotint - url = `${fdcUrl}sapui5flex.cfapps${endpointParts?.[4]}`; + + // Determine the appropriate domain based on environment + let url: string; + + if (endpointParts?.[3]) { + // Public cloud - use mTLS enabled domain with "cert" prefix + const region = endpointParts[1]; + url = `${fdcUrl}cert.cfapps.${region}.hana.ondemand.com`; + } else { + // Private cloud or other environments if (endpointParts?.[4]?.endsWith('.cn')) { // China has a special URL pattern - const parts = endpointParts?.[4]?.split('.'); + const parts = endpointParts[4].split('.'); parts.splice(2, 0, 'apps'); url = `${fdcUrl}sapui5flex${parts.join('.')}`; + } else { + url = `${fdcUrl}sapui5flex.cfapps${endpointParts?.[4]}`; } } + + // Add authorization token for non-BAS environments or private cloud + // For BAS environments with mTLS, the certificate authentication is handled automatically if (!isAppStudio() || !endpointParts?.[3]) { - // Adding authorization token for none BAS enviourment and - // for private cloud as a temporary solution until enablement of cert auth options.headers['Authorization'] = `Bearer ${cfConfig.token}`; } + return { url: url, options @@ -414,27 +423,54 @@ export class FDCService { public async getBaseApps(credentials: Credentials[], includeInvalid = false): Promise { const appHostIds = getAppHostIds(credentials); this.logger?.log(`App Host Ids: ${JSON.stringify(appHostIds)}`); - const discoveryApps = await Promise.all( - Array.from(appHostIds).map(async (appHostId: string) => { - try { - const response = await this.getFDCApps(appHostId); - if (response.status === 200) { - const results = (response.data as any)?.['results'] as CFApp[]; - results.forEach((result) => (result.appHostId = appHostId)); // store appHostId in order to know by which appHostId was the app selected - return results; - } - throw new Error( - `Failed to connect to Flexibility Design and Configuration service for app_host_id ${appHostId}. Reason: HTTP status code ${response.status.toString()}: ${ - response.statusText - }` - ); - } catch (error) { - return [{ appHostId: appHostId, messages: [error.message] }]; - } - }) - ).then((results) => results.flat()); - const validatedApps = await this.getValidatedApps(discoveryApps, credentials); + // Validate appHostIds array length (max 100 as per API specification) + if (appHostIds.size > 100) { + throw new Error(`Too many appHostIds provided. Maximum allowed is 100, but ${appHostIds.size} were found.`); + } + + const appHostIdsArray = Array.from(appHostIds); + + try { + const response = await this.getFDCApps(appHostIdsArray); + + if (response.status === 200) { + const results = (response.data as any)?.['results'] as CFApp[]; + + return this.processApps(results, credentials, includeInvalid); + } else { + throw new Error( + `Failed to connect to Flexibility Design and Configuration service. Reason: HTTP status code ${response.status}: ${response.statusText}` + ); + } + } catch (error) { + this.logger?.error(`Error in getBaseApps: ${error.message}`); + + // Create error apps for each appHostId and validate them to maintain original behavior + const errorApps: CFApp[] = appHostIdsArray.map((appHostId) => ({ + appId: '', + appName: '', + appVersion: '', + serviceName: '', + title: '', + appHostId, + messages: [error.message] + })); + + return this.processApps(errorApps, credentials, includeInvalid); + } + } + + /** + * Process and validate apps, then filter based on includeInvalid flag. + * + * @param apps - Array of apps to process + * @param credentials - Credentials for validation + * @param includeInvalid - Whether to include invalid apps in the result + * @returns Processed and filtered apps + */ + private async processApps(apps: CFApp[], credentials: Credentials[], includeInvalid: boolean): Promise { + const validatedApps = await this.getValidatedApps(apps, credentials); return includeInvalid ? validatedApps : validatedApps.filter((app) => !app.messages?.length); } @@ -536,10 +572,16 @@ export class FDCService { service-plan: `); } - private async getFDCApps(appHostId: string): Promise { + private async getFDCApps(appHostIds: string[]): Promise { const requestArguments = getFDCRequestArguments(this.cfConfig); - this.logger?.log(`App Host: ${appHostId}, request arguments: ${JSON.stringify(requestArguments)}`); - const url = `${requestArguments.url}/api/business-service/discovery?appHostId=${appHostId}`; + this.logger?.log( + `App Hosts: ${JSON.stringify(appHostIds)}, request arguments: ${JSON.stringify(requestArguments)}` + ); + + // Construct the URL with multiple appHostIds as separate query parameters + // Format: ?appHostId=&appHostId=&appHostId= + const appHostIdParams = appHostIds.map((id) => `appHostId=${encodeURIComponent(id)}`).join('&'); + const url = `${requestArguments.url}/api/business-service/discovery?${appHostIdParams}`; try { const isLoggedIn = await this.isLoggedIn(); @@ -568,8 +610,8 @@ export class FDCService { } } - private async getValidatedApps(discoveryApps: any[], credentials: any): Promise { - const validatedApps: any[] = []; + private async getValidatedApps(discoveryApps: CFApp[], credentials: Credentials[]): Promise { + const validatedApps: CFApp[] = []; await Promise.all( discoveryApps.map(async (app) => { if (!(app.messages && app.messages.length)) { @@ -582,7 +624,7 @@ export class FDCService { return validatedApps; } - private async validateSelectedApp(appParams: AppParams, credentials: any): Promise { + private async validateSelectedApp(appParams: AppParams, credentials: Credentials[]): Promise { try { const { entries, serviceInstanceGuid, manifest } = await downloadAppContent( this.cfConfig.space.GUID, From 9dc9effc6c4388910f5204652352893c340c7e18 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 26 Aug 2025 15:45:32 +0300 Subject: [PATCH 034/111] fix: app host id not found --- packages/adp-tooling/src/cf/fdc.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 63369bc763c..34e1512a181 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -1,8 +1,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import axios from 'axios'; import type * as AdmZip from 'adm-zip'; +import axios, { type AxiosResponse } from 'axios'; import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import { isAppStudio } from '@sap-ux/btp-utils'; @@ -14,7 +14,6 @@ import type { CFConfig, Space, Organization, - HttpResponse, CFApp, RequestArguments, Credentials, @@ -34,6 +33,10 @@ const HOMEDRIVE = 'HOMEDRIVE'; const HOMEPATH = 'HOMEPATH'; const WIN32 = 'win32'; +interface FDCResponse { + results: CFApp[]; +} + /** * Validate the smart template application. * @@ -435,9 +438,9 @@ export class FDCService { const response = await this.getFDCApps(appHostIdsArray); if (response.status === 200) { - const results = (response.data as any)?.['results'] as CFApp[]; - - return this.processApps(results, credentials, includeInvalid); + // TODO: Remove this once the FDC API is updated to return the appHostId + const apps = response.data.results.map((app) => ({ ...app, appHostId: appHostIdsArray[0] })); + return this.processApps(apps, credentials, includeInvalid); } else { throw new Error( `Failed to connect to Flexibility Design and Configuration service. Reason: HTTP status code ${response.status}: ${response.statusText}` @@ -572,7 +575,7 @@ export class FDCService { service-plan: `); } - private async getFDCApps(appHostIds: string[]): Promise { + private async getFDCApps(appHostIds: string[]): Promise> { const requestArguments = getFDCRequestArguments(this.cfConfig); this.logger?.log( `App Hosts: ${JSON.stringify(appHostIds)}, request arguments: ${JSON.stringify(requestArguments)}` @@ -589,7 +592,7 @@ export class FDCService { await CFLocal.cfGetAvailableOrgs(); this.loadConfig(); } - const response = await axios.get(url, requestArguments.options); + const response = await axios.get(url, requestArguments.options); this.logger?.log( `Getting FDC apps. Request url: ${url} response status: ${ response.status From 05f049fa55d7d510d11b71e875b7bbea352492e5 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 26 Aug 2025 16:37:21 +0300 Subject: [PATCH 035/111] fix: incorrect result validation --- packages/generator-adp/src/app/questions/cf-services.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 2d9b35aee57..9df3391566d 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -218,11 +218,9 @@ export class CFServicesPrompter { } }, validate: (value: string) => { - const validationResult = validateEmptyString(value); - if (typeof validationResult === 'string') { + if (!value) { return t('error.baseAppHasToBeSelected'); } - if (this.baseAppOnChoiceError !== null) { return this.baseAppOnChoiceError; } From c378bfe03b8681557c80769dd276cee782891612 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 26 Aug 2025 16:57:31 +0300 Subject: [PATCH 036/111] fix: eliminate race conditions --- packages/adp-tooling/src/cf/fdc.ts | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 34e1512a181..6720b0fe2db 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -615,15 +615,15 @@ export class FDCService { private async getValidatedApps(discoveryApps: CFApp[], credentials: Credentials[]): Promise { const validatedApps: CFApp[] = []; - await Promise.all( - discoveryApps.map(async (app) => { - if (!(app.messages && app.messages.length)) { - const messages = await this.validateSelectedApp(app, credentials); - app.messages = messages; - } - validatedApps.push(app); - }) - ); + + for (const app of discoveryApps) { + if (!(app.messages && app.messages.length)) { + const messages = await this.validateSelectedApp(app, credentials); + app.messages = messages; + } + validatedApps.push(app); + } + return validatedApps; } @@ -659,13 +659,11 @@ export class FDCService { private async getResources(files: string[]): Promise { let finalList: string[] = []; - await Promise.all( - files.map(async (file) => { - const servicesList = this.getServicesForFile(file); - const oDataFilteredServices = await this.filterServices(servicesList); - finalList = finalList.concat(oDataFilteredServices); - }) - ); + for (const file of files) { + const servicesList = this.getServicesForFile(file); + const oDataFilteredServices = await this.filterServices(servicesList); + finalList = finalList.concat(oDataFilteredServices); + } return finalList; } From 44c64bf47c3113960cb7222e1c48c98f6f8b3e85 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 27 Aug 2025 10:31:50 +0300 Subject: [PATCH 037/111] fix: approuter incompatibilty issue --- packages/adp-tooling/src/cf/fdc.ts | 3 +- packages/adp-tooling/src/cf/yaml.ts | 50 ++++++++++++------- packages/adp-tooling/src/types.ts | 4 +- packages/adp-tooling/src/writer/cf.ts | 2 +- .../src/app/questions/cf-services.ts | 10 ++-- 5 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 6720b0fe2db..38ffe48a713 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -23,6 +23,7 @@ import type { CFServiceOffering, CFAPIResponse } from '../types'; +import type { AppRouterType } from '../types'; import { t } from '../i18n'; import { downloadAppContent } from './html5-repo'; import { YamlUtils, getRouterType } from './yaml'; @@ -491,7 +492,7 @@ export class FDCService { }); } - public getApprouterType(): string { + public getApprouterType(): AppRouterType { return getRouterType(YamlUtils.yamlContent); } diff --git a/packages/adp-tooling/src/cf/yaml.ts b/packages/adp-tooling/src/cf/yaml.ts index f09d1339499..c31bddbe13b 100644 --- a/packages/adp-tooling/src/cf/yaml.ts +++ b/packages/adp-tooling/src/cf/yaml.ts @@ -5,6 +5,7 @@ import yaml from 'js-yaml'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../types'; +import { AppRouterType } from '../types'; import { createService } from './utils'; const CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; @@ -66,17 +67,17 @@ export function parseMtaFile(file: string): Yaml { * Gets the router type. * * @param {Yaml} yamlContent - The YAML content. - * @returns {string} The router type. + * @returns {AppRouterType} The router type. */ -export function getRouterType(yamlContent: Yaml): string { - const filterd: MTAModule[] | undefined = yamlContent?.modules?.filter( +export function getRouterType(yamlContent: Yaml): AppRouterType { + const filtered: MTAModule[] | undefined = yamlContent?.modules?.filter( (module: { name: string }) => module.name.includes('destination-content') || module.name.includes('approuter') ); - const routerType = filterd?.pop(); + const routerType = filtered?.pop(); if (routerType?.name.includes('approuter')) { - return 'Standalone Approuter'; + return AppRouterType.STANDALONE; } else { - return 'Approuter Managed by SAP Cloud Platform'; + return AppRouterType.MANAGED; } } @@ -153,7 +154,7 @@ function adjustMtaYamlStandaloneApprouter( function adjustMtaYamlManagedApprouter( yamlContent: any, projectName: string, - businessSolution: any, + businessSolution: string, businessService: string ): void { const appRouterName = `${projectName}-destination-content`; @@ -421,25 +422,42 @@ function writeFileCallback(error: any): void { } } +/** + * The YAML utilities class. + */ export class YamlUtils { public static timestamp: string; public static yamlContent: Yaml; public static spaceGuid: string; - private static STANDALONE_APPROUTER = 'Standalone Approuter'; - private static APPROUTER_MANAGED = 'Approuter Managed by SAP Cloud Platform'; private static yamlPath: string; private static HTML5_APPS_REPO = 'html5-apps-repo'; + /** + * Loads the YAML content. + * + * @param {string} file - The file. + */ public static loadYamlContent(file: string): void { const parsed = parseMtaFile(file); this.yamlContent = parsed as Yaml; this.yamlPath = file; } + /** + * Adjusts the MTA YAML. + * + * @param {string} projectPath - The project path. + * @param {string} moduleName - The module name. + * @param {AppRouterType} appRouterType - The app router type. + * @param {string} businessSolutionName - The business solution name. + * @param {string} businessService - The business service. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The promise. + */ public static async adjustMtaYaml( projectPath: string, moduleName: string, - appRouterType: string, + appRouterType: AppRouterType, businessSolutionName: string, businessService: string, logger?: ToolsLogger @@ -464,18 +482,14 @@ export class YamlUtils { const initialServices = yamlContent.resources.map( (resource: { parameters: { service: string } }) => resource.parameters.service ); - if (appRouterType === this.STANDALONE_APPROUTER) { + const isStandaloneApprouter = appRouterType === AppRouterType.STANDALONE; + if (isStandaloneApprouter) { adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businessServices, businessService); - } else if (appRouterType === this.APPROUTER_MANAGED) { + } else { adjustMtaYamlManagedApprouter(yamlContent, projectName, businessSolutionName, businessService); } adjustMtaYamlUDeployer(yamlContent, projectName, moduleName); - adjustMtaYamlResources( - yamlContent, - projectName, - appRouterType === this.APPROUTER_MANAGED, - this.getProjectNameForXsSecurity() - ); + adjustMtaYamlResources(yamlContent, projectName, !isStandaloneApprouter, this.getProjectNameForXsSecurity()); adjustMtaYamlOwnModule(yamlContent, moduleName); // should go last since it sorts the modules (workaround, should be removed after fixed in deployment module) adjustMtaYamlFlpModule(yamlContent, projectName, businessService); diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 54f66463352..4969c355231 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -931,7 +931,7 @@ export interface CfAdpWriterConfig { org: Organization; space: Space; html5RepoRuntimeGuid: string; - approuter: string; + approuter: AppRouterType; businessService: string; businessSolutionName?: string; }; @@ -1039,7 +1039,7 @@ export enum cfServicesPromptNames { } export type CfServicesAnswers = { - [cfServicesPromptNames.approuter]?: string; + [cfServicesPromptNames.approuter]?: AppRouterType; [cfServicesPromptNames.businessService]?: string; [cfServicesPromptNames.businessSolutionName]?: string; // Base app object returned by discovery (shape provided by FDC service) diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 79e34e3bbf4..f71d1864f61 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -49,7 +49,7 @@ export function createCfConfig(params: CreateCfConfigParams): CfAdpWriterConfig org: params.cfConfig.org, space: params.cfConfig.space, html5RepoRuntimeGuid: params.html5RepoRuntimeGuid, - approuter: params.cfServicesAnswers.approuter ?? '', + approuter: params.cfServicesAnswers.approuter ?? AppRouterType.MANAGED, businessService: params.cfServicesAnswers.businessService ?? '', businessSolutionName: params.cfServicesAnswers.businessSolutionName }, diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 9df3391566d..83387f9de01 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -2,7 +2,8 @@ import { type CfServicesAnswers, type CFServicesQuestion, type CfServicesPromptOptions, - cfServicesPromptNames + cfServicesPromptNames, + type AppRouterType } from '@sap-ux/adp-tooling'; import type { ToolsLogger } from '@sap-ux/logger'; import { validateEmptyString } from '@sap-ux/project-input-validator'; @@ -33,7 +34,7 @@ export class CFServicesPrompter { /** * The type of approuter to use. */ - private approuter: string | undefined; + private approuter: AppRouterType; /** * The business services available. */ @@ -147,11 +148,10 @@ export class CFServicesPrompter { (mtaProjectPath.indexOf('/') > -1 ? mtaProjectPath.split('/').pop() : mtaProjectPath.split('\\').pop()) ?? ''; + const hasRouter = this.fdcService.hasApprouter(mtaProjectName, modules); if (hasRouter) { - // keep behavior even if getApprouterType is not declared in typing - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.approuter = this.fdcService.getApprouterType?.(); + this.approuter = this.fdcService.getApprouterType(); } if (this.isCFLoggedIn && !hasRouter) { From d88ca7b0971707d9addd75600d12a9ac0043bf29 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 27 Aug 2025 14:59:14 +0300 Subject: [PATCH 038/111] feat: major refactoring for fdc service, and cf related business logic --- packages/adp-tooling/src/cf/fdc.ts | 358 ++++++++++++------ packages/adp-tooling/src/cf/utils.ts | 11 +- packages/adp-tooling/src/cf/yaml-loader.ts | 72 ++++ packages/adp-tooling/src/cf/yaml.ts | 107 ++---- packages/adp-tooling/src/types.ts | 8 +- packages/adp-tooling/src/writer/cf.ts | 6 - packages/generator-adp/src/app/index.ts | 8 +- .../src/app/questions/cf-services.ts | 13 +- .../src/app/questions/helper/choices.ts | 9 +- 9 files changed, 376 insertions(+), 216 deletions(-) create mode 100644 packages/adp-tooling/src/cf/yaml-loader.ts diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 38ffe48a713..f9b3f784079 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -18,15 +18,17 @@ import type { RequestArguments, Credentials, ServiceKeys, - BusinessSeviceResource, AppParams, CFServiceOffering, - CFAPIResponse + CFAPIResponse, + BusinessServiceResource, + Resource } from '../types'; import type { AppRouterType } from '../types'; import { t } from '../i18n'; import { downloadAppContent } from './html5-repo'; import { YamlUtils, getRouterType } from './yaml'; +import { YamlLoader } from './yaml-loader'; import { getApplicationType, isSupportedAppTypeForAdp } from '../source'; import { checkForCf, getAuthToken, getServiceInstanceKeys, requestCfApi } from './utils'; @@ -65,7 +67,7 @@ async function validateSmartTemplateApplication(manifest: Manifest): Promise { if (item.entryName.endsWith('manifest.json')) { try { - manifest = JSON.parse(item.getData().toString('utf8')); + manifest = JSON.parse(item.getData().toString('utf8')) as Manifest; } catch (e) { throw new Error(`Failed to parse manifest.json. Reason: ${e.message}`); } @@ -249,7 +251,7 @@ function getAppHostIds(credentials: Credentials[]): Set { credentials.forEach((credential) => { const appHostId = credential['html5-apps-repo']?.app_host_id; if (appHostId) { - appHostIds.push(appHostId.split(',').map((item: any) => item.trim())); // there might be multiple appHostIds separated by comma + appHostIds.push(appHostId.split(',').map((item: string) => item.trim())); // there might be multiple appHostIds separated by comma } }); @@ -269,7 +271,7 @@ export async function getSpaces(spaceGuid: string, logger: ToolsLogger): Promise let spaces: Space[] = []; if (spaceGuid) { try { - spaces = await CFLocal.cfGetAvailableSpaces(spaceGuid); + spaces = (await CFLocal.cfGetAvailableSpaces(spaceGuid)) as Space[]; logger?.log(`Available spaces: ${JSON.stringify(spaces)} for space guid: ${spaceGuid}.`); } catch (error) { logger?.error('Cannot get spaces'); @@ -281,33 +283,148 @@ export async function getSpaces(spaceGuid: string, logger: ToolsLogger): Promise return spaces; } +/** + * Get the approuter type. + * + * @param {string} mtaProjectPath - The path to the mta project. + * @returns {AppRouterType} The approuter type. + */ +export function getApprouterType(mtaProjectPath: string): AppRouterType { + const yamlContent = YamlLoader.getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); + return getRouterType(yamlContent); +} + +/** + * Get the module names. + * + * @param {string} mtaProjectPath - The path to the mta project. + * @returns {string[]} The module names. + */ +export function getModuleNames(mtaProjectPath: string): string[] { + const yamlContent = YamlLoader.getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); + return yamlContent?.modules?.map((module: { name: string }) => module.name) ?? []; +} + +/** + * Get the services for the file. + * + * @param {string} mtaFilePath - The path to the mta file. + * @param {ToolsLogger} logger - The logger. + * @returns {BusinessServiceResource[]} The services. + */ +export function getServicesForFile(mtaFilePath: string, logger: ToolsLogger): BusinessServiceResource[] { + const serviceNames: BusinessServiceResource[] = []; + const parsed = YamlLoader.getYamlContent(mtaFilePath); + if (parsed?.resources && Array.isArray(parsed.resources)) { + parsed.resources.forEach((resource: Resource) => { + const name = resource?.parameters?.['service-name'] || resource.name; + const label = resource?.parameters?.service; + if (name) { + serviceNames.push({ name, label }); + if (!label) { + logger?.log(`Service '${name}' will be ignored without 'service' parameter`); + } + } + }); + } + return serviceNames; +} + +/** + * Filter services based on the business services. + * + * @param {BusinessServiceResource[]} businessServices - The business services. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The filtered services. + */ +async function filterServices(businessServices: BusinessServiceResource[], logger: ToolsLogger): Promise { + const serviceLabels = businessServices.map((service) => service.label).filter((label) => label); + if (serviceLabels.length > 0) { + const url = `/v3/service_offerings?names=${serviceLabels.join(',')}`; + const json = await requestCfApi>(url); + logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); + + const businessServiceNames = new Set(businessServices.map((service) => service.label)); + const result: string[] = []; + json?.resources?.forEach((resource: CFServiceOffering) => { + if (businessServiceNames.has(resource.name)) { + const sapService = resource?.['broker_catalog']?.metadata?.sapservice; + if (sapService && ['v2', 'v4'].includes(sapService?.odataversion ?? '')) { + result.push(businessServices?.find((service) => resource.name === service.label)?.name ?? ''); + } else { + logger?.log(`Service '${resource.name}' doesn't support V2/V4 Odata and will be ignored`); + } + } + }); + + if (result.length > 0) { + return result; + } + } + throw new Error(`No business services found, please specify the business services in resource section of mts.yaml: + - name: + type: org.cloudfoundry.-service + parameters: + service: + service-name: + service-plan: `); +} + +/** + * Get the resources for the file. + * + * @param {string} mtaFilePath - The path to the mta file. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The resources. + */ +async function getResources(mtaFilePath: string, logger: ToolsLogger): Promise { + const servicesList = getServicesForFile(mtaFilePath, logger); + const oDataFilteredServices = await filterServices(servicesList, logger); + return oDataFilteredServices; +} + +/** + * Format the discovery. + * + * @param {CFApp} app - The app. + * @returns {string} The formatted discovery. + */ +export function formatDiscovery(app: CFApp): string { + return `${app.title} (${app.appId} ${app.appVersion})`; +} + +/** + * The FDC service. + */ export class FDCService { public html5RepoRuntimeGuid: string; - public manifests: any[] = []; - private CF_HOME = 'CF_HOME'; - private BEARER_SPACE = 'bearer '; - private CF_FOLDER_NAME = '.cf'; - private CONFIG_JSON_FILE = 'config.json'; - private API_CF = 'api.cf.'; - private OK = 'OK'; - private HTML5_APPS_REPO = 'html5-apps-repo'; - private MTA_YAML_FILE = 'mta.yaml'; + public manifests: Manifest[] = []; + private cfConfig: CFConfig; private vscode: any; private logger: ToolsLogger; + /** + * Constructor. + * + * @param {ToolsLogger} logger - The logger. + * @param {any} vscode - The vscode. + */ constructor(logger: ToolsLogger, vscode: any) { this.vscode = vscode; this.logger = logger; } + /** + * Load the config. + */ public loadConfig(): void { - let cfHome = process.env[this.CF_HOME]; + let cfHome = process.env['CF_HOME']; if (!cfHome) { - cfHome = path.join(getHomedir(), this.CF_FOLDER_NAME); + cfHome = path.join(getHomedir(), '.cf'); } - const configFileLocation = path.join(cfHome, this.CONFIG_JSON_FILE); + const configFileLocation = path.join(cfHome, 'config.json'); let config = {} as Config; try { @@ -317,16 +434,15 @@ export class FDCService { this.logger?.error('Cannot receive token from config.json'); } - const API_CF = this.API_CF; if (config) { const result = {} as CFConfig; if (config.Target) { - const apiCfIndex = config.Target.indexOf(API_CF); - result.url = config.Target.substring(apiCfIndex + API_CF.length); + const apiCfIndex = config.Target.indexOf('api.cf.'); + result.url = config.Target.substring(apiCfIndex + 'api.cf.'.length); } if (config.AccessToken) { - result.token = config.AccessToken.substring(this.BEARER_SPACE.length); + result.token = config.AccessToken.substring('bearer '.length); } if (config.OrganizationFields) { @@ -348,6 +464,11 @@ export class FDCService { } } + /** + * Check if the user is logged in. + * + * @returns {Promise} Whether the user is logged in. + */ public async isLoggedIn(): Promise { let isLogged = false; let orgs; @@ -369,38 +490,44 @@ export class FDCService { return isLogged; } + /** + * Check if the external login is enabled. + * + * @returns {Promise} Whether the external login is enabled. + */ public async isExternalLoginEnabled(): Promise { const commands = await this.vscode.commands.getCommands(); return commands.includes('cf.login'); } - public async isLoggedInToDifferentSource(organizacion: string, space: string, apiurl: string): Promise { + /** + * Check if the user is logged in to a different source. + * + * @param {string} organization - The organization. + * @param {string} space - The space. + * @param {string} apiUrl - The API URL. + * @returns {Promise} Whether the user is logged in to a different source. + */ + public async isLoggedInToDifferentSource(organization: string, space: string, apiUrl: string): Promise { const isLoggedIn = await this.isLoggedIn(); const cfConfig = this.getConfig(); const isLoggedToDifferentSource = isLoggedIn && - (cfConfig.org.Name !== organizacion || cfConfig.space.Name !== space || cfConfig.url !== apiurl); + (cfConfig.org.Name !== organization || cfConfig.space.Name !== space || cfConfig.url !== apiUrl); return isLoggedToDifferentSource; } - public async login(username: string, password: string, apiEndpoint: string): Promise { - let isSuccessful = false; - const loginResponse = await CFLocal.cfLogin(apiEndpoint, username, password); - if (loginResponse === this.OK) { - isSuccessful = true; - } else { - throw new Error('Login failed'); - } - - return isSuccessful; - } - + /** + * Get the organizations. + * + * @returns {Promise} The organizations. + */ public async getOrganizations(): Promise { let organizations: Organization[] = []; try { - organizations = await CFLocal.cfGetAvailableOrgs(); + organizations = (await CFLocal.cfGetAvailableOrgs()) as Organization[]; this.logger?.log(`Available organizations: ${JSON.stringify(organizations)}`); } catch (error) { this.logger?.error('Cannot get organizations'); @@ -409,6 +536,13 @@ export class FDCService { return organizations; } + /** + * Set the organization and space. + * + * @param {string} orgName - The organization name. + * @param {string} spaceName - The space name. + * @returns {Promise} The void. + */ public async setOrgSpace(orgName: string, spaceName: string): Promise { if (!orgName || !spaceName) { throw new Error('Organization or space name is not provided.'); @@ -418,12 +552,25 @@ export class FDCService { this.loadConfig(); } + /** + * Get the services for the project. + * + * @param {string} projectPath - The path to the project. + * @returns {Promise} The services. + */ public async getServices(projectPath: string): Promise { const services = await this.readMta(projectPath); this.logger?.log(`Available services defined in mta.yaml: ${JSON.stringify(services)}`); return services; } + /** + * Get the base apps. + * + * @param {Credentials[]} credentials - The credentials. + * @param {boolean} [includeInvalid] - Whether to include invalid apps. + * @returns {Promise} The base apps. + */ public async getBaseApps(credentials: Credentials[], includeInvalid = false): Promise { const appHostIds = getAppHostIds(credentials); this.logger?.log(`App Host Ids: ${JSON.stringify(appHostIds)}`); @@ -478,6 +625,13 @@ export class FDCService { return includeInvalid ? validatedApps : validatedApps.filter((app) => !app.messages?.length); } + /** + * Check if the project has an approuter. + * + * @param {string} projectName - The project name. + * @param {string[]} moduleNames - The module names. + * @returns {boolean} Whether the project has an approuter. + */ public hasApprouter(projectName: string, moduleNames: string[]): boolean { return moduleNames.some( (name) => @@ -486,29 +640,34 @@ export class FDCService { ); } - public getManifestByBaseAppId(appId: string) { + /** + * Get the manifest by base app id. + * + * @param {string} appId - The app id. + * @returns {Manifest | undefined} The manifest. + */ + public getManifestByBaseAppId(appId: string): Manifest | undefined { return this.manifests.find((appManifest) => { return appManifest['sap.app'].id === appId; }); } - public getApprouterType(): AppRouterType { - return getRouterType(YamlUtils.yamlContent); - } - - public getModuleNames(mtaProjectPath: string): string[] { - YamlUtils.loadYamlContent(path.join(mtaProjectPath, this.MTA_YAML_FILE)); - return YamlUtils.yamlContent?.modules?.map((module: { name: any }) => module.name) || []; - } - - public formatDiscovery(app: any): string { - return `${app.title} (${app.appId} ${app.appVersion})`; - } - + /** + * Get the config. + * + * @returns {CFConfig} The config. + */ public getConfig(): CFConfig { return this.cfConfig; } + /** + * Validate the OData endpoints. + * + * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. + * @param {Credentials[]} credentials - The credentials. + * @returns {Promise} The messages. + */ public async validateODataEndpoints(zipEntries: AdmZip.IZipEntry[], credentials: Credentials[]): Promise { const messages: string[] = []; let xsApp, manifest; @@ -543,39 +702,12 @@ export class FDCService { return messages; } - private async filterServices(businessServices: BusinessSeviceResource[]): Promise { - const serviceLabels = businessServices.map((service) => service.label).filter((label) => label); - if (serviceLabels.length > 0) { - const url = `/v3/service_offerings?names=${serviceLabels.join(',')}`; - const json = await requestCfApi>(url); - this.logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); - - const businessServiceNames = new Set(businessServices.map((service) => service.label)); - const result: string[] = []; - json?.resources?.forEach((resource: CFServiceOffering) => { - if (businessServiceNames.has(resource.name)) { - const sapService = resource?.['broker_catalog']?.metadata?.sapservice; - if (sapService && ['v2', 'v4'].includes(sapService?.odataversion ?? '')) { - result.push(businessServices?.find((service) => resource.name === service.label)?.name ?? ''); - } else { - this.logger?.log(`Service '${resource.name}' doesn't support V2/V4 Odata and will be ignored`); - } - } - }); - - if (result.length > 0) { - return result; - } - } - throw new Error(`No business services found, please specify the business services in resource section of mts.yaml: - - name: - type: org.cloudfoundry.-service - parameters: - service: - service-name: - service-plan: `); - } - + /** + * Get the FDC apps. + * + * @param {string[]} appHostIds - The app host ids. + * @returns {Promise>} The FDC apps. + */ private async getFDCApps(appHostIds: string[]): Promise> { const requestArguments = getFDCRequestArguments(this.cfConfig); this.logger?.log( @@ -614,11 +746,18 @@ export class FDCService { } } + /** + * Get the validated apps. + * + * @param {CFApp[]} discoveryApps - The discovery apps. + * @param {Credentials[]} credentials - The credentials. + * @returns {Promise} The validated apps. + */ private async getValidatedApps(discoveryApps: CFApp[], credentials: Credentials[]): Promise { const validatedApps: CFApp[] = []; for (const app of discoveryApps) { - if (!(app.messages && app.messages.length)) { + if (!app.messages?.length) { const messages = await this.validateSelectedApp(app, credentials); app.messages = messages; } @@ -628,6 +767,13 @@ export class FDCService { return validatedApps; } + /** + * Validate the selected app. + * + * @param {AppParams} appParams - The app parameters. + * @param {Credentials[]} credentials - The credentials. + * @returns {Promise} The messages. + */ private async validateSelectedApp(appParams: AppParams, credentials: Credentials[]): Promise { try { const { entries, serviceInstanceGuid, manifest } = await downloadAppContent( @@ -648,43 +794,19 @@ export class FDCService { } } + /** + * Read the mta file. + * + * @param {string} projectPath - The path to the project. + * @returns {Promise} The resources. + */ private async readMta(projectPath: string): Promise { if (!projectPath) { throw new Error('Project path is missing.'); } - const mtaYamlPath = path.resolve(projectPath, this.MTA_YAML_FILE); - return this.getResources([mtaYamlPath]); - } - - private async getResources(files: string[]): Promise { - let finalList: string[] = []; - - for (const file of files) { - const servicesList = this.getServicesForFile(file); - const oDataFilteredServices = await this.filterServices(servicesList); - finalList = finalList.concat(oDataFilteredServices); - } - - return finalList; - } - - private getServicesForFile(file: string): BusinessSeviceResource[] { - const serviceNames: BusinessSeviceResource[] = []; - YamlUtils.loadYamlContent(file); - const parsed = YamlUtils.yamlContent; - if (parsed && parsed.resources && Array.isArray(parsed.resources)) { - parsed.resources.forEach((resource: any) => { - const name = resource?.['parameters']?.['service-name'] || resource.name; - const label = resource?.['parameters']?.service; - if (name) { - serviceNames.push({ name, label }); - if (!label) { - this.logger?.log(`Service '${name}' will be ignored without 'service' parameter`); - } - } - }); - } - return serviceNames; + const mtaFilePath = path.resolve(projectPath, 'mta.yaml'); + const resources = await getResources(mtaFilePath, this.logger); + return resources; } } diff --git a/packages/adp-tooling/src/cf/utils.ts b/packages/adp-tooling/src/cf/utils.ts index 1a8fe6c80c2..529634c9c19 100644 --- a/packages/adp-tooling/src/cf/utils.ts +++ b/packages/adp-tooling/src/cf/utils.ts @@ -15,7 +15,6 @@ import type { CFServiceInstance, CFServiceOffering } from '../types'; -import { YamlUtils } from './yaml'; const ENV = { env: { 'CF_COLOR': 'false' } }; const CREATE_SERVICE_KEY = 'create-service-key'; @@ -59,6 +58,7 @@ export async function getServiceInstanceKeys( * @param {string[]} tags - The tags. * @param {string | null} securityFilePath - The security file path. * @param {string | null} serviceName - The service name. + * @param {string} [xsSecurityProjectName] - The project name for XS security. */ export async function createService( spaceGuid: string, @@ -67,7 +67,8 @@ export async function createService( logger?: ToolsLogger, tags: string[] = [], securityFilePath: string | null = null, - serviceName: string | undefined = undefined + serviceName: string | undefined = undefined, + xsSecurityProjectName?: string ): Promise { try { if (!serviceName) { @@ -87,11 +88,10 @@ export async function createService( if (securityFilePath) { let xsSecurity = null; try { - // TODO: replace with the path to the xs-security.json file from the templates - const filePath = path.resolve(__dirname, '../templates/cf/xs-security.json'); + const filePath = path.resolve(__dirname, '../../templates/cf/xs-security.json'); const xsContent = fs.readFileSync(filePath, 'utf-8'); xsSecurity = JSON.parse(xsContent); - xsSecurity.xsappname = YamlUtils.getProjectNameForXsSecurity(); + xsSecurity.xsappname = xsSecurityProjectName; } catch (err) { throw new Error('xs-security.json could not be parsed.'); } @@ -105,7 +105,6 @@ export async function createService( throw new Error(query.stderr); } } catch (e) { - // const errorMessage = Messages.FAILED_TO_CREATE_SERVICE_INSTANCE(serviceInstanceName, spaceGuid, e.message); const errorMessage = `Cannot create a service instance '${serviceInstanceName}' in space '${spaceGuid}'. Reason: ${e.message}`; logger?.error(errorMessage); throw new Error(errorMessage); diff --git a/packages/adp-tooling/src/cf/yaml-loader.ts b/packages/adp-tooling/src/cf/yaml-loader.ts new file mode 100644 index 00000000000..fcbac759f98 --- /dev/null +++ b/packages/adp-tooling/src/cf/yaml-loader.ts @@ -0,0 +1,72 @@ +import fs from 'fs'; +import yaml from 'js-yaml'; + +import type { Yaml } from '../types'; + +/** + * Parses the MTA file. + * + * @param {string} file - The file to parse. + * @returns {Yaml} The parsed YAML content. + */ +export function parseMtaFile(file: string): Yaml { + if (!fs.existsSync(file)) { + throw new Error(`Could not find file ${file}`); + } + + const content = fs.readFileSync(file, 'utf-8'); + let parsed: Yaml; + try { + parsed = yaml.load(content) as Yaml; + return parsed; + } catch (e) { + throw new Error(`Error parsing file ${file}`); + } +} + +/** + * Gets the project name from YAML content. + * + * @param {Yaml} yamlContent - The YAML content. + * @returns {string | null} The project name or null if not found. + */ +export function getProjectName(yamlContent: Yaml): string | null { + return yamlContent?.ID || null; +} + +/** + * Gets the project name for XS security from YAML content. + * + * @param {Yaml} yamlContent - The YAML content. + * @param {string} timestamp - The timestamp to append. + * @returns {string | null} The project name for XS security or null if not available. + */ +export function getProjectNameForXsSecurity(yamlContent: Yaml, timestamp: string): string | undefined { + const projectName = getProjectName(yamlContent); + if (!projectName || !timestamp) { + return undefined; + } + return `${projectName.toLowerCase().replace(/\./g, '_')}_${timestamp}`; +} + +/** + * Static YAML content loader. + * Handles loading and storing YAML content. + */ +export class YamlLoader { + private static yamlContent: Yaml | null = null; + + /** + * Gets the loaded YAML content. + * + * @param {string} filePath - The file path to load. + * @param {boolean} [forceReload] - Whether to force reload and bypass cache. + * @returns {Yaml} The YAML content. + */ + public static getYamlContent(filePath: string, forceReload: boolean = false): Yaml { + if (!this.yamlContent || forceReload) { + this.yamlContent = parseMtaFile(filePath); + } + return this.yamlContent; + } +} diff --git a/packages/adp-tooling/src/cf/yaml.ts b/packages/adp-tooling/src/cf/yaml.ts index c31bddbe13b..9a51bb0c377 100644 --- a/packages/adp-tooling/src/cf/yaml.ts +++ b/packages/adp-tooling/src/cf/yaml.ts @@ -7,6 +7,7 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../types'; import { AppRouterType } from '../types'; import { createService } from './utils'; +import { getProjectNameForXsSecurity, YamlLoader } from './yaml-loader'; const CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; const HTML5_APPS_REPO = 'html5-apps-repo'; @@ -41,28 +42,6 @@ export function getSAPCloudService(yamlContent: Yaml): string { return sapCloudService; } -/** - * Parses the MTA file. - * - * @param {string} file - The file to parse. - * @returns {Yaml} The parsed YAML content. - */ -export function parseMtaFile(file: string): Yaml { - if (!fs.existsSync(file)) { - throw new Error(`Could not find file ${file}`); - } - - const content = fs.readFileSync(file, 'utf-8'); - let parsed: Yaml; - try { - parsed = yaml.load(content) as Yaml; - - return parsed; - } catch (e) { - throw new Error(`Error parsing file ${file}`); - } -} - /** * Gets the router type. * @@ -89,9 +68,9 @@ export function getRouterType(yamlContent: Yaml): AppRouterType { */ export function getAppParamsFromUI5Yaml(projectPath: string): AppParamsExtended { const ui5YamlPath = path.join(projectPath, 'ui5.yaml'); - const parsedMtaFile = parseMtaFile(ui5YamlPath) as any; + const parsedYaml = YamlLoader.getYamlContent(ui5YamlPath) as any; - const appConfiguration = parsedMtaFile?.builder?.customTasks[0]?.configuration; + const appConfiguration = parsedYaml?.builder?.customTasks?.[0]?.configuration; const appParams: AppParamsExtended = { appHostId: appConfiguration?.appHostId, appName: appConfiguration?.appName, @@ -107,15 +86,9 @@ export function getAppParamsFromUI5Yaml(projectPath: string): AppParamsExtended * * @param {any} yamlContent - The YAML content. * @param {string} projectName - The project name. - * @param {ConcatArray} resourceNames - The resource names. * @param {string} businessService - The business service. */ -function adjustMtaYamlStandaloneApprouter( - yamlContent: any, - projectName: string, - resourceNames: ConcatArray, - businessService: string -): void { +function adjustMtaYamlStandaloneApprouter(yamlContent: any, projectName: string, businessService: string): void { const appRouterName = `${projectName}-approuter`; let appRouter = yamlContent.modules.find((module: { name: string }) => module.name === appRouterName); if (appRouter == null) { @@ -278,15 +251,16 @@ function adjustMtaYamlUDeployer(yamlContent: any, projectName: string, moduleNam * * @param {any} yamlContent - The YAML content. * @param {string} projectName - The project name. + * @param {string} timestamp - The timestamp. * @param {boolean} isManagedAppRouter - Whether the approuter is managed. - * @param {string} projectNameForXsSecurity - The project name for XS security. */ function adjustMtaYamlResources( yamlContent: any, projectName: string, - isManagedAppRouter: boolean, - projectNameForXsSecurity: string + timestamp: string, + isManagedAppRouter: boolean ): void { + const projectNameForXsSecurity = getProjectNameForXsSecurity(yamlContent, timestamp); const resources: Resource[] = [ { name: `${projectName}_html_repo_host`, @@ -426,23 +400,10 @@ function writeFileCallback(error: any): void { * The YAML utilities class. */ export class YamlUtils { - public static timestamp: string; - public static yamlContent: Yaml; public static spaceGuid: string; private static yamlPath: string; private static HTML5_APPS_REPO = 'html5-apps-repo'; - /** - * Loads the YAML content. - * - * @param {string} file - The file. - */ - public static loadYamlContent(file: string): void { - const parsed = parseMtaFile(file); - this.yamlContent = parsed as Yaml; - this.yamlPath = file; - } - /** * Adjusts the MTA YAML. * @@ -462,7 +423,10 @@ export class YamlUtils { businessService: string, logger?: ToolsLogger ): Promise { - this.setTimestamp(); + const timestamp = Date.now().toString(); + + const mtaYamlPath = path.join(projectPath, 'mta.yaml'); + const loadedYamlContent = YamlLoader.getYamlContent(mtaYamlPath); const defaultYaml = { ID: projectPath.split(path.sep).pop(), @@ -473,51 +437,52 @@ export class YamlUtils { }; if (!appRouterType) { - appRouterType = getRouterType(this.yamlContent); + appRouterType = getRouterType(loadedYamlContent); } - const yamlContent = Object.assign(defaultYaml, this.yamlContent); + const yamlContent = Object.assign(defaultYaml, loadedYamlContent); const projectName = yamlContent.ID.toLowerCase(); - const businessServices = yamlContent.resources.map((resource: { name: string }) => resource.name); const initialServices = yamlContent.resources.map( (resource: { parameters: { service: string } }) => resource.parameters.service ); const isStandaloneApprouter = appRouterType === AppRouterType.STANDALONE; if (isStandaloneApprouter) { - adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businessServices, businessService); + adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businessService); } else { adjustMtaYamlManagedApprouter(yamlContent, projectName, businessSolutionName, businessService); } adjustMtaYamlUDeployer(yamlContent, projectName, moduleName); - adjustMtaYamlResources(yamlContent, projectName, !isStandaloneApprouter, this.getProjectNameForXsSecurity()); + adjustMtaYamlResources(yamlContent, projectName, timestamp, !isStandaloneApprouter); adjustMtaYamlOwnModule(yamlContent, moduleName); // should go last since it sorts the modules (workaround, should be removed after fixed in deployment module) adjustMtaYamlFlpModule(yamlContent, projectName, businessService); const updatedYamlContent = yaml.dump(yamlContent); - await this.createServices(yamlContent.resources, initialServices, logger); + await this.createServices(projectPath, yamlContent, initialServices, timestamp, logger); return fs.writeFile(this.yamlPath, updatedYamlContent, 'utf-8', writeFileCallback); } - public static getProjectName(): string { - return this.yamlContent.ID; - } - - public static getProjectNameForXsSecurity(): string { - return `${this.getProjectName().toLowerCase().replace(/\./g, '_')}_${this.timestamp}`; - } - - private static setTimestamp(): void { - this.timestamp = Date.now().toString(); - } - + /** + * Creates the services. + * + * @param {string} projectPath - The project path. + * @param {Yaml} yamlContent - The YAML content. + * @param {string[]} initialServices - The initial services. + * @param {string} timestamp - The timestamp. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The promise. + */ private static async createServices( - resources: any[], + projectPath: string, + yamlContent: Yaml, initialServices: string[], + timestamp: string, logger?: ToolsLogger ): Promise { const excludeServices = initialServices.concat(['portal', this.HTML5_APPS_REPO]); - const xsSecurityPath = this.yamlPath.replace('mta.yaml', 'xs-security.json'); + const xsSecurityPath = path.join(projectPath, 'xs-security.json'); + const resources = yamlContent.resources as any[]; + const xsSecurityProjectName = getProjectNameForXsSecurity(yamlContent, timestamp); for (const resource of resources) { if (!excludeServices.includes(resource.parameters.service)) { if (resource.parameters.service === 'xsuaa') { @@ -528,7 +493,8 @@ export class YamlUtils { logger, [], xsSecurityPath, - resource.parameters.service + resource.parameters.service, + xsSecurityProjectName ); } else { await createService( @@ -538,7 +504,8 @@ export class YamlUtils { logger, [], '', - resource.parameters.service + resource.parameters.service, + xsSecurityProjectName ); } } diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 4969c355231..7ddc8d0e266 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -5,6 +5,7 @@ import type { OperationsType } from '@sap-ux/axios-extension'; import type { Editor } from 'mem-fs-editor'; import type { Destination } from '@sap-ux/btp-utils'; import type { YUIQuestion } from '@sap-ux/inquirer-common'; +import type AdmZip from 'adm-zip'; export interface DescriptorVariant { layer: UI5FlexLayer; @@ -839,10 +840,9 @@ export interface ServiceKeys { } export interface HTML5Content { - // entries: AdmZip.IZipEntry[]; - entries: any[]; // TODO: replace with AdmZip.IZipEntry[] + entries: AdmZip.IZipEntry[]; serviceInstanceGuid: string; - manifest: any; + manifest: Manifest; } export interface ServiceInstance { @@ -856,7 +856,7 @@ export interface GetServiceInstanceParams { names: string[]; } -export interface BusinessSeviceResource { +export interface BusinessServiceResource { name: string; label: string; } diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index f71d1864f61..141e9a95aca 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -1,5 +1,4 @@ import { join } from 'path'; -import fs from 'fs'; import { create as createStorage } from 'mem-fs'; import { create, type Editor } from 'mem-fs-editor'; @@ -128,11 +127,6 @@ function setDefaultsCF(config: CfAdpWriterConfig): CfAdpWriterConfig { async function adjustMtaYaml(basePath: string, config: CfAdpWriterConfig): Promise { const { app, cf } = config; - const mtaYamlPath = join(basePath, 'mta.yaml'); - if (fs.existsSync(mtaYamlPath)) { - YamlUtils.loadYamlContent(mtaYamlPath); - } - await YamlUtils.adjustMtaYaml(basePath, app.id, cf.approuter, cf.businessSolutionName ?? '', cf.businessService); } diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index f7acfadf815..3af00b2ae71 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -18,7 +18,6 @@ import { SourceManifest, isCFEnvironment, getBaseAppInbounds, - YamlUtils, isMtaProject, isCfInstalled, generateCf, @@ -65,6 +64,7 @@ import { FDCService } from '@sap-ux/adp-tooling'; import { getTargetEnvPrompt, getProjectPathPrompt } from './questions/target-env'; import { isAppStudio } from '@sap-ux/btp-utils'; import { getTemplatesOverwritePath } from '../utils/templates'; +import { YamlLoader } from '@sap-ux/adp-tooling/src/cf/yaml-loader'; const generatorTitle = 'Adaptation Project'; @@ -415,7 +415,7 @@ export default class extends Generator { this.logger.log(`Project path information: ${this.projectLocation}`); } else { this.cfProjectDestinationPath = this.destinationRoot(process.cwd()); - YamlUtils.loadYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); + YamlLoader.getYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); this.logger.log(`Project path information: ${this.cfProjectDestinationPath}`); } } @@ -527,6 +527,10 @@ export default class extends Generator { const manifest = this.fdcService.getManifestByBaseAppId(this.cfServicesAnswers.baseApp?.appId ?? ''); + if (!manifest) { + throw new Error('Manifest not found for base app.'); + } + const cfConfig = createCfConfig({ attributeAnswers: this.attributeAnswers, cfServicesAnswers: this.cfServicesAnswers, diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 83387f9de01..b6c477558d0 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -3,7 +3,9 @@ import { type CFServicesQuestion, type CfServicesPromptOptions, cfServicesPromptNames, - type AppRouterType + type AppRouterType, + getModuleNames, + getApprouterType } from '@sap-ux/adp-tooling'; import type { ToolsLogger } from '@sap-ux/logger'; import { validateEmptyString } from '@sap-ux/project-input-validator'; @@ -143,7 +145,7 @@ export class CFServicesPrompter { message: t('prompts.approuterLabel'), choices: getAppRouterChoices(this.isInternalUsage), when: () => { - const modules = this.fdcService.getModuleNames(mtaProjectPath); + const modules = getModuleNames(mtaProjectPath); const mtaProjectName = (mtaProjectPath.indexOf('/') > -1 ? mtaProjectPath.split('/').pop() @@ -151,7 +153,7 @@ export class CFServicesPrompter { const hasRouter = this.fdcService.hasApprouter(mtaProjectName, modules); if (hasRouter) { - this.approuter = this.fdcService.getApprouterType(); + this.approuter = getApprouterType(mtaProjectPath); } if (this.isCFLoggedIn && !hasRouter) { @@ -208,7 +210,7 @@ export class CFServicesPrompter { this.apps = await this.fdcService.getBaseApps(this.businessServiceKeys.credentials); this.logger?.log(`Available applications: ${JSON.stringify(this.apps)}`); } - return getCFAppChoices(this.apps, this.fdcService); + return getCFAppChoices(this.apps); } catch (e) { // log error: baseApp => choices /* the error will be shown by the validation functionality */ @@ -245,7 +247,8 @@ export class CFServicesPrompter { name: cfServicesPromptNames.businessService, message: t('prompts.businessServiceLabel'), choices: this.businessServices, - default: (_answers?: any) => (this.businessServices.length === 1 ? this.businessServices[0] ?? '' : ''), + default: (_: CfServicesAnswers) => + this.businessServices.length === 1 ? this.businessServices[0] ?? '' : '', when: (answers: CfServicesAnswers) => { return this.isCFLoggedIn && (this.approuter || answers.approuter); }, diff --git a/packages/generator-adp/src/app/questions/helper/choices.ts b/packages/generator-adp/src/app/questions/helper/choices.ts index f06a5985266..5595e534b17 100644 --- a/packages/generator-adp/src/app/questions/helper/choices.ts +++ b/packages/generator-adp/src/app/questions/helper/choices.ts @@ -1,5 +1,5 @@ -import { AppRouterType } from '@sap-ux/adp-tooling'; -import type { CFApp, FDCService, SourceApplication } from '@sap-ux/adp-tooling'; +import { AppRouterType, formatDiscovery } from '@sap-ux/adp-tooling'; +import type { CFApp, SourceApplication } from '@sap-ux/adp-tooling'; interface Choice { name: string; @@ -31,12 +31,11 @@ export const getApplicationChoices = (apps: SourceApplication[]): Choice[] => { * Get the choices for the base app. * * @param {CFApp[]} apps - The apps to get the choices for. - * @param {FDCService} fdcService - The FDC service instance. * @returns {Array<{ name: string; value: CFApp }>} The choices for the base app. */ -export const getCFAppChoices = (apps: CFApp[], fdcService: FDCService): { name: string; value: CFApp }[] => { +export const getCFAppChoices = (apps: CFApp[]): { name: string; value: CFApp }[] => { return apps.map((result: CFApp) => ({ - name: fdcService.formatDiscovery?.(result) ?? `${result.title} (${result.appId}, ${result.appVersion})`, + name: formatDiscovery(result) ?? `${result.title} (${result.appId}, ${result.appVersion})`, value: result })); }; From 52051cfbe52e21e277f64b81c0f96cbfdd24d151 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 27 Aug 2025 17:11:22 +0300 Subject: [PATCH 039/111] feat: further split code and refactor cf config, auth, fdc service --- packages/adp-tooling/src/cf/auth.ts | 62 +++ packages/adp-tooling/src/cf/config.ts | 113 ++++++ packages/adp-tooling/src/cf/fdc.ts | 355 +++++------------- packages/adp-tooling/src/cf/index.ts | 2 + packages/generator-adp/src/app/index.ts | 27 +- .../src/app/questions/cf-services.ts | 77 ++-- .../src/app/questions/helper/validators.ts | 12 +- .../src/app/questions/target-env.ts | 12 +- 8 files changed, 343 insertions(+), 317 deletions(-) create mode 100644 packages/adp-tooling/src/cf/auth.ts create mode 100644 packages/adp-tooling/src/cf/config.ts diff --git a/packages/adp-tooling/src/cf/auth.ts b/packages/adp-tooling/src/cf/auth.ts new file mode 100644 index 00000000000..acf89610c24 --- /dev/null +++ b/packages/adp-tooling/src/cf/auth.ts @@ -0,0 +1,62 @@ +import CFLocal = require('@sap/cf-tools/out/src/cf-local'); + +import type { ToolsLogger } from '@sap-ux/logger'; + +import { getAuthToken, checkForCf } from './utils'; +import type { CFConfig, Organization } from '../types'; + +/** + * Check if CF is installed. + * + * @returns {Promise} True if CF is installed, false otherwise. + */ +export async function isCfInstalled(): Promise { + let isInstalled = true; + try { + await checkForCf(); + } catch (error) { + isInstalled = false; + } + + return isInstalled; +} + +/** + * Check if the external login is enabled. + * + * @param {any} vscode - The vscode instance. + * @returns {Promise} Whether the external login is enabled. + */ +export async function isExternalLoginEnabled(vscode: any): Promise { + const commands = await vscode.commands.getCommands(); + return commands.includes('cf.login'); +} + +/** + * Check if the user is logged in Cloud Foundry. + * + * @param {CFConfig} cfConfig - The CF config. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} Whether the user is logged in. + */ +export async function isLoggedInCf(cfConfig: CFConfig, logger: ToolsLogger): Promise { + let isLogged = false; + let orgs: Organization[] = []; + + await getAuthToken(); + + if (cfConfig) { + try { + orgs = (await CFLocal.cfGetAvailableOrgs()) as Organization[]; + logger?.log(`Available organizations: ${JSON.stringify(orgs)}`); + if (orgs.length > 0) { + isLogged = true; + } + } catch (e) { + logger?.error(`Error occurred while trying to check if it is logged in: ${e?.message}`); + isLogged = false; + } + } + + return isLogged; +} diff --git a/packages/adp-tooling/src/cf/config.ts b/packages/adp-tooling/src/cf/config.ts new file mode 100644 index 00000000000..c1ac725cb16 --- /dev/null +++ b/packages/adp-tooling/src/cf/config.ts @@ -0,0 +1,113 @@ +import os from 'os'; +import fs from 'fs'; +import path from 'path'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import type { CFConfig, Config } from '../types'; + +const HOMEDRIVE = 'HOMEDRIVE'; +const HOMEPATH = 'HOMEPATH'; +const WIN32 = 'win32'; + +/** + * Get the home directory. + * + * @returns {string} The home directory. + */ +function getHomedir(): string { + let homedir = os.homedir(); + const homeDrive = process.env?.[HOMEDRIVE]; + const homePath = process.env?.[HOMEPATH]; + if (process.platform === WIN32 && typeof homeDrive === 'string' && typeof homePath === 'string') { + homedir = path.join(homeDrive, homePath); + } + + return homedir; +} + +/** + * Cloud Foundry Configuration Service + */ +export class CfConfigService { + /** + * The CF configuration. + */ + private cfConfig: CFConfig; + /** + * The logger. + */ + private logger: ToolsLogger; + + /** + * Creates an instance of CfConfigService. + * + * @param {ToolsLogger} logger - The logger. + */ + constructor(logger: ToolsLogger) { + this.logger = logger; + } + + /** + * Get the current configuration. + * + * @returns {CFConfig} The configuration. + */ + public getConfig(): CFConfig { + if (!this.cfConfig) { + this.cfConfig = this.loadConfig(); + } + + return this.cfConfig; + } + + /** + * Load the CF configuration. + * + * @returns {CFConfig} The CF configuration. + */ + private loadConfig(): CFConfig { + let cfHome = process.env['CF_HOME']; + if (!cfHome) { + cfHome = path.join(getHomedir(), '.cf'); + } + + const configFileLocation = path.join(cfHome, 'config.json'); + + let config = {} as Config; + try { + const configAsString = fs.readFileSync(configFileLocation, 'utf-8'); + config = JSON.parse(configAsString) as Config; + } catch (e) { + this.logger?.error('Cannot receive token from config.json'); + } + + const result = {} as CFConfig; + if (config) { + if (config.Target) { + const apiCfIndex = config.Target.indexOf('api.cf.'); + result.url = config.Target.substring(apiCfIndex + 'api.cf.'.length); + } + + if (config.AccessToken) { + result.token = config.AccessToken.substring('bearer '.length); + } + + if (config.OrganizationFields) { + result.org = { + Name: config.OrganizationFields.Name, + GUID: config.OrganizationFields.GUID + }; + } + + if (config.SpaceFields) { + result.space = { + Name: config.SpaceFields.Name, + GUID: config.SpaceFields.GUID + }; + } + } + + return result; + } +} diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index f9b3f784079..89d070b6414 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -1,6 +1,4 @@ -import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import type * as AdmZip from 'adm-zip'; import axios, { type AxiosResponse } from 'axios'; import CFLocal = require('@sap/cf-tools/out/src/cf-local'); @@ -10,10 +8,7 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import type { - Config, CFConfig, - Space, - Organization, CFApp, RequestArguments, Credentials, @@ -30,11 +25,9 @@ import { downloadAppContent } from './html5-repo'; import { YamlUtils, getRouterType } from './yaml'; import { YamlLoader } from './yaml-loader'; import { getApplicationType, isSupportedAppTypeForAdp } from '../source'; -import { checkForCf, getAuthToken, getServiceInstanceKeys, requestCfApi } from './utils'; - -const HOMEDRIVE = 'HOMEDRIVE'; -const HOMEPATH = 'HOMEPATH'; -const WIN32 = 'win32'; +import { getServiceInstanceKeys, requestCfApi } from './utils'; +import { isLoggedInCf } from './auth'; +import type { CfConfigService } from './config'; interface FDCResponse { results: CFApp[]; @@ -62,38 +55,6 @@ async function validateSmartTemplateApplication(manifest: Manifest): Promise} True if CF is installed, false otherwise. - */ -export async function isCfInstalled(): Promise { - let isInstalled = true; - try { - await checkForCf(); - } catch (error) { - isInstalled = false; - } - - return isInstalled; -} - /** * Get the business service keys. * @@ -260,29 +221,6 @@ function getAppHostIds(credentials: Credentials[]): Set { return new Set(appHostIds.flat()); } -/** - * Get the spaces. - * - * @param {string} spaceGuid - The space guid. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} The spaces. - */ -export async function getSpaces(spaceGuid: string, logger: ToolsLogger): Promise { - let spaces: Space[] = []; - if (spaceGuid) { - try { - spaces = (await CFLocal.cfGetAvailableSpaces(spaceGuid)) as Space[]; - logger?.log(`Available spaces: ${JSON.stringify(spaces)} for space guid: ${spaceGuid}.`); - } catch (error) { - logger?.error('Cannot get spaces'); - } - } else { - logger?.error('Invalid GUID'); - } - - return spaces; -} - /** * Get the approuter type. * @@ -394,162 +332,103 @@ export function formatDiscovery(app: CFApp): string { } /** - * The FDC service. + * Get the FDC apps. + * + * @param {string[]} appHostIds - The app host ids. + * @param {CFConfig} cfConfig - The CF config. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise>} The FDC apps. */ -export class FDCService { - public html5RepoRuntimeGuid: string; - public manifests: Manifest[] = []; +export async function getFDCApps( + appHostIds: string[], + cfConfig: CFConfig, + logger: ToolsLogger +): Promise> { + const requestArguments = getFDCRequestArguments(cfConfig); + logger?.log(`App Hosts: ${JSON.stringify(appHostIds)}, request arguments: ${JSON.stringify(requestArguments)}`); - private cfConfig: CFConfig; - private vscode: any; - private logger: ToolsLogger; + // Construct the URL with multiple appHostIds as separate query parameters + // Format: ?appHostId=&appHostId=&appHostId= + const appHostIdParams = appHostIds.map((id) => `appHostId=${encodeURIComponent(id)}`).join('&'); + const url = `${requestArguments.url}/api/business-service/discovery?${appHostIdParams}`; - /** - * Constructor. - * - * @param {ToolsLogger} logger - The logger. - * @param {any} vscode - The vscode. - */ - constructor(logger: ToolsLogger, vscode: any) { - this.vscode = vscode; - this.logger = logger; + try { + const isLoggedIn = await isLoggedInCf(cfConfig, logger); + if (!isLoggedIn) { + await CFLocal.cfGetAvailableOrgs(); + } + const response = await axios.get(url, requestArguments.options); + logger?.log( + `Getting FDC apps. Request url: ${url} response status: ${response.status}, response data: ${JSON.stringify( + response.data + )}` + ); + return response; + } catch (e) { + logger?.error( + `Getting FDC apps. Request url: ${url}, response status: ${e?.response?.status}, message: ${e.message || e}` + ); + throw new Error( + `Failed to get application from Flexibility Design and Configuration service ${url}. Reason: ${ + e.message || e + }` + ); } +} +/** + * Check if the project has an approuter. + * + * @param {string} projectName - The project name. + * @param {string[]} moduleNames - The module names. + * @returns {boolean} Whether the project has an approuter. + */ +export function hasApprouter(projectName: string, moduleNames: string[]): boolean { + return moduleNames.some( + (name) => + name === `${projectName.toLowerCase()}-destination-content` || + name === `${projectName.toLowerCase()}-approuter` + ); +} + +/** + * The FDC service. + */ +export class FDCService { /** - * Load the config. + * The HTML5 repo runtime GUID. */ - public loadConfig(): void { - let cfHome = process.env['CF_HOME']; - if (!cfHome) { - cfHome = path.join(getHomedir(), '.cf'); - } - - const configFileLocation = path.join(cfHome, 'config.json'); - - let config = {} as Config; - try { - const configAsString = fs.readFileSync(configFileLocation, 'utf-8'); - config = JSON.parse(configAsString) as Config; - } catch (e) { - this.logger?.error('Cannot receive token from config.json'); - } - - if (config) { - const result = {} as CFConfig; - if (config.Target) { - const apiCfIndex = config.Target.indexOf('api.cf.'); - result.url = config.Target.substring(apiCfIndex + 'api.cf.'.length); - } - - if (config.AccessToken) { - result.token = config.AccessToken.substring('bearer '.length); - } - - if (config.OrganizationFields) { - result.org = { - Name: config.OrganizationFields.Name, - GUID: config.OrganizationFields.GUID - }; - } - - if (config.SpaceFields) { - result.space = { - Name: config.SpaceFields.Name, - GUID: config.SpaceFields.GUID - }; - } - - this.cfConfig = result; - YamlUtils.spaceGuid = this.cfConfig?.space?.GUID; - } - } - + public html5RepoRuntimeGuid: string; /** - * Check if the user is logged in. - * - * @returns {Promise} Whether the user is logged in. + * The apps' manifests. */ - public async isLoggedIn(): Promise { - let isLogged = false; - let orgs; - await getAuthToken(); - this.loadConfig(); - if (this.cfConfig) { - try { - orgs = await CFLocal.cfGetAvailableOrgs(); - this.logger?.log(`Available organizations: ${JSON.stringify(orgs)}`); - if (orgs.length > 0) { - isLogged = true; - } - } catch (e) { - this.logger?.error(`Error occured while trying to check if it is logged in: ${e?.message}`); - isLogged = false; - } - } - - return isLogged; - } - + public manifests: Manifest[] = []; /** - * Check if the external login is enabled. - * - * @returns {Promise} Whether the external login is enabled. + * The CF config service. */ - public async isExternalLoginEnabled(): Promise { - const commands = await this.vscode.commands.getCommands(); - - return commands.includes('cf.login'); - } - + private cfConfigService: CfConfigService; /** - * Check if the user is logged in to a different source. - * - * @param {string} organization - The organization. - * @param {string} space - The space. - * @param {string} apiUrl - The API URL. - * @returns {Promise} Whether the user is logged in to a different source. + * The CF config. */ - public async isLoggedInToDifferentSource(organization: string, space: string, apiUrl: string): Promise { - const isLoggedIn = await this.isLoggedIn(); - const cfConfig = this.getConfig(); - const isLoggedToDifferentSource = - isLoggedIn && - (cfConfig.org.Name !== organization || cfConfig.space.Name !== space || cfConfig.url !== apiUrl); - - return isLoggedToDifferentSource; - } - + private cfConfig: CFConfig; /** - * Get the organizations. - * - * @returns {Promise} The organizations. + * The logger. */ - public async getOrganizations(): Promise { - let organizations: Organization[] = []; - try { - organizations = (await CFLocal.cfGetAvailableOrgs()) as Organization[]; - this.logger?.log(`Available organizations: ${JSON.stringify(organizations)}`); - } catch (error) { - this.logger?.error('Cannot get organizations'); - } - - return organizations; - } + private logger: ToolsLogger; /** - * Set the organization and space. + * Creates an instance of FDCService. * - * @param {string} orgName - The organization name. - * @param {string} spaceName - The space name. - * @returns {Promise} The void. + * @param {ToolsLogger} logger - The logger. + * @param {CfConfigService} cfConfigService - The CF config service. */ - public async setOrgSpace(orgName: string, spaceName: string): Promise { - if (!orgName || !spaceName) { - throw new Error('Organization or space name is not provided.'); + constructor(logger: ToolsLogger, cfConfigService: CfConfigService) { + this.logger = logger; + this.cfConfigService = cfConfigService; + this.cfConfig = cfConfigService.getConfig(); + if (this.cfConfig) { + YamlUtils.spaceGuid = this.cfConfig.space.GUID; } - - await CFLocal.cfSetOrgSpace(orgName, spaceName); - this.loadConfig(); } /** @@ -583,7 +462,8 @@ export class FDCService { const appHostIdsArray = Array.from(appHostIds); try { - const response = await this.getFDCApps(appHostIdsArray); + const cfConfig = this.cfConfigService.getConfig(); + const response = await getFDCApps(appHostIdsArray, cfConfig, this.logger); if (response.status === 200) { // TODO: Remove this once the FDC API is updated to return the appHostId @@ -625,21 +505,6 @@ export class FDCService { return includeInvalid ? validatedApps : validatedApps.filter((app) => !app.messages?.length); } - /** - * Check if the project has an approuter. - * - * @param {string} projectName - The project name. - * @param {string[]} moduleNames - The module names. - * @returns {boolean} Whether the project has an approuter. - */ - public hasApprouter(projectName: string, moduleNames: string[]): boolean { - return moduleNames.some( - (name) => - name === `${projectName.toLowerCase()}-destination-content` || - name === `${projectName.toLowerCase()}-approuter` - ); - } - /** * Get the manifest by base app id. * @@ -652,15 +517,6 @@ export class FDCService { }); } - /** - * Get the config. - * - * @returns {CFConfig} The config. - */ - public getConfig(): CFConfig { - return this.cfConfig; - } - /** * Validate the OData endpoints. * @@ -702,50 +558,6 @@ export class FDCService { return messages; } - /** - * Get the FDC apps. - * - * @param {string[]} appHostIds - The app host ids. - * @returns {Promise>} The FDC apps. - */ - private async getFDCApps(appHostIds: string[]): Promise> { - const requestArguments = getFDCRequestArguments(this.cfConfig); - this.logger?.log( - `App Hosts: ${JSON.stringify(appHostIds)}, request arguments: ${JSON.stringify(requestArguments)}` - ); - - // Construct the URL with multiple appHostIds as separate query parameters - // Format: ?appHostId=&appHostId=&appHostId= - const appHostIdParams = appHostIds.map((id) => `appHostId=${encodeURIComponent(id)}`).join('&'); - const url = `${requestArguments.url}/api/business-service/discovery?${appHostIdParams}`; - - try { - const isLoggedIn = await this.isLoggedIn(); - if (!isLoggedIn) { - await CFLocal.cfGetAvailableOrgs(); - this.loadConfig(); - } - const response = await axios.get(url, requestArguments.options); - this.logger?.log( - `Getting FDC apps. Request url: ${url} response status: ${ - response.status - }, response data: ${JSON.stringify(response.data)}` - ); - return response; - } catch (e) { - this.logger?.error( - `Getting FDC apps. Request url: ${url}, response status: ${e?.response?.status}, message: ${ - e.message || e - }` - ); - throw new Error( - `Failed to get application from Flexibility Design and Configuration service ${url}. Reason: ${ - e.message || e - }` - ); - } - } - /** * Get the validated apps. * @@ -776,8 +588,9 @@ export class FDCService { */ private async validateSelectedApp(appParams: AppParams, credentials: Credentials[]): Promise { try { + const cfConfig = this.cfConfigService.getConfig(); const { entries, serviceInstanceGuid, manifest } = await downloadAppContent( - this.cfConfig.space.GUID, + cfConfig.space.GUID, appParams, this.logger ); diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index 21c4b6afb50..85ee2c26afa 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -2,3 +2,5 @@ export * from './html5-repo'; export * from './utils'; export * from './yaml'; export * from './fdc'; +export * from './auth'; +export * from './config'; diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 3af00b2ae71..891a7a84dde 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -3,7 +3,6 @@ import { join } from 'path'; import Generator from 'yeoman-generator'; import { AppWizard, MessageType, Prompts as YeomanUiSteps, type IPrompt } from '@sap-devx/yeoman-ui-types'; -import type { CFConfig, CfServicesAnswers } from '@sap-ux/adp-tooling'; import { FlexLayer, SystemLookup, @@ -19,10 +18,12 @@ import { isCFEnvironment, getBaseAppInbounds, isMtaProject, - isCfInstalled, generateCf, - createCfConfig + createCfConfig, + isCfInstalled, + isLoggedInCf } from '@sap-ux/adp-tooling'; +import { type CFConfig, CfConfigService, type CfServicesAnswers } from '@sap-ux/adp-tooling'; import { ToolsLogger } from '@sap-ux/logger'; import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; import type { AbapServiceProvider } from '@sap-ux/axios-extension'; @@ -145,13 +146,14 @@ export default class extends Generator { private readonly isMtaYamlFound: boolean; private targetEnv: TargetEnv; private isCfEnv = false; - private isCFLoggedIn = false; + private isCfLoggedIn = false; private cfConfig: CFConfig; private projectLocation: string; private cfProjectDestinationPath: string; private cfServicesAnswers: CfServicesAnswers; private isExtensionInstalled: boolean; private cfInstalled: boolean; + private cfConfigService: CfConfigService; /** * Creates an instance of the generator. * @@ -163,15 +165,16 @@ export default class extends Generator { this.appWizard = opts.appWizard ?? AppWizard.create(opts); this.shouldInstallDeps = opts.shouldInstallDeps ?? true; this.toolsLogger = new ToolsLogger(); - this.fdcService = new FDCService(this.logger, opts.vscode); + this._setupLogging(); + + this.cfConfigService = new CfConfigService(this.logger); + this.fdcService = new FDCService(this.logger, this.cfConfigService); this.isMtaYamlFound = isMtaProject(process.cwd()) as boolean; // TODO: Remove this once the PR is ready. this.isExtensionInstalled = true; // isExtensionInstalled(opts.vscode, 'SAP.adp-ve-bas-ext'); this.vscode = opts.vscode; this.options = opts; - this._setupLogging(); - const jsonInputString = getFirstArgAsString(args); this.jsonInput = parseJsonInput(jsonInputString, this.logger); @@ -200,7 +203,8 @@ export default class extends Generator { this.systemLookup = new SystemLookup(this.logger); this.cfInstalled = await isCfInstalled(); - this.isCFLoggedIn = await this.fdcService.isLoggedIn(); + const cfConfig = this.cfConfigService.getConfig(); + this.isCfLoggedIn = await isLoggedInCf(cfConfig, this.logger); this.logger.info(`isCfInstalled: ${this.cfInstalled}`); if (!this.jsonInput) { @@ -439,7 +443,7 @@ export default class extends Generator { */ private async _promptForTargetEnvironment(): Promise { const targetEnvAnswers = await this.prompt([ - getTargetEnvPrompt(this.appWizard, this.cfInstalled, this.isCFLoggedIn, this.fdcService) + getTargetEnvPrompt(this.appWizard, this.cfInstalled, this.isCfLoggedIn, this.cfConfigService, this.vscode) ]); this.targetEnv = targetEnvAnswers.targetEnv; @@ -448,7 +452,7 @@ export default class extends Generator { updateCfWizardSteps(this.isCfEnv, this.prompts); - this.cfConfig = this.fdcService.getConfig(); + this.cfConfig = this.cfConfigService.getConfig(); this.logger.log(`Project organization information: ${JSON.stringify(this.cfConfig.org, null, 2)}`); this.logger.log(`Project space information: ${JSON.stringify(this.cfConfig.space, null, 2)}`); this.logger.log(`Project apiUrl information: ${JSON.stringify(this.cfConfig.url, null, 2)}`); @@ -486,8 +490,9 @@ export default class extends Generator { this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); const cfServicesQuestions = await getCFServicesPrompts({ - isCFLoggedIn: this.isCFLoggedIn, + isCfLoggedIn: this.isCfLoggedIn, fdcService: this.fdcService, + cfConfigService: this.cfConfigService, mtaProjectPath: this.cfProjectDestinationPath, isInternalUsage: isInternalFeaturesSettingEnabled(), logger: this.logger diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index b6c477558d0..00808a3bff2 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -1,11 +1,16 @@ +import type { + CfServicesAnswers, + CFServicesQuestion, + CfServicesPromptOptions, + AppRouterType, + CfConfigService +} from '@sap-ux/adp-tooling'; import { - type CfServicesAnswers, - type CFServicesQuestion, - type CfServicesPromptOptions, cfServicesPromptNames, - type AppRouterType, getModuleNames, - getApprouterType + getApprouterType, + hasApprouter, + isLoggedInCf } from '@sap-ux/adp-tooling'; import type { ToolsLogger } from '@sap-ux/logger'; import { validateEmptyString } from '@sap-ux/project-input-validator'; @@ -21,14 +26,26 @@ import { showBusinessSolutionNameQuestion } from './helper/conditions'; * Prompter for CF services. */ export class CFServicesPrompter { + /** + * The FDC service instance. + */ private readonly fdcService: FDCService; + /** + * The CF auth service instance. + */ + private readonly cfConfigService: CfConfigService; + /** + * Whether the user is using the internal usage. + */ private readonly isInternalUsage: boolean; + /** + * The logger instance. + */ private readonly logger: ToolsLogger; - /** * Whether the user is logged in to Cloud Foundry. */ - private isCFLoggedIn = false; + private isCfLoggedIn = false; /** * Whether to show the solution name prompt. */ @@ -62,15 +79,23 @@ export class CFServicesPrompter { * Constructor for CFServicesPrompter. * * @param {FDCService} fdcService - FDC service instance. - * @param {boolean} isCFLoggedIn - Whether the user is logged in to Cloud Foundry. + * @param {CfConfigService} cfConfigService - CF config service instance. + * @param {boolean} isCfLoggedIn - Whether the user is logged in to Cloud Foundry. * @param {ToolsLogger} logger - Logger instance. * @param {boolean} [isInternalUsage] - Internal usage flag. */ - constructor(fdcService: FDCService, isCFLoggedIn: boolean, logger: ToolsLogger, isInternalUsage: boolean = false) { + constructor( + fdcService: FDCService, + cfConfigService: CfConfigService, + isCfLoggedIn: boolean, + logger: ToolsLogger, + isInternalUsage: boolean = false + ) { this.fdcService = fdcService; + this.cfConfigService = cfConfigService; this.isInternalUsage = isInternalUsage; this.logger = logger; - this.isCFLoggedIn = isCFLoggedIn; + this.isCfLoggedIn = isCfLoggedIn; } /** @@ -84,7 +109,7 @@ export class CFServicesPrompter { mtaProjectPath: string, promptOptions?: CfServicesPromptOptions ): Promise { - if (this.isCFLoggedIn) { + if (this.isCfLoggedIn) { this.businessServices = await this.fdcService.getServices(mtaProjectPath); } @@ -118,7 +143,7 @@ export class CFServicesPrompter { when: (answers: CfServicesAnswers) => showBusinessSolutionNameQuestion( answers, - this.isCFLoggedIn, + this.isCfLoggedIn, this.showSolutionNamePrompt, answers.businessService ), @@ -139,6 +164,7 @@ export class CFServicesPrompter { * @returns {CFServicesQuestion} Prompt for approuter. */ private getAppRouterPrompt(mtaProjectPath: string): CFServicesQuestion { + const cfConfig = this.cfConfigService.getConfig(); return { type: 'list', name: cfServicesPromptNames.approuter, @@ -151,12 +177,12 @@ export class CFServicesPrompter { ? mtaProjectPath.split('/').pop() : mtaProjectPath.split('\\').pop()) ?? ''; - const hasRouter = this.fdcService.hasApprouter(mtaProjectName, modules); + const hasRouter = hasApprouter(mtaProjectName, modules); if (hasRouter) { this.approuter = getApprouterType(mtaProjectPath); } - if (this.isCFLoggedIn && !hasRouter) { + if (this.isCfLoggedIn && !hasRouter) { this.showSolutionNamePrompt = true; return true; } else { @@ -164,8 +190,8 @@ export class CFServicesPrompter { } }, validate: async (value: string) => { - this.isCFLoggedIn = await this.fdcService.isLoggedIn(); - if (!this.isCFLoggedIn) { + this.isCfLoggedIn = await isLoggedInCf(cfConfig, this.logger); + if (!this.isCfLoggedIn) { return t('error.cfNotLoggedIn'); } @@ -198,7 +224,7 @@ export class CFServicesPrompter { this.baseAppOnChoiceError = null; if (this.cachedServiceName != answers.businessService) { this.cachedServiceName = answers.businessService; - const config = this.fdcService.getConfig(); + const config = this.cfConfigService.getConfig(); this.businessServiceKeys = await getBusinessServiceKeys( answers.businessService ?? '', config, @@ -228,7 +254,7 @@ export class CFServicesPrompter { } return true; }, - when: (answers: any) => this.isCFLoggedIn && answers.businessService, + when: (answers: any) => this.isCfLoggedIn && answers.businessService, guiOptions: { hint: t('prompts.baseAppTooltip'), breadcrumb: true @@ -250,7 +276,7 @@ export class CFServicesPrompter { default: (_: CfServicesAnswers) => this.businessServices.length === 1 ? this.businessServices[0] ?? '' : '', when: (answers: CfServicesAnswers) => { - return this.isCFLoggedIn && (this.approuter || answers.approuter); + return this.isCfLoggedIn && (this.approuter || answers.approuter); }, validate: async (value: string) => { const validationResult = validateEmptyString(value); @@ -258,7 +284,7 @@ export class CFServicesPrompter { return t('error.businessServiceHasToBeSelected'); } - const config = this.fdcService.getConfig(); + const config = this.cfConfigService.getConfig(); this.businessServiceKeys = await getBusinessServiceKeys(value, config, this.logger); if (this.businessServiceKeys === null) { return t('error.businessServiceDoesNotExist'); @@ -278,25 +304,28 @@ export class CFServicesPrompter { /** * @param {object} param0 - Configuration object containing FDC service, internal usage flag, MTA project path, CF login status, and logger. * @param {FDCService} param0.fdcService - FDC service instance. + * @param {CfConfigService} param0.cfConfigService - CF config service instance. * @param {boolean} [param0.isInternalUsage] - Internal usage flag. * @param {string} param0.mtaProjectPath - MTA project path. - * @param {boolean} param0.isCFLoggedIn - CF login status. + * @param {boolean} param0.isCfLoggedIn - CF login status. * @param {ToolsLogger} param0.logger - Logger instance. * @returns {Promise} CF services questions. */ export async function getPrompts({ fdcService, + cfConfigService, isInternalUsage, mtaProjectPath, - isCFLoggedIn, + isCfLoggedIn, logger }: { fdcService: FDCService; + cfConfigService: CfConfigService; isInternalUsage?: boolean; mtaProjectPath: string; - isCFLoggedIn: boolean; + isCfLoggedIn: boolean; logger: ToolsLogger; }): Promise { - const prompter = new CFServicesPrompter(fdcService, isCFLoggedIn, logger, isInternalUsage); + const prompter = new CFServicesPrompter(fdcService, cfConfigService, isCfLoggedIn, logger, isInternalUsage); return prompter.getPrompts(mtaProjectPath); } diff --git a/packages/generator-adp/src/app/questions/helper/validators.ts b/packages/generator-adp/src/app/questions/helper/validators.ts index 39f3b35ce85..f7fadf3ed63 100644 --- a/packages/generator-adp/src/app/questions/helper/validators.ts +++ b/packages/generator-adp/src/app/questions/helper/validators.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import { isAppStudio } from '@sap-ux/btp-utils'; -import { isMtaProject, type FDCService, type SystemLookup } from '@sap-ux/adp-tooling'; +import { isExternalLoginEnabled, isMtaProject, type FDCService, type SystemLookup } from '@sap-ux/adp-tooling'; import { validateEmptyString, validateNamespaceAdp, validateProjectName } from '@sap-ux/project-input-validator'; import { t } from '../../../utils/i18n'; @@ -83,22 +83,22 @@ export async function validateJsonInput( * Validates the environment. * * @param {string} value - The value to validate. - * @param {FDCService} fdcService - The FDC service instance. * @param {boolean} isCFLoggedIn - Whether Cloud Foundry is logged in. + * @param {any} vscode - The vscode instance. * @returns {Promise} Returns true if the environment is valid, otherwise returns an error message. */ export async function validateEnvironment( value: string, - fdcService: FDCService, - isCFLoggedIn: boolean + isCFLoggedIn: boolean, + vscode: any ): Promise { if (value === 'CF' && !isCFLoggedIn) { return t('error.cfNotLoggedIn'); } if (value === 'CF' && !isAppStudio()) { - const isExternalLoginEnabled = await fdcService.isExternalLoginEnabled(); - if (!isExternalLoginEnabled) { + const isExtLoginEnabled = await isExternalLoginEnabled(vscode); + if (!isExtLoginEnabled) { return t('error.cfLoginCannotBeDetected'); } } diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index 6e4e266bbed..ad8ba48b9ca 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -1,7 +1,7 @@ import { MessageType } from '@sap-devx/yeoman-ui-types'; import type { AppWizard } from '@sap-devx/yeoman-ui-types'; -import type { FDCService } from '@sap-ux/adp-tooling'; +import type { CfConfigService, FDCService } from '@sap-ux/adp-tooling'; import { getDefaultTargetFolder } from '@sap-ux/fiori-generator-shared'; import type { InputQuestion, ListQuestion, YUIQuestion } from '@sap-ux/inquirer-common'; @@ -19,16 +19,18 @@ type EnvironmentChoice = { name: string; value: TargetEnv }; * @param {AppWizard} appWizard - The app wizard instance. * @param {boolean} isCfInstalled - Whether Cloud Foundry is installed. * @param {boolean} isCFLoggedIn - Whether Cloud Foundry is logged in. - * @param {FDCService} fdcService - The FDC service instance. + * @param {CfConfigService} cfConfigService - The CF config service instance. + * @param {any} vscode - The vscode instance. * @returns {object[]} The target environment prompt. */ export function getTargetEnvPrompt( appWizard: AppWizard, isCfInstalled: boolean, isCFLoggedIn: boolean, - fdcService: FDCService + cfConfigService: CfConfigService, + vscode: any ): TargetEnvQuestion { - const cfConfig = fdcService.getConfig(); + const cfConfig = cfConfigService.getConfig(); return { type: 'list', @@ -41,7 +43,7 @@ export function getTargetEnvPrompt( hint: t('prompts.targetEnvTooltip'), breadcrumb: t('prompts.targetEnvBreadcrumb') }, - validate: (value: string) => validateEnvironment(value, fdcService, isCFLoggedIn), + validate: (value: string) => validateEnvironment(value, isCFLoggedIn, vscode), additionalMessages: (value: string) => getTargetEnvAdditionalMessages(value, isCFLoggedIn, cfConfig) } as ListQuestion; } From 6cf76cb228d89dd55a69ddf3ec452144176f8dc8 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 28 Aug 2025 09:33:44 +0300 Subject: [PATCH 040/111] fix: build error --- packages/adp-tooling/src/cf/index.ts | 1 + packages/generator-adp/src/app/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index 85ee2c26afa..41fe81b2e0e 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -1,6 +1,7 @@ export * from './html5-repo'; export * from './utils'; export * from './yaml'; +export * from './yaml-loader'; export * from './fdc'; export * from './auth'; export * from './config'; diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 891a7a84dde..474a651beeb 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -65,7 +65,7 @@ import { FDCService } from '@sap-ux/adp-tooling'; import { getTargetEnvPrompt, getProjectPathPrompt } from './questions/target-env'; import { isAppStudio } from '@sap-ux/btp-utils'; import { getTemplatesOverwritePath } from '../utils/templates'; -import { YamlLoader } from '@sap-ux/adp-tooling/src/cf/yaml-loader'; +import { YamlLoader } from '@sap-ux/adp-tooling'; const generatorTitle = 'Adaptation Project'; From 05766e6959a7d9fcfa2a92d07f85634989c03233 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 28 Aug 2025 10:24:12 +0300 Subject: [PATCH 041/111] feat: extract mta releated logic --- packages/adp-tooling/src/cf/fdc.ts | 154 +-------------------------- packages/adp-tooling/src/cf/index.ts | 1 + packages/adp-tooling/src/cf/mta.ts | 140 ++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 149 deletions(-) create mode 100644 packages/adp-tooling/src/cf/mta.ts diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 89d070b6414..fad7dc6a47d 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -1,4 +1,3 @@ -import * as path from 'path'; import type * as AdmZip from 'adm-zip'; import axios, { type AxiosResponse } from 'axios'; import CFLocal = require('@sap/cf-tools/out/src/cf-local'); @@ -7,27 +6,15 @@ import { isAppStudio } from '@sap-ux/btp-utils'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import type { - CFConfig, - CFApp, - RequestArguments, - Credentials, - ServiceKeys, - AppParams, - CFServiceOffering, - CFAPIResponse, - BusinessServiceResource, - Resource -} from '../types'; -import type { AppRouterType } from '../types'; +import type { CFConfig, CFApp, RequestArguments, Credentials, ServiceKeys, AppParams } from '../types'; import { t } from '../i18n'; import { downloadAppContent } from './html5-repo'; -import { YamlUtils, getRouterType } from './yaml'; -import { YamlLoader } from './yaml-loader'; +import { YamlUtils } from './yaml'; import { getApplicationType, isSupportedAppTypeForAdp } from '../source'; -import { getServiceInstanceKeys, requestCfApi } from './utils'; +import { getServiceInstanceKeys } from './utils'; import { isLoggedInCf } from './auth'; import type { CfConfigService } from './config'; +import { readMta } from './mta'; interface FDCResponse { results: CFApp[]; @@ -221,106 +208,6 @@ function getAppHostIds(credentials: Credentials[]): Set { return new Set(appHostIds.flat()); } -/** - * Get the approuter type. - * - * @param {string} mtaProjectPath - The path to the mta project. - * @returns {AppRouterType} The approuter type. - */ -export function getApprouterType(mtaProjectPath: string): AppRouterType { - const yamlContent = YamlLoader.getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); - return getRouterType(yamlContent); -} - -/** - * Get the module names. - * - * @param {string} mtaProjectPath - The path to the mta project. - * @returns {string[]} The module names. - */ -export function getModuleNames(mtaProjectPath: string): string[] { - const yamlContent = YamlLoader.getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); - return yamlContent?.modules?.map((module: { name: string }) => module.name) ?? []; -} - -/** - * Get the services for the file. - * - * @param {string} mtaFilePath - The path to the mta file. - * @param {ToolsLogger} logger - The logger. - * @returns {BusinessServiceResource[]} The services. - */ -export function getServicesForFile(mtaFilePath: string, logger: ToolsLogger): BusinessServiceResource[] { - const serviceNames: BusinessServiceResource[] = []; - const parsed = YamlLoader.getYamlContent(mtaFilePath); - if (parsed?.resources && Array.isArray(parsed.resources)) { - parsed.resources.forEach((resource: Resource) => { - const name = resource?.parameters?.['service-name'] || resource.name; - const label = resource?.parameters?.service; - if (name) { - serviceNames.push({ name, label }); - if (!label) { - logger?.log(`Service '${name}' will be ignored without 'service' parameter`); - } - } - }); - } - return serviceNames; -} - -/** - * Filter services based on the business services. - * - * @param {BusinessServiceResource[]} businessServices - The business services. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} The filtered services. - */ -async function filterServices(businessServices: BusinessServiceResource[], logger: ToolsLogger): Promise { - const serviceLabels = businessServices.map((service) => service.label).filter((label) => label); - if (serviceLabels.length > 0) { - const url = `/v3/service_offerings?names=${serviceLabels.join(',')}`; - const json = await requestCfApi>(url); - logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); - - const businessServiceNames = new Set(businessServices.map((service) => service.label)); - const result: string[] = []; - json?.resources?.forEach((resource: CFServiceOffering) => { - if (businessServiceNames.has(resource.name)) { - const sapService = resource?.['broker_catalog']?.metadata?.sapservice; - if (sapService && ['v2', 'v4'].includes(sapService?.odataversion ?? '')) { - result.push(businessServices?.find((service) => resource.name === service.label)?.name ?? ''); - } else { - logger?.log(`Service '${resource.name}' doesn't support V2/V4 Odata and will be ignored`); - } - } - }); - - if (result.length > 0) { - return result; - } - } - throw new Error(`No business services found, please specify the business services in resource section of mts.yaml: - - name: - type: org.cloudfoundry.-service - parameters: - service: - service-name: - service-plan: `); -} - -/** - * Get the resources for the file. - * - * @param {string} mtaFilePath - The path to the mta file. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} The resources. - */ -async function getResources(mtaFilePath: string, logger: ToolsLogger): Promise { - const servicesList = getServicesForFile(mtaFilePath, logger); - const oDataFilteredServices = await filterServices(servicesList, logger); - return oDataFilteredServices; -} - /** * Format the discovery. * @@ -376,21 +263,6 @@ export async function getFDCApps( } } -/** - * Check if the project has an approuter. - * - * @param {string} projectName - The project name. - * @param {string[]} moduleNames - The module names. - * @returns {boolean} Whether the project has an approuter. - */ -export function hasApprouter(projectName: string, moduleNames: string[]): boolean { - return moduleNames.some( - (name) => - name === `${projectName.toLowerCase()}-destination-content` || - name === `${projectName.toLowerCase()}-approuter` - ); -} - /** * The FDC service. */ @@ -438,7 +310,7 @@ export class FDCService { * @returns {Promise} The services. */ public async getServices(projectPath: string): Promise { - const services = await this.readMta(projectPath); + const services = await readMta(projectPath, this.logger); this.logger?.log(`Available services defined in mta.yaml: ${JSON.stringify(services)}`); return services; } @@ -606,20 +478,4 @@ export class FDCService { return [e.message]; } } - - /** - * Read the mta file. - * - * @param {string} projectPath - The path to the project. - * @returns {Promise} The resources. - */ - private async readMta(projectPath: string): Promise { - if (!projectPath) { - throw new Error('Project path is missing.'); - } - - const mtaFilePath = path.resolve(projectPath, 'mta.yaml'); - const resources = await getResources(mtaFilePath, this.logger); - return resources; - } } diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index 41fe81b2e0e..9ba67a7c78a 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -5,3 +5,4 @@ export * from './yaml-loader'; export * from './fdc'; export * from './auth'; export * from './config'; +export * from './mta'; diff --git a/packages/adp-tooling/src/cf/mta.ts b/packages/adp-tooling/src/cf/mta.ts new file mode 100644 index 00000000000..cf59a7074ba --- /dev/null +++ b/packages/adp-tooling/src/cf/mta.ts @@ -0,0 +1,140 @@ +import * as path from 'path'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import { requestCfApi } from './utils'; +import { getRouterType } from './yaml'; +import { YamlLoader } from './yaml-loader'; +import type { CFServiceOffering, CFAPIResponse, BusinessServiceResource, Resource, AppRouterType } from '../types'; + +/** + * Get the approuter type. + * + * @param {string} mtaProjectPath - The path to the mta project. + * @returns {AppRouterType} The approuter type. + */ +export function getApprouterType(mtaProjectPath: string): AppRouterType { + const yamlContent = YamlLoader.getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); + return getRouterType(yamlContent); +} + +/** + * Get the module names. + * + * @param {string} mtaProjectPath - The path to the mta project. + * @returns {string[]} The module names. + */ +export function getModuleNames(mtaProjectPath: string): string[] { + const yamlContent = YamlLoader.getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); + return yamlContent?.modules?.map((module: { name: string }) => module.name) ?? []; +} + +/** + * Get the services for the file. + * + * @param {string} mtaFilePath - The path to the mta file. + * @param {ToolsLogger} logger - The logger. + * @returns {BusinessServiceResource[]} The services. + */ +export function getServicesForFile(mtaFilePath: string, logger: ToolsLogger): BusinessServiceResource[] { + const serviceNames: BusinessServiceResource[] = []; + const parsed = YamlLoader.getYamlContent(mtaFilePath); + if (parsed?.resources && Array.isArray(parsed.resources)) { + parsed.resources.forEach((resource: Resource) => { + const name = resource?.parameters?.['service-name'] || resource.name; + const label = resource?.parameters?.service; + if (name) { + serviceNames.push({ name, label }); + if (!label) { + logger?.log(`Service '${name}' will be ignored without 'service' parameter`); + } + } + }); + } + return serviceNames; +} + +/** + * Check if the project has an approuter. + * + * @param {string} projectName - The project name. + * @param {string[]} moduleNames - The module names. + * @returns {boolean} Whether the project has an approuter. + */ +export function hasApprouter(projectName: string, moduleNames: string[]): boolean { + return moduleNames.some( + (name) => + name === `${projectName.toLowerCase()}-destination-content` || + name === `${projectName.toLowerCase()}-approuter` + ); +} + +/** + * Filter services based on the business services. + * + * @param {BusinessServiceResource[]} businessServices - The business services. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The filtered services. + */ +async function filterServices(businessServices: BusinessServiceResource[], logger: ToolsLogger): Promise { + const serviceLabels = businessServices.map((service) => service.label).filter((label) => label); + if (serviceLabels.length > 0) { + const url = `/v3/service_offerings?names=${serviceLabels.join(',')}`; + const json = await requestCfApi>(url); + logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); + + const businessServiceNames = new Set(businessServices.map((service) => service.label)); + const result: string[] = []; + json?.resources?.forEach((resource: CFServiceOffering) => { + if (businessServiceNames.has(resource.name)) { + const sapService = resource?.['broker_catalog']?.metadata?.sapservice; + if (sapService && ['v2', 'v4'].includes(sapService?.odataversion ?? '')) { + result.push(businessServices?.find((service) => resource.name === service.label)?.name ?? ''); + } else { + logger?.log(`Service '${resource.name}' doesn't support V2/V4 Odata and will be ignored`); + } + } + }); + + if (result.length > 0) { + return result; + } + } + throw new Error(`No business services found, please specify the business services in resource section of mts.yaml: + - name: + type: org.cloudfoundry.-service + parameters: + service: + service-name: + service-plan: `); +} + +/** + * Get the resources for the file. + * + * @param {string} mtaFilePath - The path to the mta file. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The resources. + */ +export async function getResources(mtaFilePath: string, logger: ToolsLogger): Promise { + const servicesList = getServicesForFile(mtaFilePath, logger); + const oDataFilteredServices = await filterServices(servicesList, logger); + return oDataFilteredServices; +} + +/** + * Read the mta file. + * + * @param {string} projectPath - The path to the project. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The resources. + */ +export async function readMta(projectPath: string, logger: ToolsLogger): Promise { + if (!projectPath) { + throw new Error('Project path is missing.'); + } + + const mtaFilePath = path.resolve(projectPath, 'mta.yaml'); + const resources = await getResources(mtaFilePath, logger); + return resources; +} From 42eeb08e09b473e531621d14ed13bf4552d1e562 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 28 Aug 2025 10:55:28 +0300 Subject: [PATCH 042/111] feat: extract api, mta, validation related logic --- packages/adp-tooling/src/cf/api.ts | 216 +++++++++++++++ packages/adp-tooling/src/cf/fdc.ts | 304 +--------------------- packages/adp-tooling/src/cf/html5-repo.ts | 3 +- packages/adp-tooling/src/cf/index.ts | 2 + packages/adp-tooling/src/cf/mta.ts | 2 +- packages/adp-tooling/src/cf/utils.ts | 122 +++------ packages/adp-tooling/src/cf/validation.ts | 152 +++++++++++ packages/adp-tooling/src/cf/yaml.ts | 2 +- 8 files changed, 411 insertions(+), 392 deletions(-) create mode 100644 packages/adp-tooling/src/cf/api.ts create mode 100644 packages/adp-tooling/src/cf/validation.ts diff --git a/packages/adp-tooling/src/cf/api.ts b/packages/adp-tooling/src/cf/api.ts new file mode 100644 index 00000000000..6cae8097a96 --- /dev/null +++ b/packages/adp-tooling/src/cf/api.ts @@ -0,0 +1,216 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import axios, { type AxiosResponse } from 'axios'; +import CFLocal = require('@sap/cf-tools/out/src/cf-local'); +import CFToolsCli = require('@sap/cf-tools/out/src/cli'); + +import { isAppStudio } from '@sap-ux/btp-utils'; +import type { ToolsLogger } from '@sap-ux/logger'; + +import type { CFConfig, CFApp, RequestArguments, ServiceKeys, CFAPIResponse, CFServiceOffering } from '../types'; +import { getServiceInstanceKeys } from './utils'; +import { isLoggedInCf } from './auth'; + +interface FDCResponse { + results: CFApp[]; +} + +/** + * Get the business service keys. + * + * @param {string} businessService - The business service. + * @param {CFConfig} config - The CF config. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The service keys. + */ +export async function getBusinessServiceKeys( + businessService: string, + config: CFConfig, + logger: ToolsLogger +): Promise { + const serviceKeys = await getServiceInstanceKeys( + { + spaceGuids: [config.space.GUID], + names: [businessService] + }, + logger + ); + logger?.log(`Available service key instance : ${JSON.stringify(serviceKeys?.serviceInstance)}`); + return serviceKeys; +} + +/** + * Get the FDC request arguments. + * + * @param {CFConfig} cfConfig - The CF config. + * @returns {RequestArguments} The request arguments. + */ +function getFDCRequestArguments(cfConfig: CFConfig): RequestArguments { + const fdcUrl = 'https://ui5-flexibility-design-and-configuration.'; + const cfApiEndpoint = `https://api.cf.${cfConfig.url}`; + const endpointParts = /https:\/\/api\.cf(?:\.([^-.]*)(-\d+)?(\.hana\.ondemand\.com)|(.*))/.exec(cfApiEndpoint); + const options: any = { + withCredentials: true, + headers: { + 'Content-Type': 'application/json' + } + }; + + // Determine the appropriate domain based on environment + let url: string; + + if (endpointParts?.[3]) { + // Public cloud - use mTLS enabled domain with "cert" prefix + const region = endpointParts[1]; + url = `${fdcUrl}cert.cfapps.${region}.hana.ondemand.com`; + } else { + // Private cloud or other environments + if (endpointParts?.[4]?.endsWith('.cn')) { + // China has a special URL pattern + const parts = endpointParts[4].split('.'); + parts.splice(2, 0, 'apps'); + url = `${fdcUrl}sapui5flex${parts.join('.')}`; + } else { + url = `${fdcUrl}sapui5flex.cfapps${endpointParts?.[4]}`; + } + } + + // Add authorization token for non-BAS environments or private cloud + // For BAS environments with mTLS, the certificate authentication is handled automatically + if (!isAppStudio() || !endpointParts?.[3]) { + options.headers['Authorization'] = `Bearer ${cfConfig.token}`; + } + + return { + url: url, + options + }; +} + +/** + * Get the FDC apps. + * + * @param {string[]} appHostIds - The app host ids. + * @param {CFConfig} cfConfig - The CF config. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise>} The FDC apps. + */ +export async function getFDCApps( + appHostIds: string[], + cfConfig: CFConfig, + logger: ToolsLogger +): Promise> { + const requestArguments = getFDCRequestArguments(cfConfig); + logger?.log(`App Hosts: ${JSON.stringify(appHostIds)}, request arguments: ${JSON.stringify(requestArguments)}`); + + // Construct the URL with multiple appHostIds as separate query parameters + // Format: ?appHostId=&appHostId=&appHostId= + const appHostIdParams = appHostIds.map((id) => `appHostId=${encodeURIComponent(id)}`).join('&'); + const url = `${requestArguments.url}/api/business-service/discovery?${appHostIdParams}`; + + try { + const isLoggedIn = await isLoggedInCf(cfConfig, logger); + if (!isLoggedIn) { + await CFLocal.cfGetAvailableOrgs(); + } + const response = await axios.get(url, requestArguments.options); + logger?.log( + `Getting FDC apps. Request url: ${url} response status: ${response.status}, response data: ${JSON.stringify( + response.data + )}` + ); + return response; + } catch (e) { + logger?.error( + `Getting FDC apps. Request url: ${url}, response status: ${e?.response?.status}, message: ${e.message || e}` + ); + throw new Error( + `Failed to get application from Flexibility Design and Configuration service ${url}. Reason: ${ + e.message || e + }` + ); + } +} + +/** + * Request CF API. + * + * @param {string} url - The URL. + * @returns {Promise} The response. + */ +export async function requestCfApi(url: string): Promise { + try { + const response = await CFToolsCli.Cli.execute(['curl', url], { env: { 'CF_COLOR': 'false' } }); + if (response.exitCode === 0) { + try { + return JSON.parse(response.stdout); + } catch (e) { + throw new Error(`Failed to parse response from request CF API: ${e.message}`); + } + } + throw new Error(response.stderr); + } catch (e) { + throw new Error(`Request to CF API failed. Reason: ${e.message}`); + } +} + +/** + * Creates a service. + * + * @param {string} spaceGuid - The space GUID. + * @param {string} plan - The plan. + * @param {string} serviceInstanceName - The service instance name. + * @param {ToolsLogger} logger - The logger. + * @param {string[]} tags - The tags. + * @param {string | null} securityFilePath - The security file path. + * @param {string | null} serviceName - The service name. + * @param {string} [xsSecurityProjectName] - The project name for XS security. + */ +export async function createService( + spaceGuid: string, + plan: string, + serviceInstanceName: string, + logger?: ToolsLogger, + tags: string[] = [], + securityFilePath: string | null = null, + serviceName: string | undefined = undefined, + xsSecurityProjectName?: string +): Promise { + try { + if (!serviceName) { + const json: CFAPIResponse = await requestCfApi>( + `/v3/service_offerings?per_page=1000&space_guids=${spaceGuid}` + ); + const serviceOffering = json?.resources?.find( + (resource: CFServiceOffering) => resource.tags && tags.every((tag) => resource.tags?.includes(tag)) + ); + serviceName = serviceOffering?.name; + } + logger?.log( + `Creating service instance '${serviceInstanceName}' of service '${serviceName}' with '${plan}' plan` + ); + + const commandParameters: string[] = ['create-service', serviceName ?? '', plan, serviceInstanceName]; + if (securityFilePath) { + let xsSecurity = null; + try { + const filePath = path.resolve(__dirname, '../../templates/cf/xs-security.json'); + const xsContent = fs.readFileSync(filePath, 'utf-8'); + xsSecurity = JSON.parse(xsContent); + xsSecurity.xsappname = xsSecurityProjectName; + } catch (err) { + throw new Error('xs-security.json could not be parsed.'); + } + + commandParameters.push('-c'); + commandParameters.push(JSON.stringify(xsSecurity)); + } + + await CFToolsCli.Cli.execute(commandParameters); + logger?.log(`Service instance '${serviceInstanceName}' created successfully`); + } catch (e) { + const errorMessage = `Failed to create service instance '${serviceInstanceName}'. Reason: ${e.message}`; + logger?.error(errorMessage); + throw new Error(errorMessage); + } +} diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index fad7dc6a47d..425211ef355 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -1,267 +1,14 @@ -import type * as AdmZip from 'adm-zip'; -import axios, { type AxiosResponse } from 'axios'; -import CFLocal = require('@sap/cf-tools/out/src/cf-local'); - -import { isAppStudio } from '@sap-ux/btp-utils'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import type { CFConfig, CFApp, RequestArguments, Credentials, ServiceKeys, AppParams } from '../types'; -import { t } from '../i18n'; +import type { CFConfig, CFApp, Credentials, AppParams } from '../types'; import { downloadAppContent } from './html5-repo'; import { YamlUtils } from './yaml'; -import { getApplicationType, isSupportedAppTypeForAdp } from '../source'; -import { getServiceInstanceKeys } from './utils'; -import { isLoggedInCf } from './auth'; +import { getAppHostIds } from './utils'; import type { CfConfigService } from './config'; import { readMta } from './mta'; - -interface FDCResponse { - results: CFApp[]; -} - -/** - * Validate the smart template application. - * - * @param {Manifest} manifest - The manifest. - * @returns {Promise} The messages. - */ -async function validateSmartTemplateApplication(manifest: Manifest): Promise { - const messages: string[] = []; - const appType = getApplicationType(manifest); - - if (isSupportedAppTypeForAdp(appType)) { - if (manifest['sap.ui5'] && manifest['sap.ui5'].flexEnabled === false) { - return messages.concat(t('error.appDoesNotSupportFlexibility')); - } - } else { - return messages.concat( - "Select a different application. Adaptation project doesn't support the selected application." - ); - } - return messages; -} - -/** - * Get the business service keys. - * - * @param {string} businessService - The business service. - * @param {CFConfig} config - The CF config. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} The service keys. - */ -export async function getBusinessServiceKeys( - businessService: string, - config: CFConfig, - logger: ToolsLogger -): Promise { - const serviceKeys = await getServiceInstanceKeys( - { - spaceGuids: [config.space.GUID], - names: [businessService] - }, - logger - ); - logger?.log(`Available service key instance : ${JSON.stringify(serviceKeys?.serviceInstance)}`); - return serviceKeys; -} - -/** - * Get the FDC request arguments. - * - * @param {CFConfig} cfConfig - The CF config. - * @returns {RequestArguments} The request arguments. - */ -function getFDCRequestArguments(cfConfig: CFConfig): RequestArguments { - const fdcUrl = 'https://ui5-flexibility-design-and-configuration.'; - const cfApiEndpoint = `https://api.cf.${cfConfig.url}`; - const endpointParts = /https:\/\/api\.cf(?:\.([^-.]*)(-\d+)?(\.hana\.ondemand\.com)|(.*))/.exec(cfApiEndpoint); - const options: any = { - withCredentials: true, - headers: { - 'Content-Type': 'application/json' - } - }; - - // Determine the appropriate domain based on environment - let url: string; - - if (endpointParts?.[3]) { - // Public cloud - use mTLS enabled domain with "cert" prefix - const region = endpointParts[1]; - url = `${fdcUrl}cert.cfapps.${region}.hana.ondemand.com`; - } else { - // Private cloud or other environments - if (endpointParts?.[4]?.endsWith('.cn')) { - // China has a special URL pattern - const parts = endpointParts[4].split('.'); - parts.splice(2, 0, 'apps'); - url = `${fdcUrl}sapui5flex${parts.join('.')}`; - } else { - url = `${fdcUrl}sapui5flex.cfapps${endpointParts?.[4]}`; - } - } - - // Add authorization token for non-BAS environments or private cloud - // For BAS environments with mTLS, the certificate authentication is handled automatically - if (!isAppStudio() || !endpointParts?.[3]) { - options.headers['Authorization'] = `Bearer ${cfConfig.token}`; - } - - return { - url: url, - options - }; -} - -/** - * Normalize the route regex. - * - * @param {string} value - The value. - * @returns {RegExp} The normalized route regex. - */ -function normalizeRouteRegex(value: string): RegExp { - return new RegExp(value.replace('^/', '^(/)*').replace('/(.*)$', '(/)*(.*)$')); -} - -/** - * Match the routes and data sources. - * - * @param {any} dataSources - The data sources. - * @param {any} routes - The routes. - * @param {any} serviceKeyEndpoints - The service key endpoints. - * @returns {string[]} The messages. - */ -function matchRoutesAndDatasources(dataSources: any, routes: any, serviceKeyEndpoints: any): string[] { - const messages: string[] = []; - routes.forEach((route: any) => { - if (route.endpoint && !serviceKeyEndpoints.includes(route.endpoint)) { - messages.push(`Route endpoint '${route.endpoint}' doesn't match a corresponding OData endpoint`); - } - }); - - Object.keys(dataSources).forEach((dataSourceName) => { - if (!routes.some((route: any) => dataSources[dataSourceName].uri?.match(normalizeRouteRegex(route.source)))) { - messages.push(`Data source '${dataSourceName}' doesn't match a corresponding route in xs-app.json routes`); - } - }); - return messages; -} - -/** - * Extract the xs-app.json from the zip entries. - * - * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. - * @returns {any} The xs-app.json. - */ -function extractXSApp(zipEntries: AdmZip.IZipEntry[]): any { - let xsApp; - zipEntries.forEach((item) => { - if (item.entryName.endsWith('xs-app.json')) { - try { - xsApp = JSON.parse(item.getData().toString('utf8')); - } catch (e) { - throw new Error(`Failed to parse xs-app.json. Reason: ${e.message}`); - } - } - }); - return xsApp; -} - -/** - * Extract the manifest.json from the zip entries. - * - * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. - * @returns {Manifest | undefined} The manifest. - */ -function extractManifest(zipEntries: AdmZip.IZipEntry[]): Manifest | undefined { - let manifest: Manifest | undefined; - zipEntries.forEach((item) => { - if (item.entryName.endsWith('manifest.json')) { - try { - manifest = JSON.parse(item.getData().toString('utf8')) as Manifest; - } catch (e) { - throw new Error(`Failed to parse manifest.json. Reason: ${e.message}`); - } - } - }); - return manifest; -} - -/** - * Get the app host ids. - * - * @param {Credentials[]} credentials - The credentials. - * @returns {Set} The app host ids. - */ -function getAppHostIds(credentials: Credentials[]): Set { - const appHostIds: string[] = []; - credentials.forEach((credential) => { - const appHostId = credential['html5-apps-repo']?.app_host_id; - if (appHostId) { - appHostIds.push(appHostId.split(',').map((item: string) => item.trim())); // there might be multiple appHostIds separated by comma - } - }); - - // appHostIds is now an array of arrays of strings (from split) - // Flatten the array and create a Set - return new Set(appHostIds.flat()); -} - -/** - * Format the discovery. - * - * @param {CFApp} app - The app. - * @returns {string} The formatted discovery. - */ -export function formatDiscovery(app: CFApp): string { - return `${app.title} (${app.appId} ${app.appVersion})`; -} - -/** - * Get the FDC apps. - * - * @param {string[]} appHostIds - The app host ids. - * @param {CFConfig} cfConfig - The CF config. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise>} The FDC apps. - */ -export async function getFDCApps( - appHostIds: string[], - cfConfig: CFConfig, - logger: ToolsLogger -): Promise> { - const requestArguments = getFDCRequestArguments(cfConfig); - logger?.log(`App Hosts: ${JSON.stringify(appHostIds)}, request arguments: ${JSON.stringify(requestArguments)}`); - - // Construct the URL with multiple appHostIds as separate query parameters - // Format: ?appHostId=&appHostId=&appHostId= - const appHostIdParams = appHostIds.map((id) => `appHostId=${encodeURIComponent(id)}`).join('&'); - const url = `${requestArguments.url}/api/business-service/discovery?${appHostIdParams}`; - - try { - const isLoggedIn = await isLoggedInCf(cfConfig, logger); - if (!isLoggedIn) { - await CFLocal.cfGetAvailableOrgs(); - } - const response = await axios.get(url, requestArguments.options); - logger?.log( - `Getting FDC apps. Request url: ${url} response status: ${response.status}, response data: ${JSON.stringify( - response.data - )}` - ); - return response; - } catch (e) { - logger?.error( - `Getting FDC apps. Request url: ${url}, response status: ${e?.response?.status}, message: ${e.message || e}` - ); - throw new Error( - `Failed to get application from Flexibility Design and Configuration service ${url}. Reason: ${ - e.message || e - }` - ); - } -} +import { validateODataEndpoints, validateSmartTemplateApplication } from './validation'; +import { getFDCApps } from './api'; /** * The FDC service. @@ -389,47 +136,6 @@ export class FDCService { }); } - /** - * Validate the OData endpoints. - * - * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. - * @param {Credentials[]} credentials - The credentials. - * @returns {Promise} The messages. - */ - public async validateODataEndpoints(zipEntries: AdmZip.IZipEntry[], credentials: Credentials[]): Promise { - const messages: string[] = []; - let xsApp, manifest; - try { - xsApp = extractXSApp(zipEntries); - this.logger?.log(`ODATA endpoints: ${JSON.stringify(xsApp)}`); - } catch (error) { - messages.push(error.message); - return messages; - } - - try { - manifest = extractManifest(zipEntries); - this.logger?.log(`Extracted manifest: ${JSON.stringify(manifest)}`); - } catch (error) { - messages.push(error.message); - return messages; - } - - const dataSources = manifest?.['sap.app']?.dataSources; - const routes = (xsApp as any)?.routes; - if (dataSources && routes) { - const serviceKeyEndpoints = ([] as string[]).concat( - ...credentials.map((item) => (item.endpoints ? Object.keys(item.endpoints) : [])) - ); - messages.push(...matchRoutesAndDatasources(dataSources, routes, serviceKeyEndpoints)); - } else if (routes && !dataSources) { - messages.push("Base app manifest.json doesn't contain data sources specified in xs-app.json"); - } else if (!routes && dataSources) { - messages.push("Base app xs-app.json doesn't contain data sources routes specified in manifest.json"); - } - return messages; - } - /** * Get the validated apps. * @@ -470,7 +176,7 @@ export class FDCService { const messages = await validateSmartTemplateApplication(manifest); this.html5RepoRuntimeGuid = serviceInstanceGuid; if (messages?.length === 0) { - return this.validateODataEndpoints(entries, credentials); + return validateODataEndpoints(entries, credentials, this.logger); } else { return messages; } diff --git a/packages/adp-tooling/src/cf/html5-repo.ts b/packages/adp-tooling/src/cf/html5-repo.ts index be12c8a70fd..3846613d19b 100644 --- a/packages/adp-tooling/src/cf/html5-repo.ts +++ b/packages/adp-tooling/src/cf/html5-repo.ts @@ -3,7 +3,8 @@ import AdmZip from 'adm-zip'; import type { ToolsLogger } from '@sap-ux/logger'; -import { getServiceInstanceKeys, createService } from './utils'; +import { createService } from './api'; +import { getServiceInstanceKeys } from './utils'; import type { HTML5Content, ServiceKeys, Uaa, AppParams } from '../types'; /** diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index 9ba67a7c78a..2509b0cd50c 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -6,3 +6,5 @@ export * from './fdc'; export * from './auth'; export * from './config'; export * from './mta'; +export * from './api'; +export * from './validation'; diff --git a/packages/adp-tooling/src/cf/mta.ts b/packages/adp-tooling/src/cf/mta.ts index cf59a7074ba..187d63002a7 100644 --- a/packages/adp-tooling/src/cf/mta.ts +++ b/packages/adp-tooling/src/cf/mta.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import type { ToolsLogger } from '@sap-ux/logger'; -import { requestCfApi } from './utils'; +import { requestCfApi } from './api'; import { getRouterType } from './yaml'; import { YamlLoader } from './yaml-loader'; import type { CFServiceOffering, CFAPIResponse, BusinessServiceResource, Resource, AppRouterType } from '../types'; diff --git a/packages/adp-tooling/src/cf/utils.ts b/packages/adp-tooling/src/cf/utils.ts index 529634c9c19..f8f83f43506 100644 --- a/packages/adp-tooling/src/cf/utils.ts +++ b/packages/adp-tooling/src/cf/utils.ts @@ -1,5 +1,3 @@ -import fs from 'fs'; -import path from 'path'; import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import CFToolsCli = require('@sap/cf-tools/out/src/cli'); import { eFilters } from '@sap/cf-tools/out/src/types'; @@ -13,8 +11,9 @@ import type { Credentials, CFAPIResponse, CFServiceInstance, - CFServiceOffering + CFApp } from '../types'; +import { requestCfApi } from './api'; const ENV = { env: { 'CF_COLOR': 'false' } }; const CREATE_SERVICE_KEY = 'create-service-key'; @@ -48,93 +47,6 @@ export async function getServiceInstanceKeys( } } -/** - * Creates a service. - * - * @param {string} spaceGuid - The space GUID. - * @param {string} plan - The plan. - * @param {string} serviceInstanceName - The service instance name. - * @param {ToolsLogger} logger - The logger. - * @param {string[]} tags - The tags. - * @param {string | null} securityFilePath - The security file path. - * @param {string | null} serviceName - The service name. - * @param {string} [xsSecurityProjectName] - The project name for XS security. - */ -export async function createService( - spaceGuid: string, - plan: string, - serviceInstanceName: string, - logger?: ToolsLogger, - tags: string[] = [], - securityFilePath: string | null = null, - serviceName: string | undefined = undefined, - xsSecurityProjectName?: string -): Promise { - try { - if (!serviceName) { - const json: CFAPIResponse = await requestCfApi>( - `/v3/service_offerings?per_page=1000&space_guids=${spaceGuid}` - ); - const serviceOffering = json?.resources?.find( - (resource: CFServiceOffering) => resource.tags && tags.every((tag) => resource.tags?.includes(tag)) - ); - serviceName = serviceOffering?.name; - } - logger?.log( - `Creating service instance '${serviceInstanceName}' of service '${serviceName}' with '${plan}' plan` - ); - - const commandParameters: string[] = ['create-service', serviceName ?? '', plan, serviceInstanceName]; - if (securityFilePath) { - let xsSecurity = null; - try { - const filePath = path.resolve(__dirname, '../../templates/cf/xs-security.json'); - const xsContent = fs.readFileSync(filePath, 'utf-8'); - xsSecurity = JSON.parse(xsContent); - xsSecurity.xsappname = xsSecurityProjectName; - } catch (err) { - throw new Error('xs-security.json could not be parsed.'); - } - - commandParameters.push('-c'); - commandParameters.push(JSON.stringify(xsSecurity)); - } - - const query = await CFToolsCli.Cli.execute(commandParameters); - if (query.exitCode !== 0) { - throw new Error(query.stderr); - } - } catch (e) { - const errorMessage = `Cannot create a service instance '${serviceInstanceName}' in space '${spaceGuid}'. Reason: ${e.message}`; - logger?.error(errorMessage); - throw new Error(errorMessage); - } -} - -/** - * Requests the CF API. - * - * @param {string} url - The URL to request. - * @returns {Promise} The response from the CF API. - * @template T - The type of the response. - */ -export async function requestCfApi(url: string): Promise { - try { - const response = await CFToolsCli.Cli.execute(['curl', url], ENV); - if (response.exitCode === 0) { - try { - return JSON.parse(response.stdout); - } catch (e) { - throw new Error(`Failed to parse response from request CF API: ${e.message}`); - } - } - throw new Error(response.stderr); - } catch (e) { - // log error: CFUtils.ts=>requestCfApi(params) - throw new Error(`Request to CF API failed. Reason: ${e.message}`); - } -} - /** * Gets the authentication token. * @@ -270,3 +182,33 @@ async function createServiceKey(serviceInstanceName: string, serviceKeyName: str throw new Error(`Failed to create service key for instance name ${serviceInstanceName}. Reason: ${e.message}`); } } + +/** + * Format the discovery. + * + * @param {CFApp} app - The app. + * @returns {string} The formatted discovery. + */ +export function formatDiscovery(app: CFApp): string { + return `${app.title} (${app.appId} ${app.appVersion})`; +} + +/** + * Get the app host ids. + * + * @param {Credentials[]} credentials - The credentials. + * @returns {Set} The app host ids. + */ +export function getAppHostIds(credentials: Credentials[]): Set { + const appHostIds: string[] = []; + credentials.forEach((credential) => { + const appHostId = credential['html5-apps-repo']?.app_host_id; + if (appHostId) { + appHostIds.push(appHostId.split(',').map((item: string) => item.trim())); // there might be multiple appHostIds separated by comma + } + }); + + // appHostIds is now an array of arrays of strings (from split) + // Flatten the array and create a Set + return new Set(appHostIds.flat()); +} diff --git a/packages/adp-tooling/src/cf/validation.ts b/packages/adp-tooling/src/cf/validation.ts new file mode 100644 index 00000000000..94277a59465 --- /dev/null +++ b/packages/adp-tooling/src/cf/validation.ts @@ -0,0 +1,152 @@ +import type AdmZip from 'adm-zip'; + +import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; + +import { t } from '../i18n'; +import { getApplicationType } from '../source/manifest'; +import { isSupportedAppTypeForAdp } from '../source/manifest'; +import type { Credentials } from '../types'; + +/** + * Normalize the route regex. + * + * @param {string} value - The value. + * @returns {RegExp} The normalized route regex. + */ +function normalizeRouteRegex(value: string): RegExp { + return new RegExp(value.replace('^/', '^(/)*').replace('/(.*)$', '(/)*(.*)$')); +} + +/** + * Validate the smart template application. + * + * @param {Manifest} manifest - The manifest. + * @returns {Promise} The messages. + */ +export async function validateSmartTemplateApplication(manifest: Manifest): Promise { + const messages: string[] = []; + const appType = getApplicationType(manifest); + + if (isSupportedAppTypeForAdp(appType)) { + if (manifest['sap.ui5'] && manifest['sap.ui5'].flexEnabled === false) { + return messages.concat(t('error.appDoesNotSupportFlexibility')); + } + } else { + return messages.concat( + "Select a different application. Adaptation project doesn't support the selected application." + ); + } + return messages; +} + +/** + * Extract the xs-app.json from the zip entries. + * + * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. + * @returns {any} The xs-app.json. + */ +export function extractXSApp(zipEntries: AdmZip.IZipEntry[]): any { + let xsApp; + zipEntries.forEach((item) => { + if (item.entryName.endsWith('xs-app.json')) { + try { + xsApp = JSON.parse(item.getData().toString('utf8')); + } catch (e) { + throw new Error(`Failed to parse xs-app.json. Reason: ${e.message}`); + } + } + }); + return xsApp; +} + +/** + * Match the routes and data sources. + * + * @param {any} dataSources - The data sources. + * @param {any} routes - The routes. + * @param {any} serviceKeyEndpoints - The service key endpoints. + * @returns {string[]} The messages. + */ +function matchRoutesAndDatasources(dataSources: any, routes: any, serviceKeyEndpoints: any): string[] { + const messages: string[] = []; + routes.forEach((route: any) => { + if (route.endpoint && !serviceKeyEndpoints.includes(route.endpoint)) { + messages.push(`Route endpoint '${route.endpoint}' doesn't match a corresponding OData endpoint`); + } + }); + + Object.keys(dataSources).forEach((dataSourceName) => { + if (!routes.some((route: any) => dataSources[dataSourceName].uri?.match(normalizeRouteRegex(route.source)))) { + messages.push(`Data source '${dataSourceName}' doesn't match a corresponding route in xs-app.json routes`); + } + }); + return messages; +} + +/** + * Extract the manifest.json from the zip entries. + * + * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. + * @returns {Manifest | undefined} The manifest. + */ +function extractManifest(zipEntries: AdmZip.IZipEntry[]): Manifest | undefined { + let manifest: Manifest | undefined; + zipEntries.forEach((item) => { + if (item.entryName.endsWith('manifest.json')) { + try { + manifest = JSON.parse(item.getData().toString('utf8')) as Manifest; + } catch (e) { + throw new Error(`Failed to parse manifest.json. Reason: ${e.message}`); + } + } + }); + return manifest; +} + +/** + * Validate the OData endpoints. + * + * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. + * @param {Credentials[]} credentials - The credentials. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The messages. + */ +export async function validateODataEndpoints( + zipEntries: AdmZip.IZipEntry[], + credentials: Credentials[], + logger: ToolsLogger +): Promise { + const messages: string[] = []; + let xsApp; + let manifest: Manifest | undefined; + try { + xsApp = extractXSApp(zipEntries); + logger?.log(`ODATA endpoints: ${JSON.stringify(xsApp)}`); + } catch (error) { + messages.push(error.message); + return messages; + } + + try { + manifest = extractManifest(zipEntries); + logger?.log(`Extracted manifest: ${JSON.stringify(manifest)}`); + } catch (error) { + messages.push(error.message); + return messages; + } + + const dataSources = manifest?.['sap.app']?.dataSources; + const routes = (xsApp as any)?.routes; + if (dataSources && routes) { + const serviceKeyEndpoints = ([] as string[]).concat( + ...credentials.map((item) => (item.endpoints ? Object.keys(item.endpoints) : [])) + ); + messages.push(...matchRoutesAndDatasources(dataSources, routes, serviceKeyEndpoints)); + } else if (routes && !dataSources) { + messages.push("Base app manifest.json doesn't contain data sources specified in xs-app.json"); + } else if (!routes && dataSources) { + messages.push("Base app xs-app.json doesn't contain data sources routes specified in manifest.json"); + } + return messages; +} diff --git a/packages/adp-tooling/src/cf/yaml.ts b/packages/adp-tooling/src/cf/yaml.ts index 9a51bb0c377..116bd282bc6 100644 --- a/packages/adp-tooling/src/cf/yaml.ts +++ b/packages/adp-tooling/src/cf/yaml.ts @@ -6,7 +6,7 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../types'; import { AppRouterType } from '../types'; -import { createService } from './utils'; +import { createService } from './api'; import { getProjectNameForXsSecurity, YamlLoader } from './yaml-loader'; const CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; From 7a01679dd3cccc2b3072d47d97f1844eb1b8ec8f Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 28 Aug 2025 16:31:49 +0300 Subject: [PATCH 043/111] feat: split and move business logic from fdc --- packages/adp-tooling/src/cf/app/content.ts | 82 +++++++++ packages/adp-tooling/src/cf/app/discovery.ts | 69 ++++++++ packages/adp-tooling/src/cf/app/index.ts | 3 + packages/adp-tooling/src/cf/app/validation.ts | 73 ++++++++ packages/adp-tooling/src/cf/fdc.ts | 157 +++--------------- packages/adp-tooling/src/cf/html5-repo.ts | 11 +- packages/adp-tooling/src/cf/index.ts | 2 +- packages/adp-tooling/src/cf/mta.ts | 17 +- packages/adp-tooling/src/writer/cf.ts | 15 +- packages/generator-adp/src/app/index.ts | 5 +- .../src/app/questions/cf-services.ts | 33 +--- .../src/app/questions/helper/validators.ts | 9 +- .../src/app/questions/target-env.ts | 13 +- 13 files changed, 295 insertions(+), 194 deletions(-) create mode 100644 packages/adp-tooling/src/cf/app/content.ts create mode 100644 packages/adp-tooling/src/cf/app/discovery.ts create mode 100644 packages/adp-tooling/src/cf/app/index.ts create mode 100644 packages/adp-tooling/src/cf/app/validation.ts diff --git a/packages/adp-tooling/src/cf/app/content.ts b/packages/adp-tooling/src/cf/app/content.ts new file mode 100644 index 00000000000..7ec06ebba56 --- /dev/null +++ b/packages/adp-tooling/src/cf/app/content.ts @@ -0,0 +1,82 @@ +import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; + +import { downloadAppContent } from '../html5-repo'; +import type { CFConfig, AppParams } from '../../types'; + +/** + * App Content Service - Handles app content downloading and manifest management. + */ +export class AppContentService { + /** + * The apps' manifests. + */ + private manifests: Manifest[] = []; + /** + * The HTML5 repo runtime GUID. + */ + private html5RepoRuntimeGuid: string = ''; + + /** + * Constructor. + * + * @param {ToolsLogger} logger - The logger. + */ + constructor(private logger: ToolsLogger) {} + + /** + * Get all stored manifests. + * + * @returns {Manifest[]} All manifests + */ + public getManifests(): Manifest[] { + return this.manifests; + } + + /** + * Download app content and extract manifest. + * + * @param {AppParams} appParams - The app parameters + * @param {CFConfig} cfConfig - The CF configuration + * @returns {Promise<{entries: any[], serviceInstanceGuid: string, manifest: Manifest}>} The downloaded content + */ + public async getAppContent( + appParams: AppParams, + cfConfig: CFConfig + ): Promise<{ + entries: any[]; + serviceInstanceGuid: string; + manifest: Manifest; + }> { + const { entries, serviceInstanceGuid, manifest } = await downloadAppContent( + cfConfig.space.GUID, + appParams, + this.logger + ); + + // Store the manifest and runtime GUID + this.manifests.push(manifest); + this.html5RepoRuntimeGuid = serviceInstanceGuid; + + return { entries, serviceInstanceGuid, manifest }; + } + + /** + * Get the manifest by base app id. + * + * @param {string} appId - The app id + * @returns {Manifest | undefined} The manifest + */ + public getManifestByBaseAppId(appId: string): Manifest | undefined { + return this.manifests.find((manifest) => manifest['sap.app'].id === appId); + } + + /** + * Get the HTML5 repo runtime GUID. + * + * @returns {string} The runtime GUID. + */ + public getHtml5RepoRuntimeGuid(): string { + return this.html5RepoRuntimeGuid; + } +} diff --git a/packages/adp-tooling/src/cf/app/discovery.ts b/packages/adp-tooling/src/cf/app/discovery.ts new file mode 100644 index 00000000000..1c3ef570341 --- /dev/null +++ b/packages/adp-tooling/src/cf/app/discovery.ts @@ -0,0 +1,69 @@ +import type { ToolsLogger } from '@sap-ux/logger'; + +import { getFDCApps } from '../api'; +import { getAppHostIds } from '../utils'; +import type { CFConfig, CFApp, Credentials } from '../../types'; + +/** + * Filter apps based on validation status. + * + * @param {CFApp[]} apps - The apps to filter + * @param {boolean} includeInvalid - Whether to include invalid apps + * @returns {CFApp[]} The filtered apps + */ +export function filterCfApps(apps: CFApp[], includeInvalid: boolean): CFApp[] { + return includeInvalid ? apps : apps.filter((app) => !app.messages?.length); +} + +/** + * Discover apps from FDC API based on credentials. + * + * @param {Credentials[]} credentials - The credentials containing app host IDs + * @param {CFConfig} cfConfig - The CF configuration + * @param {ToolsLogger} logger - The logger + * @returns {Promise} The discovered apps + */ +export async function discoverCfApps( + credentials: Credentials[], + cfConfig: CFConfig, + logger: ToolsLogger +): Promise { + const appHostIds = getAppHostIds(credentials); + logger?.log(`App Host Ids: ${JSON.stringify(appHostIds)}`); + + // Validate appHostIds array length (max 100 as per API specification) + if (appHostIds.size > 100) { + throw new Error(`Too many appHostIds provided. Maximum allowed is 100, but ${appHostIds.size} were found.`); + } + + const appHostIdsArray = Array.from(appHostIds); + + try { + const response = await getFDCApps(appHostIdsArray, cfConfig, logger); + + if (response.status === 200) { + // TODO: Remove this once the FDC API is updated to return the appHostId + const apps = response.data.results.map((app) => ({ ...app, appHostId: appHostIdsArray[0] })); + return apps; + } else { + throw new Error( + `Failed to connect to Flexibility Design and Configuration service. Reason: HTTP status code ${response.status}: ${response.statusText}` + ); + } + } catch (error) { + logger?.error(`Error in discoverApps: ${error.message}`); + + // Create error apps for each appHostId to maintain original behavior + const errorApps: CFApp[] = appHostIdsArray.map((appHostId) => ({ + appId: '', + appName: '', + appVersion: '', + serviceName: '', + title: '', + appHostId, + messages: [error.message] + })); + + return errorApps; + } +} diff --git a/packages/adp-tooling/src/cf/app/index.ts b/packages/adp-tooling/src/cf/app/index.ts new file mode 100644 index 00000000000..b12d0e7af55 --- /dev/null +++ b/packages/adp-tooling/src/cf/app/index.ts @@ -0,0 +1,3 @@ +export * from './validation'; +export * from './content'; +export * from './discovery'; diff --git a/packages/adp-tooling/src/cf/app/validation.ts b/packages/adp-tooling/src/cf/app/validation.ts new file mode 100644 index 00000000000..48e2fff32cd --- /dev/null +++ b/packages/adp-tooling/src/cf/app/validation.ts @@ -0,0 +1,73 @@ +import type AdmZip from 'adm-zip'; + +import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; + +import type { AppContentService } from './content'; +import type { CFApp, Credentials, CFConfig } from '../../types'; +import { validateSmartTemplateApplication, validateODataEndpoints } from '../validation'; + +/** + * Validate a single app. + * + * @param {Manifest} manifest - The manifest to validate. + * @param {AdmZip.IZipEntry[]} entries - The entries to validate. + * @param {Credentials[]} credentials - The credentials for validation. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} Validation messages. + */ +export async function validateApp( + manifest: Manifest, + entries: AdmZip.IZipEntry[], + credentials: Credentials[], + logger: ToolsLogger +): Promise { + try { + const smartTemplateMessages = await validateSmartTemplateApplication(manifest); + + if (smartTemplateMessages.length === 0) { + return validateODataEndpoints(entries, credentials, logger); + } else { + return smartTemplateMessages; + } + } catch (e) { + return [e.message]; + } +} + +/** + * App Validation Service - Handles validation orchestration. + */ +export class AppValidationService { + /** + * Constructor. + * + * @param {ToolsLogger} logger - The logger. + * @param {AppContentService} appContent - The app content service. + */ + constructor(private logger: ToolsLogger, private appContent: AppContentService) {} + + /** + * Validate multiple apps. + * + * @param {CFApp[]} apps - The apps to validate + * @param {Credentials[]} credentials - The credentials for validation + * @param {CFConfig} cfConfig - The CF configuration + * @returns {Promise} The validated apps with messages + */ + public async getValidatedApps(apps: CFApp[], credentials: Credentials[], cfConfig: CFConfig): Promise { + const validatedApps: CFApp[] = []; + + for (const app of apps) { + if (!app.messages?.length) { + const { entries, manifest } = await this.appContent.getAppContent(app, cfConfig); + + const messages = await validateApp(manifest, entries, credentials, this.logger); + app.messages = messages; + } + validatedApps.push(app); + } + + return validatedApps; + } +} diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 425211ef355..96df90c1e43 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -1,39 +1,27 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import type { CFConfig, CFApp, Credentials, AppParams } from '../types'; -import { downloadAppContent } from './html5-repo'; import { YamlUtils } from './yaml'; -import { getAppHostIds } from './utils'; import type { CfConfigService } from './config'; -import { readMta } from './mta'; -import { validateODataEndpoints, validateSmartTemplateApplication } from './validation'; -import { getFDCApps } from './api'; +import type { CFConfig, CFApp, Credentials } from '../types'; +import { filterCfApps, discoverCfApps, AppContentService, AppValidationService } from './app'; /** - * The FDC service. + * FDC Service - Orchestrates app discovery, content downloading, and validation. */ export class FDCService { - /** - * The HTML5 repo runtime GUID. - */ - public html5RepoRuntimeGuid: string; - /** - * The apps' manifests. - */ - public manifests: Manifest[] = []; - /** - * The CF config service. - */ - private cfConfigService: CfConfigService; /** * The CF config. */ private cfConfig: CFConfig; /** - * The logger. + * The app content service. + */ + private appContent: AppContentService; + /** + * The app validation service. */ - private logger: ToolsLogger; + private appValidation: AppValidationService; /** * Creates an instance of FDCService. @@ -41,27 +29,17 @@ export class FDCService { * @param {ToolsLogger} logger - The logger. * @param {CfConfigService} cfConfigService - The CF config service. */ - constructor(logger: ToolsLogger, cfConfigService: CfConfigService) { - this.logger = logger; - this.cfConfigService = cfConfigService; + constructor(private logger: ToolsLogger, private cfConfigService: CfConfigService) { this.cfConfig = cfConfigService.getConfig(); + + this.appContent = new AppContentService(logger); + this.appValidation = new AppValidationService(logger, this.appContent); + if (this.cfConfig) { YamlUtils.spaceGuid = this.cfConfig.space.GUID; } } - /** - * Get the services for the project. - * - * @param {string} projectPath - The path to the project. - * @returns {Promise} The services. - */ - public async getServices(projectPath: string): Promise { - const services = await readMta(projectPath, this.logger); - this.logger?.log(`Available services defined in mta.yaml: ${JSON.stringify(services)}`); - return services; - } - /** * Get the base apps. * @@ -69,59 +47,14 @@ export class FDCService { * @param {boolean} [includeInvalid] - Whether to include invalid apps. * @returns {Promise} The base apps. */ - public async getBaseApps(credentials: Credentials[], includeInvalid = false): Promise { - const appHostIds = getAppHostIds(credentials); - this.logger?.log(`App Host Ids: ${JSON.stringify(appHostIds)}`); - - // Validate appHostIds array length (max 100 as per API specification) - if (appHostIds.size > 100) { - throw new Error(`Too many appHostIds provided. Maximum allowed is 100, but ${appHostIds.size} were found.`); - } - - const appHostIdsArray = Array.from(appHostIds); + public async getBaseApps(credentials: Credentials[], includeInvalid: boolean = false): Promise { + const cfConfig = this.cfConfigService.getConfig(); - try { - const cfConfig = this.cfConfigService.getConfig(); - const response = await getFDCApps(appHostIdsArray, cfConfig, this.logger); + const apps = await discoverCfApps(credentials, cfConfig, this.logger); - if (response.status === 200) { - // TODO: Remove this once the FDC API is updated to return the appHostId - const apps = response.data.results.map((app) => ({ ...app, appHostId: appHostIdsArray[0] })); - return this.processApps(apps, credentials, includeInvalid); - } else { - throw new Error( - `Failed to connect to Flexibility Design and Configuration service. Reason: HTTP status code ${response.status}: ${response.statusText}` - ); - } - } catch (error) { - this.logger?.error(`Error in getBaseApps: ${error.message}`); + const validatedApps = await this.appValidation.getValidatedApps(apps, credentials, cfConfig); - // Create error apps for each appHostId and validate them to maintain original behavior - const errorApps: CFApp[] = appHostIdsArray.map((appHostId) => ({ - appId: '', - appName: '', - appVersion: '', - serviceName: '', - title: '', - appHostId, - messages: [error.message] - })); - - return this.processApps(errorApps, credentials, includeInvalid); - } - } - - /** - * Process and validate apps, then filter based on includeInvalid flag. - * - * @param apps - Array of apps to process - * @param credentials - Credentials for validation - * @param includeInvalid - Whether to include invalid apps in the result - * @returns Processed and filtered apps - */ - private async processApps(apps: CFApp[], credentials: Credentials[], includeInvalid: boolean): Promise { - const validatedApps = await this.getValidatedApps(apps, credentials); - return includeInvalid ? validatedApps : validatedApps.filter((app) => !app.messages?.length); + return filterCfApps(validatedApps, includeInvalid); } /** @@ -131,57 +64,15 @@ export class FDCService { * @returns {Manifest | undefined} The manifest. */ public getManifestByBaseAppId(appId: string): Manifest | undefined { - return this.manifests.find((appManifest) => { - return appManifest['sap.app'].id === appId; - }); - } - - /** - * Get the validated apps. - * - * @param {CFApp[]} discoveryApps - The discovery apps. - * @param {Credentials[]} credentials - The credentials. - * @returns {Promise} The validated apps. - */ - private async getValidatedApps(discoveryApps: CFApp[], credentials: Credentials[]): Promise { - const validatedApps: CFApp[] = []; - - for (const app of discoveryApps) { - if (!app.messages?.length) { - const messages = await this.validateSelectedApp(app, credentials); - app.messages = messages; - } - validatedApps.push(app); - } - - return validatedApps; + return this.appContent.getManifestByBaseAppId(appId); } /** - * Validate the selected app. + * Get the HTML5 repo runtime GUID. * - * @param {AppParams} appParams - The app parameters. - * @param {Credentials[]} credentials - The credentials. - * @returns {Promise} The messages. + * @returns {string} The runtime GUID. */ - private async validateSelectedApp(appParams: AppParams, credentials: Credentials[]): Promise { - try { - const cfConfig = this.cfConfigService.getConfig(); - const { entries, serviceInstanceGuid, manifest } = await downloadAppContent( - cfConfig.space.GUID, - appParams, - this.logger - ); - this.manifests.push(manifest); - const messages = await validateSmartTemplateApplication(manifest); - this.html5RepoRuntimeGuid = serviceInstanceGuid; - if (messages?.length === 0) { - return validateODataEndpoints(entries, credentials, this.logger); - } else { - return messages; - } - } catch (e) { - return [e.message]; - } + public getHtml5RepoRuntimeGuid(): string { + return this.appContent.getHtml5RepoRuntimeGuid(); } } diff --git a/packages/adp-tooling/src/cf/html5-repo.ts b/packages/adp-tooling/src/cf/html5-repo.ts index 3846613d19b..4473180f42f 100644 --- a/packages/adp-tooling/src/cf/html5-repo.ts +++ b/packages/adp-tooling/src/cf/html5-repo.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import AdmZip from 'adm-zip'; import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; import { createService } from './api'; import { getServiceInstanceKeys } from './utils'; @@ -105,11 +106,7 @@ export async function downloadAppContent( const appNameVersion = `${appName}-${appVersion}`; try { const htmlRepoCredentials = await getHtml5RepoCredentials(spaceGuid, logger); - if ( - htmlRepoCredentials?.credentials && - htmlRepoCredentials?.credentials.length && - htmlRepoCredentials?.credentials[0]?.uaa - ) { + if (htmlRepoCredentials?.credentials?.length > 0 && htmlRepoCredentials?.credentials[0]?.uaa) { const token = await getToken(htmlRepoCredentials.credentials[0].uaa); const uri = `${htmlRepoCredentials.credentials[0].uri}/applications/content/${appNameVersion}?pathSuffixFilter=manifest.json,xs-app.json`; const zip = await downloadZip(token, appHostId, uri); @@ -119,7 +116,7 @@ export async function downloadAppContent( } catch (e) { throw new Error(`Failed to parse zip content from HTML5 repository. Reason: ${e.message}`); } - if (!(admZip && admZip.getEntries().length)) { + if (!admZip?.getEntries?.().length) { throw new Error('No zip content was parsed from HTML5 repository'); } const zipEntry = admZip.getEntries().find((zipEntry) => zipEntry.entryName === 'manifest.json'); @@ -128,7 +125,7 @@ export async function downloadAppContent( } try { - const manifest = JSON.parse(zipEntry.getData().toString('utf8')); + const manifest = JSON.parse(zipEntry.getData().toString('utf8')) as Manifest; return { entries: admZip.getEntries(), serviceInstanceGuid: htmlRepoCredentials.serviceInstance.guid, diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index 2509b0cd50c..9b62b81982e 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -7,4 +7,4 @@ export * from './auth'; export * from './config'; export * from './mta'; export * from './api'; -export * from './validation'; +export * from './app'; diff --git a/packages/adp-tooling/src/cf/mta.ts b/packages/adp-tooling/src/cf/mta.ts index 187d63002a7..cf8b6236e12 100644 --- a/packages/adp-tooling/src/cf/mta.ts +++ b/packages/adp-tooling/src/cf/mta.ts @@ -110,7 +110,20 @@ async function filterServices(businessServices: BusinessServiceResource[], logge } /** - * Get the resources for the file. + * Get the services for the MTA project. + * + * @param {string} projectPath - The path to the project. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The services. + */ +export async function getMtaServices(projectPath: string, logger: ToolsLogger): Promise { + const services = await readMta(projectPath, logger); + logger?.log(`Available services defined in mta.yaml: ${JSON.stringify(services)}`); + return services; +} + +/** + * Get the resources for the MTA file. * * @param {string} mtaFilePath - The path to the mta file. * @param {ToolsLogger} logger - The logger. @@ -123,7 +136,7 @@ export async function getResources(mtaFilePath: string, logger: ToolsLogger): Pr } /** - * Read the mta file. + * Read the MTA file. * * @param {string} projectPath - The path to the project. * @param {ToolsLogger} logger - The logger. diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 141e9a95aca..4b679185816 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -81,7 +81,8 @@ export async function generateCf(basePath: string, config: CfAdpWriterConfig, fs const fullConfig = setDefaultsCF(config); - await adjustMtaYaml(basePath, fullConfig); + const { app, cf } = fullConfig; + await YamlUtils.adjustMtaYaml(basePath, app.id, cf.approuter, cf.businessSolutionName ?? '', cf.businessService); if (fullConfig.app.i18nModels) { writeI18nModels(basePath, fullConfig.app.i18nModels, fs); @@ -118,18 +119,6 @@ function setDefaultsCF(config: CfAdpWriterConfig): CfAdpWriterConfig { return configWithDefaults; } -/** - * Adjust MTA YAML file for CF project. - * - * @param {string} basePath - The base path. - * @param {CfAdpWriterConfig} config - The CF configuration. - */ -async function adjustMtaYaml(basePath: string, config: CfAdpWriterConfig): Promise { - const { app, cf } = config; - - await YamlUtils.adjustMtaYaml(basePath, app.id, cf.approuter, cf.businessSolutionName ?? '', cf.businessService); -} - /** * Write CF-specific templates and configuration files. * diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 474a651beeb..51d38fc5ea4 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -412,7 +412,7 @@ export default class extends Generator { */ private async _promptForCfProjectPath(): Promise { if (!this.isMtaYamlFound) { - const pathAnswers = await this.prompt([getProjectPathPrompt(this.fdcService, this.vscode)]); + const pathAnswers = await this.prompt([getProjectPathPrompt(this.logger, this.vscode)]); this.projectLocation = pathAnswers.projectLocation; this.projectLocation = fs.realpathSync(this.projectLocation, 'utf-8'); this.cfProjectDestinationPath = this.destinationRoot(this.projectLocation); @@ -536,13 +536,14 @@ export default class extends Generator { throw new Error('Manifest not found for base app.'); } + const html5RepoRuntimeGuid = this.fdcService.getHtml5RepoRuntimeGuid(); const cfConfig = createCfConfig({ attributeAnswers: this.attributeAnswers, cfServicesAnswers: this.cfServicesAnswers, cfConfig: this.cfConfig, layer: this.layer, manifest, - html5RepoRuntimeGuid: this.fdcService.html5RepoRuntimeGuid, + html5RepoRuntimeGuid, projectPath, publicVersions }); diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 00808a3bff2..6a710a3ecb3 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -10,7 +10,8 @@ import { getModuleNames, getApprouterType, hasApprouter, - isLoggedInCf + isLoggedInCf, + getMtaServices } from '@sap-ux/adp-tooling'; import type { ToolsLogger } from '@sap-ux/logger'; import { validateEmptyString } from '@sap-ux/project-input-validator'; @@ -26,22 +27,6 @@ import { showBusinessSolutionNameQuestion } from './helper/conditions'; * Prompter for CF services. */ export class CFServicesPrompter { - /** - * The FDC service instance. - */ - private readonly fdcService: FDCService; - /** - * The CF auth service instance. - */ - private readonly cfConfigService: CfConfigService; - /** - * Whether the user is using the internal usage. - */ - private readonly isInternalUsage: boolean; - /** - * The logger instance. - */ - private readonly logger: ToolsLogger; /** * Whether the user is logged in to Cloud Foundry. */ @@ -85,16 +70,12 @@ export class CFServicesPrompter { * @param {boolean} [isInternalUsage] - Internal usage flag. */ constructor( - fdcService: FDCService, - cfConfigService: CfConfigService, + private readonly fdcService: FDCService, + private readonly cfConfigService: CfConfigService, isCfLoggedIn: boolean, - logger: ToolsLogger, - isInternalUsage: boolean = false + private readonly logger: ToolsLogger, + private readonly isInternalUsage: boolean = false ) { - this.fdcService = fdcService; - this.cfConfigService = cfConfigService; - this.isInternalUsage = isInternalUsage; - this.logger = logger; this.isCfLoggedIn = isCfLoggedIn; } @@ -110,7 +91,7 @@ export class CFServicesPrompter { promptOptions?: CfServicesPromptOptions ): Promise { if (this.isCfLoggedIn) { - this.businessServices = await this.fdcService.getServices(mtaProjectPath); + this.businessServices = await getMtaServices(mtaProjectPath, this.logger); } const keyedPrompts: Record = { diff --git a/packages/generator-adp/src/app/questions/helper/validators.ts b/packages/generator-adp/src/app/questions/helper/validators.ts index f7fadf3ed63..3ae071c5819 100644 --- a/packages/generator-adp/src/app/questions/helper/validators.ts +++ b/packages/generator-adp/src/app/questions/helper/validators.ts @@ -1,7 +1,8 @@ import fs from 'fs'; import { isAppStudio } from '@sap-ux/btp-utils'; -import { isExternalLoginEnabled, isMtaProject, type FDCService, type SystemLookup } from '@sap-ux/adp-tooling'; +import type { ToolsLogger } from '@sap-ux/logger'; +import { getMtaServices, isExternalLoginEnabled, isMtaProject, type SystemLookup } from '@sap-ux/adp-tooling'; import { validateEmptyString, validateNamespaceAdp, validateProjectName } from '@sap-ux/project-input-validator'; import { t } from '../../../utils/i18n'; @@ -110,10 +111,10 @@ export async function validateEnvironment( * Validates the project path. * * @param {string} projectPath - The path to the project. - * @param {FDCService} fdcService - The FDC service instance. + * @param {ToolsLogger} logger - The logger. * @returns {Promise} Returns true if the project path is valid, otherwise returns an error message. */ -export async function validateProjectPath(projectPath: string, fdcService: FDCService): Promise { +export async function validateProjectPath(projectPath: string, logger: ToolsLogger): Promise { const validationResult = validateEmptyString(projectPath); if (typeof validationResult === 'string') { return validationResult; @@ -135,7 +136,7 @@ export async function validateProjectPath(projectPath: string, fdcService: FDCSe let services: string[]; try { - services = await fdcService.getServices(projectPath); + services = await getMtaServices(projectPath, logger); } catch (err) { services = []; } diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index ad8ba48b9ca..ab3553805a9 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -1,15 +1,16 @@ import { MessageType } from '@sap-devx/yeoman-ui-types'; import type { AppWizard } from '@sap-devx/yeoman-ui-types'; -import type { CfConfigService, FDCService } from '@sap-ux/adp-tooling'; +import type { ToolsLogger } from '@sap-ux/logger'; +import type { CfConfigService } from '@sap-ux/adp-tooling'; import { getDefaultTargetFolder } from '@sap-ux/fiori-generator-shared'; import type { InputQuestion, ListQuestion, YUIQuestion } from '@sap-ux/inquirer-common'; +import { t } from '../../utils/i18n'; import type { ProjectLocationAnswers } from '../types'; +import { getTargetEnvAdditionalMessages } from './helper/additional-messages'; import { validateEnvironment, validateProjectPath } from './helper/validators'; import { TargetEnv, type TargetEnvAnswers, type TargetEnvQuestion } from '../types'; -import { t } from '../../utils/i18n'; -import { getTargetEnvAdditionalMessages } from './helper/additional-messages'; type EnvironmentChoice = { name: string; value: TargetEnv }; @@ -70,11 +71,11 @@ export function getEnvironments(appWizard: AppWizard, isCfInstalled: boolean): E /** * Returns the project path prompt. * - * @param {FDCService} fdcService - The FDC service instance. + * @param {ToolsLogger} logger - The logger. * @param {any} vscode - The VSCode instance. * @returns {YUIQuestion[]} The project path prompt. */ -export function getProjectPathPrompt(fdcService: FDCService, vscode: any): YUIQuestion { +export function getProjectPathPrompt(logger: ToolsLogger, vscode: any): YUIQuestion { return { type: 'input', name: 'projectLocation', @@ -85,7 +86,7 @@ export function getProjectPathPrompt(fdcService: FDCService, vscode: any): YUIQu breadcrumb: t('prompts.projectLocationBreadcrumb') }, message: t('prompts.projectLocationLabel'), - validate: (value: string) => validateProjectPath(value, fdcService), + validate: (value: string) => validateProjectPath(value, logger), default: () => getDefaultTargetFolder(vscode), store: false } as InputQuestion; From db0dea18483556ec0682824fd1045af17d4d4b51 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 28 Aug 2025 17:06:03 +0300 Subject: [PATCH 044/111] feat: rethink folder structure for cf --- packages/adp-tooling/src/cf/app/content.ts | 2 +- packages/adp-tooling/src/cf/app/discovery.ts | 33 ++- .../src/cf/{ => app}/html5-repo.ts | 5 +- packages/adp-tooling/src/cf/app/index.ts | 1 + packages/adp-tooling/src/cf/app/validation.ts | 2 +- .../adp-tooling/src/cf/{ => core}/auth.ts | 4 +- .../adp-tooling/src/cf/{ => core}/config.ts | 2 +- packages/adp-tooling/src/cf/core/index.ts | 2 + packages/adp-tooling/src/cf/fdc.ts | 4 +- packages/adp-tooling/src/cf/index.ts | 12 +- packages/adp-tooling/src/cf/project/index.ts | 3 + .../adp-tooling/src/cf/{ => project}/mta.ts | 4 +- .../src/cf/{ => project}/yaml-loader.ts | 2 +- .../adp-tooling/src/cf/{ => project}/yaml.ts | 6 +- .../adp-tooling/src/cf/{ => services}/api.ts | 104 ++++++++- packages/adp-tooling/src/cf/services/cli.ts | 86 +++++++ packages/adp-tooling/src/cf/services/index.ts | 2 + packages/adp-tooling/src/cf/utils.ts | 214 ------------------ packages/adp-tooling/src/cf/utils/index.ts | 1 + .../src/cf/{ => utils}/validation.ts | 8 +- packages/adp-tooling/src/writer/cf.ts | 2 +- 21 files changed, 251 insertions(+), 248 deletions(-) rename packages/adp-tooling/src/cf/{ => app}/html5-repo.ts (98%) rename packages/adp-tooling/src/cf/{ => core}/auth.ts (93%) rename packages/adp-tooling/src/cf/{ => core}/config.ts (98%) create mode 100644 packages/adp-tooling/src/cf/core/index.ts create mode 100644 packages/adp-tooling/src/cf/project/index.ts rename packages/adp-tooling/src/cf/{ => project}/mta.ts (98%) rename packages/adp-tooling/src/cf/{ => project}/yaml-loader.ts (98%) rename packages/adp-tooling/src/cf/{ => project}/yaml.ts (99%) rename packages/adp-tooling/src/cf/{ => services}/api.ts (66%) create mode 100644 packages/adp-tooling/src/cf/services/cli.ts create mode 100644 packages/adp-tooling/src/cf/services/index.ts delete mode 100644 packages/adp-tooling/src/cf/utils.ts create mode 100644 packages/adp-tooling/src/cf/utils/index.ts rename packages/adp-tooling/src/cf/{ => utils}/validation.ts (96%) diff --git a/packages/adp-tooling/src/cf/app/content.ts b/packages/adp-tooling/src/cf/app/content.ts index 7ec06ebba56..31350133f4e 100644 --- a/packages/adp-tooling/src/cf/app/content.ts +++ b/packages/adp-tooling/src/cf/app/content.ts @@ -1,7 +1,7 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import { downloadAppContent } from '../html5-repo'; +import { downloadAppContent } from './html5-repo'; import type { CFConfig, AppParams } from '../../types'; /** diff --git a/packages/adp-tooling/src/cf/app/discovery.ts b/packages/adp-tooling/src/cf/app/discovery.ts index 1c3ef570341..881917e1701 100644 --- a/packages/adp-tooling/src/cf/app/discovery.ts +++ b/packages/adp-tooling/src/cf/app/discovery.ts @@ -1,7 +1,6 @@ import type { ToolsLogger } from '@sap-ux/logger'; -import { getFDCApps } from '../api'; -import { getAppHostIds } from '../utils'; +import { getFDCApps } from '../services/api'; import type { CFConfig, CFApp, Credentials } from '../../types'; /** @@ -15,6 +14,36 @@ export function filterCfApps(apps: CFApp[], includeInvalid: boolean): CFApp[] { return includeInvalid ? apps : apps.filter((app) => !app.messages?.length); } +/** + * Format the discovery. + * + * @param {CFApp} app - The app. + * @returns {string} The formatted discovery. + */ +export function formatDiscovery(app: CFApp): string { + return `${app.title} (${app.appId} ${app.appVersion})`; +} + +/** + * Get the app host ids. + * + * @param {Credentials[]} credentials - The credentials. + * @returns {Set} The app host ids. + */ +export function getAppHostIds(credentials: Credentials[]): Set { + const appHostIds: string[] = []; + credentials.forEach((credential) => { + const appHostId = credential['html5-apps-repo']?.app_host_id; + if (appHostId) { + appHostIds.push(appHostId.split(',').map((item: string) => item.trim())); // there might be multiple appHostIds separated by comma + } + }); + + // appHostIds is now an array of arrays of strings (from split) + // Flatten the array and create a Set + return new Set(appHostIds.flat()); +} + /** * Discover apps from FDC API based on credentials. * diff --git a/packages/adp-tooling/src/cf/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts similarity index 98% rename from packages/adp-tooling/src/cf/html5-repo.ts rename to packages/adp-tooling/src/cf/app/html5-repo.ts index 4473180f42f..24d483c878a 100644 --- a/packages/adp-tooling/src/cf/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -4,9 +4,8 @@ import AdmZip from 'adm-zip'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import { createService } from './api'; -import { getServiceInstanceKeys } from './utils'; -import type { HTML5Content, ServiceKeys, Uaa, AppParams } from '../types'; +import { createService, getServiceInstanceKeys } from '../services/api'; +import type { HTML5Content, ServiceKeys, Uaa, AppParams } from '../../types'; /** * Get the OAuth token from HTML5 repository. diff --git a/packages/adp-tooling/src/cf/app/index.ts b/packages/adp-tooling/src/cf/app/index.ts index b12d0e7af55..3481282b7a3 100644 --- a/packages/adp-tooling/src/cf/app/index.ts +++ b/packages/adp-tooling/src/cf/app/index.ts @@ -1,3 +1,4 @@ export * from './validation'; export * from './content'; export * from './discovery'; +export * from './html5-repo'; diff --git a/packages/adp-tooling/src/cf/app/validation.ts b/packages/adp-tooling/src/cf/app/validation.ts index 48e2fff32cd..b617b86c174 100644 --- a/packages/adp-tooling/src/cf/app/validation.ts +++ b/packages/adp-tooling/src/cf/app/validation.ts @@ -5,7 +5,7 @@ import type { Manifest } from '@sap-ux/project-access'; import type { AppContentService } from './content'; import type { CFApp, Credentials, CFConfig } from '../../types'; -import { validateSmartTemplateApplication, validateODataEndpoints } from '../validation'; +import { validateSmartTemplateApplication, validateODataEndpoints } from '../utils/validation'; /** * Validate a single app. diff --git a/packages/adp-tooling/src/cf/auth.ts b/packages/adp-tooling/src/cf/core/auth.ts similarity index 93% rename from packages/adp-tooling/src/cf/auth.ts rename to packages/adp-tooling/src/cf/core/auth.ts index acf89610c24..f5b5f06d8e5 100644 --- a/packages/adp-tooling/src/cf/auth.ts +++ b/packages/adp-tooling/src/cf/core/auth.ts @@ -2,8 +2,8 @@ import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import type { ToolsLogger } from '@sap-ux/logger'; -import { getAuthToken, checkForCf } from './utils'; -import type { CFConfig, Organization } from '../types'; +import { getAuthToken, checkForCf } from '../services/cli'; +import type { CFConfig, Organization } from '../../types'; /** * Check if CF is installed. diff --git a/packages/adp-tooling/src/cf/config.ts b/packages/adp-tooling/src/cf/core/config.ts similarity index 98% rename from packages/adp-tooling/src/cf/config.ts rename to packages/adp-tooling/src/cf/core/config.ts index c1ac725cb16..5f5172c61a6 100644 --- a/packages/adp-tooling/src/cf/config.ts +++ b/packages/adp-tooling/src/cf/core/config.ts @@ -4,7 +4,7 @@ import path from 'path'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { CFConfig, Config } from '../types'; +import type { CFConfig, Config } from '../../types'; const HOMEDRIVE = 'HOMEDRIVE'; const HOMEPATH = 'HOMEPATH'; diff --git a/packages/adp-tooling/src/cf/core/index.ts b/packages/adp-tooling/src/cf/core/index.ts new file mode 100644 index 00000000000..c09071fa930 --- /dev/null +++ b/packages/adp-tooling/src/cf/core/index.ts @@ -0,0 +1,2 @@ +export * from './auth'; +export * from './config'; diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts index 96df90c1e43..d0c55c71f3c 100644 --- a/packages/adp-tooling/src/cf/fdc.ts +++ b/packages/adp-tooling/src/cf/fdc.ts @@ -1,8 +1,8 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import { YamlUtils } from './yaml'; -import type { CfConfigService } from './config'; +import { YamlUtils } from './project/yaml'; +import type { CfConfigService } from './core/config'; import type { CFConfig, CFApp, Credentials } from '../types'; import { filterCfApps, discoverCfApps, AppContentService, AppValidationService } from './app'; diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index 9b62b81982e..2270b2600cc 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -1,10 +1,6 @@ -export * from './html5-repo'; -export * from './utils'; -export * from './yaml'; -export * from './yaml-loader'; +export * from './project'; export * from './fdc'; -export * from './auth'; -export * from './config'; -export * from './mta'; -export * from './api'; export * from './app'; +export * from './core'; +export * from './services'; +export * from './utils'; diff --git a/packages/adp-tooling/src/cf/project/index.ts b/packages/adp-tooling/src/cf/project/index.ts new file mode 100644 index 00000000000..71de22604f9 --- /dev/null +++ b/packages/adp-tooling/src/cf/project/index.ts @@ -0,0 +1,3 @@ +export * from './yaml'; +export * from './yaml-loader'; +export * from './mta'; diff --git a/packages/adp-tooling/src/cf/mta.ts b/packages/adp-tooling/src/cf/project/mta.ts similarity index 98% rename from packages/adp-tooling/src/cf/mta.ts rename to packages/adp-tooling/src/cf/project/mta.ts index cf8b6236e12..b09545d59d2 100644 --- a/packages/adp-tooling/src/cf/mta.ts +++ b/packages/adp-tooling/src/cf/project/mta.ts @@ -2,10 +2,10 @@ import * as path from 'path'; import type { ToolsLogger } from '@sap-ux/logger'; -import { requestCfApi } from './api'; +import { requestCfApi } from '../services/api'; import { getRouterType } from './yaml'; import { YamlLoader } from './yaml-loader'; -import type { CFServiceOffering, CFAPIResponse, BusinessServiceResource, Resource, AppRouterType } from '../types'; +import type { CFServiceOffering, CFAPIResponse, BusinessServiceResource, Resource, AppRouterType } from '../../types'; /** * Get the approuter type. diff --git a/packages/adp-tooling/src/cf/yaml-loader.ts b/packages/adp-tooling/src/cf/project/yaml-loader.ts similarity index 98% rename from packages/adp-tooling/src/cf/yaml-loader.ts rename to packages/adp-tooling/src/cf/project/yaml-loader.ts index fcbac759f98..9ed1607e706 100644 --- a/packages/adp-tooling/src/cf/yaml-loader.ts +++ b/packages/adp-tooling/src/cf/project/yaml-loader.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import yaml from 'js-yaml'; -import type { Yaml } from '../types'; +import type { Yaml } from '../../types'; /** * Parses the MTA file. diff --git a/packages/adp-tooling/src/cf/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts similarity index 99% rename from packages/adp-tooling/src/cf/yaml.ts rename to packages/adp-tooling/src/cf/project/yaml.ts index 116bd282bc6..91d1f190927 100644 --- a/packages/adp-tooling/src/cf/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -4,9 +4,9 @@ import yaml from 'js-yaml'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../types'; -import { AppRouterType } from '../types'; -import { createService } from './api'; +import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../../types'; +import { AppRouterType } from '../../types'; +import { createService } from '../services/api'; import { getProjectNameForXsSecurity, YamlLoader } from './yaml-loader'; const CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; diff --git a/packages/adp-tooling/src/cf/api.ts b/packages/adp-tooling/src/cf/services/api.ts similarity index 66% rename from packages/adp-tooling/src/cf/api.ts rename to packages/adp-tooling/src/cf/services/api.ts index 6cae8097a96..7d9513a3f8a 100644 --- a/packages/adp-tooling/src/cf/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -7,9 +7,20 @@ import CFToolsCli = require('@sap/cf-tools/out/src/cli'); import { isAppStudio } from '@sap-ux/btp-utils'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { CFConfig, CFApp, RequestArguments, ServiceKeys, CFAPIResponse, CFServiceOffering } from '../types'; -import { getServiceInstanceKeys } from './utils'; -import { isLoggedInCf } from './auth'; +import type { + CFConfig, + CFApp, + RequestArguments, + ServiceKeys, + CFAPIResponse, + CFServiceOffering, + GetServiceInstanceParams, + ServiceInstance, + CFServiceInstance, + Credentials +} from '../../types'; +import { isLoggedInCf } from '../core/auth'; +import { createServiceKey, getServiceKeys } from './cli'; interface FDCResponse { results: CFApp[]; @@ -214,3 +225,90 @@ export async function createService( throw new Error(errorMessage); } } + +/** + * Gets the service instance keys. + * + * @param {GetServiceInstanceParams} serviceInstanceQuery - The service instance query. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The service instance keys. + */ +export async function getServiceInstanceKeys( + serviceInstanceQuery: GetServiceInstanceParams, + logger: ToolsLogger +): Promise { + try { + const serviceInstances = await getServiceInstance(serviceInstanceQuery); + if (serviceInstances?.length > 0) { + // we can use any instance in the list to connect to HTML5 Repo + logger?.log(`Use '${serviceInstances[0].name}' HTML5 Repo instance`); + return { + credentials: await getOrCreateServiceKeys(serviceInstances[0], logger), + serviceInstance: serviceInstances[0] + }; + } + return null; + } catch (e) { + const errorMessage = `Failed to get service instance keys. Reason: ${e.message}`; + logger?.error(errorMessage); + throw new Error(errorMessage); + } +} + +/** + * Gets the service instance. + * + * @param {GetServiceInstanceParams} params - The service instance parameters. + * @returns {Promise} The service instance. + */ +async function getServiceInstance(params: GetServiceInstanceParams): Promise { + const PARAM_MAP: Map = new Map([ + ['spaceGuids', 'space_guids'], + ['planNames', 'service_plan_names'], + ['names', 'names'] + ]); + const parameters = Object.entries(params) + .filter(([_, value]) => value?.length > 0) + .map(([key, value]) => `${PARAM_MAP.get(key)}=${value.join(',')}`); + const uriParameters = parameters.length > 0 ? `?${parameters.join('&')}` : ''; + const uri = `/v3/service_instances` + uriParameters; + try { + const json = await requestCfApi>(uri); + if (json?.resources && Array.isArray(json.resources)) { + return json.resources.map((service: CFServiceInstance) => ({ + name: service.name, + guid: service.guid + })); + } + throw new Error('No valid JSON for service instance'); + } catch (e) { + // log error: CFUtils.ts=>getServiceInstance with uriParameters + throw new Error(`Failed to get service instance with params ${uriParameters}. Reason: ${e.message}`); + } +} + +/** + * Gets the service instance keys. + * + * @param {ServiceInstance} serviceInstance - The service instance. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The service instance keys. + */ +async function getOrCreateServiceKeys(serviceInstance: ServiceInstance, logger: ToolsLogger): Promise { + try { + const credentials = await getServiceKeys(serviceInstance.guid); + if (credentials?.length > 0) { + return credentials; + } else { + const serviceKeyName = serviceInstance.name + '_key'; + logger?.log(`Creating service key '${serviceKeyName}' for service instance '${serviceInstance.name}'`); + await createServiceKey(serviceInstance.name, serviceKeyName); + return getServiceKeys(serviceInstance.guid); + } + } catch (e) { + // log error: CFUtils.ts=>getOrCreateServiceKeys with param + throw new Error( + `Failed to get or create service keys for instance name ${serviceInstance.name}. Reason: ${e.message}` + ); + } +} diff --git a/packages/adp-tooling/src/cf/services/cli.ts b/packages/adp-tooling/src/cf/services/cli.ts new file mode 100644 index 00000000000..8f75f45544a --- /dev/null +++ b/packages/adp-tooling/src/cf/services/cli.ts @@ -0,0 +1,86 @@ +import CFLocal = require('@sap/cf-tools/out/src/cf-local'); +import CFToolsCli = require('@sap/cf-tools/out/src/cli'); +import { eFilters } from '@sap/cf-tools/out/src/types'; + +import type { Credentials } from '../../types'; + +const ENV = { env: { 'CF_COLOR': 'false' } }; +const CREATE_SERVICE_KEY = 'create-service-key'; + +/** + * Gets the authentication token. + * + * @returns {Promise} The authentication token. + */ +export async function getAuthToken(): Promise { + const response = await CFToolsCli.Cli.execute(['oauth-token'], ENV); + if (response.exitCode === 0) { + return response.stdout; + } + return response.stderr; +} + +/** + * Checks if Cloud Foundry is installed. + */ +export async function checkForCf(): Promise { + try { + const response = await CFToolsCli.Cli.execute(['version'], ENV); + if (response.exitCode !== 0) { + throw new Error(response.stderr); + } + } catch (error) { + // log error: CFUtils.ts=>checkForCf + throw new Error('Cloud Foundry is not installed in your space.'); + } +} + +/** + * Logs out from Cloud Foundry. + */ +export async function cFLogout(): Promise { + await CFToolsCli.Cli.execute(['logout']); +} + +/** + * Gets the service instance credentials. + * + * @param {string} serviceInstanceGuid - The service instance GUID. + * @returns {Promise} The service instance credentials. + */ +export async function getServiceKeys(serviceInstanceGuid: string): Promise { + try { + return await CFLocal.cfGetInstanceCredentials({ + filters: [ + { + value: serviceInstanceGuid, + // key: eFilters.service_instance_guid + key: eFilters.service_instance_guid + } + ] + }); + } catch (e) { + // log error: CFUtils.ts=>getServiceKeys for guid + throw new Error( + `Failed to get service instance credentials from CFLocal for guid ${serviceInstanceGuid}. Reason: ${e.message}` + ); + } +} + +/** + * Creates a service key. + * + * @param {string} serviceInstanceName - The service instance name. + * @param {string} serviceKeyName - The service key name. + */ +export async function createServiceKey(serviceInstanceName: string, serviceKeyName: string): Promise { + try { + const cliResult = await CFToolsCli.Cli.execute([CREATE_SERVICE_KEY, serviceInstanceName, serviceKeyName], ENV); + if (cliResult.exitCode !== 0) { + throw new Error(cliResult.stderr); + } + } catch (e) { + // log error: CFUtils.ts=>createServiceKey for serviceInstanceName + throw new Error(`Failed to create service key for instance name ${serviceInstanceName}. Reason: ${e.message}`); + } +} diff --git a/packages/adp-tooling/src/cf/services/index.ts b/packages/adp-tooling/src/cf/services/index.ts new file mode 100644 index 00000000000..fbba508af55 --- /dev/null +++ b/packages/adp-tooling/src/cf/services/index.ts @@ -0,0 +1,2 @@ +export * from './api'; +export * from './cli'; diff --git a/packages/adp-tooling/src/cf/utils.ts b/packages/adp-tooling/src/cf/utils.ts deleted file mode 100644 index f8f83f43506..00000000000 --- a/packages/adp-tooling/src/cf/utils.ts +++ /dev/null @@ -1,214 +0,0 @@ -import CFLocal = require('@sap/cf-tools/out/src/cf-local'); -import CFToolsCli = require('@sap/cf-tools/out/src/cli'); -import { eFilters } from '@sap/cf-tools/out/src/types'; - -import type { ToolsLogger } from '@sap-ux/logger'; - -import type { - GetServiceInstanceParams, - ServiceKeys, - ServiceInstance, - Credentials, - CFAPIResponse, - CFServiceInstance, - CFApp -} from '../types'; -import { requestCfApi } from './api'; - -const ENV = { env: { 'CF_COLOR': 'false' } }; -const CREATE_SERVICE_KEY = 'create-service-key'; - -/** - * Gets the service instance keys. - * - * @param {GetServiceInstanceParams} serviceInstanceQuery - The service instance query. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} The service instance keys. - */ -export async function getServiceInstanceKeys( - serviceInstanceQuery: GetServiceInstanceParams, - logger: ToolsLogger -): Promise { - try { - const serviceInstances = await getServiceInstance(serviceInstanceQuery); - if (serviceInstances?.length > 0) { - // we can use any instance in the list to connect to HTML5 Repo - logger?.log(`Use '${serviceInstances[0].name}' HTML5 Repo instance`); - return { - credentials: await getOrCreateServiceKeys(serviceInstances[0], logger), - serviceInstance: serviceInstances[0] - }; - } - return null; - } catch (e) { - const errorMessage = `Failed to get service instance keys. Reason: ${e.message}`; - logger?.error(errorMessage); - throw new Error(errorMessage); - } -} - -/** - * Gets the authentication token. - * - * @returns {Promise} The authentication token. - */ -export async function getAuthToken(): Promise { - const response = await CFToolsCli.Cli.execute(['oauth-token'], ENV); - if (response.exitCode === 0) { - return response.stdout; - } - return response.stderr; -} - -/** - * Checks if Cloud Foundry is installed. - */ -export async function checkForCf(): Promise { - try { - const response = await CFToolsCli.Cli.execute(['version'], ENV); - if (response.exitCode !== 0) { - throw new Error(response.stderr); - } - } catch (error) { - // log error: CFUtils.ts=>checkForCf - throw new Error('Cloud Foundry is not installed in your space.'); - } -} - -/** - * Logs out from Cloud Foundry. - */ -export async function cFLogout(): Promise { - await CFToolsCli.Cli.execute(['logout']); -} - -/** - * Gets the service instance. - * - * @param {GetServiceInstanceParams} params - The service instance parameters. - * @returns {Promise} The service instance. - */ -async function getServiceInstance(params: GetServiceInstanceParams): Promise { - const PARAM_MAP: Map = new Map([ - ['spaceGuids', 'space_guids'], - ['planNames', 'service_plan_names'], - ['names', 'names'] - ]); - const parameters = Object.entries(params) - .filter(([_, value]) => value?.length > 0) - .map(([key, value]) => `${PARAM_MAP.get(key)}=${value.join(',')}`); - const uriParameters = parameters.length > 0 ? `?${parameters.join('&')}` : ''; - const uri = `/v3/service_instances` + uriParameters; - try { - const json = await requestCfApi>(uri); - if (json?.resources && Array.isArray(json.resources)) { - return json.resources.map((service: CFServiceInstance) => ({ - name: service.name, - guid: service.guid - })); - } - throw new Error('No valid JSON for service instance'); - } catch (e) { - // log error: CFUtils.ts=>getServiceInstance with uriParameters - throw new Error(`Failed to get service instance with params ${uriParameters}. Reason: ${e.message}`); - } -} - -/** - * Gets the service instance keys. - * - * @param {ServiceInstance} serviceInstance - The service instance. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} The service instance keys. - */ -async function getOrCreateServiceKeys(serviceInstance: ServiceInstance, logger: ToolsLogger): Promise { - try { - const credentials = await getServiceKeys(serviceInstance.guid); - if (credentials?.length > 0) { - return credentials; - } else { - const serviceKeyName = serviceInstance.name + '_key'; - logger?.log(`Creating service key '${serviceKeyName}' for service instance '${serviceInstance.name}'`); - await createServiceKey(serviceInstance.name, serviceKeyName); - return getServiceKeys(serviceInstance.guid); - } - } catch (e) { - // log error: CFUtils.ts=>getOrCreateServiceKeys with param - throw new Error( - `Failed to get or create service keys for instance name ${serviceInstance.name}. Reason: ${e.message}` - ); - } -} - -/** - * Gets the service instance credentials. - * - * @param {string} serviceInstanceGuid - The service instance GUID. - * @returns {Promise} The service instance credentials. - */ -async function getServiceKeys(serviceInstanceGuid: string): Promise { - try { - return await CFLocal.cfGetInstanceCredentials({ - filters: [ - { - value: serviceInstanceGuid, - // key: eFilters.service_instance_guid - key: eFilters.service_instance_guid - } - ] - }); - } catch (e) { - // log error: CFUtils.ts=>getServiceKeys for guid - throw new Error( - `Failed to get service instance credentials from CFLocal for guid ${serviceInstanceGuid}. Reason: ${e.message}` - ); - } -} - -/** - * Creates a service key. - * - * @param {string} serviceInstanceName - The service instance name. - * @param {string} serviceKeyName - The service key name. - */ -async function createServiceKey(serviceInstanceName: string, serviceKeyName: string): Promise { - try { - const cliResult = await CFToolsCli.Cli.execute([CREATE_SERVICE_KEY, serviceInstanceName, serviceKeyName], ENV); - if (cliResult.exitCode !== 0) { - throw new Error(cliResult.stderr); - } - } catch (e) { - // log error: CFUtils.ts=>createServiceKey for serviceInstanceName - throw new Error(`Failed to create service key for instance name ${serviceInstanceName}. Reason: ${e.message}`); - } -} - -/** - * Format the discovery. - * - * @param {CFApp} app - The app. - * @returns {string} The formatted discovery. - */ -export function formatDiscovery(app: CFApp): string { - return `${app.title} (${app.appId} ${app.appVersion})`; -} - -/** - * Get the app host ids. - * - * @param {Credentials[]} credentials - The credentials. - * @returns {Set} The app host ids. - */ -export function getAppHostIds(credentials: Credentials[]): Set { - const appHostIds: string[] = []; - credentials.forEach((credential) => { - const appHostId = credential['html5-apps-repo']?.app_host_id; - if (appHostId) { - appHostIds.push(appHostId.split(',').map((item: string) => item.trim())); // there might be multiple appHostIds separated by comma - } - }); - - // appHostIds is now an array of arrays of strings (from split) - // Flatten the array and create a Set - return new Set(appHostIds.flat()); -} diff --git a/packages/adp-tooling/src/cf/utils/index.ts b/packages/adp-tooling/src/cf/utils/index.ts new file mode 100644 index 00000000000..4d5ffa36ab3 --- /dev/null +++ b/packages/adp-tooling/src/cf/utils/index.ts @@ -0,0 +1 @@ +export * from './validation'; diff --git a/packages/adp-tooling/src/cf/validation.ts b/packages/adp-tooling/src/cf/utils/validation.ts similarity index 96% rename from packages/adp-tooling/src/cf/validation.ts rename to packages/adp-tooling/src/cf/utils/validation.ts index 94277a59465..06e8d5e0585 100644 --- a/packages/adp-tooling/src/cf/validation.ts +++ b/packages/adp-tooling/src/cf/utils/validation.ts @@ -3,10 +3,10 @@ import type AdmZip from 'adm-zip'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; -import { t } from '../i18n'; -import { getApplicationType } from '../source/manifest'; -import { isSupportedAppTypeForAdp } from '../source/manifest'; -import type { Credentials } from '../types'; +import { t } from '../../i18n'; +import type { Credentials } from '../../types'; +import { getApplicationType } from '../../source/manifest'; +import { isSupportedAppTypeForAdp } from '../../source/manifest'; /** * Normalize the route regex. diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 4b679185816..bae43856cc3 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -13,7 +13,7 @@ import { type DescriptorVariant, type Content } from '../types'; -import { YamlUtils } from '../cf/yaml'; +import { YamlUtils } from '../cf/project/yaml'; import { fillDescriptorContent } from './manifest'; import { getLatestVersion } from '../ui5/version-info'; From 40b9c6d4cc7f422936bc645ce21f9699ccfebee1 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 29 Aug 2025 10:29:16 +0300 Subject: [PATCH 045/111] feat: dismantle fdc service and refactor code --- packages/adp-tooling/src/cf/app/content.ts | 17 +-- packages/adp-tooling/src/cf/app/index.ts | 1 + packages/adp-tooling/src/cf/app/validation.ts | 53 ++++----- packages/adp-tooling/src/cf/app/workflow.ts | 29 +++++ packages/adp-tooling/src/cf/core/config.ts | 105 ++++++------------ packages/adp-tooling/src/cf/fdc.ts | 78 ------------- packages/adp-tooling/src/cf/index.ts | 1 - packages/generator-adp/src/app/index.ts | 74 ++++++++---- .../src/app/questions/cf-services.ts | 67 ++++++----- .../src/app/questions/target-env.ts | 8 +- 10 files changed, 187 insertions(+), 246 deletions(-) create mode 100644 packages/adp-tooling/src/cf/app/workflow.ts delete mode 100644 packages/adp-tooling/src/cf/fdc.ts diff --git a/packages/adp-tooling/src/cf/app/content.ts b/packages/adp-tooling/src/cf/app/content.ts index 31350133f4e..7beca3883d0 100644 --- a/packages/adp-tooling/src/cf/app/content.ts +++ b/packages/adp-tooling/src/cf/app/content.ts @@ -24,20 +24,11 @@ export class AppContentService { */ constructor(private logger: ToolsLogger) {} - /** - * Get all stored manifests. - * - * @returns {Manifest[]} All manifests - */ - public getManifests(): Manifest[] { - return this.manifests; - } - /** * Download app content and extract manifest. * - * @param {AppParams} appParams - The app parameters - * @param {CFConfig} cfConfig - The CF configuration + * @param {AppParams} appParams - The app parameters. + * @param {CFConfig} cfConfig - The CF configuration. * @returns {Promise<{entries: any[], serviceInstanceGuid: string, manifest: Manifest}>} The downloaded content */ public async getAppContent( @@ -64,8 +55,8 @@ export class AppContentService { /** * Get the manifest by base app id. * - * @param {string} appId - The app id - * @returns {Manifest | undefined} The manifest + * @param {string} appId - The app id. + * @returns {Manifest | undefined} The manifest. */ public getManifestByBaseAppId(appId: string): Manifest | undefined { return this.manifests.find((manifest) => manifest['sap.app'].id === appId); diff --git a/packages/adp-tooling/src/cf/app/index.ts b/packages/adp-tooling/src/cf/app/index.ts index 3481282b7a3..14fa2f32832 100644 --- a/packages/adp-tooling/src/cf/app/index.ts +++ b/packages/adp-tooling/src/cf/app/index.ts @@ -2,3 +2,4 @@ export * from './validation'; export * from './content'; export * from './discovery'; export * from './html5-repo'; +export * from './workflow'; diff --git a/packages/adp-tooling/src/cf/app/validation.ts b/packages/adp-tooling/src/cf/app/validation.ts index b617b86c174..0904c01d88c 100644 --- a/packages/adp-tooling/src/cf/app/validation.ts +++ b/packages/adp-tooling/src/cf/app/validation.ts @@ -36,38 +36,33 @@ export async function validateApp( } /** - * App Validation Service - Handles validation orchestration. + * Validate multiple apps. + * + * @param {CFApp[]} apps - The apps to validate. + * @param {Credentials[]} credentials - The credentials for validation. + * @param {CFConfig} cfConfig - The CF configuration. + * @param {AppContentService} appContent - The app content service. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The validated apps with messages. */ -export class AppValidationService { - /** - * Constructor. - * - * @param {ToolsLogger} logger - The logger. - * @param {AppContentService} appContent - The app content service. - */ - constructor(private logger: ToolsLogger, private appContent: AppContentService) {} - - /** - * Validate multiple apps. - * - * @param {CFApp[]} apps - The apps to validate - * @param {Credentials[]} credentials - The credentials for validation - * @param {CFConfig} cfConfig - The CF configuration - * @returns {Promise} The validated apps with messages - */ - public async getValidatedApps(apps: CFApp[], credentials: Credentials[], cfConfig: CFConfig): Promise { - const validatedApps: CFApp[] = []; +export async function getValidatedApps( + apps: CFApp[], + credentials: Credentials[], + cfConfig: CFConfig, + appContent: AppContentService, + logger: ToolsLogger +): Promise { + const validatedApps: CFApp[] = []; - for (const app of apps) { - if (!app.messages?.length) { - const { entries, manifest } = await this.appContent.getAppContent(app, cfConfig); + for (const app of apps) { + if (!app.messages?.length) { + const { entries, manifest } = await appContent.getAppContent(app, cfConfig); - const messages = await validateApp(manifest, entries, credentials, this.logger); - app.messages = messages; - } - validatedApps.push(app); + const messages = await validateApp(manifest, entries, credentials, logger); + app.messages = messages; } - - return validatedApps; + validatedApps.push(app); } + + return validatedApps; } diff --git a/packages/adp-tooling/src/cf/app/workflow.ts b/packages/adp-tooling/src/cf/app/workflow.ts new file mode 100644 index 00000000000..75f270b7e78 --- /dev/null +++ b/packages/adp-tooling/src/cf/app/workflow.ts @@ -0,0 +1,29 @@ +import type { ToolsLogger } from '@sap-ux/logger'; + +import type { Credentials } from '../../types'; +import { getValidatedApps } from './validation'; +import type { CFConfig, CFApp } from '../../types'; +import type { AppContentService } from './content'; +import { discoverCfApps, filterCfApps } from './discovery'; + +/** + * Get the base apps. + * + * @param {Credentials[]} credentials - The credentials. + * @param {CFConfig} cfConfig - The CF config. + * @param {ToolsLogger} logger - The logger. + * @param {AppContentService} appContentService - The app content service. + * @param {boolean} [includeInvalid] - Whether to include invalid apps. + * @returns {Promise} The base apps. + */ +export async function getBaseApps( + credentials: Credentials[], + cfConfig: CFConfig, + logger: ToolsLogger, + appContentService: AppContentService, + includeInvalid: boolean = false +): Promise { + const apps = await discoverCfApps(credentials, cfConfig, logger); + const validatedApps = await getValidatedApps(apps, credentials, cfConfig, appContentService, logger); + return filterCfApps(validatedApps, includeInvalid); +} diff --git a/packages/adp-tooling/src/cf/core/config.ts b/packages/adp-tooling/src/cf/core/config.ts index 5f5172c61a6..e92fa04f5dc 100644 --- a/packages/adp-tooling/src/cf/core/config.ts +++ b/packages/adp-tooling/src/cf/core/config.ts @@ -27,87 +27,52 @@ function getHomedir(): string { } /** - * Cloud Foundry Configuration Service + * Load the CF configuration. + * + * @param {ToolsLogger} logger - The logger. + * @returns {CFConfig} The CF configuration. */ -export class CfConfigService { - /** - * The CF configuration. - */ - private cfConfig: CFConfig; - /** - * The logger. - */ - private logger: ToolsLogger; - - /** - * Creates an instance of CfConfigService. - * - * @param {ToolsLogger} logger - The logger. - */ - constructor(logger: ToolsLogger) { - this.logger = logger; +export function loadCfConfig(logger: ToolsLogger): CFConfig { + let cfHome = process.env['CF_HOME']; + if (!cfHome) { + cfHome = path.join(getHomedir(), '.cf'); } - /** - * Get the current configuration. - * - * @returns {CFConfig} The configuration. - */ - public getConfig(): CFConfig { - if (!this.cfConfig) { - this.cfConfig = this.loadConfig(); - } + const configFileLocation = path.join(cfHome, 'config.json'); - return this.cfConfig; + let config = {} as Config; + try { + const configAsString = fs.readFileSync(configFileLocation, 'utf-8'); + config = JSON.parse(configAsString) as Config; + } catch (e) { + logger?.error('Cannot receive token from config.json'); } - /** - * Load the CF configuration. - * - * @returns {CFConfig} The CF configuration. - */ - private loadConfig(): CFConfig { - let cfHome = process.env['CF_HOME']; - if (!cfHome) { - cfHome = path.join(getHomedir(), '.cf'); + const result = {} as CFConfig; + if (config) { + if (config.Target) { + const apiCfIndex = config.Target.indexOf('api.cf.'); + result.url = config.Target.substring(apiCfIndex + 'api.cf.'.length); } - const configFileLocation = path.join(cfHome, 'config.json'); - - let config = {} as Config; - try { - const configAsString = fs.readFileSync(configFileLocation, 'utf-8'); - config = JSON.parse(configAsString) as Config; - } catch (e) { - this.logger?.error('Cannot receive token from config.json'); + if (config.AccessToken) { + result.token = config.AccessToken.substring('bearer '.length); } - const result = {} as CFConfig; - if (config) { - if (config.Target) { - const apiCfIndex = config.Target.indexOf('api.cf.'); - result.url = config.Target.substring(apiCfIndex + 'api.cf.'.length); - } - - if (config.AccessToken) { - result.token = config.AccessToken.substring('bearer '.length); - } - - if (config.OrganizationFields) { - result.org = { - Name: config.OrganizationFields.Name, - GUID: config.OrganizationFields.GUID - }; - } - - if (config.SpaceFields) { - result.space = { - Name: config.SpaceFields.Name, - GUID: config.SpaceFields.GUID - }; - } + if (config.OrganizationFields) { + result.org = { + Name: config.OrganizationFields.Name, + GUID: config.OrganizationFields.GUID + }; } - return result; + if (config.SpaceFields) { + result.space = { + Name: config.SpaceFields.Name, + GUID: config.SpaceFields.GUID + }; + } } + + return result; } diff --git a/packages/adp-tooling/src/cf/fdc.ts b/packages/adp-tooling/src/cf/fdc.ts deleted file mode 100644 index d0c55c71f3c..00000000000 --- a/packages/adp-tooling/src/cf/fdc.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { ToolsLogger } from '@sap-ux/logger'; -import type { Manifest } from '@sap-ux/project-access'; - -import { YamlUtils } from './project/yaml'; -import type { CfConfigService } from './core/config'; -import type { CFConfig, CFApp, Credentials } from '../types'; -import { filterCfApps, discoverCfApps, AppContentService, AppValidationService } from './app'; - -/** - * FDC Service - Orchestrates app discovery, content downloading, and validation. - */ -export class FDCService { - /** - * The CF config. - */ - private cfConfig: CFConfig; - /** - * The app content service. - */ - private appContent: AppContentService; - /** - * The app validation service. - */ - private appValidation: AppValidationService; - - /** - * Creates an instance of FDCService. - * - * @param {ToolsLogger} logger - The logger. - * @param {CfConfigService} cfConfigService - The CF config service. - */ - constructor(private logger: ToolsLogger, private cfConfigService: CfConfigService) { - this.cfConfig = cfConfigService.getConfig(); - - this.appContent = new AppContentService(logger); - this.appValidation = new AppValidationService(logger, this.appContent); - - if (this.cfConfig) { - YamlUtils.spaceGuid = this.cfConfig.space.GUID; - } - } - - /** - * Get the base apps. - * - * @param {Credentials[]} credentials - The credentials. - * @param {boolean} [includeInvalid] - Whether to include invalid apps. - * @returns {Promise} The base apps. - */ - public async getBaseApps(credentials: Credentials[], includeInvalid: boolean = false): Promise { - const cfConfig = this.cfConfigService.getConfig(); - - const apps = await discoverCfApps(credentials, cfConfig, this.logger); - - const validatedApps = await this.appValidation.getValidatedApps(apps, credentials, cfConfig); - - return filterCfApps(validatedApps, includeInvalid); - } - - /** - * Get the manifest by base app id. - * - * @param {string} appId - The app id. - * @returns {Manifest | undefined} The manifest. - */ - public getManifestByBaseAppId(appId: string): Manifest | undefined { - return this.appContent.getManifestByBaseAppId(appId); - } - - /** - * Get the HTML5 repo runtime GUID. - * - * @returns {string} The runtime GUID. - */ - public getHtml5RepoRuntimeGuid(): string { - return this.appContent.getHtml5RepoRuntimeGuid(); - } -} diff --git a/packages/adp-tooling/src/cf/index.ts b/packages/adp-tooling/src/cf/index.ts index 2270b2600cc..c44dbdcad5c 100644 --- a/packages/adp-tooling/src/cf/index.ts +++ b/packages/adp-tooling/src/cf/index.ts @@ -1,5 +1,4 @@ export * from './project'; -export * from './fdc'; export * from './app'; export * from './core'; export * from './services'; diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 51d38fc5ea4..a1808d97da6 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -21,9 +21,12 @@ import { generateCf, createCfConfig, isCfInstalled, - isLoggedInCf + isLoggedInCf, + AppContentService, + YamlUtils, + loadCfConfig } from '@sap-ux/adp-tooling'; -import { type CFConfig, CfConfigService, type CfServicesAnswers } from '@sap-ux/adp-tooling'; +import { type CFConfig, type CfServicesAnswers } from '@sap-ux/adp-tooling'; import { ToolsLogger } from '@sap-ux/logger'; import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; import type { AbapServiceProvider } from '@sap-ux/axios-extension'; @@ -61,7 +64,6 @@ import { updateCfWizardSteps } from '../utils/steps'; import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderChoice } from '../utils/workspace'; -import { FDCService } from '@sap-ux/adp-tooling'; import { getTargetEnvPrompt, getProjectPathPrompt } from './questions/target-env'; import { isAppStudio } from '@sap-ux/btp-utils'; import { getTemplatesOverwritePath } from '../utils/templates'; @@ -142,18 +144,51 @@ export default class extends Generator { * Base application inbounds, if the base application is an FLP app. */ private baseAppInbounds?: ManifestNamespace.Inbound; - private readonly fdcService: FDCService; - private readonly isMtaYamlFound: boolean; + /** + * Target environment. + */ private targetEnv: TargetEnv; + /** + * Indicates if the current environment is a CF environment. + */ private isCfEnv = false; + /** + * Indicates if the user is logged in to CF. + */ private isCfLoggedIn = false; + /** + * CF config. + */ private cfConfig: CFConfig; + /** + * Indicates if the current project is an MTA project. + */ + private readonly isMtaYamlFound: boolean; + /** + * Project location. + */ private projectLocation: string; + /** + * CF project destination path. + */ private cfProjectDestinationPath: string; + /** + * CF services answers. + */ private cfServicesAnswers: CfServicesAnswers; + /** + * Indicates if the extension is installed. + */ private isExtensionInstalled: boolean; + /** + * Indicates if CF is installed. + */ private cfInstalled: boolean; - private cfConfigService: CfConfigService; + /** + * App content service. + */ + private appContentService: AppContentService; + /** * Creates an instance of the generator. * @@ -165,15 +200,14 @@ export default class extends Generator { this.appWizard = opts.appWizard ?? AppWizard.create(opts); this.shouldInstallDeps = opts.shouldInstallDeps ?? true; this.toolsLogger = new ToolsLogger(); + this.vscode = opts.vscode; this._setupLogging(); + this.options = opts; - this.cfConfigService = new CfConfigService(this.logger); - this.fdcService = new FDCService(this.logger, this.cfConfigService); + this.appContentService = new AppContentService(this.logger); this.isMtaYamlFound = isMtaProject(process.cwd()) as boolean; // TODO: Remove this once the PR is ready. this.isExtensionInstalled = true; // isExtensionInstalled(opts.vscode, 'SAP.adp-ve-bas-ext'); - this.vscode = opts.vscode; - this.options = opts; const jsonInputString = getFirstArgAsString(args); this.jsonInput = parseJsonInput(jsonInputString, this.logger); @@ -203,8 +237,9 @@ export default class extends Generator { this.systemLookup = new SystemLookup(this.logger); this.cfInstalled = await isCfInstalled(); - const cfConfig = this.cfConfigService.getConfig(); - this.isCfLoggedIn = await isLoggedInCf(cfConfig, this.logger); + this.cfConfig = loadCfConfig(this.logger); + YamlUtils.spaceGuid = this.cfConfig.space.GUID; + this.isCfLoggedIn = await isLoggedInCf(this.cfConfig, this.logger); this.logger.info(`isCfInstalled: ${this.cfInstalled}`); if (!this.jsonInput) { @@ -443,7 +478,7 @@ export default class extends Generator { */ private async _promptForTargetEnvironment(): Promise { const targetEnvAnswers = await this.prompt([ - getTargetEnvPrompt(this.appWizard, this.cfInstalled, this.isCfLoggedIn, this.cfConfigService, this.vscode) + getTargetEnvPrompt(this.appWizard, this.cfInstalled, this.isCfLoggedIn, this.cfConfig, this.vscode) ]); this.targetEnv = targetEnvAnswers.targetEnv; @@ -452,7 +487,6 @@ export default class extends Generator { updateCfWizardSteps(this.isCfEnv, this.prompts); - this.cfConfig = this.cfConfigService.getConfig(); this.logger.log(`Project organization information: ${JSON.stringify(this.cfConfig.org, null, 2)}`); this.logger.log(`Project space information: ${JSON.stringify(this.cfConfig.space, null, 2)}`); this.logger.log(`Project apiUrl information: ${JSON.stringify(this.cfConfig.url, null, 2)}`); @@ -490,11 +524,11 @@ export default class extends Generator { this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); const cfServicesQuestions = await getCFServicesPrompts({ - isCfLoggedIn: this.isCfLoggedIn, - fdcService: this.fdcService, - cfConfigService: this.cfConfigService, - mtaProjectPath: this.cfProjectDestinationPath, + cfConfig: this.cfConfig, isInternalUsage: isInternalFeaturesSettingEnabled(), + mtaProjectPath: this.cfProjectDestinationPath, + isCfLoggedIn: this.isCfLoggedIn, + appContentService: this.appContentService, logger: this.logger }); this.cfServicesAnswers = await this.prompt(cfServicesQuestions); @@ -530,13 +564,13 @@ export default class extends Generator { const projectPath = this.isMtaYamlFound ? process.cwd() : this.destinationPath(); const publicVersions = await fetchPublicVersions(this.logger); - const manifest = this.fdcService.getManifestByBaseAppId(this.cfServicesAnswers.baseApp?.appId ?? ''); + const manifest = this.appContentService.getManifestByBaseAppId(this.cfServicesAnswers.baseApp?.appId ?? ''); if (!manifest) { throw new Error('Manifest not found for base app.'); } - const html5RepoRuntimeGuid = this.fdcService.getHtml5RepoRuntimeGuid(); + const html5RepoRuntimeGuid = this.appContentService.getHtml5RepoRuntimeGuid(); const cfConfig = createCfConfig({ attributeAnswers: this.attributeAnswers, cfServicesAnswers: this.cfServicesAnswers, diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 6a710a3ecb3..63a3038af14 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -3,7 +3,8 @@ import type { CFServicesQuestion, CfServicesPromptOptions, AppRouterType, - CfConfigService + AppContentService, + CFConfig } from '@sap-ux/adp-tooling'; import { cfServicesPromptNames, @@ -11,12 +12,13 @@ import { getApprouterType, hasApprouter, isLoggedInCf, - getMtaServices + getMtaServices, + getBaseApps } from '@sap-ux/adp-tooling'; import type { ToolsLogger } from '@sap-ux/logger'; import { validateEmptyString } from '@sap-ux/project-input-validator'; import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; -import { getBusinessServiceKeys, type CFApp, type FDCService, type ServiceKeys } from '@sap-ux/adp-tooling'; +import { getBusinessServiceKeys, type CFApp, type ServiceKeys } from '@sap-ux/adp-tooling'; import { t } from '../../utils/i18n'; import { validateBusinessSolutionName } from './helper/validators'; @@ -63,18 +65,16 @@ export class CFServicesPrompter { /** * Constructor for CFServicesPrompter. * - * @param {FDCService} fdcService - FDC service instance. - * @param {CfConfigService} cfConfigService - CF config service instance. + * @param {boolean} [isInternalUsage] - Internal usage flag. * @param {boolean} isCfLoggedIn - Whether the user is logged in to Cloud Foundry. + * @param {AppContentService} appContentService - App content service instance. * @param {ToolsLogger} logger - Logger instance. - * @param {boolean} [isInternalUsage] - Internal usage flag. */ constructor( - private readonly fdcService: FDCService, - private readonly cfConfigService: CfConfigService, + private readonly isInternalUsage: boolean = false, isCfLoggedIn: boolean, - private readonly logger: ToolsLogger, - private readonly isInternalUsage: boolean = false + private readonly appContentService: AppContentService, + private readonly logger: ToolsLogger ) { this.isCfLoggedIn = isCfLoggedIn; } @@ -83,11 +83,13 @@ export class CFServicesPrompter { * Builds the CF services prompts, keyed and hide-filtered like attributes.ts. * * @param {string} mtaProjectPath - MTA project path + * @param {CFConfig} cfConfig - CF config service instance. * @param {CfServicesPromptOptions} [promptOptions] - Optional per-prompt visibility controls * @returns {Promise} CF services questions */ public async getPrompts( mtaProjectPath: string, + cfConfig: CFConfig, promptOptions?: CfServicesPromptOptions ): Promise { if (this.isCfLoggedIn) { @@ -95,10 +97,10 @@ export class CFServicesPrompter { } const keyedPrompts: Record = { - [cfServicesPromptNames.approuter]: this.getAppRouterPrompt(mtaProjectPath), - [cfServicesPromptNames.businessService]: this.getBusinessServicesPrompt(), + [cfServicesPromptNames.approuter]: this.getAppRouterPrompt(mtaProjectPath, cfConfig), + [cfServicesPromptNames.businessService]: this.getBusinessServicesPrompt(cfConfig), [cfServicesPromptNames.businessSolutionName]: this.getBusinessSolutionNamePrompt(), - [cfServicesPromptNames.baseApp]: this.getBaseAppPrompt() + [cfServicesPromptNames.baseApp]: this.getBaseAppPrompt(cfConfig) }; const questions = Object.entries(keyedPrompts) @@ -142,10 +144,10 @@ export class CFServicesPrompter { * Prompt for approuter. * * @param {string} mtaProjectPath - MTA project path. + * @param {CFConfig} cfConfig - CF config service instance. * @returns {CFServicesQuestion} Prompt for approuter. */ - private getAppRouterPrompt(mtaProjectPath: string): CFServicesQuestion { - const cfConfig = this.cfConfigService.getConfig(); + private getAppRouterPrompt(mtaProjectPath: string, cfConfig: CFConfig): CFServicesQuestion { return { type: 'list', name: cfServicesPromptNames.approuter, @@ -193,9 +195,10 @@ export class CFServicesPrompter { /** * Prompt for base application. * + * @param {CFConfig} cfConfig - CF config service instance. * @returns {CFServicesQuestion} Prompt for base application. */ - private getBaseAppPrompt(): CFServicesQuestion { + private getBaseAppPrompt(cfConfig: CFConfig): CFServicesQuestion { return { type: 'list', name: cfServicesPromptNames.baseApp, @@ -205,16 +208,20 @@ export class CFServicesPrompter { this.baseAppOnChoiceError = null; if (this.cachedServiceName != answers.businessService) { this.cachedServiceName = answers.businessService; - const config = this.cfConfigService.getConfig(); this.businessServiceKeys = await getBusinessServiceKeys( answers.businessService ?? '', - config, + cfConfig, this.logger ); if (!this.businessServiceKeys) { return []; } - this.apps = await this.fdcService.getBaseApps(this.businessServiceKeys.credentials); + this.apps = await getBaseApps( + this.businessServiceKeys.credentials, + cfConfig, + this.logger, + this.appContentService + ); this.logger?.log(`Available applications: ${JSON.stringify(this.apps)}`); } return getCFAppChoices(this.apps); @@ -246,9 +253,10 @@ export class CFServicesPrompter { /** * Prompt for business services. * + * @param {CFConfig} cfConfig - CF config service instance. * @returns {CFServicesQuestion} Prompt for business services. */ - private getBusinessServicesPrompt(): CFServicesQuestion { + private getBusinessServicesPrompt(cfConfig: CFConfig): CFServicesQuestion { return { type: 'list', name: cfServicesPromptNames.businessService, @@ -265,8 +273,7 @@ export class CFServicesPrompter { return t('error.businessServiceHasToBeSelected'); } - const config = this.cfConfigService.getConfig(); - this.businessServiceKeys = await getBusinessServiceKeys(value, config, this.logger); + this.businessServiceKeys = await getBusinessServiceKeys(value, cfConfig, this.logger); if (this.businessServiceKeys === null) { return t('error.businessServiceDoesNotExist'); } @@ -284,29 +291,29 @@ export class CFServicesPrompter { /** * @param {object} param0 - Configuration object containing FDC service, internal usage flag, MTA project path, CF login status, and logger. - * @param {FDCService} param0.fdcService - FDC service instance. - * @param {CfConfigService} param0.cfConfigService - CF config service instance. + * @param {CFConfig} param0.cfConfig - CF config service instance. * @param {boolean} [param0.isInternalUsage] - Internal usage flag. * @param {string} param0.mtaProjectPath - MTA project path. * @param {boolean} param0.isCfLoggedIn - CF login status. * @param {ToolsLogger} param0.logger - Logger instance. + * @param {AppContentService} param0.appContentService - App content service instance. * @returns {Promise} CF services questions. */ export async function getPrompts({ - fdcService, - cfConfigService, + cfConfig, isInternalUsage, mtaProjectPath, isCfLoggedIn, + appContentService, logger }: { - fdcService: FDCService; - cfConfigService: CfConfigService; + cfConfig: CFConfig; isInternalUsage?: boolean; mtaProjectPath: string; isCfLoggedIn: boolean; + appContentService: AppContentService; logger: ToolsLogger; }): Promise { - const prompter = new CFServicesPrompter(fdcService, cfConfigService, isCfLoggedIn, logger, isInternalUsage); - return prompter.getPrompts(mtaProjectPath); + const prompter = new CFServicesPrompter(isInternalUsage, isCfLoggedIn, appContentService, logger); + return prompter.getPrompts(mtaProjectPath, cfConfig); } diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index ab3553805a9..ee5a2a04727 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -2,7 +2,7 @@ import { MessageType } from '@sap-devx/yeoman-ui-types'; import type { AppWizard } from '@sap-devx/yeoman-ui-types'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { CfConfigService } from '@sap-ux/adp-tooling'; +import type { CFConfig } from '@sap-ux/adp-tooling'; import { getDefaultTargetFolder } from '@sap-ux/fiori-generator-shared'; import type { InputQuestion, ListQuestion, YUIQuestion } from '@sap-ux/inquirer-common'; @@ -20,7 +20,7 @@ type EnvironmentChoice = { name: string; value: TargetEnv }; * @param {AppWizard} appWizard - The app wizard instance. * @param {boolean} isCfInstalled - Whether Cloud Foundry is installed. * @param {boolean} isCFLoggedIn - Whether Cloud Foundry is logged in. - * @param {CfConfigService} cfConfigService - The CF config service instance. + * @param {CFConfig} cfConfig - The CF config service instance. * @param {any} vscode - The vscode instance. * @returns {object[]} The target environment prompt. */ @@ -28,11 +28,9 @@ export function getTargetEnvPrompt( appWizard: AppWizard, isCfInstalled: boolean, isCFLoggedIn: boolean, - cfConfigService: CfConfigService, + cfConfig: CFConfig, vscode: any ): TargetEnvQuestion { - const cfConfig = cfConfigService.getConfig(); - return { type: 'list', name: 'targetEnv', From f164eb4f0e5ec84321fbdf36559160fe4a8c6c8a Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 29 Aug 2025 11:12:38 +0300 Subject: [PATCH 046/111] fix: yaml path undefined --- packages/adp-tooling/src/cf/project/yaml.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index 91d1f190927..e778d74e005 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -401,7 +401,6 @@ function writeFileCallback(error: any): void { */ export class YamlUtils { public static spaceGuid: string; - private static yamlPath: string; private static HTML5_APPS_REPO = 'html5-apps-repo'; /** @@ -459,7 +458,7 @@ export class YamlUtils { const updatedYamlContent = yaml.dump(yamlContent); await this.createServices(projectPath, yamlContent, initialServices, timestamp, logger); - return fs.writeFile(this.yamlPath, updatedYamlContent, 'utf-8', writeFileCallback); + return fs.writeFile(mtaYamlPath, updatedYamlContent, 'utf-8', writeFileCallback); } /** From e073bacc5175db1700c4018a365ba11c8c01cc6e Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 29 Aug 2025 11:21:18 +0300 Subject: [PATCH 047/111] fix: template path --- packages/adp-tooling/src/cf/services/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 7d9513a3f8a..de0984d0a61 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -205,7 +205,7 @@ export async function createService( if (securityFilePath) { let xsSecurity = null; try { - const filePath = path.resolve(__dirname, '../../templates/cf/xs-security.json'); + const filePath = path.resolve(__dirname, '../../../templates/cf/xs-security.json'); const xsContent = fs.readFileSync(filePath, 'utf-8'); xsSecurity = JSON.parse(xsContent); xsSecurity.xsappname = xsSecurityProjectName; From 5237a5903ec83da5fb994be65193048be82c3fe8 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 29 Aug 2025 12:56:03 +0300 Subject: [PATCH 048/111] fix: template path --- packages/adp-tooling/src/writer/cf.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index bae43856cc3..9f3bff00cc0 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -159,11 +159,11 @@ async function writeCfTemplates(basePath: string, config: CfAdpWriterConfig, fs: { app: variant } ); - fs.copyTpl(join(baseTmplPath, 'cf/package.json'), join(basePath, project.folder, 'package.json'), { + fs.copyTpl(join(baseTmplPath, 'cf/package.json'), join(project.folder, 'package.json'), { module: project.name }); - fs.copyTpl(join(baseTmplPath, 'cf/ui5.yaml'), join(basePath, project.folder, 'ui5.yaml'), { + fs.copyTpl(join(baseTmplPath, 'cf/ui5.yaml'), join(project.folder, 'ui5.yaml'), { appHostId: baseApp.appHostId, appName: baseApp.appName, appVersion: baseApp.appVersion, @@ -187,20 +187,16 @@ async function writeCfTemplates(basePath: string, config: CfAdpWriterConfig, fs: cfOrganization: cf.org.GUID }; - fs.writeJSON(join(basePath, project.folder, '.adp/config.json'), configJson); + fs.writeJSON(join(project.folder, '.adp/config.json'), configJson); - fs.copyTpl( - join(baseTmplPath, 'cf/i18n/i18n.properties'), - join(basePath, project.folder, 'webapp/i18n/i18n.properties'), - { - module: project.name, - moduleTitle: app.title, - appVariantId: app.namespace, - i18nGuid: config.app.i18nDescription - } - ); + fs.copyTpl(join(baseTmplPath, 'cf/i18n/i18n.properties'), join(project.folder, 'webapp/i18n/i18n.properties'), { + module: project.name, + moduleTitle: app.title, + appVariantId: app.namespace, + i18nGuid: config.app.i18nDescription + }); - fs.copy(join(baseTmplPath, 'cf/_gitignore'), join(basePath, project.folder, '.gitignore')); + fs.copy(join(baseTmplPath, 'cf/_gitignore'), join(project.folder, '.gitignore')); if (options?.addStandaloneApprouter) { fs.copyTpl( From 0443a007708f2dd4640397f5cfa2f2859381a866 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 1 Sep 2025 14:28:01 +0300 Subject: [PATCH 049/111] feat: refactor yaml class --- packages/adp-tooling/src/cf/app/html5-repo.ts | 4 - packages/adp-tooling/src/cf/project/yaml.ts | 172 ++++++------------ packages/adp-tooling/src/cf/services/api.ts | 56 +++++- packages/adp-tooling/src/writer/cf.ts | 11 +- packages/generator-adp/src/app/index.ts | 2 - 5 files changed, 119 insertions(+), 126 deletions(-) diff --git a/packages/adp-tooling/src/cf/app/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts index 24d483c878a..b986e337ffd 100644 --- a/packages/adp-tooling/src/cf/app/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -26,7 +26,6 @@ export async function getToken(uaa: Uaa): Promise { const response = await axios.get(uri, options); return response.data['access_token']; } catch (e) { - // log error: HTML5RepoUtils.ts=>getToken(params) throw new Error(`Failed to get the OAuth token from HTML5 repository. Reason: ${e.message}`); } } @@ -51,7 +50,6 @@ export async function downloadZip(token: string, appHostId: string, uri: string) }); return response.data; } catch (e) { - // log error: HTML5RepoUtils.ts=>downloadZip(params) throw new Error(`Failed to download zip from HTML5 repository. Reason: ${e.message}`); } } @@ -83,7 +81,6 @@ export async function getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLo } return serviceKeys; } catch (e) { - // log error: HTML5RepoUtils.ts=>getHtml5RepoCredentials(spaceGuid) throw new Error(`Failed to get credentials from HTML5 repository for space ${spaceGuid}. Reason: ${e.message}`); } } @@ -137,7 +134,6 @@ export async function downloadAppContent( throw new Error('No UAA credentials found for HTML5 repository'); } } catch (e) { - // log error: HTML5RepoUtils.ts=>downloadAppContent(params) throw new Error( `Failed to download the application content from HTML5 repository for space ${spaceGuid} and app ${appName} (${appHostId}). Reason: ${e.message}` ); diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index e778d74e005..5d033556998 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -4,10 +4,10 @@ import yaml from 'js-yaml'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../../types'; import { AppRouterType } from '../../types'; -import { createService } from '../services/api'; +import { createServices } from '../services/api'; import { getProjectNameForXsSecurity, YamlLoader } from './yaml-loader'; +import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../../types'; const CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; const HTML5_APPS_REPO = 'html5-apps-repo'; @@ -386,128 +386,66 @@ function adjustMtaYamlFlpModule(yamlContent: { modules: any[] }, projectName: an } /** - * Writes the file callback. + * Adjusts the MTA YAML. * - * @param {any} error - The error. - */ -function writeFileCallback(error: any): void { - if (error) { - throw new Error('Cannot save mta.yaml file.'); - } -} - -/** - * The YAML utilities class. + * @param {string} projectPath - The project path. + * @param {string} moduleName - The module name. + * @param {AppRouterType} appRouterType - The app router type. + * @param {string} businessSolutionName - The business solution name. + * @param {string} businessService - The business service. + * @param {string} spaceGuid - The space GUID. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The promise. */ -export class YamlUtils { - public static spaceGuid: string; - private static HTML5_APPS_REPO = 'html5-apps-repo'; +export async function adjustMtaYaml( + projectPath: string, + moduleName: string, + appRouterType: AppRouterType, + businessSolutionName: string, + businessService: string, + spaceGuid: string, + logger?: ToolsLogger +): Promise { + const timestamp = Date.now().toString(); - /** - * Adjusts the MTA YAML. - * - * @param {string} projectPath - The project path. - * @param {string} moduleName - The module name. - * @param {AppRouterType} appRouterType - The app router type. - * @param {string} businessSolutionName - The business solution name. - * @param {string} businessService - The business service. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} The promise. - */ - public static async adjustMtaYaml( - projectPath: string, - moduleName: string, - appRouterType: AppRouterType, - businessSolutionName: string, - businessService: string, - logger?: ToolsLogger - ): Promise { - const timestamp = Date.now().toString(); - - const mtaYamlPath = path.join(projectPath, 'mta.yaml'); - const loadedYamlContent = YamlLoader.getYamlContent(mtaYamlPath); - - const defaultYaml = { - ID: projectPath.split(path.sep).pop(), - version: '0.0.1', - modules: [] as any[], - resources: [] as any[], - '_schema-version': '3.2' - }; + const mtaYamlPath = path.join(projectPath, 'mta.yaml'); + const loadedYamlContent = YamlLoader.getYamlContent(mtaYamlPath); - if (!appRouterType) { - appRouterType = getRouterType(loadedYamlContent); - } + const defaultYaml = { + ID: projectPath.split(path.sep).pop(), + version: '0.0.1', + modules: [] as any[], + resources: [] as any[], + '_schema-version': '3.2' + }; - const yamlContent = Object.assign(defaultYaml, loadedYamlContent); - const projectName = yamlContent.ID.toLowerCase(); - const initialServices = yamlContent.resources.map( - (resource: { parameters: { service: string } }) => resource.parameters.service - ); - const isStandaloneApprouter = appRouterType === AppRouterType.STANDALONE; - if (isStandaloneApprouter) { - adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businessService); - } else { - adjustMtaYamlManagedApprouter(yamlContent, projectName, businessSolutionName, businessService); - } - adjustMtaYamlUDeployer(yamlContent, projectName, moduleName); - adjustMtaYamlResources(yamlContent, projectName, timestamp, !isStandaloneApprouter); - adjustMtaYamlOwnModule(yamlContent, moduleName); - // should go last since it sorts the modules (workaround, should be removed after fixed in deployment module) - adjustMtaYamlFlpModule(yamlContent, projectName, businessService); + if (!appRouterType) { + appRouterType = getRouterType(loadedYamlContent); + } - const updatedYamlContent = yaml.dump(yamlContent); - await this.createServices(projectPath, yamlContent, initialServices, timestamp, logger); - return fs.writeFile(mtaYamlPath, updatedYamlContent, 'utf-8', writeFileCallback); + const yamlContent = Object.assign(defaultYaml, loadedYamlContent); + const projectName = yamlContent.ID.toLowerCase(); + const initialServices = yamlContent.resources.map( + (resource: { parameters: { service: string } }) => resource.parameters.service + ); + const isStandaloneApprouter = appRouterType === AppRouterType.STANDALONE; + if (isStandaloneApprouter) { + adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businessService); + } else { + adjustMtaYamlManagedApprouter(yamlContent, projectName, businessSolutionName, businessService); } + adjustMtaYamlUDeployer(yamlContent, projectName, moduleName); + adjustMtaYamlResources(yamlContent, projectName, timestamp, !isStandaloneApprouter); + adjustMtaYamlOwnModule(yamlContent, moduleName); + // should go last since it sorts the modules (workaround, should be removed after fixed in deployment module) + adjustMtaYamlFlpModule(yamlContent, projectName, businessService); - /** - * Creates the services. - * - * @param {string} projectPath - The project path. - * @param {Yaml} yamlContent - The YAML content. - * @param {string[]} initialServices - The initial services. - * @param {string} timestamp - The timestamp. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} The promise. - */ - private static async createServices( - projectPath: string, - yamlContent: Yaml, - initialServices: string[], - timestamp: string, - logger?: ToolsLogger - ): Promise { - const excludeServices = initialServices.concat(['portal', this.HTML5_APPS_REPO]); - const xsSecurityPath = path.join(projectPath, 'xs-security.json'); - const resources = yamlContent.resources as any[]; - const xsSecurityProjectName = getProjectNameForXsSecurity(yamlContent, timestamp); - for (const resource of resources) { - if (!excludeServices.includes(resource.parameters.service)) { - if (resource.parameters.service === 'xsuaa') { - await createService( - this.spaceGuid, - resource.parameters['service-plan'], - resource.parameters['service-name'], - logger, - [], - xsSecurityPath, - resource.parameters.service, - xsSecurityProjectName - ); - } else { - await createService( - this.spaceGuid, - resource.parameters['service-plan'], - resource.parameters['service-name'], - logger, - [], - '', - resource.parameters.service, - xsSecurityProjectName - ); - } - } + await createServices(projectPath, yamlContent, initialServices, timestamp, spaceGuid, logger); + + const updatedYamlContent = yaml.dump(yamlContent); + return fs.writeFile(mtaYamlPath, updatedYamlContent, 'utf-8', (error) => { + if (error) { + throw new Error('Cannot save mta.yaml file.'); } - } + }); } diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index de0984d0a61..5eacd9b8376 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -17,10 +17,12 @@ import type { GetServiceInstanceParams, ServiceInstance, CFServiceInstance, - Credentials + Credentials, + Yaml } from '../../types'; import { isLoggedInCf } from '../core/auth'; import { createServiceKey, getServiceKeys } from './cli'; +import { getProjectNameForXsSecurity } from '../project'; interface FDCResponse { results: CFApp[]; @@ -226,6 +228,58 @@ export async function createService( } } +/** + * Creates the services. + * + * @param {string} projectPath - The project path. + * @param {Yaml} yamlContent - The YAML content. + * @param {string[]} initialServices - The initial services. + * @param {string} timestamp - The timestamp. + * @param {string} spaceGuid - The space GUID. + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} The promise. + */ +export async function createServices( + projectPath: string, + yamlContent: Yaml, + initialServices: string[], + timestamp: string, + spaceGuid: string, + logger?: ToolsLogger +): Promise { + const excludeServices = initialServices.concat(['portal', 'html5-apps-repo']); + const xsSecurityPath = path.join(projectPath, 'xs-security.json'); + const resources = yamlContent.resources as any[]; + const xsSecurityProjectName = getProjectNameForXsSecurity(yamlContent, timestamp); + for (const resource of resources) { + if (!excludeServices.includes(resource.parameters.service)) { + if (resource.parameters.service === 'xsuaa') { + await createService( + spaceGuid, + resource.parameters['service-plan'], + resource.parameters['service-name'], + logger, + [], + xsSecurityPath, + resource.parameters.service, + xsSecurityProjectName + ); + } else { + await createService( + spaceGuid, + resource.parameters['service-plan'], + resource.parameters['service-name'], + logger, + [], + '', + resource.parameters.service, + xsSecurityProjectName + ); + } + } + } +} + /** * Gets the service instance keys. * diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 9f3bff00cc0..2800ee3dc0a 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -13,9 +13,9 @@ import { type DescriptorVariant, type Content } from '../types'; -import { YamlUtils } from '../cf/project/yaml'; import { fillDescriptorContent } from './manifest'; import { getLatestVersion } from '../ui5/version-info'; +import { adjustMtaYaml } from '../cf'; const baseTmplPath = join(__dirname, '../../templates'); @@ -82,7 +82,14 @@ export async function generateCf(basePath: string, config: CfAdpWriterConfig, fs const fullConfig = setDefaultsCF(config); const { app, cf } = fullConfig; - await YamlUtils.adjustMtaYaml(basePath, app.id, cf.approuter, cf.businessSolutionName ?? '', cf.businessService); + await adjustMtaYaml( + basePath, + app.id, + cf.approuter, + cf.businessSolutionName ?? '', + cf.businessService, + cf.space.GUID + ); if (fullConfig.app.i18nModels) { writeI18nModels(basePath, fullConfig.app.i18nModels, fs); diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index a1808d97da6..4118dbe69e6 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -23,7 +23,6 @@ import { isCfInstalled, isLoggedInCf, AppContentService, - YamlUtils, loadCfConfig } from '@sap-ux/adp-tooling'; import { type CFConfig, type CfServicesAnswers } from '@sap-ux/adp-tooling'; @@ -238,7 +237,6 @@ export default class extends Generator { this.cfInstalled = await isCfInstalled(); this.cfConfig = loadCfConfig(this.logger); - YamlUtils.spaceGuid = this.cfConfig.space.GUID; this.isCfLoggedIn = await isLoggedInCf(this.cfConfig, this.logger); this.logger.info(`isCfInstalled: ${this.cfInstalled}`); From 0ed9fc2ace7b39c8ccf5db1cb0b06851f7105ed6 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 1 Sep 2025 16:12:22 +0300 Subject: [PATCH 050/111] feat: enhance types, rafactor code --- packages/adp-tooling/src/cf/app/content.ts | 16 +- packages/adp-tooling/src/cf/app/discovery.ts | 14 +- packages/adp-tooling/src/cf/app/html5-repo.ts | 21 +- packages/adp-tooling/src/cf/app/validation.ts | 14 +- packages/adp-tooling/src/cf/app/workflow.ts | 12 +- packages/adp-tooling/src/cf/core/auth.ts | 6 +- packages/adp-tooling/src/cf/core/config.ts | 8 +- packages/adp-tooling/src/cf/project/mta.ts | 16 +- .../adp-tooling/src/cf/project/yaml-loader.ts | 50 ++--- packages/adp-tooling/src/cf/project/yaml.ts | 134 ++++++------ packages/adp-tooling/src/cf/services/api.ts | 71 +++--- packages/adp-tooling/src/cf/services/cli.ts | 6 +- .../adp-tooling/src/cf/utils/validation.ts | 7 +- packages/adp-tooling/src/types.ts | 204 +++++++++++++----- packages/generator-adp/src/app/index.ts | 8 +- .../src/app/questions/cf-services.ts | 22 +- .../src/app/questions/target-env.ts | 6 +- 17 files changed, 344 insertions(+), 271 deletions(-) diff --git a/packages/adp-tooling/src/cf/app/content.ts b/packages/adp-tooling/src/cf/app/content.ts index 7beca3883d0..324a47d823f 100644 --- a/packages/adp-tooling/src/cf/app/content.ts +++ b/packages/adp-tooling/src/cf/app/content.ts @@ -1,8 +1,10 @@ +import type AdmZip from 'adm-zip'; + import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import { downloadAppContent } from './html5-repo'; -import type { CFConfig, AppParams } from '../../types'; +import type { CfConfig, CfAppParams } from '../../types'; /** * App Content Service - Handles app content downloading and manifest management. @@ -27,15 +29,15 @@ export class AppContentService { /** * Download app content and extract manifest. * - * @param {AppParams} appParams - The app parameters. - * @param {CFConfig} cfConfig - The CF configuration. - * @returns {Promise<{entries: any[], serviceInstanceGuid: string, manifest: Manifest}>} The downloaded content + * @param {CfAppParams} appParams - The app parameters. + * @param {CfConfig} cfConfig - The CF configuration. + * @returns {Promise<{entries: AdmZip.IZipEntry[], serviceInstanceGuid: string, manifest: Manifest}>} The downloaded content */ public async getAppContent( - appParams: AppParams, - cfConfig: CFConfig + appParams: CfAppParams, + cfConfig: CfConfig ): Promise<{ - entries: any[]; + entries: AdmZip.IZipEntry[]; serviceInstanceGuid: string; manifest: Manifest; }> { diff --git a/packages/adp-tooling/src/cf/app/discovery.ts b/packages/adp-tooling/src/cf/app/discovery.ts index 881917e1701..c301ae4a40f 100644 --- a/packages/adp-tooling/src/cf/app/discovery.ts +++ b/packages/adp-tooling/src/cf/app/discovery.ts @@ -1,7 +1,7 @@ import type { ToolsLogger } from '@sap-ux/logger'; import { getFDCApps } from '../services/api'; -import type { CFConfig, CFApp, Credentials } from '../../types'; +import type { CfConfig, CFApp, CfCredentials } from '../../types'; /** * Filter apps based on validation status. @@ -27,10 +27,10 @@ export function formatDiscovery(app: CFApp): string { /** * Get the app host ids. * - * @param {Credentials[]} credentials - The credentials. + * @param {CfCredentials[]} credentials - The credentials. * @returns {Set} The app host ids. */ -export function getAppHostIds(credentials: Credentials[]): Set { +export function getAppHostIds(credentials: CfCredentials[]): Set { const appHostIds: string[] = []; credentials.forEach((credential) => { const appHostId = credential['html5-apps-repo']?.app_host_id; @@ -47,14 +47,14 @@ export function getAppHostIds(credentials: Credentials[]): Set { /** * Discover apps from FDC API based on credentials. * - * @param {Credentials[]} credentials - The credentials containing app host IDs - * @param {CFConfig} cfConfig - The CF configuration + * @param {CfCredentials[]} credentials - The credentials containing app host IDs + * @param {CfConfig} cfConfig - The CF configuration * @param {ToolsLogger} logger - The logger * @returns {Promise} The discovered apps */ export async function discoverCfApps( - credentials: Credentials[], - cfConfig: CFConfig, + credentials: CfCredentials[], + cfConfig: CfConfig, logger: ToolsLogger ): Promise { const appHostIds = getAppHostIds(credentials); diff --git a/packages/adp-tooling/src/cf/app/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts index b986e337ffd..08a0073c278 100644 --- a/packages/adp-tooling/src/cf/app/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -5,7 +5,9 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import { createService, getServiceInstanceKeys } from '../services/api'; -import type { HTML5Content, ServiceKeys, Uaa, AppParams } from '../../types'; +import type { HTML5Content, ServiceKeys, Uaa, CfAppParams } from '../../types'; + +const HTML5_APPS_REPO_RUNTIME = 'html5-apps-repo-runtime'; /** * Get the OAuth token from HTML5 repository. @@ -59,23 +61,22 @@ export async function downloadZip(token: string, appHostId: string, uri: string) * * @param {string} spaceGuid space guid * @param {ToolsLogger} logger logger to log messages - * @returns {Promise} credentials json object + * @returns {Promise} credentials json object */ export async function getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLogger): Promise { - const INSTANCE_NAME = 'html5-apps-repo-runtime'; try { let serviceKeys = await getServiceInstanceKeys( { spaceGuids: [spaceGuid], planNames: ['app-runtime'], - names: [INSTANCE_NAME] + names: [HTML5_APPS_REPO_RUNTIME] }, logger ); - if (serviceKeys === null || serviceKeys?.credentials === null || serviceKeys?.credentials?.length === 0) { - await createService(spaceGuid, 'app-runtime', INSTANCE_NAME, logger, ['html5-apps-repo-rt']); - serviceKeys = await getServiceInstanceKeys({ names: [INSTANCE_NAME] }, logger); - if (serviceKeys === null || serviceKeys?.credentials === null || serviceKeys?.credentials?.length === 0) { + if (!serviceKeys?.credentials?.length) { + await createService(spaceGuid, 'app-runtime', HTML5_APPS_REPO_RUNTIME, logger, ['html5-apps-repo-rt']); + serviceKeys = await getServiceInstanceKeys({ names: [HTML5_APPS_REPO_RUNTIME] }, logger); + if (!serviceKeys?.credentials?.length) { throw new Error('Cannot find HTML5 Repo runtime in current space'); } } @@ -89,13 +90,13 @@ export async function getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLo * Download base app manifest.json and xs-app.json from HTML5 repository. * * @param {string} spaceGuid current space guid - * @param {AppParams} parameters appName, appVersion, appHostId + * @param {CfAppParams} parameters appName, appVersion, appHostId * @param {ToolsLogger} logger logger to log messages * @returns {Promise} manifest.json and xs-app.json */ export async function downloadAppContent( spaceGuid: string, - parameters: AppParams, + parameters: CfAppParams, logger: ToolsLogger ): Promise { const { appHostId, appName, appVersion } = parameters; diff --git a/packages/adp-tooling/src/cf/app/validation.ts b/packages/adp-tooling/src/cf/app/validation.ts index 0904c01d88c..743c5d8b547 100644 --- a/packages/adp-tooling/src/cf/app/validation.ts +++ b/packages/adp-tooling/src/cf/app/validation.ts @@ -4,7 +4,7 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import type { AppContentService } from './content'; -import type { CFApp, Credentials, CFConfig } from '../../types'; +import type { CFApp, CfCredentials, CfConfig } from '../../types'; import { validateSmartTemplateApplication, validateODataEndpoints } from '../utils/validation'; /** @@ -12,14 +12,14 @@ import { validateSmartTemplateApplication, validateODataEndpoints } from '../uti * * @param {Manifest} manifest - The manifest to validate. * @param {AdmZip.IZipEntry[]} entries - The entries to validate. - * @param {Credentials[]} credentials - The credentials for validation. + * @param {CfCredentials[]} credentials - The credentials for validation. * @param {ToolsLogger} logger - The logger. * @returns {Promise} Validation messages. */ export async function validateApp( manifest: Manifest, entries: AdmZip.IZipEntry[], - credentials: Credentials[], + credentials: CfCredentials[], logger: ToolsLogger ): Promise { try { @@ -39,16 +39,16 @@ export async function validateApp( * Validate multiple apps. * * @param {CFApp[]} apps - The apps to validate. - * @param {Credentials[]} credentials - The credentials for validation. - * @param {CFConfig} cfConfig - The CF configuration. + * @param {CfCredentials[]} credentials - The credentials for validation. + * @param {CfConfig} cfConfig - The CF configuration. * @param {AppContentService} appContent - The app content service. * @param {ToolsLogger} logger - The logger. * @returns {Promise} The validated apps with messages. */ export async function getValidatedApps( apps: CFApp[], - credentials: Credentials[], - cfConfig: CFConfig, + credentials: CfCredentials[], + cfConfig: CfConfig, appContent: AppContentService, logger: ToolsLogger ): Promise { diff --git a/packages/adp-tooling/src/cf/app/workflow.ts b/packages/adp-tooling/src/cf/app/workflow.ts index 75f270b7e78..9526ecd935b 100644 --- a/packages/adp-tooling/src/cf/app/workflow.ts +++ b/packages/adp-tooling/src/cf/app/workflow.ts @@ -1,24 +1,24 @@ import type { ToolsLogger } from '@sap-ux/logger'; -import type { Credentials } from '../../types'; +import type { CfCredentials } from '../../types'; import { getValidatedApps } from './validation'; -import type { CFConfig, CFApp } from '../../types'; +import type { CfConfig, CFApp } from '../../types'; import type { AppContentService } from './content'; import { discoverCfApps, filterCfApps } from './discovery'; /** * Get the base apps. * - * @param {Credentials[]} credentials - The credentials. - * @param {CFConfig} cfConfig - The CF config. + * @param {CfCredentials[]} credentials - The credentials. + * @param {CfConfig} cfConfig - The CF config. * @param {ToolsLogger} logger - The logger. * @param {AppContentService} appContentService - The app content service. * @param {boolean} [includeInvalid] - Whether to include invalid apps. * @returns {Promise} The base apps. */ export async function getBaseApps( - credentials: Credentials[], - cfConfig: CFConfig, + credentials: CfCredentials[], + cfConfig: CfConfig, logger: ToolsLogger, appContentService: AppContentService, includeInvalid: boolean = false diff --git a/packages/adp-tooling/src/cf/core/auth.ts b/packages/adp-tooling/src/cf/core/auth.ts index f5b5f06d8e5..7074320c982 100644 --- a/packages/adp-tooling/src/cf/core/auth.ts +++ b/packages/adp-tooling/src/cf/core/auth.ts @@ -3,7 +3,7 @@ import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import type { ToolsLogger } from '@sap-ux/logger'; import { getAuthToken, checkForCf } from '../services/cli'; -import type { CFConfig, Organization } from '../../types'; +import type { CfConfig, Organization } from '../../types'; /** * Check if CF is installed. @@ -35,11 +35,11 @@ export async function isExternalLoginEnabled(vscode: any): Promise { /** * Check if the user is logged in Cloud Foundry. * - * @param {CFConfig} cfConfig - The CF config. + * @param {CfConfig} cfConfig - The CF config. * @param {ToolsLogger} logger - The logger. * @returns {Promise} Whether the user is logged in. */ -export async function isLoggedInCf(cfConfig: CFConfig, logger: ToolsLogger): Promise { +export async function isLoggedInCf(cfConfig: CfConfig, logger: ToolsLogger): Promise { let isLogged = false; let orgs: Organization[] = []; diff --git a/packages/adp-tooling/src/cf/core/config.ts b/packages/adp-tooling/src/cf/core/config.ts index e92fa04f5dc..a076da4ea9e 100644 --- a/packages/adp-tooling/src/cf/core/config.ts +++ b/packages/adp-tooling/src/cf/core/config.ts @@ -4,7 +4,7 @@ import path from 'path'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { CFConfig, Config } from '../../types'; +import type { CfConfig, Config } from '../../types'; const HOMEDRIVE = 'HOMEDRIVE'; const HOMEPATH = 'HOMEPATH'; @@ -30,9 +30,9 @@ function getHomedir(): string { * Load the CF configuration. * * @param {ToolsLogger} logger - The logger. - * @returns {CFConfig} The CF configuration. + * @returns {CfConfig} The CF configuration. */ -export function loadCfConfig(logger: ToolsLogger): CFConfig { +export function loadCfConfig(logger: ToolsLogger): CfConfig { let cfHome = process.env['CF_HOME']; if (!cfHome) { cfHome = path.join(getHomedir(), '.cf'); @@ -48,7 +48,7 @@ export function loadCfConfig(logger: ToolsLogger): CFConfig { logger?.error('Cannot receive token from config.json'); } - const result = {} as CFConfig; + const result = {} as CfConfig; if (config) { if (config.Target) { const apiCfIndex = config.Target.indexOf('api.cf.'); diff --git a/packages/adp-tooling/src/cf/project/mta.ts b/packages/adp-tooling/src/cf/project/mta.ts index b09545d59d2..ab9bd97c87b 100644 --- a/packages/adp-tooling/src/cf/project/mta.ts +++ b/packages/adp-tooling/src/cf/project/mta.ts @@ -4,8 +4,8 @@ import type { ToolsLogger } from '@sap-ux/logger'; import { requestCfApi } from '../services/api'; import { getRouterType } from './yaml'; -import { YamlLoader } from './yaml-loader'; -import type { CFServiceOffering, CFAPIResponse, BusinessServiceResource, Resource, AppRouterType } from '../../types'; +import { getYamlContent } from './yaml-loader'; +import type { CfServiceOffering, CfAPIResponse, BusinessServiceResource, Resource, AppRouterType } from '../../types'; /** * Get the approuter type. @@ -14,7 +14,7 @@ import type { CFServiceOffering, CFAPIResponse, BusinessServiceResource, Resourc * @returns {AppRouterType} The approuter type. */ export function getApprouterType(mtaProjectPath: string): AppRouterType { - const yamlContent = YamlLoader.getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); + const yamlContent = getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); return getRouterType(yamlContent); } @@ -25,7 +25,7 @@ export function getApprouterType(mtaProjectPath: string): AppRouterType { * @returns {string[]} The module names. */ export function getModuleNames(mtaProjectPath: string): string[] { - const yamlContent = YamlLoader.getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); + const yamlContent = getYamlContent(path.join(mtaProjectPath, 'mta.yaml')); return yamlContent?.modules?.map((module: { name: string }) => module.name) ?? []; } @@ -38,11 +38,11 @@ export function getModuleNames(mtaProjectPath: string): string[] { */ export function getServicesForFile(mtaFilePath: string, logger: ToolsLogger): BusinessServiceResource[] { const serviceNames: BusinessServiceResource[] = []; - const parsed = YamlLoader.getYamlContent(mtaFilePath); + const parsed = getYamlContent(mtaFilePath); if (parsed?.resources && Array.isArray(parsed.resources)) { parsed.resources.forEach((resource: Resource) => { const name = resource?.parameters?.['service-name'] || resource.name; - const label = resource?.parameters?.service; + const label = resource?.parameters?.service as string; if (name) { serviceNames.push({ name, label }); if (!label) { @@ -80,12 +80,12 @@ async function filterServices(businessServices: BusinessServiceResource[], logge const serviceLabels = businessServices.map((service) => service.label).filter((label) => label); if (serviceLabels.length > 0) { const url = `/v3/service_offerings?names=${serviceLabels.join(',')}`; - const json = await requestCfApi>(url); + const json = await requestCfApi>(url); logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); const businessServiceNames = new Set(businessServices.map((service) => service.label)); const result: string[] = []; - json?.resources?.forEach((resource: CFServiceOffering) => { + json?.resources?.forEach((resource: CfServiceOffering) => { if (businessServiceNames.has(resource.name)) { const sapService = resource?.['broker_catalog']?.metadata?.sapservice; if (sapService && ['v2', 'v4'].includes(sapService?.odataversion ?? '')) { diff --git a/packages/adp-tooling/src/cf/project/yaml-loader.ts b/packages/adp-tooling/src/cf/project/yaml-loader.ts index 9ed1607e706..0eecde3a290 100644 --- a/packages/adp-tooling/src/cf/project/yaml-loader.ts +++ b/packages/adp-tooling/src/cf/project/yaml-loader.ts @@ -1,72 +1,50 @@ import fs from 'fs'; import yaml from 'js-yaml'; -import type { Yaml } from '../../types'; +import type { MtaYaml } from '../../types'; /** * Parses the MTA file. * - * @param {string} file - The file to parse. - * @returns {Yaml} The parsed YAML content. + * @param {string} filePath - The file to parse. + * @returns {MtaYaml} The parsed YAML content. */ -export function parseMtaFile(file: string): Yaml { - if (!fs.existsSync(file)) { - throw new Error(`Could not find file ${file}`); +export function getYamlContent(filePath: string): T { + if (!fs.existsSync(filePath)) { + throw new Error(`Could not find file ${filePath}`); } - const content = fs.readFileSync(file, 'utf-8'); - let parsed: Yaml; + const content = fs.readFileSync(filePath, 'utf-8'); + let parsed: T; try { - parsed = yaml.load(content) as Yaml; + parsed = yaml.load(content) as T; return parsed; } catch (e) { - throw new Error(`Error parsing file ${file}`); + throw new Error(`Error parsing file ${filePath}`); } } /** * Gets the project name from YAML content. * - * @param {Yaml} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @returns {string | null} The project name or null if not found. */ -export function getProjectName(yamlContent: Yaml): string | null { +export function getProjectName(yamlContent: MtaYaml): string | null { return yamlContent?.ID || null; } /** * Gets the project name for XS security from YAML content. * - * @param {Yaml} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @param {string} timestamp - The timestamp to append. * @returns {string | null} The project name for XS security or null if not available. */ -export function getProjectNameForXsSecurity(yamlContent: Yaml, timestamp: string): string | undefined { +export function getProjectNameForXsSecurity(yamlContent: MtaYaml, timestamp: string): string | undefined { const projectName = getProjectName(yamlContent); if (!projectName || !timestamp) { return undefined; } return `${projectName.toLowerCase().replace(/\./g, '_')}_${timestamp}`; } - -/** - * Static YAML content loader. - * Handles loading and storing YAML content. - */ -export class YamlLoader { - private static yamlContent: Yaml | null = null; - - /** - * Gets the loaded YAML content. - * - * @param {string} filePath - The file path to load. - * @param {boolean} [forceReload] - Whether to force reload and bypass cache. - * @returns {Yaml} The YAML content. - */ - public static getYamlContent(filePath: string, forceReload: boolean = false): Yaml { - if (!this.yamlContent || forceReload) { - this.yamlContent = parseMtaFile(filePath); - } - return this.yamlContent; - } -} diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index 5d033556998..8b9aeac94bd 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -4,10 +4,18 @@ import yaml from 'js-yaml'; import type { ToolsLogger } from '@sap-ux/logger'; +import type { + MtaModule, + AppParamsExtended, + MtaDestination, + MtaResource, + MtaRequire, + CfUI5Yaml, + MtaYaml +} from '../../types'; import { AppRouterType } from '../../types'; import { createServices } from '../services/api'; -import { getProjectNameForXsSecurity, YamlLoader } from './yaml-loader'; -import type { Resource, Yaml, MTAModule, AppParamsExtended } from '../../types'; +import { getProjectNameForXsSecurity, getYamlContent } from './yaml-loader'; const CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; const HTML5_APPS_REPO = 'html5-apps-repo'; @@ -26,18 +34,16 @@ export function isMtaProject(selectedPath: string): boolean { /** * Gets the SAP Cloud Service. * - * @param {Yaml} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @returns {string} The SAP Cloud Service. */ -export function getSAPCloudService(yamlContent: Yaml): string { - const modules = yamlContent?.modules?.filter((module: { name: string }) => - module.name.includes('destination-content') - ); +export function getSAPCloudService(yamlContent: MtaYaml): string { + const modules = yamlContent?.modules?.filter((module: MtaModule) => module.name.includes('destination-content')); const destinations = modules?.[0]?.parameters?.content?.instance?.destinations; - let sapCloudService = destinations?.find((destination: { Name: string }) => + const mtaDestination = destinations?.find((destination: MtaDestination) => destination.Name.includes('html_repo_host') ); - sapCloudService = sapCloudService?.['sap.cloud.service'].replace(/_/g, '.'); + const sapCloudService = mtaDestination?.['sap.cloud.service']?.replace(/_/g, '.') ?? ''; return sapCloudService; } @@ -45,12 +51,12 @@ export function getSAPCloudService(yamlContent: Yaml): string { /** * Gets the router type. * - * @param {Yaml} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @returns {AppRouterType} The router type. */ -export function getRouterType(yamlContent: Yaml): AppRouterType { - const filtered: MTAModule[] | undefined = yamlContent?.modules?.filter( - (module: { name: string }) => module.name.includes('destination-content') || module.name.includes('approuter') +export function getRouterType(yamlContent: MtaYaml): AppRouterType { + const filtered: MtaModule[] | undefined = yamlContent?.modules?.filter( + (module: MtaModule) => module.name.includes('destination-content') || module.name.includes('approuter') ); const routerType = filtered?.pop(); if (routerType?.name.includes('approuter')) { @@ -64,18 +70,18 @@ export function getRouterType(yamlContent: Yaml): AppRouterType { * Gets the app params from the UI5 YAML file. * * @param {string} projectPath - The project path. - * @returns {Promise} The app params. + * @returns {AppParamsExtended} The app params. */ export function getAppParamsFromUI5Yaml(projectPath: string): AppParamsExtended { const ui5YamlPath = path.join(projectPath, 'ui5.yaml'); - const parsedYaml = YamlLoader.getYamlContent(ui5YamlPath) as any; + const parsedYaml = getYamlContent(ui5YamlPath); const appConfiguration = parsedYaml?.builder?.customTasks?.[0]?.configuration; const appParams: AppParamsExtended = { - appHostId: appConfiguration?.appHostId, - appName: appConfiguration?.appName, - appVersion: appConfiguration?.appVersion, - spaceGuid: appConfiguration?.space + appHostId: appConfiguration?.appHostId || '', + appName: appConfiguration?.appVersion || '', + appVersion: appConfiguration?.appVersion || '', + spaceGuid: appConfiguration?.space || '' }; return appParams; @@ -84,13 +90,13 @@ export function getAppParamsFromUI5Yaml(projectPath: string): AppParamsExtended /** * Adjusts the MTA YAML for a standalone approuter. * - * @param {any} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @param {string} projectName - The project name. * @param {string} businessService - The business service. */ -function adjustMtaYamlStandaloneApprouter(yamlContent: any, projectName: string, businessService: string): void { +function adjustMtaYamlStandaloneApprouter(yamlContent: MtaYaml, projectName: string, businessService: string): void { const appRouterName = `${projectName}-approuter`; - let appRouter = yamlContent.modules.find((module: { name: string }) => module.name === appRouterName); + let appRouter = yamlContent.modules?.find((module: MtaModule) => module.name === appRouterName); if (appRouter == null) { appRouter = { name: appRouterName, @@ -102,7 +108,7 @@ function adjustMtaYamlStandaloneApprouter(yamlContent: any, projectName: string, 'memory': '256M' } }; - yamlContent.modules.push(appRouter); + yamlContent.modules?.push(appRouter); } const requires = [ `${projectName}_html_repo_runtime`, @@ -110,8 +116,8 @@ function adjustMtaYamlStandaloneApprouter(yamlContent: any, projectName: string, `portal_resources_${projectName}` ].concat(businessService); requires.forEach((name) => { - if (appRouter.requires.every((existing: { name: string }) => existing.name !== name)) { - appRouter.requires.push({ name }); + if (appRouter.requires?.every((existing: { name: string }) => existing.name !== name)) { + appRouter.requires?.push({ name }); } }); } @@ -119,19 +125,19 @@ function adjustMtaYamlStandaloneApprouter(yamlContent: any, projectName: string, /** * Adjusts the MTA YAML for a managed approuter. * - * @param {any} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @param {string} projectName - The project name. - * @param {any} businessSolution - The business solution. + * @param {string} businessSolution - The business solution. * @param {string} businessService - The business service. */ function adjustMtaYamlManagedApprouter( - yamlContent: any, + yamlContent: MtaYaml, projectName: string, businessSolution: string, businessService: string ): void { const appRouterName = `${projectName}-destination-content`; - let appRouter = yamlContent.modules.find((module: { name: string }) => module.name === appRouterName); + let appRouter = yamlContent.modules?.find((module: MtaModule) => module.name === appRouterName); if (appRouter == null) { businessSolution = businessSolution.split('.').join('_'); appRouter = { @@ -201,20 +207,20 @@ function adjustMtaYamlManagedApprouter( } } }; - yamlContent.modules.push(appRouter); + yamlContent.modules?.push(appRouter); } } /** * Adjusts the MTA YAML for a UI deployer. * - * @param {any} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @param {string} projectName - The project name. * @param {string} moduleName - The module name. */ -function adjustMtaYamlUDeployer(yamlContent: any, projectName: string, moduleName: string): void { +function adjustMtaYamlUDeployer(yamlContent: MtaYaml, projectName: string, moduleName: string): void { const uiDeployerName = `${projectName}_ui_deployer`; - let uiDeployer = yamlContent.modules.find((module: { name: string }) => module.name === uiDeployerName); + let uiDeployer = yamlContent.modules?.find((module: MtaModule) => module.name === uiDeployerName); if (uiDeployer == null) { uiDeployer = { name: uiDeployerName, @@ -226,19 +232,19 @@ function adjustMtaYamlUDeployer(yamlContent: any, projectName: string, moduleNam requires: [] } }; - yamlContent.modules.push(uiDeployer); + yamlContent.modules?.push(uiDeployer); } const htmlRepoHostName = `${projectName}_html_repo_host`; - if (uiDeployer.requires.every((req: { name: string }) => req.name !== htmlRepoHostName)) { - uiDeployer.requires.push({ + if (uiDeployer.requires?.every((req: { name: string }) => req.name !== htmlRepoHostName)) { + uiDeployer.requires?.push({ name: htmlRepoHostName, parameters: { 'content-target': true } }); } - if (uiDeployer['build-parameters'].requires.every((require: { name: any }) => require.name !== moduleName)) { - uiDeployer['build-parameters'].requires.push({ + if (uiDeployer['build-parameters']?.requires?.every((require: { name: string }) => require.name !== moduleName)) { + uiDeployer['build-parameters']?.requires?.push({ artifacts: [`${moduleName}.zip`], name: moduleName, 'target-path': 'resources/' @@ -249,19 +255,19 @@ function adjustMtaYamlUDeployer(yamlContent: any, projectName: string, moduleNam /** * Adjusts the MTA YAML for resources. * - * @param {any} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @param {string} projectName - The project name. * @param {string} timestamp - The timestamp. * @param {boolean} isManagedAppRouter - Whether the approuter is managed. */ function adjustMtaYamlResources( - yamlContent: any, + yamlContent: MtaYaml, projectName: string, timestamp: string, isManagedAppRouter: boolean ): void { const projectNameForXsSecurity = getProjectNameForXsSecurity(yamlContent, timestamp); - const resources: Resource[] = [ + const resources: MtaResource[] = [ { name: `${projectName}_html_repo_host`, type: CF_MANAGED_SERVICE, @@ -319,8 +325,8 @@ function adjustMtaYamlResources( } resources.forEach((resource) => { - if (yamlContent.resources.every((existing: { name: string }) => existing.name !== resource.name)) { - yamlContent.resources.push(resource); + if (yamlContent.resources?.every((existing: MtaResource) => existing.name !== resource.name)) { + yamlContent.resources?.push(resource); } }); } @@ -328,11 +334,11 @@ function adjustMtaYamlResources( /** * Adjusts the MTA YAML for the own module. * - * @param {any} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @param {string} moduleName - The module name. */ -function adjustMtaYamlOwnModule(yamlContent: any, moduleName: string): void { - let module = yamlContent.modules.find((module: { name: string }) => module.name === moduleName); +function adjustMtaYamlOwnModule(yamlContent: MtaYaml, moduleName: string): void { + let module = yamlContent.modules?.find((module: MtaModule) => module.name === moduleName); if (module == null) { module = { name: moduleName, @@ -344,17 +350,17 @@ function adjustMtaYamlOwnModule(yamlContent: any, moduleName: string): void { 'supported-platforms': [] } }; - yamlContent.modules.push(module); + yamlContent.modules?.push(module); } } /** * Adds a module if it does not exist. * - * @param {any[]} requires - The requires. - * @param {any} name - The name. + * @param {MtaRequire[]} requires - The requires. + * @param {string} name - The name. */ -function addModuleIfNotExists(requires: { name: any }[], name: any): void { +function addModuleIfNotExists(requires: MtaRequire[], name: string): void { if (requires.every((require) => require.name !== name)) { requires.push({ name }); } @@ -363,23 +369,22 @@ function addModuleIfNotExists(requires: { name: any }[], name: any): void { /** * Adjusts the MTA YAML for the FLP module. * - * @param {any} yamlContent - The YAML content. - * @param {any} yamlContent.modules - The modules. + * @param {MtaYaml} yamlContent - The YAML content. * @param {string} projectName - The project name. * @param {string} businessService - The business service. */ -function adjustMtaYamlFlpModule(yamlContent: { modules: any[] }, projectName: any, businessService: string): void { - yamlContent.modules.forEach((module, index) => { +function adjustMtaYamlFlpModule(yamlContent: MtaYaml, projectName: string, businessService: string): void { + yamlContent.modules?.forEach((module, index) => { if (module.type === SAP_APPLICATION_CONTENT && module.requires) { const portalResources = module.requires.find( - (require: { name: string }) => require.name === `portal_resources_${projectName}` + (require: MtaRequire) => require.name === `portal_resources_${projectName}` ); - if (portalResources?.['parameters']?.['service-key']?.['name'] === 'content-deploy-key') { + if (portalResources?.parameters?.['service-key']?.name === 'content-deploy-key') { addModuleIfNotExists(module.requires, `${projectName}_html_repo_host`); addModuleIfNotExists(module.requires, `${projectName}_ui_deployer`); addModuleIfNotExists(module.requires, businessService); // move flp module to last position - yamlContent.modules.push(yamlContent.modules.splice(index, 1)[0]); + yamlContent.modules?.push(yamlContent.modules.splice(index, 1)[0]); } } }); @@ -409,13 +414,13 @@ export async function adjustMtaYaml( const timestamp = Date.now().toString(); const mtaYamlPath = path.join(projectPath, 'mta.yaml'); - const loadedYamlContent = YamlLoader.getYamlContent(mtaYamlPath); + const loadedYamlContent = getYamlContent(mtaYamlPath); - const defaultYaml = { - ID: projectPath.split(path.sep).pop(), + const defaultYaml: MtaYaml = { + ID: projectPath.split(path.sep).pop() ?? '', version: '0.0.1', - modules: [] as any[], - resources: [] as any[], + modules: [], + resources: [], '_schema-version': '3.2' }; @@ -425,9 +430,8 @@ export async function adjustMtaYaml( const yamlContent = Object.assign(defaultYaml, loadedYamlContent); const projectName = yamlContent.ID.toLowerCase(); - const initialServices = yamlContent.resources.map( - (resource: { parameters: { service: string } }) => resource.parameters.service - ); + const initialServices = + yamlContent.resources?.map((resource: MtaResource) => resource.parameters.service ?? '') ?? []; const isStandaloneApprouter = appRouterType === AppRouterType.STANDALONE; if (isStandaloneApprouter) { adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businessService); diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 5eacd9b8376..dd3e3f16100 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -8,17 +8,17 @@ import { isAppStudio } from '@sap-ux/btp-utils'; import type { ToolsLogger } from '@sap-ux/logger'; import type { - CFConfig, + CfConfig, CFApp, RequestArguments, ServiceKeys, - CFAPIResponse, - CFServiceOffering, + CfAPIResponse, + CfServiceOffering, GetServiceInstanceParams, ServiceInstance, - CFServiceInstance, - Credentials, - Yaml + CfServiceInstance, + CfCredentials, + MtaYaml } from '../../types'; import { isLoggedInCf } from '../core/auth'; import { createServiceKey, getServiceKeys } from './cli'; @@ -28,17 +28,23 @@ interface FDCResponse { results: CFApp[]; } +const PARAM_MAP: Map = new Map([ + ['spaceGuids', 'space_guids'], + ['planNames', 'service_plan_names'], + ['names', 'names'] +]); + /** * Get the business service keys. * * @param {string} businessService - The business service. - * @param {CFConfig} config - The CF config. + * @param {CfConfig} config - The CF config. * @param {ToolsLogger} logger - The logger. * @returns {Promise} The service keys. */ export async function getBusinessServiceKeys( businessService: string, - config: CFConfig, + config: CfConfig, logger: ToolsLogger ): Promise { const serviceKeys = await getServiceInstanceKeys( @@ -55,10 +61,10 @@ export async function getBusinessServiceKeys( /** * Get the FDC request arguments. * - * @param {CFConfig} cfConfig - The CF config. + * @param {CfConfig} cfConfig - The CF config. * @returns {RequestArguments} The request arguments. */ -function getFDCRequestArguments(cfConfig: CFConfig): RequestArguments { +function getFDCRequestArguments(cfConfig: CfConfig): RequestArguments { const fdcUrl = 'https://ui5-flexibility-design-and-configuration.'; const cfApiEndpoint = `https://api.cf.${cfConfig.url}`; const endpointParts = /https:\/\/api\.cf(?:\.([^-.]*)(-\d+)?(\.hana\.ondemand\.com)|(.*))/.exec(cfApiEndpoint); @@ -69,7 +75,6 @@ function getFDCRequestArguments(cfConfig: CFConfig): RequestArguments { } }; - // Determine the appropriate domain based on environment let url: string; if (endpointParts?.[3]) { @@ -104,13 +109,13 @@ function getFDCRequestArguments(cfConfig: CFConfig): RequestArguments { * Get the FDC apps. * * @param {string[]} appHostIds - The app host ids. - * @param {CFConfig} cfConfig - The CF config. + * @param {CfConfig} cfConfig - The CF config. * @param {ToolsLogger} logger - The logger. * @returns {Promise>} The FDC apps. */ export async function getFDCApps( appHostIds: string[], - cfConfig: CFConfig, + cfConfig: CfConfig, logger: ToolsLogger ): Promise> { const requestArguments = getFDCRequestArguments(cfConfig); @@ -191,11 +196,11 @@ export async function createService( ): Promise { try { if (!serviceName) { - const json: CFAPIResponse = await requestCfApi>( + const json: CfAPIResponse = await requestCfApi>( `/v3/service_offerings?per_page=1000&space_guids=${spaceGuid}` ); const serviceOffering = json?.resources?.find( - (resource: CFServiceOffering) => resource.tags && tags.every((tag) => resource.tags?.includes(tag)) + (resource: CfServiceOffering) => resource.tags && tags.every((tag) => resource.tags?.includes(tag)) ); serviceName = serviceOffering?.name; } @@ -209,7 +214,7 @@ export async function createService( try { const filePath = path.resolve(__dirname, '../../../templates/cf/xs-security.json'); const xsContent = fs.readFileSync(filePath, 'utf-8'); - xsSecurity = JSON.parse(xsContent); + xsSecurity = JSON.parse(xsContent) as unknown as { xsappname?: string }; xsSecurity.xsappname = xsSecurityProjectName; } catch (err) { throw new Error('xs-security.json could not be parsed.'); @@ -232,7 +237,7 @@ export async function createService( * Creates the services. * * @param {string} projectPath - The project path. - * @param {Yaml} yamlContent - The YAML content. + * @param {MtaYaml} yamlContent - The YAML content. * @param {string[]} initialServices - The initial services. * @param {string} timestamp - The timestamp. * @param {string} spaceGuid - The space GUID. @@ -241,7 +246,7 @@ export async function createService( */ export async function createServices( projectPath: string, - yamlContent: Yaml, + yamlContent: MtaYaml, initialServices: string[], timestamp: string, spaceGuid: string, @@ -249,15 +254,14 @@ export async function createServices( ): Promise { const excludeServices = initialServices.concat(['portal', 'html5-apps-repo']); const xsSecurityPath = path.join(projectPath, 'xs-security.json'); - const resources = yamlContent.resources as any[]; const xsSecurityProjectName = getProjectNameForXsSecurity(yamlContent, timestamp); - for (const resource of resources) { - if (!excludeServices.includes(resource.parameters.service)) { - if (resource.parameters.service === 'xsuaa') { + for (const resource of yamlContent.resources ?? []) { + if (!excludeServices.includes(resource?.parameters?.service ?? '')) { + if (resource?.parameters?.service === 'xsuaa') { await createService( spaceGuid, - resource.parameters['service-plan'], - resource.parameters['service-name'], + resource.parameters['service-plan'] ?? '', + resource.parameters['service-name'] ?? '', logger, [], xsSecurityPath, @@ -267,8 +271,8 @@ export async function createServices( } else { await createService( spaceGuid, - resource.parameters['service-plan'], - resource.parameters['service-name'], + resource.parameters['service-plan'] ?? '', + resource.parameters['service-name'] ?? '', logger, [], '', @@ -294,7 +298,7 @@ export async function getServiceInstanceKeys( try { const serviceInstances = await getServiceInstance(serviceInstanceQuery); if (serviceInstances?.length > 0) { - // we can use any instance in the list to connect to HTML5 Repo + // We can use any instance in the list to connect to HTML5 Repo logger?.log(`Use '${serviceInstances[0].name}' HTML5 Repo instance`); return { credentials: await getOrCreateServiceKeys(serviceInstances[0], logger), @@ -316,27 +320,21 @@ export async function getServiceInstanceKeys( * @returns {Promise} The service instance. */ async function getServiceInstance(params: GetServiceInstanceParams): Promise { - const PARAM_MAP: Map = new Map([ - ['spaceGuids', 'space_guids'], - ['planNames', 'service_plan_names'], - ['names', 'names'] - ]); const parameters = Object.entries(params) .filter(([_, value]) => value?.length > 0) .map(([key, value]) => `${PARAM_MAP.get(key)}=${value.join(',')}`); const uriParameters = parameters.length > 0 ? `?${parameters.join('&')}` : ''; const uri = `/v3/service_instances` + uriParameters; try { - const json = await requestCfApi>(uri); + const json = await requestCfApi>(uri); if (json?.resources && Array.isArray(json.resources)) { - return json.resources.map((service: CFServiceInstance) => ({ + return json.resources.map((service: CfServiceInstance) => ({ name: service.name, guid: service.guid })); } throw new Error('No valid JSON for service instance'); } catch (e) { - // log error: CFUtils.ts=>getServiceInstance with uriParameters throw new Error(`Failed to get service instance with params ${uriParameters}. Reason: ${e.message}`); } } @@ -348,7 +346,7 @@ async function getServiceInstance(params: GetServiceInstanceParams): Promise} The service instance keys. */ -async function getOrCreateServiceKeys(serviceInstance: ServiceInstance, logger: ToolsLogger): Promise { +async function getOrCreateServiceKeys(serviceInstance: ServiceInstance, logger: ToolsLogger): Promise { try { const credentials = await getServiceKeys(serviceInstance.guid); if (credentials?.length > 0) { @@ -360,7 +358,6 @@ async function getOrCreateServiceKeys(serviceInstance: ServiceInstance, logger: return getServiceKeys(serviceInstance.guid); } } catch (e) { - // log error: CFUtils.ts=>getOrCreateServiceKeys with param throw new Error( `Failed to get or create service keys for instance name ${serviceInstance.name}. Reason: ${e.message}` ); diff --git a/packages/adp-tooling/src/cf/services/cli.ts b/packages/adp-tooling/src/cf/services/cli.ts index 8f75f45544a..a4ac1883f27 100644 --- a/packages/adp-tooling/src/cf/services/cli.ts +++ b/packages/adp-tooling/src/cf/services/cli.ts @@ -2,7 +2,7 @@ import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import CFToolsCli = require('@sap/cf-tools/out/src/cli'); import { eFilters } from '@sap/cf-tools/out/src/types'; -import type { Credentials } from '../../types'; +import type { CfCredentials } from '../../types'; const ENV = { env: { 'CF_COLOR': 'false' } }; const CREATE_SERVICE_KEY = 'create-service-key'; @@ -46,9 +46,9 @@ export async function cFLogout(): Promise { * Gets the service instance credentials. * * @param {string} serviceInstanceGuid - The service instance GUID. - * @returns {Promise} The service instance credentials. + * @returns {Promise} The service instance credentials. */ -export async function getServiceKeys(serviceInstanceGuid: string): Promise { +export async function getServiceKeys(serviceInstanceGuid: string): Promise { try { return await CFLocal.cfGetInstanceCredentials({ filters: [ diff --git a/packages/adp-tooling/src/cf/utils/validation.ts b/packages/adp-tooling/src/cf/utils/validation.ts index 06e8d5e0585..c84a35610e6 100644 --- a/packages/adp-tooling/src/cf/utils/validation.ts +++ b/packages/adp-tooling/src/cf/utils/validation.ts @@ -4,7 +4,7 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import { t } from '../../i18n'; -import type { Credentials } from '../../types'; +import type { CfCredentials } from '../../types'; import { getApplicationType } from '../../source/manifest'; import { isSupportedAppTypeForAdp } from '../../source/manifest'; @@ -108,13 +108,13 @@ function extractManifest(zipEntries: AdmZip.IZipEntry[]): Manifest | undefined { * Validate the OData endpoints. * * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. - * @param {Credentials[]} credentials - The credentials. + * @param {CfCredentials[]} credentials - The credentials. * @param {ToolsLogger} logger - The logger. * @returns {Promise} The messages. */ export async function validateODataEndpoints( zipEntries: AdmZip.IZipEntry[], - credentials: Credentials[], + credentials: CfCredentials[], logger: ToolsLogger ): Promise { const messages: string[] = []; @@ -136,6 +136,7 @@ export async function validateODataEndpoints( return messages; } + // TODO: Add type for xsApp and matchRoutesAndDatasources const dataSources = manifest?.['sap.app']?.dataSources; const routes = (xsApp as any)?.routes; if (dataSources && routes) { diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 7ddc8d0e266..207d3bd3ef6 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -811,23 +811,17 @@ export interface Uaa { url: string; } -export interface AppParams { +export interface CfAppParams { appName: string; appVersion: string; appHostId: string; } -export interface AppParamsExtended extends AppParams { +export interface AppParamsExtended extends CfAppParams { spaceGuid: string; } -export interface CFParameters { - org: string; - space: string; - html5RepoRuntime: string; -} - -export interface Credentials { +export interface CfCredentials { [key: string]: any; uaa: Uaa; uri: string; @@ -835,7 +829,7 @@ export interface Credentials { } export interface ServiceKeys { - credentials: Credentials[]; + credentials: CfCredentials[]; serviceInstance: ServiceInstance; } @@ -861,36 +855,130 @@ export interface BusinessServiceResource { label: string; } -export interface AppParams { +/** + * Cloud Foundry ADP UI5 YAML Types + */ +export interface UI5YamlCustomTaskConfiguration { + appHostId: string; appName: string; appVersion: string; - appHostId: string; + moduleName: string; + org: string; + space: string; + html5RepoRuntime: string; + sapCloudService: string; } -export interface Resource { +export interface UI5YamlCustomTask { name: string; + beforeTask?: string; + configuration: UI5YamlCustomTaskConfiguration; +} + +export interface UI5YamlBuilder { + customTasks: UI5YamlCustomTask[]; +} + +export interface UI5YamlMetadata { + name: string; +} + +export interface CfUI5Yaml { + specVersion: string; type: string; - parameters: any; + metadata: UI5YamlMetadata; + builder: UI5YamlBuilder; } -export interface Yaml { - '_schema-version': string; - 'ID': string; - 'version': string; - resources?: any[]; - modules?: MTAModule[]; +/** + * Cloud Foundry ADP MTA YAML Types + */ +export interface MtaDestination { + Name: string; + ServiceInstanceName: string; + ServiceKeyName: string; + Authentication?: string; + 'sap.cloud.service'?: string; +} + +export interface MtaContentInstance { + destinations: MtaDestination[]; + existing_destinations_policy?: string; +} + +export interface MtaContent { + instance: MtaContentInstance; } -export interface MTAModule { +export interface MtaServiceKey { + name: string; +} + +export interface MtaParameters { + 'service-key'?: MtaServiceKey; + 'content-target'?: boolean; + content?: MtaContent; + 'no-source'?: boolean; + 'build-result'?: string; + requires?: MtaBuildRequire[]; + builder?: string; + commands?: string[]; + 'supported-platforms'?: string[]; + 'disk-quota'?: string; + memory?: string; + service?: string; + 'service-plan'?: string; + 'service-name'?: string; + path?: string; + config?: Record; +} + +export interface MtaBuildRequire { + artifacts?: string[]; + name: string; + 'target-path'?: string; +} + +export interface MtaRequire { + name: string; + parameters?: MtaParameters; +} + +export interface MtaModule { + name: string; + type: string; + path?: string; + requires?: MtaRequire[]; + 'build-parameters'?: MtaParameters; + parameters?: MtaParameters; +} + +export interface MtaResource { name: string; - parameters: any; - path: string; - requires: MTARequire[]; type: string; + parameters: MtaParameters; +} + +export interface MtaYaml { + '_schema-version': string; + 'ID': string; + 'version': string; + builder?: { + customTasks?: { + configuration?: { + appHostId: string; + }; + }[]; + }; + resources?: MtaResource[]; + modules?: MtaModule[]; } -export interface MTARequire { +// Legacy types for backward compatibility +export interface Resource { name: string; + type: string; + parameters: MtaParameters; } export interface ODataTargetSource { @@ -955,7 +1043,7 @@ export interface CfAdpWriterConfig { export interface CreateCfConfigParams { attributeAnswers: AttributesAnswers; cfServicesAnswers: CfServicesAnswers; - cfConfig: CFConfig; + cfConfig: CfConfig; layer: FlexLayer; manifest: Manifest; html5RepoRuntimeGuid: string; @@ -993,7 +1081,7 @@ export interface Space { Name: string; } -export interface CFConfig { +export interface CfConfig { org: Organization; space: Space; token: string; @@ -1081,42 +1169,44 @@ export interface RequestArguments { }; } -// CF API Response Interfaces -export interface CFAPIResponse { - pagination: CFPagination; +/** + * CF API Response + */ +export interface CfAPIResponse { + pagination: CfPagination; resources: T[]; } -export interface CFPagination { +export interface CfPagination { total_results: number; total_pages: number; - first: CFPaginationLink; - last: CFPaginationLink; - next: CFPaginationLink | null; - previous: CFPaginationLink | null; + first: CfPaginationLink; + last: CfPaginationLink; + next: CfPaginationLink | null; + previous: CfPaginationLink | null; } -export interface CFPaginationLink { +export interface CfPaginationLink { href: string; } -export interface CFServiceInstance { +export interface CfServiceInstance { guid: string; created_at: string; updated_at: string; name: string; tags: string[]; - last_operation: CFLastOperation; + last_operation: CfLastOperation; type: string; maintenance_info: Record; upgrade_available: boolean; dashboard_url: string | null; - relationships: CFServiceInstanceRelationships; - metadata: CFMetadata; - links: CFServiceInstanceLinks; + relationships: CfServiceInstanceRelationships; + metadata: CfMetadata; + links: CfServiceInstanceLinks; } -export interface CFLastOperation { +export interface CfLastOperation { type: string; state: string; description: string; @@ -1124,37 +1214,37 @@ export interface CFLastOperation { created_at: string; } -export interface CFServiceInstanceRelationships { - space: CFRelationshipData; - service_plan: CFRelationshipData; +export interface CfServiceInstanceRelationships { + space: CfRelationshipData; + service_plan: CfRelationshipData; } -export interface CFRelationshipData { +export interface CfRelationshipData { data: { guid: string; }; } -export interface CFMetadata { +export interface CfMetadata { labels: Record; annotations: Record; } -export interface CFServiceInstanceLinks { - self: CFLink; - space: CFLink; - service_credential_bindings: CFLink; - service_route_bindings: CFLink; - service_plan: CFLink; - parameters: CFLink; - shared_spaces: CFLink; +export interface CfServiceInstanceLinks { + self: CfLink; + space: CfLink; + service_credential_bindings: CfLink; + service_route_bindings: CfLink; + service_plan: CfLink; + parameters: CfLink; + shared_spaces: CfLink; } -export interface CFLink { +export interface CfLink { href: string; } -export interface CFServiceOffering { +export interface CfServiceOffering { name: string; tags?: string[]; broker_catalog?: { diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 4118dbe69e6..17ccfc6c9b2 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -25,7 +25,7 @@ import { AppContentService, loadCfConfig } from '@sap-ux/adp-tooling'; -import { type CFConfig, type CfServicesAnswers } from '@sap-ux/adp-tooling'; +import { type CfConfig, type CfServicesAnswers } from '@sap-ux/adp-tooling'; import { ToolsLogger } from '@sap-ux/logger'; import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; import type { AbapServiceProvider } from '@sap-ux/axios-extension'; @@ -66,7 +66,7 @@ import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderCho import { getTargetEnvPrompt, getProjectPathPrompt } from './questions/target-env'; import { isAppStudio } from '@sap-ux/btp-utils'; import { getTemplatesOverwritePath } from '../utils/templates'; -import { YamlLoader } from '@sap-ux/adp-tooling'; +import { getYamlContent } from '@sap-ux/adp-tooling'; const generatorTitle = 'Adaptation Project'; @@ -158,7 +158,7 @@ export default class extends Generator { /** * CF config. */ - private cfConfig: CFConfig; + private cfConfig: CfConfig; /** * Indicates if the current project is an MTA project. */ @@ -452,7 +452,7 @@ export default class extends Generator { this.logger.log(`Project path information: ${this.projectLocation}`); } else { this.cfProjectDestinationPath = this.destinationRoot(process.cwd()); - YamlLoader.getYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); + getYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); this.logger.log(`Project path information: ${this.cfProjectDestinationPath}`); } } diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 63a3038af14..d52aaa1c425 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -4,7 +4,7 @@ import type { CfServicesPromptOptions, AppRouterType, AppContentService, - CFConfig + CfConfig } from '@sap-ux/adp-tooling'; import { cfServicesPromptNames, @@ -83,13 +83,13 @@ export class CFServicesPrompter { * Builds the CF services prompts, keyed and hide-filtered like attributes.ts. * * @param {string} mtaProjectPath - MTA project path - * @param {CFConfig} cfConfig - CF config service instance. + * @param {CfConfig} cfConfig - CF config service instance. * @param {CfServicesPromptOptions} [promptOptions] - Optional per-prompt visibility controls * @returns {Promise} CF services questions */ public async getPrompts( mtaProjectPath: string, - cfConfig: CFConfig, + cfConfig: CfConfig, promptOptions?: CfServicesPromptOptions ): Promise { if (this.isCfLoggedIn) { @@ -144,10 +144,10 @@ export class CFServicesPrompter { * Prompt for approuter. * * @param {string} mtaProjectPath - MTA project path. - * @param {CFConfig} cfConfig - CF config service instance. + * @param {CfConfig} cfConfig - CF config service instance. * @returns {CFServicesQuestion} Prompt for approuter. */ - private getAppRouterPrompt(mtaProjectPath: string, cfConfig: CFConfig): CFServicesQuestion { + private getAppRouterPrompt(mtaProjectPath: string, cfConfig: CfConfig): CFServicesQuestion { return { type: 'list', name: cfServicesPromptNames.approuter, @@ -195,10 +195,10 @@ export class CFServicesPrompter { /** * Prompt for base application. * - * @param {CFConfig} cfConfig - CF config service instance. + * @param {CfConfig} cfConfig - CF config service instance. * @returns {CFServicesQuestion} Prompt for base application. */ - private getBaseAppPrompt(cfConfig: CFConfig): CFServicesQuestion { + private getBaseAppPrompt(cfConfig: CfConfig): CFServicesQuestion { return { type: 'list', name: cfServicesPromptNames.baseApp, @@ -253,10 +253,10 @@ export class CFServicesPrompter { /** * Prompt for business services. * - * @param {CFConfig} cfConfig - CF config service instance. + * @param {CfConfig} cfConfig - CF config service instance. * @returns {CFServicesQuestion} Prompt for business services. */ - private getBusinessServicesPrompt(cfConfig: CFConfig): CFServicesQuestion { + private getBusinessServicesPrompt(cfConfig: CfConfig): CFServicesQuestion { return { type: 'list', name: cfServicesPromptNames.businessService, @@ -291,7 +291,7 @@ export class CFServicesPrompter { /** * @param {object} param0 - Configuration object containing FDC service, internal usage flag, MTA project path, CF login status, and logger. - * @param {CFConfig} param0.cfConfig - CF config service instance. + * @param {CfConfig} param0.cfConfig - CF config service instance. * @param {boolean} [param0.isInternalUsage] - Internal usage flag. * @param {string} param0.mtaProjectPath - MTA project path. * @param {boolean} param0.isCfLoggedIn - CF login status. @@ -307,7 +307,7 @@ export async function getPrompts({ appContentService, logger }: { - cfConfig: CFConfig; + cfConfig: CfConfig; isInternalUsage?: boolean; mtaProjectPath: string; isCfLoggedIn: boolean; diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index ee5a2a04727..94c649dd70a 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -2,7 +2,7 @@ import { MessageType } from '@sap-devx/yeoman-ui-types'; import type { AppWizard } from '@sap-devx/yeoman-ui-types'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { CFConfig } from '@sap-ux/adp-tooling'; +import type { CfConfig } from '@sap-ux/adp-tooling'; import { getDefaultTargetFolder } from '@sap-ux/fiori-generator-shared'; import type { InputQuestion, ListQuestion, YUIQuestion } from '@sap-ux/inquirer-common'; @@ -20,7 +20,7 @@ type EnvironmentChoice = { name: string; value: TargetEnv }; * @param {AppWizard} appWizard - The app wizard instance. * @param {boolean} isCfInstalled - Whether Cloud Foundry is installed. * @param {boolean} isCFLoggedIn - Whether Cloud Foundry is logged in. - * @param {CFConfig} cfConfig - The CF config service instance. + * @param {CfConfig} cfConfig - The CF config service instance. * @param {any} vscode - The vscode instance. * @returns {object[]} The target environment prompt. */ @@ -28,7 +28,7 @@ export function getTargetEnvPrompt( appWizard: AppWizard, isCfInstalled: boolean, isCFLoggedIn: boolean, - cfConfig: CFConfig, + cfConfig: CfConfig, vscode: any ): TargetEnvQuestion { return { From 9d7ffdb2f7f645f8249ffa6d42ec904adba5a3bf Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 1 Sep 2025 16:25:31 +0300 Subject: [PATCH 051/111] chore: update texts in methods --- packages/adp-tooling/src/cf/app/content.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/adp-tooling/src/cf/app/content.ts b/packages/adp-tooling/src/cf/app/content.ts index 324a47d823f..14a4946b16b 100644 --- a/packages/adp-tooling/src/cf/app/content.ts +++ b/packages/adp-tooling/src/cf/app/content.ts @@ -46,8 +46,7 @@ export class AppContentService { appParams, this.logger ); - - // Store the manifest and runtime GUID + // TODO: This class will be removed when we change the validation and manifest retrieval logic when prompting this.manifests.push(manifest); this.html5RepoRuntimeGuid = serviceInstanceGuid; From 60820f63c8eed280f772608a540481a9e9d7e1bd Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 3 Sep 2025 16:02:15 +0300 Subject: [PATCH 052/111] feat: refactor cf app extraction and validation logic --- packages/adp-tooling/src/cf/app/content.ts | 74 ------------- packages/adp-tooling/src/cf/app/discovery.ts | 61 ++--------- packages/adp-tooling/src/cf/app/index.ts | 3 - packages/adp-tooling/src/cf/app/validation.ts | 68 ------------ packages/adp-tooling/src/cf/app/workflow.ts | 29 ----- packages/adp-tooling/src/cf/core/config.ts | 10 +- packages/adp-tooling/src/cf/services/api.ts | 23 ++-- packages/adp-tooling/src/cf/services/cli.ts | 3 - .../adp-tooling/src/cf/utils/validation.ts | 62 ++++++----- packages/adp-tooling/src/types.ts | 10 ++ packages/generator-adp/src/app/index.ts | 34 +++--- .../src/app/questions/cf-services.ts | 103 ++++++++++-------- 12 files changed, 141 insertions(+), 339 deletions(-) delete mode 100644 packages/adp-tooling/src/cf/app/content.ts delete mode 100644 packages/adp-tooling/src/cf/app/validation.ts delete mode 100644 packages/adp-tooling/src/cf/app/workflow.ts diff --git a/packages/adp-tooling/src/cf/app/content.ts b/packages/adp-tooling/src/cf/app/content.ts deleted file mode 100644 index 14a4946b16b..00000000000 --- a/packages/adp-tooling/src/cf/app/content.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type AdmZip from 'adm-zip'; - -import type { ToolsLogger } from '@sap-ux/logger'; -import type { Manifest } from '@sap-ux/project-access'; - -import { downloadAppContent } from './html5-repo'; -import type { CfConfig, CfAppParams } from '../../types'; - -/** - * App Content Service - Handles app content downloading and manifest management. - */ -export class AppContentService { - /** - * The apps' manifests. - */ - private manifests: Manifest[] = []; - /** - * The HTML5 repo runtime GUID. - */ - private html5RepoRuntimeGuid: string = ''; - - /** - * Constructor. - * - * @param {ToolsLogger} logger - The logger. - */ - constructor(private logger: ToolsLogger) {} - - /** - * Download app content and extract manifest. - * - * @param {CfAppParams} appParams - The app parameters. - * @param {CfConfig} cfConfig - The CF configuration. - * @returns {Promise<{entries: AdmZip.IZipEntry[], serviceInstanceGuid: string, manifest: Manifest}>} The downloaded content - */ - public async getAppContent( - appParams: CfAppParams, - cfConfig: CfConfig - ): Promise<{ - entries: AdmZip.IZipEntry[]; - serviceInstanceGuid: string; - manifest: Manifest; - }> { - const { entries, serviceInstanceGuid, manifest } = await downloadAppContent( - cfConfig.space.GUID, - appParams, - this.logger - ); - // TODO: This class will be removed when we change the validation and manifest retrieval logic when prompting - this.manifests.push(manifest); - this.html5RepoRuntimeGuid = serviceInstanceGuid; - - return { entries, serviceInstanceGuid, manifest }; - } - - /** - * Get the manifest by base app id. - * - * @param {string} appId - The app id. - * @returns {Manifest | undefined} The manifest. - */ - public getManifestByBaseAppId(appId: string): Manifest | undefined { - return this.manifests.find((manifest) => manifest['sap.app'].id === appId); - } - - /** - * Get the HTML5 repo runtime GUID. - * - * @returns {string} The runtime GUID. - */ - public getHtml5RepoRuntimeGuid(): string { - return this.html5RepoRuntimeGuid; - } -} diff --git a/packages/adp-tooling/src/cf/app/discovery.ts b/packages/adp-tooling/src/cf/app/discovery.ts index c301ae4a40f..0e1ecec5b5e 100644 --- a/packages/adp-tooling/src/cf/app/discovery.ts +++ b/packages/adp-tooling/src/cf/app/discovery.ts @@ -3,17 +3,6 @@ import type { ToolsLogger } from '@sap-ux/logger'; import { getFDCApps } from '../services/api'; import type { CfConfig, CFApp, CfCredentials } from '../../types'; -/** - * Filter apps based on validation status. - * - * @param {CFApp[]} apps - The apps to filter - * @param {boolean} includeInvalid - Whether to include invalid apps - * @returns {CFApp[]} The filtered apps - */ -export function filterCfApps(apps: CFApp[], includeInvalid: boolean): CFApp[] { - return includeInvalid ? apps : apps.filter((app) => !app.messages?.length); -} - /** * Format the discovery. * @@ -28,20 +17,21 @@ export function formatDiscovery(app: CFApp): string { * Get the app host ids. * * @param {CfCredentials[]} credentials - The credentials. - * @returns {Set} The app host ids. + * @returns {string[]} The app host ids. */ -export function getAppHostIds(credentials: CfCredentials[]): Set { +export function getAppHostIds(credentials: CfCredentials[]): string[] { const appHostIds: string[] = []; + credentials.forEach((credential) => { const appHostId = credential['html5-apps-repo']?.app_host_id; if (appHostId) { - appHostIds.push(appHostId.split(',').map((item: string) => item.trim())); // there might be multiple appHostIds separated by comma + // There might be multiple appHostIds separated by comma + const ids = appHostId.split(',').map((item: string) => item.trim()); + appHostIds.push(...ids); } }); - // appHostIds is now an array of arrays of strings (from split) - // Flatten the array and create a Set - return new Set(appHostIds.flat()); + return [...new Set(appHostIds)]; } /** @@ -52,7 +42,7 @@ export function getAppHostIds(credentials: CfCredentials[]): Set { * @param {ToolsLogger} logger - The logger * @returns {Promise} The discovered apps */ -export async function discoverCfApps( +export async function getCfApps( credentials: CfCredentials[], cfConfig: CfConfig, logger: ToolsLogger @@ -61,38 +51,9 @@ export async function discoverCfApps( logger?.log(`App Host Ids: ${JSON.stringify(appHostIds)}`); // Validate appHostIds array length (max 100 as per API specification) - if (appHostIds.size > 100) { - throw new Error(`Too many appHostIds provided. Maximum allowed is 100, but ${appHostIds.size} were found.`); + if (appHostIds.length > 100) { + throw new Error(`Too many appHostIds provided. Maximum allowed is 100, but ${appHostIds.length} were found.`); } - const appHostIdsArray = Array.from(appHostIds); - - try { - const response = await getFDCApps(appHostIdsArray, cfConfig, logger); - - if (response.status === 200) { - // TODO: Remove this once the FDC API is updated to return the appHostId - const apps = response.data.results.map((app) => ({ ...app, appHostId: appHostIdsArray[0] })); - return apps; - } else { - throw new Error( - `Failed to connect to Flexibility Design and Configuration service. Reason: HTTP status code ${response.status}: ${response.statusText}` - ); - } - } catch (error) { - logger?.error(`Error in discoverApps: ${error.message}`); - - // Create error apps for each appHostId to maintain original behavior - const errorApps: CFApp[] = appHostIdsArray.map((appHostId) => ({ - appId: '', - appName: '', - appVersion: '', - serviceName: '', - title: '', - appHostId, - messages: [error.message] - })); - - return errorApps; - } + return getFDCApps(appHostIds, cfConfig, logger); } diff --git a/packages/adp-tooling/src/cf/app/index.ts b/packages/adp-tooling/src/cf/app/index.ts index 14fa2f32832..ffbbe3f3805 100644 --- a/packages/adp-tooling/src/cf/app/index.ts +++ b/packages/adp-tooling/src/cf/app/index.ts @@ -1,5 +1,2 @@ -export * from './validation'; -export * from './content'; export * from './discovery'; export * from './html5-repo'; -export * from './workflow'; diff --git a/packages/adp-tooling/src/cf/app/validation.ts b/packages/adp-tooling/src/cf/app/validation.ts deleted file mode 100644 index 743c5d8b547..00000000000 --- a/packages/adp-tooling/src/cf/app/validation.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type AdmZip from 'adm-zip'; - -import type { ToolsLogger } from '@sap-ux/logger'; -import type { Manifest } from '@sap-ux/project-access'; - -import type { AppContentService } from './content'; -import type { CFApp, CfCredentials, CfConfig } from '../../types'; -import { validateSmartTemplateApplication, validateODataEndpoints } from '../utils/validation'; - -/** - * Validate a single app. - * - * @param {Manifest} manifest - The manifest to validate. - * @param {AdmZip.IZipEntry[]} entries - The entries to validate. - * @param {CfCredentials[]} credentials - The credentials for validation. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} Validation messages. - */ -export async function validateApp( - manifest: Manifest, - entries: AdmZip.IZipEntry[], - credentials: CfCredentials[], - logger: ToolsLogger -): Promise { - try { - const smartTemplateMessages = await validateSmartTemplateApplication(manifest); - - if (smartTemplateMessages.length === 0) { - return validateODataEndpoints(entries, credentials, logger); - } else { - return smartTemplateMessages; - } - } catch (e) { - return [e.message]; - } -} - -/** - * Validate multiple apps. - * - * @param {CFApp[]} apps - The apps to validate. - * @param {CfCredentials[]} credentials - The credentials for validation. - * @param {CfConfig} cfConfig - The CF configuration. - * @param {AppContentService} appContent - The app content service. - * @param {ToolsLogger} logger - The logger. - * @returns {Promise} The validated apps with messages. - */ -export async function getValidatedApps( - apps: CFApp[], - credentials: CfCredentials[], - cfConfig: CfConfig, - appContent: AppContentService, - logger: ToolsLogger -): Promise { - const validatedApps: CFApp[] = []; - - for (const app of apps) { - if (!app.messages?.length) { - const { entries, manifest } = await appContent.getAppContent(app, cfConfig); - - const messages = await validateApp(manifest, entries, credentials, logger); - app.messages = messages; - } - validatedApps.push(app); - } - - return validatedApps; -} diff --git a/packages/adp-tooling/src/cf/app/workflow.ts b/packages/adp-tooling/src/cf/app/workflow.ts deleted file mode 100644 index 9526ecd935b..00000000000 --- a/packages/adp-tooling/src/cf/app/workflow.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ToolsLogger } from '@sap-ux/logger'; - -import type { CfCredentials } from '../../types'; -import { getValidatedApps } from './validation'; -import type { CfConfig, CFApp } from '../../types'; -import type { AppContentService } from './content'; -import { discoverCfApps, filterCfApps } from './discovery'; - -/** - * Get the base apps. - * - * @param {CfCredentials[]} credentials - The credentials. - * @param {CfConfig} cfConfig - The CF config. - * @param {ToolsLogger} logger - The logger. - * @param {AppContentService} appContentService - The app content service. - * @param {boolean} [includeInvalid] - Whether to include invalid apps. - * @returns {Promise} The base apps. - */ -export async function getBaseApps( - credentials: CfCredentials[], - cfConfig: CfConfig, - logger: ToolsLogger, - appContentService: AppContentService, - includeInvalid: boolean = false -): Promise { - const apps = await discoverCfApps(credentials, cfConfig, logger); - const validatedApps = await getValidatedApps(apps, credentials, cfConfig, appContentService, logger); - return filterCfApps(validatedApps, includeInvalid); -} diff --git a/packages/adp-tooling/src/cf/core/config.ts b/packages/adp-tooling/src/cf/core/config.ts index a076da4ea9e..285edd09830 100644 --- a/packages/adp-tooling/src/cf/core/config.ts +++ b/packages/adp-tooling/src/cf/core/config.ts @@ -6,10 +6,6 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { CfConfig, Config } from '../../types'; -const HOMEDRIVE = 'HOMEDRIVE'; -const HOMEPATH = 'HOMEPATH'; -const WIN32 = 'win32'; - /** * Get the home directory. * @@ -17,9 +13,9 @@ const WIN32 = 'win32'; */ function getHomedir(): string { let homedir = os.homedir(); - const homeDrive = process.env?.[HOMEDRIVE]; - const homePath = process.env?.[HOMEPATH]; - if (process.platform === WIN32 && typeof homeDrive === 'string' && typeof homePath === 'string') { + const homeDrive = process.env?.['HOMEDRIVE']; + const homePath = process.env?.['HOMEPATH']; + if (process.platform === 'win32' && typeof homeDrive === 'string' && typeof homePath === 'string') { homedir = path.join(homeDrive, homePath); } diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index dd3e3f16100..ae921e2e423 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -113,16 +113,10 @@ function getFDCRequestArguments(cfConfig: CfConfig): RequestArguments { * @param {ToolsLogger} logger - The logger. * @returns {Promise>} The FDC apps. */ -export async function getFDCApps( - appHostIds: string[], - cfConfig: CfConfig, - logger: ToolsLogger -): Promise> { +export async function getFDCApps(appHostIds: string[], cfConfig: CfConfig, logger: ToolsLogger): Promise { const requestArguments = getFDCRequestArguments(cfConfig); logger?.log(`App Hosts: ${JSON.stringify(appHostIds)}, request arguments: ${JSON.stringify(requestArguments)}`); - // Construct the URL with multiple appHostIds as separate query parameters - // Format: ?appHostId=&appHostId=&appHostId= const appHostIdParams = appHostIds.map((id) => `appHostId=${encodeURIComponent(id)}`).join('&'); const url = `${requestArguments.url}/api/business-service/discovery?${appHostIdParams}`; @@ -137,16 +131,19 @@ export async function getFDCApps( response.data )}` ); - return response; + + if (response.status === 200) { + return response.data.results; + } else { + throw new Error( + `Failed to connect to FDC service. Reason: HTTP status code ${response.status}: ${response.statusText}` + ); + } } catch (e) { logger?.error( `Getting FDC apps. Request url: ${url}, response status: ${e?.response?.status}, message: ${e.message || e}` ); - throw new Error( - `Failed to get application from Flexibility Design and Configuration service ${url}. Reason: ${ - e.message || e - }` - ); + throw e; } } diff --git a/packages/adp-tooling/src/cf/services/cli.ts b/packages/adp-tooling/src/cf/services/cli.ts index a4ac1883f27..4b9148feb4d 100644 --- a/packages/adp-tooling/src/cf/services/cli.ts +++ b/packages/adp-tooling/src/cf/services/cli.ts @@ -30,7 +30,6 @@ export async function checkForCf(): Promise { throw new Error(response.stderr); } } catch (error) { - // log error: CFUtils.ts=>checkForCf throw new Error('Cloud Foundry is not installed in your space.'); } } @@ -60,7 +59,6 @@ export async function getServiceKeys(serviceInstanceGuid: string): PromisegetServiceKeys for guid throw new Error( `Failed to get service instance credentials from CFLocal for guid ${serviceInstanceGuid}. Reason: ${e.message}` ); @@ -80,7 +78,6 @@ export async function createServiceKey(serviceInstanceName: string, serviceKeyNa throw new Error(cliResult.stderr); } } catch (e) { - // log error: CFUtils.ts=>createServiceKey for serviceInstanceName throw new Error(`Failed to create service key for instance name ${serviceInstanceName}. Reason: ${e.message}`); } } diff --git a/packages/adp-tooling/src/cf/utils/validation.ts b/packages/adp-tooling/src/cf/utils/validation.ts index c84a35610e6..a4b92f2b30c 100644 --- a/packages/adp-tooling/src/cf/utils/validation.ts +++ b/packages/adp-tooling/src/cf/utils/validation.ts @@ -1,10 +1,10 @@ import type AdmZip from 'adm-zip'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { Manifest } from '@sap-ux/project-access'; +import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; import { t } from '../../i18n'; -import type { CfCredentials } from '../../types'; +import type { CfCredentials, XsAppRoute } from '../../types'; import { getApplicationType } from '../../source/manifest'; import { isSupportedAppTypeForAdp } from '../../source/manifest'; @@ -22,22 +22,18 @@ function normalizeRouteRegex(value: string): RegExp { * Validate the smart template application. * * @param {Manifest} manifest - The manifest. - * @returns {Promise} The messages. + * @returns {Promise} The messages. */ -export async function validateSmartTemplateApplication(manifest: Manifest): Promise { - const messages: string[] = []; +export async function validateSmartTemplateApplication(manifest: Manifest): Promise { const appType = getApplicationType(manifest); if (isSupportedAppTypeForAdp(appType)) { if (manifest['sap.ui5'] && manifest['sap.ui5'].flexEnabled === false) { - return messages.concat(t('error.appDoesNotSupportFlexibility')); + throw new Error(t('error.appDoesNotSupportFlexibility')); } } else { - return messages.concat( - "Select a different application. Adaptation project doesn't support the selected application." - ); + throw new Error("Adaptation project doesn't support the selected application. Select a different application."); } - return messages; } /** @@ -46,7 +42,7 @@ export async function validateSmartTemplateApplication(manifest: Manifest): Prom * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. * @returns {any} The xs-app.json. */ -export function extractXSApp(zipEntries: AdmZip.IZipEntry[]): any { +export function extractXSApp(zipEntries: AdmZip.IZipEntry[]): { routes: XsAppRoute[] } | undefined { let xsApp; zipEntries.forEach((item) => { if (item.entryName.endsWith('xs-app.json')) { @@ -63,21 +59,29 @@ export function extractXSApp(zipEntries: AdmZip.IZipEntry[]): any { /** * Match the routes and data sources. * - * @param {any} dataSources - The data sources. - * @param {any} routes - The routes. - * @param {any} serviceKeyEndpoints - The service key endpoints. + * @param {Record} dataSources - The data sources from manifest.json. + * @param {XsAppRoute[]} routes - The routes from xs-app.json. + * @param {string[]} serviceKeyEndpoints - The service key endpoints. * @returns {string[]} The messages. */ -function matchRoutesAndDatasources(dataSources: any, routes: any, serviceKeyEndpoints: any): string[] { +function matchRoutesAndDatasources( + dataSources: Record | undefined, + routes: XsAppRoute[], + serviceKeyEndpoints: string[] +): string[] { const messages: string[] = []; - routes.forEach((route: any) => { + routes.forEach((route: XsAppRoute) => { if (route.endpoint && !serviceKeyEndpoints.includes(route.endpoint)) { messages.push(`Route endpoint '${route.endpoint}' doesn't match a corresponding OData endpoint`); } }); - Object.keys(dataSources).forEach((dataSourceName) => { - if (!routes.some((route: any) => dataSources[dataSourceName].uri?.match(normalizeRouteRegex(route.source)))) { + Object.keys(dataSources ?? {}).forEach((dataSourceName) => { + if ( + !routes.some((route: XsAppRoute) => + dataSources?.[dataSourceName].uri?.match(normalizeRouteRegex(route.source)) + ) + ) { messages.push(`Data source '${dataSourceName}' doesn't match a corresponding route in xs-app.json routes`); } }); @@ -105,7 +109,7 @@ function extractManifest(zipEntries: AdmZip.IZipEntry[]): Manifest | undefined { } /** - * Validate the OData endpoints. + * Validate the OData endpoints, data sources and routes. * * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. * @param {CfCredentials[]} credentials - The credentials. @@ -116,29 +120,26 @@ export async function validateODataEndpoints( zipEntries: AdmZip.IZipEntry[], credentials: CfCredentials[], logger: ToolsLogger -): Promise { +): Promise { const messages: string[] = []; let xsApp; - let manifest: Manifest | undefined; try { xsApp = extractXSApp(zipEntries); logger?.log(`ODATA endpoints: ${JSON.stringify(xsApp)}`); } catch (error) { - messages.push(error.message); - return messages; + messages.push(error); } + let manifest: Manifest | undefined; try { manifest = extractManifest(zipEntries); logger?.log(`Extracted manifest: ${JSON.stringify(manifest)}`); } catch (error) { - messages.push(error.message); - return messages; + messages.push(error); } - // TODO: Add type for xsApp and matchRoutesAndDatasources const dataSources = manifest?.['sap.app']?.dataSources; - const routes = (xsApp as any)?.routes; + const routes = xsApp?.routes; if (dataSources && routes) { const serviceKeyEndpoints = ([] as string[]).concat( ...credentials.map((item) => (item.endpoints ? Object.keys(item.endpoints) : [])) @@ -149,5 +150,10 @@ export async function validateODataEndpoints( } else if (!routes && dataSources) { messages.push("Base app xs-app.json doesn't contain data sources routes specified in manifest.json"); } - return messages; + + if (messages.length > 0) { + const errorMessages = messages.join('\n'); + logger?.error(`OData endpoints validation failed:\n${errorMessages}`); + throw new Error('OData endpoints validation failed. Please check the logs for more details.'); + } } diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 207d3bd3ef6..943f5470648 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -805,6 +805,15 @@ export interface InboundChange { }; } +/** + * Route structure from xs-app.json + */ +export interface XsAppRoute { + source: string; + endpoint?: string; + [key: string]: unknown; +} + export interface Uaa { clientid: string; clientsecret: string; @@ -1114,6 +1123,7 @@ export interface CFApp { title: string; appHostId: string; messages?: string[]; + serviceInstanceGuid?: string; } /** diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 17ccfc6c9b2..7d90eb471b3 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -22,7 +22,6 @@ import { createCfConfig, isCfInstalled, isLoggedInCf, - AppContentService, loadCfConfig } from '@sap-ux/adp-tooling'; import { type CfConfig, type CfServicesAnswers } from '@sap-ux/adp-tooling'; @@ -37,6 +36,8 @@ import { // isExtensionInstalled, sendTelemetry } from '@sap-ux/fiori-generator-shared'; +import { isAppStudio } from '@sap-ux/btp-utils'; +import { getYamlContent } from '@sap-ux/adp-tooling'; import { getFlexLayer } from './layer'; import { initI18n, t } from '../utils/i18n'; @@ -44,7 +45,7 @@ import { EventName } from '../telemetryEvents'; import { setHeaderTitle } from '../utils/opts'; import AdpGeneratorLogger from '../utils/logger'; import { getPrompts } from './questions/attributes'; -import { getPrompts as getCFServicesPrompts } from './questions/cf-services'; +import { CFServicesPrompter } from './questions/cf-services'; import { ConfigPrompter } from './questions/configuration'; import { validateJsonInput } from './questions/helper/validators'; import { getPackageInfo, installDependencies } from '../utils/deps'; @@ -64,9 +65,7 @@ import { } from '../utils/steps'; import { existsInWorkspace, showWorkspaceFolderWarning, handleWorkspaceFolderChoice } from '../utils/workspace'; import { getTargetEnvPrompt, getProjectPathPrompt } from './questions/target-env'; -import { isAppStudio } from '@sap-ux/btp-utils'; import { getTemplatesOverwritePath } from '../utils/templates'; -import { getYamlContent } from '@sap-ux/adp-tooling'; const generatorTitle = 'Adaptation Project'; @@ -118,6 +117,10 @@ export default class extends Generator { * Instance of the configuration prompter class. */ private prompter: ConfigPrompter; + /** + * Instance of the CF services prompter class. + */ + private cfPrompter: CFServicesPrompter; /** * JSON object representing the complete adaptation project configuration, * passed as a CLI argument. @@ -183,10 +186,6 @@ export default class extends Generator { * Indicates if CF is installed. */ private cfInstalled: boolean; - /** - * App content service. - */ - private appContentService: AppContentService; /** * Creates an instance of the generator. @@ -203,7 +202,6 @@ export default class extends Generator { this._setupLogging(); this.options = opts; - this.appContentService = new AppContentService(this.logger); this.isMtaYamlFound = isMtaProject(process.cwd()) as boolean; // TODO: Remove this once the PR is ready. this.isExtensionInstalled = true; // isExtensionInstalled(opts.vscode, 'SAP.adp-ve-bas-ext'); @@ -240,10 +238,12 @@ export default class extends Generator { this.isCfLoggedIn = await isLoggedInCf(this.cfConfig, this.logger); this.logger.info(`isCfInstalled: ${this.cfInstalled}`); + const isInternalUsage = isInternalFeaturesSettingEnabled(); if (!this.jsonInput) { const shouldShowTargetEnv = isAppStudio() && this.cfInstalled && this.isExtensionInstalled; this.prompts.splice(0, 0, getWizardPages(shouldShowTargetEnv)); this.prompter = this._getOrCreatePrompter(); + this.cfPrompter = new CFServicesPrompter(isInternalUsage, this.isCfLoggedIn, this.logger); } await TelemetryHelper.initTelemetrySettings({ @@ -251,7 +251,7 @@ export default class extends Generator { name: '@sap/generator-fiori:generator-adp', version: this.rootGeneratorVersion() }, - internalFeature: isInternalFeaturesSettingEnabled(), + internalFeature: isInternalUsage, watchTelemetrySettingStore: false }); } @@ -521,14 +521,7 @@ export default class extends Generator { this.attributeAnswers = await this.prompt(attributesQuestions); this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); - const cfServicesQuestions = await getCFServicesPrompts({ - cfConfig: this.cfConfig, - isInternalUsage: isInternalFeaturesSettingEnabled(), - mtaProjectPath: this.cfProjectDestinationPath, - isCfLoggedIn: this.isCfLoggedIn, - appContentService: this.appContentService, - logger: this.logger - }); + const cfServicesQuestions = await this.cfPrompter.getPrompts(this.cfProjectDestinationPath, this.cfConfig); this.cfServicesAnswers = await this.prompt(cfServicesQuestions); this.logger.info(`CF Services Answers: ${JSON.stringify(this.cfServicesAnswers, null, 2)}`); } @@ -562,13 +555,12 @@ export default class extends Generator { const projectPath = this.isMtaYamlFound ? process.cwd() : this.destinationPath(); const publicVersions = await fetchPublicVersions(this.logger); - const manifest = this.appContentService.getManifestByBaseAppId(this.cfServicesAnswers.baseApp?.appId ?? ''); - + const manifest = this.cfPrompter.manifest; if (!manifest) { throw new Error('Manifest not found for base app.'); } - const html5RepoRuntimeGuid = this.appContentService.getHtml5RepoRuntimeGuid(); + const html5RepoRuntimeGuid = this.cfPrompter.serviceInstanceGuid; const cfConfig = createCfConfig({ attributeAnswers: this.attributeAnswers, cfServicesAnswers: this.cfServicesAnswers, diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index d52aaa1c425..a11633645d0 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -1,9 +1,10 @@ +import { Severity } from '@sap-devx/yeoman-ui-types'; + import type { CfServicesAnswers, CFServicesQuestion, CfServicesPromptOptions, AppRouterType, - AppContentService, CfConfig } from '@sap-ux/adp-tooling'; import { @@ -13,9 +14,13 @@ import { hasApprouter, isLoggedInCf, getMtaServices, - getBaseApps + getCfApps, + downloadAppContent, + validateSmartTemplateApplication, + validateODataEndpoints } from '@sap-ux/adp-tooling'; import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; import { validateEmptyString } from '@sap-ux/project-input-validator'; import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; import { getBusinessServiceKeys, type CFApp, type ServiceKeys } from '@sap-ux/adp-tooling'; @@ -61,19 +66,43 @@ export class CFServicesPrompter { * The error message when choosing a base app. */ private baseAppOnChoiceError: string | null = null; + /** + * The service instance GUID. + */ + private html5RepoServiceInstanceGuid: string; + /** + * The manifest. + */ + private appManifest: Manifest | undefined; + + /** + * Returns the loaded application manifest. + * + * @returns Application manifest. + */ + public get manifest(): Manifest | undefined { + return this.appManifest; + } + + /** + * Returns the service instance GUID. + * + * @returns Service instance GUID. + */ + public get serviceInstanceGuid(): string { + return this.html5RepoServiceInstanceGuid; + } /** * Constructor for CFServicesPrompter. * * @param {boolean} [isInternalUsage] - Internal usage flag. * @param {boolean} isCfLoggedIn - Whether the user is logged in to Cloud Foundry. - * @param {AppContentService} appContentService - App content service instance. * @param {ToolsLogger} logger - Logger instance. */ constructor( private readonly isInternalUsage: boolean = false, isCfLoggedIn: boolean, - private readonly appContentService: AppContentService, private readonly logger: ToolsLogger ) { this.isCfLoggedIn = isCfLoggedIn; @@ -216,32 +245,49 @@ export class CFServicesPrompter { if (!this.businessServiceKeys) { return []; } - this.apps = await getBaseApps( - this.businessServiceKeys.credentials, - cfConfig, - this.logger, - this.appContentService - ); + this.apps = await getCfApps(this.businessServiceKeys.credentials, cfConfig, this.logger); this.logger?.log(`Available applications: ${JSON.stringify(this.apps)}`); } return getCFAppChoices(this.apps); } catch (e) { - // log error: baseApp => choices - /* the error will be shown by the validation functionality */ this.baseAppOnChoiceError = e instanceof Error ? e.message : 'Unknown error'; this.logger?.error(`Failed to get base apps: ${e.message}`); return []; } }, - validate: (value: string) => { - if (!value) { + validate: async (app: CFApp) => { + if (!app) { return t('error.baseAppHasToBeSelected'); } if (this.baseAppOnChoiceError !== null) { return this.baseAppOnChoiceError; } + try { + const { entries, serviceInstanceGuid, manifest } = await downloadAppContent( + cfConfig.space.GUID, + app, + this.logger + ); + this.appManifest = manifest; + this.html5RepoServiceInstanceGuid = serviceInstanceGuid; + + await validateSmartTemplateApplication(manifest); + await validateODataEndpoints(entries, this.businessServiceKeys!.credentials, this.logger); + } catch (e) { + return e.message; + } + return true; }, + additionalMessages: (_: CFApp) => { + if (this.baseAppOnChoiceError) { + return { + message: this.baseAppOnChoiceError, + severity: Severity.error + }; + } + return undefined; + }, when: (answers: any) => this.isCfLoggedIn && answers.businessService, guiOptions: { hint: t('prompts.baseAppTooltip'), @@ -288,32 +334,3 @@ export class CFServicesPrompter { } as ListQuestion; } } - -/** - * @param {object} param0 - Configuration object containing FDC service, internal usage flag, MTA project path, CF login status, and logger. - * @param {CfConfig} param0.cfConfig - CF config service instance. - * @param {boolean} [param0.isInternalUsage] - Internal usage flag. - * @param {string} param0.mtaProjectPath - MTA project path. - * @param {boolean} param0.isCfLoggedIn - CF login status. - * @param {ToolsLogger} param0.logger - Logger instance. - * @param {AppContentService} param0.appContentService - App content service instance. - * @returns {Promise} CF services questions. - */ -export async function getPrompts({ - cfConfig, - isInternalUsage, - mtaProjectPath, - isCfLoggedIn, - appContentService, - logger -}: { - cfConfig: CfConfig; - isInternalUsage?: boolean; - mtaProjectPath: string; - isCfLoggedIn: boolean; - appContentService: AppContentService; - logger: ToolsLogger; -}): Promise { - const prompter = new CFServicesPrompter(isInternalUsage, isCfLoggedIn, appContentService, logger); - return prompter.getPrompts(mtaProjectPath, cfConfig); -} From 30e9aad42f88c96862381ce33fe6fc8e1d079a18 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 4 Sep 2025 09:23:47 +0300 Subject: [PATCH 053/111] feat: improve app validation and selection prompt logic --- packages/adp-tooling/src/cf/project/mta.ts | 12 ++++ .../src/app/questions/cf-services.ts | 69 +++++-------------- 2 files changed, 31 insertions(+), 50 deletions(-) diff --git a/packages/adp-tooling/src/cf/project/mta.ts b/packages/adp-tooling/src/cf/project/mta.ts index ab9bd97c87b..69713cf09b8 100644 --- a/packages/adp-tooling/src/cf/project/mta.ts +++ b/packages/adp-tooling/src/cf/project/mta.ts @@ -29,6 +29,18 @@ export function getModuleNames(mtaProjectPath: string): string[] { return yamlContent?.modules?.map((module: { name: string }) => module.name) ?? []; } +/** + * Get the mta project name. + * + * @param {string} mtaProjectPath - The path to the mta project. + * @returns {string} The mta project name. + */ +export function getMtaProjectName(mtaProjectPath: string): string { + const mtaProjectName = + (mtaProjectPath.indexOf('/') > -1 ? mtaProjectPath.split('/').pop() : mtaProjectPath.split('\\').pop()) ?? ''; + return mtaProjectName; +} + /** * Get the services for the file. * diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index a11633645d0..524a2643bd1 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -1,5 +1,3 @@ -import { Severity } from '@sap-devx/yeoman-ui-types'; - import type { CfServicesAnswers, CFServicesQuestion, @@ -17,7 +15,8 @@ import { getCfApps, downloadAppContent, validateSmartTemplateApplication, - validateODataEndpoints + validateODataEndpoints, + getMtaProjectName } from '@sap-ux/adp-tooling'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; @@ -184,11 +183,7 @@ export class CFServicesPrompter { choices: getAppRouterChoices(this.isInternalUsage), when: () => { const modules = getModuleNames(mtaProjectPath); - const mtaProjectName = - (mtaProjectPath.indexOf('/') > -1 - ? mtaProjectPath.split('/').pop() - : mtaProjectPath.split('\\').pop()) ?? ''; - + const mtaProjectName = getMtaProjectName(mtaProjectPath); const hasRouter = hasApprouter(mtaProjectName, modules); if (hasRouter) { this.approuter = getApprouterType(mtaProjectPath); @@ -232,36 +227,12 @@ export class CFServicesPrompter { type: 'list', name: cfServicesPromptNames.baseApp, message: t('prompts.baseAppLabel'), - choices: async (answers: CfServicesAnswers): Promise => { - try { - this.baseAppOnChoiceError = null; - if (this.cachedServiceName != answers.businessService) { - this.cachedServiceName = answers.businessService; - this.businessServiceKeys = await getBusinessServiceKeys( - answers.businessService ?? '', - cfConfig, - this.logger - ); - if (!this.businessServiceKeys) { - return []; - } - this.apps = await getCfApps(this.businessServiceKeys.credentials, cfConfig, this.logger); - this.logger?.log(`Available applications: ${JSON.stringify(this.apps)}`); - } - return getCFAppChoices(this.apps); - } catch (e) { - this.baseAppOnChoiceError = e instanceof Error ? e.message : 'Unknown error'; - this.logger?.error(`Failed to get base apps: ${e.message}`); - return []; - } - }, + choices: (_: CfServicesAnswers) => getCFAppChoices(this.apps), validate: async (app: CFApp) => { if (!app) { return t('error.baseAppHasToBeSelected'); } - if (this.baseAppOnChoiceError !== null) { - return this.baseAppOnChoiceError; - } + try { const { entries, serviceInstanceGuid, manifest } = await downloadAppContent( cfConfig.space.GUID, @@ -279,16 +250,7 @@ export class CFServicesPrompter { return true; }, - additionalMessages: (_: CFApp) => { - if (this.baseAppOnChoiceError) { - return { - message: this.baseAppOnChoiceError, - severity: Severity.error - }; - } - return undefined; - }, - when: (answers: any) => this.isCfLoggedIn && answers.businessService, + when: (answers: CfServicesAnswers) => this.isCfLoggedIn && answers.businessService && !!this.apps.length, guiOptions: { hint: t('prompts.baseAppTooltip'), breadcrumb: true @@ -310,18 +272,25 @@ export class CFServicesPrompter { choices: this.businessServices, default: (_: CfServicesAnswers) => this.businessServices.length === 1 ? this.businessServices[0] ?? '' : '', - when: (answers: CfServicesAnswers) => { - return this.isCfLoggedIn && (this.approuter || answers.approuter); - }, + when: (answers: CfServicesAnswers) => this.isCfLoggedIn && (this.approuter || answers.approuter), validate: async (value: string) => { const validationResult = validateEmptyString(value); if (typeof validationResult === 'string') { return t('error.businessServiceHasToBeSelected'); } - this.businessServiceKeys = await getBusinessServiceKeys(value, cfConfig, this.logger); - if (this.businessServiceKeys === null) { - return t('error.businessServiceDoesNotExist'); + try { + this.businessServiceKeys = await getBusinessServiceKeys(value, cfConfig, this.logger); + if (this.businessServiceKeys === null) { + return t('error.businessServiceDoesNotExist'); + } + + this.apps = await getCfApps(this.businessServiceKeys.credentials, cfConfig, this.logger); + this.logger?.log(`Available applications: ${JSON.stringify(this.apps)}`); + } catch (e) { + this.apps = []; + this.logger?.error(`Failed to get available applications: ${e.message}`); + return e.message; } return true; From 169d5337d22332a1625283517a227dcfe2ea3605 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 4 Sep 2025 10:11:45 +0300 Subject: [PATCH 054/111] refactor: extract messages into i18n --- packages/adp-tooling/src/cf/app/discovery.ts | 3 ++- packages/adp-tooling/src/cf/app/html5-repo.ts | 15 +++++++------ packages/adp-tooling/src/cf/project/mta.ts | 13 ++++-------- packages/adp-tooling/src/cf/services/api.ts | 18 ++++++++-------- packages/adp-tooling/src/cf/services/cli.ts | 17 ++++++++------- .../adp-tooling/src/cf/utils/validation.ts | 8 +++---- .../src/translations/adp-tooling.i18n.json | 21 ++++++++++++++++++- 7 files changed, 55 insertions(+), 40 deletions(-) diff --git a/packages/adp-tooling/src/cf/app/discovery.ts b/packages/adp-tooling/src/cf/app/discovery.ts index 0e1ecec5b5e..bc0558d61c5 100644 --- a/packages/adp-tooling/src/cf/app/discovery.ts +++ b/packages/adp-tooling/src/cf/app/discovery.ts @@ -1,5 +1,6 @@ import type { ToolsLogger } from '@sap-ux/logger'; +import { t } from '../../i18n'; import { getFDCApps } from '../services/api'; import type { CfConfig, CFApp, CfCredentials } from '../../types'; @@ -52,7 +53,7 @@ export async function getCfApps( // Validate appHostIds array length (max 100 as per API specification) if (appHostIds.length > 100) { - throw new Error(`Too many appHostIds provided. Maximum allowed is 100, but ${appHostIds.length} were found.`); + throw new Error(t('error.tooManyAppHostIds', { appHostIdsLength: appHostIds.length })); } return getFDCApps(appHostIds, cfConfig, logger); diff --git a/packages/adp-tooling/src/cf/app/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts index 08a0073c278..29763637b5f 100644 --- a/packages/adp-tooling/src/cf/app/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -4,6 +4,7 @@ import AdmZip from 'adm-zip'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; +import { t } from '../../i18n'; import { createService, getServiceInstanceKeys } from '../services/api'; import type { HTML5Content, ServiceKeys, Uaa, CfAppParams } from '../../types'; @@ -111,14 +112,14 @@ export async function downloadAppContent( try { admZip = new AdmZip(zip); } catch (e) { - throw new Error(`Failed to parse zip content from HTML5 repository. Reason: ${e.message}`); + throw new Error(t('error.failedToParseZipContentFromHtml5Repo', { error: e.message })); } if (!admZip?.getEntries?.().length) { - throw new Error('No zip content was parsed from HTML5 repository'); + throw new Error(t('error.noZipContentParsedFromHtml5Repo')); } const zipEntry = admZip.getEntries().find((zipEntry) => zipEntry.entryName === 'manifest.json'); if (!zipEntry) { - throw new Error('Failed to find manifest.json in the application content from HTML5 repository'); + throw new Error(t('error.failedToFindManifestJsonInHtml5Repo')); } try { @@ -129,14 +130,12 @@ export async function downloadAppContent( manifest: manifest }; } catch (error) { - throw new Error('Failed to parse manifest.json.'); + throw new Error(t('error.failedToParseManifestJson', { error: error.message })); } } else { - throw new Error('No UAA credentials found for HTML5 repository'); + throw new Error(t('error.noUaaCredentialsFoundForHtml5Repo')); } } catch (e) { - throw new Error( - `Failed to download the application content from HTML5 repository for space ${spaceGuid} and app ${appName} (${appHostId}). Reason: ${e.message}` - ); + throw new Error(t('error.failedToDownloadAppContent', { spaceGuid, appName, appHostId, error: e.message })); } } diff --git a/packages/adp-tooling/src/cf/project/mta.ts b/packages/adp-tooling/src/cf/project/mta.ts index 69713cf09b8..7c97cbd07fe 100644 --- a/packages/adp-tooling/src/cf/project/mta.ts +++ b/packages/adp-tooling/src/cf/project/mta.ts @@ -2,9 +2,10 @@ import * as path from 'path'; import type { ToolsLogger } from '@sap-ux/logger'; -import { requestCfApi } from '../services/api'; +import { t } from '../../i18n'; import { getRouterType } from './yaml'; import { getYamlContent } from './yaml-loader'; +import { requestCfApi } from '../services/api'; import type { CfServiceOffering, CfAPIResponse, BusinessServiceResource, Resource, AppRouterType } from '../../types'; /** @@ -112,13 +113,7 @@ async function filterServices(businessServices: BusinessServiceResource[], logge return result; } } - throw new Error(`No business services found, please specify the business services in resource section of mts.yaml: - - name: - type: org.cloudfoundry.-service - parameters: - service: - service-name: - service-plan: `); + throw new Error(t('error.noBusinessServicesFound')); } /** @@ -156,7 +151,7 @@ export async function getResources(mtaFilePath: string, logger: ToolsLogger): Pr */ export async function readMta(projectPath: string, logger: ToolsLogger): Promise { if (!projectPath) { - throw new Error('Project path is missing.'); + throw new Error(t('error.mtaProjectPathMissing')); } const mtaFilePath = path.resolve(projectPath, 'mta.yaml'); diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index ae921e2e423..d2d75afcbe7 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -20,6 +20,7 @@ import type { CfCredentials, MtaYaml } from '../../types'; +import { t } from '../../i18n'; import { isLoggedInCf } from '../core/auth'; import { createServiceKey, getServiceKeys } from './cli'; import { getProjectNameForXsSecurity } from '../project'; @@ -304,7 +305,7 @@ export async function getServiceInstanceKeys( } return null; } catch (e) { - const errorMessage = `Failed to get service instance keys. Reason: ${e.message}`; + const errorMessage = t('error.failedToGetServiceInstanceKeys', { error: e.message }); logger?.error(errorMessage); throw new Error(errorMessage); } @@ -330,9 +331,9 @@ async function getServiceInstance(params: GetServiceInstanceParams): Promise} The service instance keys. */ async function getOrCreateServiceKeys(serviceInstance: ServiceInstance, logger: ToolsLogger): Promise { + const serviceInstanceName = serviceInstance.name; try { const credentials = await getServiceKeys(serviceInstance.guid); if (credentials?.length > 0) { return credentials; } else { - const serviceKeyName = serviceInstance.name + '_key'; - logger?.log(`Creating service key '${serviceKeyName}' for service instance '${serviceInstance.name}'`); - await createServiceKey(serviceInstance.name, serviceKeyName); + const serviceKeyName = serviceInstanceName + '_key'; + logger?.log(`Creating service key '${serviceKeyName}' for service instance '${serviceInstanceName}'`); + await createServiceKey(serviceInstanceName, serviceKeyName); return getServiceKeys(serviceInstance.guid); } } catch (e) { - throw new Error( - `Failed to get or create service keys for instance name ${serviceInstance.name}. Reason: ${e.message}` - ); + throw new Error(t('error.failedToGetOrCreateServiceKeys', { serviceInstanceName, error: e.message })); } } diff --git a/packages/adp-tooling/src/cf/services/cli.ts b/packages/adp-tooling/src/cf/services/cli.ts index 4b9148feb4d..3b0a7822545 100644 --- a/packages/adp-tooling/src/cf/services/cli.ts +++ b/packages/adp-tooling/src/cf/services/cli.ts @@ -2,10 +2,10 @@ import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import CFToolsCli = require('@sap/cf-tools/out/src/cli'); import { eFilters } from '@sap/cf-tools/out/src/types'; +import { t } from '../../i18n'; import type { CfCredentials } from '../../types'; const ENV = { env: { 'CF_COLOR': 'false' } }; -const CREATE_SERVICE_KEY = 'create-service-key'; /** * Gets the authentication token. @@ -29,8 +29,8 @@ export async function checkForCf(): Promise { if (response.exitCode !== 0) { throw new Error(response.stderr); } - } catch (error) { - throw new Error('Cloud Foundry is not installed in your space.'); + } catch (e) { + throw new Error(t('error.cfNotInstalled', { error: e.message })); } } @@ -59,9 +59,7 @@ export async function getServiceKeys(serviceInstanceGuid: string): Promise { try { - const cliResult = await CFToolsCli.Cli.execute([CREATE_SERVICE_KEY, serviceInstanceName, serviceKeyName], ENV); + const cliResult = await CFToolsCli.Cli.execute( + ['create-service-key', serviceInstanceName, serviceKeyName], + ENV + ); if (cliResult.exitCode !== 0) { throw new Error(cliResult.stderr); } } catch (e) { - throw new Error(`Failed to create service key for instance name ${serviceInstanceName}. Reason: ${e.message}`); + throw new Error(t('error.createServiceKeyFailed', { serviceInstanceName, error: e.message })); } } diff --git a/packages/adp-tooling/src/cf/utils/validation.ts b/packages/adp-tooling/src/cf/utils/validation.ts index a4b92f2b30c..92696c671ca 100644 --- a/packages/adp-tooling/src/cf/utils/validation.ts +++ b/packages/adp-tooling/src/cf/utils/validation.ts @@ -32,7 +32,7 @@ export async function validateSmartTemplateApplication(manifest: Manifest): Prom throw new Error(t('error.appDoesNotSupportFlexibility')); } } else { - throw new Error("Adaptation project doesn't support the selected application. Select a different application."); + throw new Error(t('error.adpDoesNotSupportSelectedApplication')); } } @@ -49,7 +49,7 @@ export function extractXSApp(zipEntries: AdmZip.IZipEntry[]): { routes: XsAppRou try { xsApp = JSON.parse(item.getData().toString('utf8')); } catch (e) { - throw new Error(`Failed to parse xs-app.json. Reason: ${e.message}`); + throw new Error(t('error.failedToParseXsAppJson', { error: e.message })); } } }); @@ -101,7 +101,7 @@ function extractManifest(zipEntries: AdmZip.IZipEntry[]): Manifest | undefined { try { manifest = JSON.parse(item.getData().toString('utf8')) as Manifest; } catch (e) { - throw new Error(`Failed to parse manifest.json. Reason: ${e.message}`); + throw new Error(t('error.failedToParseManifestJson', { error: e.message })); } } }); @@ -154,6 +154,6 @@ export async function validateODataEndpoints( if (messages.length > 0) { const errorMessages = messages.join('\n'); logger?.error(`OData endpoints validation failed:\n${errorMessages}`); - throw new Error('OData endpoints validation failed. Please check the logs for more details.'); + throw new Error(t('error.oDataEndpointsValidationFailed')); } } diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index 6a369f61910..a6045fa42c2 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -75,7 +75,26 @@ "ui5VersionNotDetectedError": "The SAPUI5 version of the selected system cannot be determined. You will be able to create and edit adaptation projects using the newest version but it will not be usable on this system until the system`s SAPUI5 version is upgraded to version 1.71 or higher." }, "error": { - "appDoesNotSupportFlexibility": "The selected application does not support flexibility because it has (`flexEnabled=false`). SAPUI5 Adaptation Project only supports applications that support flexibility. Please select a different application." + "appDoesNotSupportFlexibility": "The selected application does not support flexibility because it has (`flexEnabled=false`). SAPUI5 Adaptation Project only supports applications that support flexibility. Please select a different application.", + "failedToParseXsAppJson": "Failed to parse xs-app.json. Reason: {{error}}", + "failedToParseManifestJson": "Failed to parse manifest.json. Reason: {{error}}", + "oDataEndpointsValidationFailed": "OData endpoints validation failed. Please check the logs for more details.", + "adpDoesNotSupportSelectedApp": "Adaptation project doesn't support the selected application. Please select a different application.", + "cfNotInstalled": "Cloud Foundry is not installed in your space: {{error}}", + "cfGetInstanceCredentialsFailed": "Failed to get service instance credentials from CFLocal for GUID {{serviceInstanceGuid}}. Reason: {{error}}", + "createServiceKeyFailed": "Failed to create service key for instance name {{serviceInstanceName}}. Reason: {{error}}", + "failedToGetServiceInstanceKeys": "Failed to get service instance keys. Reason: {{error}}", + "noValidJsonForServiceInstance": "JSON is not valid for service instance.", + "failedToGetServiceInstance": "Failed to get service instance with params {{uriParameters}}. Reason: {{error}}", + "failedToGetOrCreateServiceKeys": "Failed to get or create service keys for instance name {{serviceInstanceName}}. Reason: {{error}}", + "mtaProjectPathMissing": "MTA project path is missing. Please provide the path to a valid MTA project.", + "noBusinessServicesFound": "No business services found, please specify the business services in resource section of mta.yaml: - name: type: org.cloudfoundry.-service parameters: service: service-name: service-plan: ", + "failedToParseZipContentFromHtml5Repo": "Failed to parse zip content from HTML5 repository. Reason: {{error}}", + "noZipContentParsedFromHtml5Repo": "No zip content was parsed from HTML5 repository.", + "failedToFindManifestJsonInHtml5Repo": "Failed to find manifest.json in the application content from HTML5 repository.", + "noUaaCredentialsFoundForHtml5Repo": "No UAA credentials found for HTML5 repository.", + "failedToDownloadAppContent": "Failed to download the application content from HTML5 repository for space {{spaceGuid}} and app {{appName}} ({{appHostId}}). Reason: {{error}}", + "tooManyAppHostIds": "Too many appHostIds provided. Maximum allowed is 100, but {{appHostIdsLength}} were found." }, "choices": { "true": "true", From a82dfe564d10baadebc225840919141a45c98387 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 4 Sep 2025 10:37:47 +0300 Subject: [PATCH 055/111] refactor: extract messages into i18n --- packages/adp-tooling/src/cf/app/html5-repo.ts | 16 +++---- packages/adp-tooling/src/cf/core/auth.ts | 5 +- .../adp-tooling/src/cf/utils/validation.ts | 48 ++++++++----------- .../src/translations/adp-tooling.i18n.json | 6 ++- packages/adp-tooling/src/types.ts | 6 +++ 5 files changed, 42 insertions(+), 39 deletions(-) diff --git a/packages/adp-tooling/src/cf/app/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts index 29763637b5f..93c5a4871b5 100644 --- a/packages/adp-tooling/src/cf/app/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -29,17 +29,17 @@ export async function getToken(uaa: Uaa): Promise { const response = await axios.get(uri, options); return response.data['access_token']; } catch (e) { - throw new Error(`Failed to get the OAuth token from HTML5 repository. Reason: ${e.message}`); + throw new Error(t('error.failedToGetAuthKey', { error: e.message })); } } /** * Download zip from HTML5 repository. * - * @param {string} token html5 reposiotry token - * @param {string} appHostId appHostId where content is stored - * @param {string} uri url with parameters - * @returns {Promise} file buffer content + * @param {string} token - HTML5 reposiotry token. + * @param {string} appHostId - appHostId where content is stored. + * @param {string} uri - URL with parameters. + * @returns {Promise} File buffer content. */ export async function downloadZip(token: string, appHostId: string, uri: string): Promise { try { @@ -53,7 +53,7 @@ export async function downloadZip(token: string, appHostId: string, uri: string) }); return response.data; } catch (e) { - throw new Error(`Failed to download zip from HTML5 repository. Reason: ${e.message}`); + throw new Error(t('error.failedToDownloadZipFromHtml5Repo', { error: e.message })); } } @@ -78,12 +78,12 @@ export async function getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLo await createService(spaceGuid, 'app-runtime', HTML5_APPS_REPO_RUNTIME, logger, ['html5-apps-repo-rt']); serviceKeys = await getServiceInstanceKeys({ names: [HTML5_APPS_REPO_RUNTIME] }, logger); if (!serviceKeys?.credentials?.length) { - throw new Error('Cannot find HTML5 Repo runtime in current space'); + throw new Error(t('error.cannotFindHtml5RepoRuntimeInCurrentSpace')); } } return serviceKeys; } catch (e) { - throw new Error(`Failed to get credentials from HTML5 repository for space ${spaceGuid}. Reason: ${e.message}`); + throw new Error(t('error.failedToGetCredentialsFromHtml5Repo', { error: e.message })); } } diff --git a/packages/adp-tooling/src/cf/core/auth.ts b/packages/adp-tooling/src/cf/core/auth.ts index 7074320c982..c33b81d50bb 100644 --- a/packages/adp-tooling/src/cf/core/auth.ts +++ b/packages/adp-tooling/src/cf/core/auth.ts @@ -11,14 +11,13 @@ import type { CfConfig, Organization } from '../../types'; * @returns {Promise} True if CF is installed, false otherwise. */ export async function isCfInstalled(): Promise { - let isInstalled = true; try { await checkForCf(); } catch (error) { - isInstalled = false; + return false; } - return isInstalled; + return true; } /** diff --git a/packages/adp-tooling/src/cf/utils/validation.ts b/packages/adp-tooling/src/cf/utils/validation.ts index 92696c671ca..67b60651ca0 100644 --- a/packages/adp-tooling/src/cf/utils/validation.ts +++ b/packages/adp-tooling/src/cf/utils/validation.ts @@ -4,12 +4,12 @@ import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; import { t } from '../../i18n'; -import type { CfCredentials, XsAppRoute } from '../../types'; +import type { CfCredentials, XsApp, XsAppRoute } from '../../types'; import { getApplicationType } from '../../source/manifest'; import { isSupportedAppTypeForAdp } from '../../source/manifest'; /** - * Normalize the route regex. + * Normalize the xs-app route regex. * * @param {string} value - The value. * @returns {RegExp} The normalized route regex. @@ -27,13 +27,13 @@ function normalizeRouteRegex(value: string): RegExp { export async function validateSmartTemplateApplication(manifest: Manifest): Promise { const appType = getApplicationType(manifest); - if (isSupportedAppTypeForAdp(appType)) { - if (manifest['sap.ui5'] && manifest['sap.ui5'].flexEnabled === false) { - throw new Error(t('error.appDoesNotSupportFlexibility')); - } - } else { + if (!isSupportedAppTypeForAdp(appType)) { throw new Error(t('error.adpDoesNotSupportSelectedApplication')); } + + if (manifest['sap.ui5'] && manifest['sap.ui5'].flexEnabled === false) { + throw new Error(t('error.appDoesNotSupportFlexibility')); + } } /** @@ -42,17 +42,14 @@ export async function validateSmartTemplateApplication(manifest: Manifest): Prom * @param {AdmZip.IZipEntry[]} zipEntries - The zip entries. * @returns {any} The xs-app.json. */ -export function extractXSApp(zipEntries: AdmZip.IZipEntry[]): { routes: XsAppRoute[] } | undefined { - let xsApp; - zipEntries.forEach((item) => { - if (item.entryName.endsWith('xs-app.json')) { - try { - xsApp = JSON.parse(item.getData().toString('utf8')); - } catch (e) { - throw new Error(t('error.failedToParseXsAppJson', { error: e.message })); - } - } - }); +export function extractXSApp(zipEntries: AdmZip.IZipEntry[]): XsApp | undefined { + let xsApp: XsApp | undefined; + const xsAppEntry = zipEntries.find((item) => item.entryName.endsWith('xs-app.json')); + try { + xsApp = JSON.parse(xsAppEntry?.getData().toString('utf8') ?? '') as XsApp; + } catch (e) { + throw new Error(t('error.failedToParseXsAppJson', { error: e.message })); + } return xsApp; } @@ -96,15 +93,12 @@ function matchRoutesAndDatasources( */ function extractManifest(zipEntries: AdmZip.IZipEntry[]): Manifest | undefined { let manifest: Manifest | undefined; - zipEntries.forEach((item) => { - if (item.entryName.endsWith('manifest.json')) { - try { - manifest = JSON.parse(item.getData().toString('utf8')) as Manifest; - } catch (e) { - throw new Error(t('error.failedToParseManifestJson', { error: e.message })); - } - } - }); + const manifestEntry = zipEntries.find((item) => item.entryName.endsWith('manifest.json')); + try { + manifest = JSON.parse(manifestEntry?.getData().toString('utf8') ?? '') as Manifest; + } catch (e) { + throw new Error(t('error.failedToParseManifestJson', { error: e.message })); + } return manifest; } diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index a6045fa42c2..fa4fc9205b0 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -94,7 +94,11 @@ "failedToFindManifestJsonInHtml5Repo": "Failed to find manifest.json in the application content from HTML5 repository.", "noUaaCredentialsFoundForHtml5Repo": "No UAA credentials found for HTML5 repository.", "failedToDownloadAppContent": "Failed to download the application content from HTML5 repository for space {{spaceGuid}} and app {{appName}} ({{appHostId}}). Reason: {{error}}", - "tooManyAppHostIds": "Too many appHostIds provided. Maximum allowed is 100, but {{appHostIdsLength}} were found." + "tooManyAppHostIds": "Too many appHostIds provided. Maximum allowed is 100, but {{appHostIdsLength}} were found.", + "failedToGetAuthKey": "Failed to get the OAuth token from HTML5 repository. Reason: {{error}}", + "failedToDownloadZipFromHtml5Repo": "Failed to download zip from HTML5 repository. Reason: {{error}}", + "cannotFindHtml5RepoRuntimeInCurrentSpace": "Cannot find HTML5 Repo runtime in current space", + "failedToGetCredentialsFromHtml5Repo": "Failed to get credentials from HTML5 repository for space {{spaceGuid}}. Reason: {{error}}" }, "choices": { "true": "true", diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 943f5470648..b193e352cd6 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -814,6 +814,12 @@ export interface XsAppRoute { [key: string]: unknown; } +export interface XsApp { + welcomeFile?: string; + authenticationMethod?: string; + routes: XsAppRoute[]; +} + export interface Uaa { clientid: string; clientsecret: string; From b3b77271b080802dc089e0fd329788049892da1c Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 4 Sep 2025 10:40:16 +0300 Subject: [PATCH 056/111] refactor: extract messages into i18n --- packages/adp-tooling/src/cf/app/html5-repo.ts | 6 +++--- packages/adp-tooling/src/translations/adp-tooling.i18n.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/adp-tooling/src/cf/app/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts index 93c5a4871b5..9ab3b7b03d7 100644 --- a/packages/adp-tooling/src/cf/app/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -78,7 +78,7 @@ export async function getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLo await createService(spaceGuid, 'app-runtime', HTML5_APPS_REPO_RUNTIME, logger, ['html5-apps-repo-rt']); serviceKeys = await getServiceInstanceKeys({ names: [HTML5_APPS_REPO_RUNTIME] }, logger); if (!serviceKeys?.credentials?.length) { - throw new Error(t('error.cannotFindHtml5RepoRuntimeInCurrentSpace')); + throw new Error(t('error.cannotFindHtml5RepoRuntime')); } } return serviceKeys; @@ -112,10 +112,10 @@ export async function downloadAppContent( try { admZip = new AdmZip(zip); } catch (e) { - throw new Error(t('error.failedToParseZipContentFromHtml5Repo', { error: e.message })); + throw new Error(t('error.failedToParseZipContent', { error: e.message })); } if (!admZip?.getEntries?.().length) { - throw new Error(t('error.noZipContentParsedFromHtml5Repo')); + throw new Error(t('error.noZipContentParsed')); } const zipEntry = admZip.getEntries().find((zipEntry) => zipEntry.entryName === 'manifest.json'); if (!zipEntry) { diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index fa4fc9205b0..12c6cc7d96e 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -89,15 +89,15 @@ "failedToGetOrCreateServiceKeys": "Failed to get or create service keys for instance name {{serviceInstanceName}}. Reason: {{error}}", "mtaProjectPathMissing": "MTA project path is missing. Please provide the path to a valid MTA project.", "noBusinessServicesFound": "No business services found, please specify the business services in resource section of mta.yaml: - name: type: org.cloudfoundry.-service parameters: service: service-name: service-plan: ", - "failedToParseZipContentFromHtml5Repo": "Failed to parse zip content from HTML5 repository. Reason: {{error}}", - "noZipContentParsedFromHtml5Repo": "No zip content was parsed from HTML5 repository.", + "failedToParseZipContent": "Failed to parse zip content from HTML5 repository. Reason: {{error}}", + "noZipContentParsed": "No zip content was parsed from HTML5 repository.", "failedToFindManifestJsonInHtml5Repo": "Failed to find manifest.json in the application content from HTML5 repository.", "noUaaCredentialsFoundForHtml5Repo": "No UAA credentials found for HTML5 repository.", "failedToDownloadAppContent": "Failed to download the application content from HTML5 repository for space {{spaceGuid}} and app {{appName}} ({{appHostId}}). Reason: {{error}}", "tooManyAppHostIds": "Too many appHostIds provided. Maximum allowed is 100, but {{appHostIdsLength}} were found.", "failedToGetAuthKey": "Failed to get the OAuth token from HTML5 repository. Reason: {{error}}", "failedToDownloadZipFromHtml5Repo": "Failed to download zip from HTML5 repository. Reason: {{error}}", - "cannotFindHtml5RepoRuntimeInCurrentSpace": "Cannot find HTML5 Repo runtime in current space", + "cannotFindHtml5RepoRuntime": "Cannot find HTML5 Repo runtime in current space", "failedToGetCredentialsFromHtml5Repo": "Failed to get credentials from HTML5 repository for space {{spaceGuid}}. Reason: {{error}}" }, "choices": { From 85a06ff3b9e844743bed05b77a28118966341eaf Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 4 Sep 2025 11:18:57 +0300 Subject: [PATCH 057/111] refactor: extract messages into i18n --- packages/adp-tooling/src/cf/services/api.ts | 30 +++++++------------ .../src/translations/adp-tooling.i18n.json | 8 ++++- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index d2d75afcbe7..988ad31e55f 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -126,25 +126,18 @@ export async function getFDCApps(appHostIds: string[], cfConfig: CfConfig, logge if (!isLoggedIn) { await CFLocal.cfGetAvailableOrgs(); } + const response = await axios.get(url, requestArguments.options); - logger?.log( - `Getting FDC apps. Request url: ${url} response status: ${response.status}, response data: ${JSON.stringify( - response.data - )}` - ); if (response.status === 200) { + logger?.log(`Retrieved FDC apps with request url: ${JSON.stringify(response.data)}`); return response.data.results; } else { - throw new Error( - `Failed to connect to FDC service. Reason: HTTP status code ${response.status}: ${response.statusText}` - ); + throw new Error(t('error.failedToConnectToFDCService', { status: response.status })); } - } catch (e) { - logger?.error( - `Getting FDC apps. Request url: ${url}, response status: ${e?.response?.status}, message: ${e.message || e}` - ); - throw e; + } catch (error) { + logger?.error(`Getting FDC apps failed. Request url: ${url}. ${error}`); + throw new Error(t('error.failedToGetFDCApps', { error: error.message })); } } @@ -161,12 +154,12 @@ export async function requestCfApi(url: string): Promise { try { return JSON.parse(response.stdout); } catch (e) { - throw new Error(`Failed to parse response from request CF API: ${e.message}`); + throw new Error(t('error.failedToParseCFAPIResponse', { error: e.message })); } } throw new Error(response.stderr); } catch (e) { - throw new Error(`Request to CF API failed. Reason: ${e.message}`); + throw new Error(t('error.failedToRequestCFAPI', { error: e.message })); } } @@ -215,7 +208,7 @@ export async function createService( xsSecurity = JSON.parse(xsContent) as unknown as { xsappname?: string }; xsSecurity.xsappname = xsSecurityProjectName; } catch (err) { - throw new Error('xs-security.json could not be parsed.'); + throw new Error(t('error.xsSecurityJsonCouldNotBeParsed')); } commandParameters.push('-c'); @@ -225,9 +218,8 @@ export async function createService( await CFToolsCli.Cli.execute(commandParameters); logger?.log(`Service instance '${serviceInstanceName}' created successfully`); } catch (e) { - const errorMessage = `Failed to create service instance '${serviceInstanceName}'. Reason: ${e.message}`; - logger?.error(errorMessage); - throw new Error(errorMessage); + logger?.error(e); + throw new Error(t('error.failedToCreateServiceInstance', { serviceInstanceName, error: e.message })); } } diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index 12c6cc7d96e..c636fc43844 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -98,7 +98,13 @@ "failedToGetAuthKey": "Failed to get the OAuth token from HTML5 repository. Reason: {{error}}", "failedToDownloadZipFromHtml5Repo": "Failed to download zip from HTML5 repository. Reason: {{error}}", "cannotFindHtml5RepoRuntime": "Cannot find HTML5 Repo runtime in current space", - "failedToGetCredentialsFromHtml5Repo": "Failed to get credentials from HTML5 repository for space {{spaceGuid}}. Reason: {{error}}" + "failedToGetCredentialsFromHtml5Repo": "Failed to get credentials from HTML5 repository for space {{spaceGuid}}. Reason: {{error}}", + "failedToParseCFAPIResponse": "Failed to parse CF API response: {{error}}", + "failedToRequestCFAPI": "Request to CF API failed. Reason: {{error}}", + "xsSecurityJsonCouldNotBeParsed": "xs-security.json could not be parsed.", + "failedToCreateServiceInstance": "Failed to create service instance '{{serviceInstanceName}}'. Reason: {{error}}", + "failedToGetFDCApps": "Getting FDC apps failed: {{error}}", + "failedToConnectToFDCService": "Failed to connect to FDC service: '{{status}}'" }, "choices": { "true": "true", From bae5cf50bd485bfc2fcc53cf4107c373dfc96395 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 4 Sep 2025 11:41:55 +0300 Subject: [PATCH 058/111] refactor: improve code --- packages/adp-tooling/src/cf/app/html5-repo.ts | 59 ++++++++++--------- packages/adp-tooling/src/cf/project/mta.ts | 44 +++++++------- 2 files changed, 54 insertions(+), 49 deletions(-) diff --git a/packages/adp-tooling/src/cf/app/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts index 9ab3b7b03d7..fa005a8e948 100644 --- a/packages/adp-tooling/src/cf/app/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -104,37 +104,38 @@ export async function downloadAppContent( const appNameVersion = `${appName}-${appVersion}`; try { const htmlRepoCredentials = await getHtml5RepoCredentials(spaceGuid, logger); - if (htmlRepoCredentials?.credentials?.length > 0 && htmlRepoCredentials?.credentials[0]?.uaa) { - const token = await getToken(htmlRepoCredentials.credentials[0].uaa); - const uri = `${htmlRepoCredentials.credentials[0].uri}/applications/content/${appNameVersion}?pathSuffixFilter=manifest.json,xs-app.json`; - const zip = await downloadZip(token, appHostId, uri); - let admZip; - try { - admZip = new AdmZip(zip); - } catch (e) { - throw new Error(t('error.failedToParseZipContent', { error: e.message })); - } - if (!admZip?.getEntries?.().length) { - throw new Error(t('error.noZipContentParsed')); - } - const zipEntry = admZip.getEntries().find((zipEntry) => zipEntry.entryName === 'manifest.json'); - if (!zipEntry) { - throw new Error(t('error.failedToFindManifestJsonInHtml5Repo')); - } - - try { - const manifest = JSON.parse(zipEntry.getData().toString('utf8')) as Manifest; - return { - entries: admZip.getEntries(), - serviceInstanceGuid: htmlRepoCredentials.serviceInstance.guid, - manifest: manifest - }; - } catch (error) { - throw new Error(t('error.failedToParseManifestJson', { error: error.message })); - } - } else { + if (htmlRepoCredentials?.credentials?.length === 0) { throw new Error(t('error.noUaaCredentialsFoundForHtml5Repo')); } + + const token = await getToken(htmlRepoCredentials.credentials[0].uaa); + const uri = `${htmlRepoCredentials.credentials[0].uri}/applications/content/${appNameVersion}?pathSuffixFilter=manifest.json,xs-app.json`; + const zip = await downloadZip(token, appHostId, uri); + + let admZip; + try { + admZip = new AdmZip(zip); + } catch (e) { + throw new Error(t('error.failedToParseZipContent', { error: e.message })); + } + if (!admZip?.getEntries?.().length) { + throw new Error(t('error.noZipContentParsed')); + } + const zipEntry = admZip.getEntries().find((zipEntry) => zipEntry.entryName === 'manifest.json'); + if (!zipEntry) { + throw new Error(t('error.failedToFindManifestJsonInHtml5Repo')); + } + + try { + const manifest = JSON.parse(zipEntry.getData().toString('utf8')) as Manifest; + return { + entries: admZip.getEntries(), + serviceInstanceGuid: htmlRepoCredentials.serviceInstance.guid, + manifest: manifest + }; + } catch (e) { + throw new Error(t('error.failedToParseManifestJson', { error: e.message })); + } } catch (e) { throw new Error(t('error.failedToDownloadAppContent', { spaceGuid, appName, appHostId, error: e.message })); } diff --git a/packages/adp-tooling/src/cf/project/mta.ts b/packages/adp-tooling/src/cf/project/mta.ts index 7c97cbd07fe..1cb0ab99daf 100644 --- a/packages/adp-tooling/src/cf/project/mta.ts +++ b/packages/adp-tooling/src/cf/project/mta.ts @@ -91,29 +91,33 @@ export function hasApprouter(projectName: string, moduleNames: string[]): boolea */ async function filterServices(businessServices: BusinessServiceResource[], logger: ToolsLogger): Promise { const serviceLabels = businessServices.map((service) => service.label).filter((label) => label); - if (serviceLabels.length > 0) { - const url = `/v3/service_offerings?names=${serviceLabels.join(',')}`; - const json = await requestCfApi>(url); - logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); - - const businessServiceNames = new Set(businessServices.map((service) => service.label)); - const result: string[] = []; - json?.resources?.forEach((resource: CfServiceOffering) => { - if (businessServiceNames.has(resource.name)) { - const sapService = resource?.['broker_catalog']?.metadata?.sapservice; - if (sapService && ['v2', 'v4'].includes(sapService?.odataversion ?? '')) { - result.push(businessServices?.find((service) => resource.name === service.label)?.name ?? ''); - } else { - logger?.log(`Service '${resource.name}' doesn't support V2/V4 Odata and will be ignored`); - } - } - }); - if (result.length > 0) { - return result; + if (serviceLabels.length === 0) { + throw new Error(t('error.noBusinessServicesFound')); + } + + const url = `/v3/service_offerings?names=${serviceLabels.join(',')}`; + const json = await requestCfApi>(url); + logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); + + const businessServiceNames = new Set(businessServices.map((service) => service.label)); + const result: string[] = []; + json?.resources?.forEach((resource: CfServiceOffering) => { + if (businessServiceNames.has(resource.name)) { + const sapService = resource?.['broker_catalog']?.metadata?.sapservice; + if (sapService && ['v2', 'v4'].includes(sapService?.odataversion ?? '')) { + result.push(businessServices?.find((service) => resource.name === service.label)?.name ?? ''); + } else { + logger?.log(`Service '${resource.name}' doesn't support V2/V4 Odata and will be ignored`); + } } + }); + + if (result.length === 0) { + throw new Error(t('error.noBusinessServicesFound')); } - throw new Error(t('error.noBusinessServicesFound')); + + return result; } /** From bb550243d53d7ac3df6ad2a69bd449b8cd72020b Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 4 Sep 2025 13:38:44 +0300 Subject: [PATCH 059/111] refactor: improve writing logic --- packages/adp-tooling/src/writer/cf.ts | 116 +-------------- .../adp-tooling/src/writer/project-utils.ts | 139 +++++++++++++++++- .../templates/cf/manifest.appdescr_variant | 17 --- 3 files changed, 145 insertions(+), 127 deletions(-) delete mode 100644 packages/adp-tooling/templates/cf/manifest.appdescr_variant diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 2800ee3dc0a..22c9c089957 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -7,17 +7,14 @@ import { getI18nDescription, getI18nModels, writeI18nModels } from './i18n'; import { type CfAdpWriterConfig, type FlexLayer, - ApplicationType, type CreateCfConfigParams, AppRouterType, - type DescriptorVariant, type Content } from '../types'; +import { adjustMtaYaml } from '../cf'; import { fillDescriptorContent } from './manifest'; import { getLatestVersion } from '../ui5/version-info'; -import { adjustMtaYaml } from '../cf'; - -const baseTmplPath = join(__dirname, '../../templates'); +import { getCfVariant, writeCfTemplates } from './project-utils'; /** * Create CF configuration from batch objects. @@ -81,7 +78,7 @@ export async function generateCf(basePath: string, config: CfAdpWriterConfig, fs const fullConfig = setDefaultsCF(config); - const { app, cf } = fullConfig; + const { app, cf, ui5 } = fullConfig; await adjustMtaYaml( basePath, app.id, @@ -95,7 +92,10 @@ export async function generateCf(basePath: string, config: CfAdpWriterConfig, fs writeI18nModels(basePath, fullConfig.app.i18nModels, fs); } - await writeCfTemplates(basePath, fullConfig, fs); + const variant = getCfVariant(config); + fillDescriptorContent(variant.content as Content[], app.appType, ui5.version, app.i18nModels); + + await writeCfTemplates(basePath, variant, fullConfig, fs); return fs; } @@ -125,105 +125,3 @@ function setDefaultsCF(config: CfAdpWriterConfig): CfAdpWriterConfig { return configWithDefaults; } - -/** - * Write CF-specific templates and configuration files. - * - * @param {string} basePath - The base path. - * @param {CfAdpWriterConfig} config - The CF configuration. - * @param {Editor} fs - The memfs editor instance. - */ -async function writeCfTemplates(basePath: string, config: CfAdpWriterConfig, fs: Editor): Promise { - const { app, baseApp, cf, project, ui5, options } = config; - - const variant: DescriptorVariant = { - layer: app.layer, - reference: app.id, - id: app.namespace, - namespace: 'apps/' + app.id + '/appVariants/' + app.namespace + '/', - content: [ - { - changeType: 'appdescr_ui5_setMinUI5Version', - content: { - minUI5Version: ui5.version - } - }, - { - changeType: 'appdescr_app_setTitle', - content: {}, - texts: { - i18n: 'i18n/i18n.properties' - } - } - ] - }; - - fillDescriptorContent(variant.content as Content[], app.appType, ui5.version, app.i18nModels); - - fs.copyTpl( - join(baseTmplPath, 'project/webapp/manifest.appdescr_variant'), - join(project.folder, 'webapp', 'manifest.appdescr_variant'), - { app: variant } - ); - - fs.copyTpl(join(baseTmplPath, 'cf/package.json'), join(project.folder, 'package.json'), { - module: project.name - }); - - fs.copyTpl(join(baseTmplPath, 'cf/ui5.yaml'), join(project.folder, 'ui5.yaml'), { - appHostId: baseApp.appHostId, - appName: baseApp.appName, - appVersion: baseApp.appVersion, - module: project.name, - html5RepoRuntime: cf.html5RepoRuntimeGuid, - org: cf.org.GUID, - space: cf.space.GUID, - sapCloudService: cf.businessSolutionName ?? '' - }); - - const configJson = { - componentname: app.namespace, - appvariant: project.name, - layer: app.layer, - isOVPApp: app.appType === ApplicationType.FIORI_ELEMENTS_OVP, - isFioriElement: app.appType === ApplicationType.FIORI_ELEMENTS, - environment: 'CF', - ui5Version: ui5.version, - cfApiUrl: cf.url, - cfSpace: cf.space.GUID, - cfOrganization: cf.org.GUID - }; - - fs.writeJSON(join(project.folder, '.adp/config.json'), configJson); - - fs.copyTpl(join(baseTmplPath, 'cf/i18n/i18n.properties'), join(project.folder, 'webapp/i18n/i18n.properties'), { - module: project.name, - moduleTitle: app.title, - appVariantId: app.namespace, - i18nGuid: config.app.i18nDescription - }); - - fs.copy(join(baseTmplPath, 'cf/_gitignore'), join(project.folder, '.gitignore')); - - if (options?.addStandaloneApprouter) { - fs.copyTpl( - join(baseTmplPath, 'cf/approuter/package.json'), - join(basePath, `${project.name}-approuter/package.json`), - { - projectName: project.name - } - ); - - fs.copyTpl( - join(baseTmplPath, 'cf/approuter/xs-app.json'), - join(basePath, `${project.name}-approuter/xs-app.json`), - {} - ); - } - - if (!fs.exists(join(basePath, 'xs-security.json'))) { - fs.copyTpl(join(baseTmplPath, 'cf/xs-security.json'), join(basePath, 'xs-security.json'), { - projectName: project.name - }); - } -} diff --git a/packages/adp-tooling/src/writer/project-utils.ts b/packages/adp-tooling/src/writer/project-utils.ts index 7646624b0fe..48fc05aaaba 100644 --- a/packages/adp-tooling/src/writer/project-utils.ts +++ b/packages/adp-tooling/src/writer/project-utils.ts @@ -2,7 +2,15 @@ import { join } from 'path'; import { readFileSync } from 'fs'; import { v4 as uuidv4 } from 'uuid'; import type { Editor } from 'mem-fs-editor'; -import type { CloudApp, AdpWriterConfig, CustomConfig, TypesConfig } from '../types'; +import { + type CloudApp, + type AdpWriterConfig, + type CustomConfig, + type TypesConfig, + type CfAdpWriterConfig, + type DescriptorVariant, + ApplicationType +} from '../types'; import { enhanceUI5DeployYaml, enhanceUI5Yaml, @@ -84,6 +92,63 @@ export function getCustomConfig(environment: OperationsType, { name: id, version }; } +/** + * Get the variant for the CF project. + * + * @param {CfAdpWriterConfig} config - The CF configuration. + * @returns {DescriptorVariant} The variant for the CF project. + */ +export function getCfVariant(config: CfAdpWriterConfig): DescriptorVariant { + const { app, ui5 } = config; + const variant: DescriptorVariant = { + layer: app.layer, + reference: app.id, + id: app.namespace, + namespace: 'apps/' + app.id + '/appVariants/' + app.namespace + '/', + content: [ + { + changeType: 'appdescr_ui5_setMinUI5Version', + content: { + minUI5Version: ui5.version + } + }, + { + changeType: 'appdescr_app_setTitle', + content: {}, + texts: { + i18n: 'i18n/i18n.properties' + } + } + ] + }; + + return variant; +} + +/** + * Get the ADP config for the CF project. + * + * @param {CfAdpWriterConfig} config - The CF configuration. + * @returns {Record} The ADP config for the CF project. + */ +export function getCfAdpConfig(config: CfAdpWriterConfig): Record { + const { app, project, ui5, cf } = config; + const configJson = { + componentname: app.namespace, + appvariant: project.name, + layer: app.layer, + isOVPApp: app.appType === ApplicationType.FIORI_ELEMENTS_OVP, + isFioriElement: app.appType === ApplicationType.FIORI_ELEMENTS, + environment: 'CF', + ui5Version: ui5.version, + cfApiUrl: cf.url, + cfSpace: cf.space.GUID, + cfOrganization: cf.org.GUID + }; + + return configJson; +} + /** * Writes a given project template files within a specified folder in the project directory. * @@ -169,3 +234,75 @@ export async function writeUI5DeployYaml(projectPath: string, data: AdpWriterCon throw new Error(`Could not write ui5-deploy.yaml file. Reason: ${e.message}`); } } + +/** + * Write CF-specific templates and configuration files. + * + * @param {string} basePath - The base path. + * @param {DescriptorVariant} variant - The descriptor variant. + * @param {CfAdpWriterConfig} config - The CF configuration. + * @param {Editor} fs - The memfs editor instance. + */ +export async function writeCfTemplates( + basePath: string, + variant: DescriptorVariant, + config: CfAdpWriterConfig, + fs: Editor +): Promise { + const baseTmplPath = join(__dirname, '../../templates'); + const { app, baseApp, cf, project, options } = config; + + fs.copyTpl( + join(baseTmplPath, 'project/webapp/manifest.appdescr_variant'), + join(project.folder, 'webapp', 'manifest.appdescr_variant'), + { app: variant } + ); + + fs.copyTpl(join(baseTmplPath, 'cf/package.json'), join(project.folder, 'package.json'), { + module: project.name + }); + + fs.copyTpl(join(baseTmplPath, 'cf/ui5.yaml'), join(project.folder, 'ui5.yaml'), { + appHostId: baseApp.appHostId, + appName: baseApp.appName, + appVersion: baseApp.appVersion, + module: project.name, + html5RepoRuntime: cf.html5RepoRuntimeGuid, + org: cf.org.GUID, + space: cf.space.GUID, + sapCloudService: cf.businessSolutionName ?? '' + }); + + fs.writeJSON(join(project.folder, '.adp/config.json'), getCfAdpConfig(config)); + + fs.copyTpl(join(baseTmplPath, 'cf/i18n/i18n.properties'), join(project.folder, 'webapp/i18n/i18n.properties'), { + module: project.name, + moduleTitle: app.title, + appVariantId: app.namespace, + i18nGuid: config.app.i18nDescription + }); + + fs.copy(join(baseTmplPath, 'cf/_gitignore'), join(project.folder, '.gitignore')); + + if (options?.addStandaloneApprouter) { + fs.copyTpl( + join(baseTmplPath, 'cf/approuter/package.json'), + join(basePath, `${project.name}-approuter/package.json`), + { + projectName: project.name + } + ); + + fs.copyTpl( + join(baseTmplPath, 'cf/approuter/xs-app.json'), + join(basePath, `${project.name}-approuter/xs-app.json`), + {} + ); + } + + if (!fs.exists(join(basePath, 'xs-security.json'))) { + fs.copyTpl(join(baseTmplPath, 'cf/xs-security.json'), join(basePath, 'xs-security.json'), { + projectName: project.name + }); + } +} diff --git a/packages/adp-tooling/templates/cf/manifest.appdescr_variant b/packages/adp-tooling/templates/cf/manifest.appdescr_variant deleted file mode 100644 index f87bf0fa194..00000000000 --- a/packages/adp-tooling/templates/cf/manifest.appdescr_variant +++ /dev/null @@ -1,17 +0,0 @@ -{ - "fileName": "manifest", - "layer": "<%= layer %>", - "fileType": "appdescr_variant", - "reference": "<%= appId %>", - "id": "<%= appVariantId %>", - "namespace": "apps/<%= appId %>/appVariants/<%= appVariantId %>/", - "content": [ - { - "changeType": "appdescr_app_setTitle", - "content": {}, - "texts": { - "i18n": "i18n/i18n.properties" - } - } - ] -} \ No newline at end of file From 98bd2fd991d6a0ad7861d9019ae6d98cb700343b Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 4 Sep 2025 13:39:25 +0300 Subject: [PATCH 060/111] refactor: improve writing logic --- packages/adp-tooling/src/writer/cf.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 22c9c089957..f178e1e4f24 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -92,7 +92,7 @@ export async function generateCf(basePath: string, config: CfAdpWriterConfig, fs writeI18nModels(basePath, fullConfig.app.i18nModels, fs); } - const variant = getCfVariant(config); + const variant = getCfVariant(fullConfig); fillDescriptorContent(variant.content as Content[], app.appType, ui5.version, app.i18nModels); await writeCfTemplates(basePath, variant, fullConfig, fs); From 3d6b8c68b3220745d433f426e9c5b95276cddd7b Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 4 Sep 2025 13:44:52 +0300 Subject: [PATCH 061/111] refactor: improve writing logic --- packages/adp-tooling/src/writer/cf.ts | 61 +----------------- .../adp-tooling/src/writer/writer-config.ts | 62 ++++++++++++++++++- packages/generator-adp/src/app/index.ts | 4 +- 3 files changed, 65 insertions(+), 62 deletions(-) diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index f178e1e4f24..ae25582effc 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -1,67 +1,12 @@ -import { join } from 'path'; import { create as createStorage } from 'mem-fs'; import { create, type Editor } from 'mem-fs-editor'; -import { getApplicationType } from '../source'; -import { getI18nDescription, getI18nModels, writeI18nModels } from './i18n'; -import { - type CfAdpWriterConfig, - type FlexLayer, - type CreateCfConfigParams, - AppRouterType, - type Content -} from '../types'; import { adjustMtaYaml } from '../cf'; +import { getApplicationType } from '../source'; import { fillDescriptorContent } from './manifest'; -import { getLatestVersion } from '../ui5/version-info'; import { getCfVariant, writeCfTemplates } from './project-utils'; - -/** - * Create CF configuration from batch objects. - * - * @param {CreateCfConfigParams} params - The configuration parameters containing batch objects. - * @returns {CfAdpWriterConfig} The CF configuration. - */ -export function createCfConfig(params: CreateCfConfigParams): CfAdpWriterConfig { - const baseApp = params.cfServicesAnswers.baseApp; - - if (!baseApp) { - throw new Error('Base app is required for CF project generation'); - } - - const ui5Version = getLatestVersion(params.publicVersions); - - return { - app: { - id: baseApp.appId, - title: params.attributeAnswers.title, - layer: params.layer, - namespace: params.attributeAnswers.namespace, - manifest: params.manifest - }, - baseApp, - cf: { - url: params.cfConfig.url, - org: params.cfConfig.org, - space: params.cfConfig.space, - html5RepoRuntimeGuid: params.html5RepoRuntimeGuid, - approuter: params.cfServicesAnswers.approuter ?? AppRouterType.MANAGED, - businessService: params.cfServicesAnswers.businessService ?? '', - businessSolutionName: params.cfServicesAnswers.businessSolutionName - }, - project: { - name: params.attributeAnswers.projectName, - path: params.projectPath, - folder: join(params.projectPath, params.attributeAnswers.projectName) - }, - ui5: { - version: ui5Version - }, - options: { - addStandaloneApprouter: params.cfServicesAnswers.approuter === AppRouterType.STANDALONE - } - }; -} +import { getI18nDescription, getI18nModels, writeI18nModels } from './i18n'; +import { type CfAdpWriterConfig, type FlexLayer, type Content } from '../types'; /** * Writes the CF adp-project template to the mem-fs-editor instance. diff --git a/packages/adp-tooling/src/writer/writer-config.ts b/packages/adp-tooling/src/writer/writer-config.ts index 8e409b2f4ee..ff3995f8196 100644 --- a/packages/adp-tooling/src/writer/writer-config.ts +++ b/packages/adp-tooling/src/writer/writer-config.ts @@ -1,7 +1,19 @@ +import { join } from 'path'; + import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest, Package } from '@sap-ux/project-access'; import type { AbapServiceProvider } from '@sap-ux/axios-extension'; +import type { + AdpWriterConfig, + AttributesAnswers, + CfAdpWriterConfig, + CloudApp, + ConfigAnswers, + CreateCfConfigParams, + OnpremApp, + UI5Version +} from '../types'; import { getFormattedVersion, getLatestVersion, @@ -10,10 +22,9 @@ import { getVersionToBeUsed, shouldSetMinUI5Version } from '../ui5'; -import { FlexLayer } from '../types'; import { getProviderConfig } from '../abap'; import { getCustomConfig } from './project-utils'; -import type { AdpWriterConfig, AttributesAnswers, CloudApp, ConfigAnswers, OnpremApp, UI5Version } from '../types'; +import { AppRouterType, FlexLayer } from '../types'; export interface ConfigOptions { /** @@ -154,3 +165,50 @@ export function getUi5Config( shouldSetMinVersion: shouldSetMinUI5Version(systemVersion) }; } + +/** + * Create CF configuration from batch objects. + * + * @param {CreateCfConfigParams} params - The configuration parameters containing batch objects. + * @returns {CfAdpWriterConfig} The CF configuration. + */ +export function getCfConfig(params: CreateCfConfigParams): CfAdpWriterConfig { + const baseApp = params.cfServicesAnswers.baseApp; + + if (!baseApp) { + throw new Error('Base app is required for CF project generation'); + } + + const ui5Version = getLatestVersion(params.publicVersions); + + return { + app: { + id: baseApp.appId, + title: params.attributeAnswers.title, + layer: params.layer, + namespace: params.attributeAnswers.namespace, + manifest: params.manifest + }, + baseApp, + cf: { + url: params.cfConfig.url, + org: params.cfConfig.org, + space: params.cfConfig.space, + html5RepoRuntimeGuid: params.html5RepoRuntimeGuid, + approuter: params.cfServicesAnswers.approuter ?? AppRouterType.MANAGED, + businessService: params.cfServicesAnswers.businessService ?? '', + businessSolutionName: params.cfServicesAnswers.businessSolutionName + }, + project: { + name: params.attributeAnswers.projectName, + path: params.projectPath, + folder: join(params.projectPath, params.attributeAnswers.projectName) + }, + ui5: { + version: ui5Version + }, + options: { + addStandaloneApprouter: params.cfServicesAnswers.approuter === AppRouterType.STANDALONE + } + }; +} diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 7d90eb471b3..f0cb5412d16 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -19,7 +19,7 @@ import { getBaseAppInbounds, isMtaProject, generateCf, - createCfConfig, + getCfConfig, isCfInstalled, isLoggedInCf, loadCfConfig @@ -561,7 +561,7 @@ export default class extends Generator { } const html5RepoRuntimeGuid = this.cfPrompter.serviceInstanceGuid; - const cfConfig = createCfConfig({ + const cfConfig = getCfConfig({ attributeAnswers: this.attributeAnswers, cfServicesAnswers: this.cfServicesAnswers, cfConfig: this.cfConfig, From f46ee8d8e96a108f05b354b0fb007b222721287d Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 16 Sep 2025 13:17:04 +0300 Subject: [PATCH 062/111] refactor: change tooltip texts --- .../src/translations/adp-tooling.i18n.json | 54 +++++++++---------- .../src/translations/generator-adp.i18n.json | 12 ++--- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index c636fc43844..399e8089380 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -75,36 +75,36 @@ "ui5VersionNotDetectedError": "The SAPUI5 version of the selected system cannot be determined. You will be able to create and edit adaptation projects using the newest version but it will not be usable on this system until the system`s SAPUI5 version is upgraded to version 1.71 or higher." }, "error": { - "appDoesNotSupportFlexibility": "The selected application does not support flexibility because it has (`flexEnabled=false`). SAPUI5 Adaptation Project only supports applications that support flexibility. Please select a different application.", - "failedToParseXsAppJson": "Failed to parse xs-app.json. Reason: {{error}}", - "failedToParseManifestJson": "Failed to parse manifest.json. Reason: {{error}}", - "oDataEndpointsValidationFailed": "OData endpoints validation failed. Please check the logs for more details.", + "appDoesNotSupportFlexibility": "The selected application does not support flexibility because it has `flexEnabled=false`. SAPUI5 Adaptation Project only supports applications that support flexibility. Please select a different application.", + "failedToParseXsAppJson": "Failed to parse `xs-app.json`. Error: {{error}}", + "failedToParseManifestJson": "Failed to parse the `manifest.json` file. Error: {{error}}", + "oDataEndpointsValidationFailed": "Validation for the OData endpoints has failed. For more information, check the logs.", "adpDoesNotSupportSelectedApp": "Adaptation project doesn't support the selected application. Please select a different application.", "cfNotInstalled": "Cloud Foundry is not installed in your space: {{error}}", - "cfGetInstanceCredentialsFailed": "Failed to get service instance credentials from CFLocal for GUID {{serviceInstanceGuid}}. Reason: {{error}}", - "createServiceKeyFailed": "Failed to create service key for instance name {{serviceInstanceName}}. Reason: {{error}}", - "failedToGetServiceInstanceKeys": "Failed to get service instance keys. Reason: {{error}}", - "noValidJsonForServiceInstance": "JSON is not valid for service instance.", - "failedToGetServiceInstance": "Failed to get service instance with params {{uriParameters}}. Reason: {{error}}", - "failedToGetOrCreateServiceKeys": "Failed to get or create service keys for instance name {{serviceInstanceName}}. Reason: {{error}}", - "mtaProjectPathMissing": "MTA project path is missing. Please provide the path to a valid MTA project.", + "cfGetInstanceCredentialsFailed": "Failed to retrieve the service instance credentials from `CFLocal for GUID`: {{serviceInstanceGuid}}. Error: {{error}}", + "createServiceKeyFailed": "Failed to create the service key for the instance: {{serviceInstanceName}}. Error: {{error}}", + "failedToGetServiceInstanceKeys": "Failed to retrieve the service instance keys. Error: {{error}}", + "noValidJsonForServiceInstance": "The JSON is not valid for the service instance. Ensure it is valid and try again.", + "failedToGetServiceInstance": "Failed to retrieve the service instance with params: {{uriParameters}}. Error: {{error}}", + "failedToGetOrCreateServiceKeys": "Failed to retrieve or create service keys for the instance: {{serviceInstanceName}}. Error: {{error}}", + "mtaProjectPathMissing": "The MTA project path is missing. Provide the path to a valid MTA project.", "noBusinessServicesFound": "No business services found, please specify the business services in resource section of mta.yaml: - name: type: org.cloudfoundry.-service parameters: service: service-name: service-plan: ", - "failedToParseZipContent": "Failed to parse zip content from HTML5 repository. Reason: {{error}}", - "noZipContentParsed": "No zip content was parsed from HTML5 repository.", - "failedToFindManifestJsonInHtml5Repo": "Failed to find manifest.json in the application content from HTML5 repository.", - "noUaaCredentialsFoundForHtml5Repo": "No UAA credentials found for HTML5 repository.", - "failedToDownloadAppContent": "Failed to download the application content from HTML5 repository for space {{spaceGuid}} and app {{appName}} ({{appHostId}}). Reason: {{error}}", - "tooManyAppHostIds": "Too many appHostIds provided. Maximum allowed is 100, but {{appHostIdsLength}} were found.", - "failedToGetAuthKey": "Failed to get the OAuth token from HTML5 repository. Reason: {{error}}", - "failedToDownloadZipFromHtml5Repo": "Failed to download zip from HTML5 repository. Reason: {{error}}", - "cannotFindHtml5RepoRuntime": "Cannot find HTML5 Repo runtime in current space", - "failedToGetCredentialsFromHtml5Repo": "Failed to get credentials from HTML5 repository for space {{spaceGuid}}. Reason: {{error}}", - "failedToParseCFAPIResponse": "Failed to parse CF API response: {{error}}", - "failedToRequestCFAPI": "Request to CF API failed. Reason: {{error}}", - "xsSecurityJsonCouldNotBeParsed": "xs-security.json could not be parsed.", - "failedToCreateServiceInstance": "Failed to create service instance '{{serviceInstanceName}}'. Reason: {{error}}", - "failedToGetFDCApps": "Getting FDC apps failed: {{error}}", - "failedToConnectToFDCService": "Failed to connect to FDC service: '{{status}}'" + "failedToParseZipContent": "Failed to parse the zip content from the HTML5 repository. Error: {{error}}", + "noZipContentParsed": "No zip content was parsed from the HTML5 repository. For more information, check the logs.", + "failedToFindManifestJsonInHtml5Repo": "Failed to find the `manifest.json` file in the application content from HTML5 repository. Ensure the `manifest.json` file exists and try again.", + "noUaaCredentialsFoundForHtml5Repo": "No UAA credentials found for the HTML5 repository. Ensure the UAA credentials exist and try again.", + "failedToDownloadAppContent": "Failed to download the application content from the HTML5 repository for the space: {{spaceGuid}} and app: {{appName}} ({{appHostId}}). Error: {{error}}", + "tooManyAppHostIds": "Too many `appHostIds` provided. The maximum is 100 but {{appHostIdsLength}} were found. Reduce the `appHostIds` and try again.", + "failedToGetAuthKey": "Failed to retrieve the OAuth token from the HTML5 repository. Error: {{error}}", + "failedToDownloadZipFromHtml5Repo": "Failed to download the zip file from the HTML5 repository. Error: {{error}}", + "cannotFindHtml5RepoRuntime": "Cannot find the HTML5 repo runtime in the current space. Ensure the HTML5 repo runtime exists and try again.", + "failedToGetCredentialsFromHtml5Repo": "Failed to retrieve the credentials from the HTML5 repository for space: {{spaceGuid}}. Error: {{error}}", + "failedToParseCFAPIResponse": "Failed to parse the CF API response: {{error}}", + "failedToRequestCFAPI": "Request to the CF API failed. Error: {{error}}", + "xsSecurityJsonCouldNotBeParsed": "The `xs-security.json` file could not be parsed. Ensure the `xs-security.json` file exists and try again.", + "failedToCreateServiceInstance": "Failed to create the service instance: '{{serviceInstanceName}}'. Error: {{error}}", + "failedToGetFDCApps": "Retrieving FDC apps failed: {{error}}", + "failedToConnectToFDCService": "Failed to connect to the FDC service: '{{status}}'" }, "choices": { "true": "true", diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index a6a21a6802a..ce8649185e0 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -51,17 +51,17 @@ "targetEnvTooltip": "Select the target environment for your Adaptation Project.", "targetEnvBreadcrumb": "Target Environment", "projectLocationLabel": "Specify the path to the project root", - "projectLocationTooltip": "Select the path to the root of your project", + "projectLocationTooltip": "Select the path to the root of your project.", "projectLocationBreadcrumb": "Project Path", "businessSolutionNameLabel": "Enter a unique name for the business solution of the project", "businessSolutionBreadcrumb": "Business Solution", - "businessSolutionNameTooltip": "Business solution name must consist of at least two segments and they should be separated by period.", + "businessSolutionNameTooltip": "The business solution name must consist of at least two segments and they must be separated by a period.", "approuterLabel": "HTML5 Application Runtime", - "approuterTooltip": "Select the HTML5 application runtime that you want to use", + "approuterTooltip": "Select the HTML5 application runtime that you want to use.", "baseAppLabel": "Base Application", - "baseAppTooltip": "Select the base application you want to use", + "baseAppTooltip": "Select the base application you want to use.", "businessServiceLabel": "Business Service", - "businessServiceTooltip": "Select the business service you want to use", + "businessServiceTooltip": "Select the business service you want to use.", "appInfoLabel": "Synchronous views are detected for this application. Therefore, the controller extensions are not supported. Controller extension functionality on these views will be disabled.", "notSupportedAdpOverAdpLabel": "You have selected 'Adaptation Project' as the base. The selected system has an SAPUI5 version lower than 1.90. Therefore, it does not support 'Adaptation Project' as а base for a new adaptation project. You are able to create such а project, but after deployment, it will not work until the SAPUI5 version of the system has been updated.", "isPartiallySupportedAdpOverAdpLabel": "You have selected 'Adaptation Project' as the base. The selected system has an SAPUI5 version lower than 1.96 and for your adaptation project based on an adaptation project to work after deployment, you need to apply SAP Note 756 SP0 on your system.", @@ -83,7 +83,7 @@ "notDeployableSystemError": "The system that you have selected is not an ABAP On-Premise system which supports `DTA_FOLDER` deployment. Adaptation projects are only supported on those systems. Please choose an ABAP On-Premise system which supports `DTA_FOLDER` deployment.", "notFlexEnabledError": "The system that you have selected is not an ABAP On-Premise system which supports flexibility. Adaptation projects are only supported on those systems. Please choose an ABAP On-Premise system which supports flexibility. If you continue, you will only be able to create an extension project.", "manifestCouldNotBeValidated": "The `manifest.json` file of the selected application cannot be validated. Please select a different application.", - "appDoesNotSupportFlexibility": "The selected application does not support flexibility because it has (`flexEnabled=false`). SAPUI5 Adaptation Project only supports applications that support flexibility. Please select a different application.", + "appDoesNotSupportFlexibility": "The selected application does not support flexibility because it has `flexEnabled=false`. SAPUI5 Adaptation Project only supports applications that support flexibility. Please select a different application.", "appDoesNotSupportManifest": "The selected application is not supported by SAPUI5 Adaptation Project because it does not have a `manifest.json` file. Please select a different application.", "extensibilityExtensionNotFound": "The Extensibility Project generator plugin was not found in your dev space and it is required for this action. To proceed, please install the extension.", "creatingExtensionProjectError": "Creating the extension project failed. To see the error, view the logs.", From 720db4c102a93b84f6e6f00dce8126765c8d6078 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 16 Sep 2025 15:42:10 +0300 Subject: [PATCH 063/111] refactor: change tooltip texts --- packages/adp-tooling/src/translations/adp-tooling.i18n.json | 4 ++-- .../generator-adp/src/translations/generator-adp.i18n.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index 399e8089380..a5139aa303a 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -88,7 +88,7 @@ "failedToGetServiceInstance": "Failed to retrieve the service instance with params: {{uriParameters}}. Error: {{error}}", "failedToGetOrCreateServiceKeys": "Failed to retrieve or create service keys for the instance: {{serviceInstanceName}}. Error: {{error}}", "mtaProjectPathMissing": "The MTA project path is missing. Provide the path to a valid MTA project.", - "noBusinessServicesFound": "No business services found, please specify the business services in resource section of mta.yaml: - name: type: org.cloudfoundry.-service parameters: service: service-name: service-plan: ", + "noBusinessServicesFound": "No business services found. Specify the business services in the resource section of the mta.yaml file: - name: type: org.cloudfoundry.-service parameters: service: service-name: service-plan: ", "failedToParseZipContent": "Failed to parse the zip content from the HTML5 repository. Error: {{error}}", "noZipContentParsed": "No zip content was parsed from the HTML5 repository. For more information, check the logs.", "failedToFindManifestJsonInHtml5Repo": "Failed to find the `manifest.json` file in the application content from HTML5 repository. Ensure the `manifest.json` file exists and try again.", @@ -101,7 +101,7 @@ "failedToGetCredentialsFromHtml5Repo": "Failed to retrieve the credentials from the HTML5 repository for space: {{spaceGuid}}. Error: {{error}}", "failedToParseCFAPIResponse": "Failed to parse the CF API response: {{error}}", "failedToRequestCFAPI": "Request to the CF API failed. Error: {{error}}", - "xsSecurityJsonCouldNotBeParsed": "The `xs-security.json` file could not be parsed. Ensure the `xs-security.json` file exists and try again.", + "xsSecurityJsonCouldNotBeParsed": "The xs-security.json file could not be parsed. Ensure the xs-security.json file exists and try again.", "failedToCreateServiceInstance": "Failed to create the service instance: '{{serviceInstanceName}}'. Error: {{error}}", "failedToGetFDCApps": "Retrieving FDC apps failed: {{error}}", "failedToConnectToFDCService": "Failed to connect to the FDC service: '{{status}}'" diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index ce8649185e0..769fd98a1df 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -50,12 +50,12 @@ "targetEnvLabel": "Environment", "targetEnvTooltip": "Select the target environment for your Adaptation Project.", "targetEnvBreadcrumb": "Target Environment", - "projectLocationLabel": "Specify the path to the project root", + "projectLocationLabel": "Project Root Path", "projectLocationTooltip": "Select the path to the root of your project.", "projectLocationBreadcrumb": "Project Path", - "businessSolutionNameLabel": "Enter a unique name for the business solution of the project", + "businessSolutionNameLabel": "Business Solution Name", "businessSolutionBreadcrumb": "Business Solution", - "businessSolutionNameTooltip": "The business solution name must consist of at least two segments and they must be separated by a period.", + "businessSolutionNameTooltip": "The business solution name must be unique and consist of at least two segments and they must be separated by a period.", "approuterLabel": "HTML5 Application Runtime", "approuterTooltip": "Select the HTML5 application runtime that you want to use.", "baseAppLabel": "Base Application", From 095fdd6312cfc7dbb814b590b4b75ac5c33f7e0e Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 16 Sep 2025 16:13:18 +0300 Subject: [PATCH 064/111] test: fix tests --- .../test/adp-validators.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/project-input-validator/test/adp-validators.test.ts b/packages/project-input-validator/test/adp-validators.test.ts index c83971b4691..1e41a731dad 100644 --- a/packages/project-input-validator/test/adp-validators.test.ts +++ b/packages/project-input-validator/test/adp-validators.test.ts @@ -91,20 +91,26 @@ describe('project input validators', () => { }); it('returns error if value is empty', () => { - expect(validateProjectName('', path, true)).toBe(t('general.inputCannotBeEmpty')); + expect(validateProjectName('', path, true, false)).toBe(t('general.inputCannotBeEmpty')); }); it('returns error if name contains uppercase letters', () => { - expect(validateProjectName('ProjectName', path, true)).toBe(t('adp.projectNameUppercaseError')); + expect(validateProjectName('ProjectName', path, true, false)).toBe(t('adp.projectNameUppercaseError')); }); it('delegates to internal validation if not customer base', () => { - const result = validateProjectName('validname', path, false); + const result = validateProjectName('validname', path, false, false); expect(result).toBe(t('adp.projectNameValidationErrorInt')); }); + it('delegates to internal validation if not customer base and CF environment', () => { + existsSyncMock.mockReturnValue(true); + const result = validateProjectName('validname', path, false, true); + expect(result).toBe(t(t('adp.duplicatedProjectName'))); + }); + it('delegates to external validation if customer base', () => { - const result = validateProjectName('validname', path, true); + const result = validateProjectName('validname', path, true, false); expect(result).toBe(true); }); }); From 3811c0f2f2c9888a8e99f1d5c6c02648dc65037d Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 16 Sep 2025 16:15:54 +0300 Subject: [PATCH 065/111] test: fix tests --- .../project-input-validator/test/adp-validators.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/project-input-validator/test/adp-validators.test.ts b/packages/project-input-validator/test/adp-validators.test.ts index 1e41a731dad..836ddb1953f 100644 --- a/packages/project-input-validator/test/adp-validators.test.ts +++ b/packages/project-input-validator/test/adp-validators.test.ts @@ -103,7 +103,7 @@ describe('project input validators', () => { expect(result).toBe(t('adp.projectNameValidationErrorInt')); }); - it('delegates to internal validation if not customer base and CF environment', () => { + it('returns error if project name is duplicated and CF environment', () => { existsSyncMock.mockReturnValue(true); const result = validateProjectName('validname', path, false, true); expect(result).toBe(t(t('adp.duplicatedProjectName'))); @@ -113,6 +113,12 @@ describe('project input validators', () => { const result = validateProjectName('validname', path, true, false); expect(result).toBe(true); }); + + it('returns true if project name is not duplicated and CF environment', () => { + existsSyncMock.mockReturnValue(false); + const result = validateProjectName('validname', path, true, true); + expect(result).toBe(true); + }); }); describe('validateProjectNameExternal', () => { From 8ef0c9ca3318d74a01656e6f4fb1d8f540031523 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 16 Sep 2025 17:06:16 +0300 Subject: [PATCH 066/111] test: fix tests --- packages/generator-adp/test/unit/questions/attribute.test.ts | 4 ++-- packages/generator-adp/test/unit/utils/steps.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/generator-adp/test/unit/questions/attribute.test.ts b/packages/generator-adp/test/unit/questions/attribute.test.ts index 2a22e4f9512..15694a926c4 100644 --- a/packages/generator-adp/test/unit/questions/attribute.test.ts +++ b/packages/generator-adp/test/unit/questions/attribute.test.ts @@ -46,7 +46,7 @@ const mockConfig = { layer: FlexLayer.CUSTOMER_BASE, ui5Versions: ['1.118.0', '1.119.0'], isVersionDetected: true, - prompts: new YeomanUiSteps(getWizardPages()) + prompts: new YeomanUiSteps(getWizardPages(false)) }; const getDefaultVersionMock = getDefaultVersion as jest.Mock; @@ -82,7 +82,7 @@ describe('Attribute Prompts', () => { const validateFn = (prompt as any).validate; validateFn('project1', { targetFolder: '' }); - expect(validateProjectNameMock).toHaveBeenCalledWith('project1', mockPath, true); + expect(validateProjectNameMock).toHaveBeenCalledWith('project1', mockPath, true, false); }); }); diff --git a/packages/generator-adp/test/unit/utils/steps.test.ts b/packages/generator-adp/test/unit/utils/steps.test.ts index 561f6a8ba2d..17599846585 100644 --- a/packages/generator-adp/test/unit/utils/steps.test.ts +++ b/packages/generator-adp/test/unit/utils/steps.test.ts @@ -21,7 +21,7 @@ describe('Wizard Steps Utility', () => { }); beforeEach(() => { - prompts = new Prompts(getWizardPages()); + prompts = new Prompts(getWizardPages(false)); }); it('should add a new step when it does not exist', () => { @@ -94,7 +94,7 @@ describe('updateFlpWizardSteps', () => { }); beforeEach(() => { - prompts = new Prompts(getWizardPages()); + prompts = new Prompts(getWizardPages(false)); }); describe('when hasBaseAppInbound is true (2 pages)', () => { From 0e8216a1e8fe83e24d674714ba3bcf76fec120e3 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 17 Sep 2025 08:44:46 +0300 Subject: [PATCH 067/111] refactor: change tooltip texts --- packages/adp-tooling/src/translations/adp-tooling.i18n.json | 2 +- .../generator-adp/src/translations/generator-adp.i18n.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index a5139aa303a..a6f45cf3b55 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -88,7 +88,7 @@ "failedToGetServiceInstance": "Failed to retrieve the service instance with params: {{uriParameters}}. Error: {{error}}", "failedToGetOrCreateServiceKeys": "Failed to retrieve or create service keys for the instance: {{serviceInstanceName}}. Error: {{error}}", "mtaProjectPathMissing": "The MTA project path is missing. Provide the path to a valid MTA project.", - "noBusinessServicesFound": "No business services found. Specify the business services in the resource section of the mta.yaml file: - name: type: org.cloudfoundry.-service parameters: service: service-name: service-plan: ", + "noBusinessServicesFound": "No business services found. Specify the business services in the resource section of the mta.yaml file: - name: type: org.cloudfoundry.-service parameters: service: service-name: service-plan: ", "failedToParseZipContent": "Failed to parse the zip content from the HTML5 repository. Error: {{error}}", "noZipContentParsed": "No zip content was parsed from the HTML5 repository. For more information, check the logs.", "failedToFindManifestJsonInHtml5Repo": "Failed to find the `manifest.json` file in the application content from HTML5 repository. Ensure the `manifest.json` file exists and try again.", diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index b601470f4be..e5beefdf424 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -99,8 +99,8 @@ "businessServiceHasToBeSelected": "Business service has to be selected. Please select a business service.", "businessServiceDoesNotExist": "The service chosen does not exist in cockpit or the user is not member of the needed space.", "businessSolutionNameInvalid": "Business solution name must consist of at least two segments and they should be separated by period.", - "cfLoginCannotBeDetected": "CF Login cannot be detected as extension in current installation of VSCode, please refer to documentation (link not yet available) in order to install it.", - "projectDoesNotExist": "The project does not exist. Please select a suitable MTA project.", + "cfLoginCannotBeDetected": "CF Login cannot be detected as an extension in Visual Studio Code. For more information, see (link not yet available).", + "projectDoesNotExist": "The project does not exist. Please select an MTA project.", "projectDoesNotExistMta": "Provide the path to the MTA project where you want to have your Adaptation Project created.", "noAdaptableBusinessServiceFoundInMta": "No adaptable business service found in the MTA.", "fetchBaseInboundsFailed": "Fetching base application inbounds failed: {{error}}." From 725a8199e865f5602b9dfe3648ef03c6a31aa3a8 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 19 Sep 2025 11:37:14 +0300 Subject: [PATCH 068/111] test: add tests --- packages/adp-tooling/src/cf/app/html5-repo.ts | 9 +- .../test/unit/cf/app/discovery.test.ts | 300 +++++++++++++ .../test/unit/cf/app/html5-repo.test.ts | 396 ++++++++++++++++++ 3 files changed, 700 insertions(+), 5 deletions(-) create mode 100644 packages/adp-tooling/test/unit/cf/app/discovery.test.ts create mode 100644 packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts diff --git a/packages/adp-tooling/src/cf/app/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts index fa005a8e948..5f9d18dbae3 100644 --- a/packages/adp-tooling/src/cf/app/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -78,6 +78,7 @@ export async function getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLo await createService(spaceGuid, 'app-runtime', HTML5_APPS_REPO_RUNTIME, logger, ['html5-apps-repo-rt']); serviceKeys = await getServiceInstanceKeys({ names: [HTML5_APPS_REPO_RUNTIME] }, logger); if (!serviceKeys?.credentials?.length) { + logger.debug(t('error.noUaaCredentialsFoundForHtml5Repo')); throw new Error(t('error.cannotFindHtml5RepoRuntime')); } } @@ -104,12 +105,9 @@ export async function downloadAppContent( const appNameVersion = `${appName}-${appVersion}`; try { const htmlRepoCredentials = await getHtml5RepoCredentials(spaceGuid, logger); - if (htmlRepoCredentials?.credentials?.length === 0) { - throw new Error(t('error.noUaaCredentialsFoundForHtml5Repo')); - } - const token = await getToken(htmlRepoCredentials.credentials[0].uaa); - const uri = `${htmlRepoCredentials.credentials[0].uri}/applications/content/${appNameVersion}?pathSuffixFilter=manifest.json,xs-app.json`; + const token = await getToken(htmlRepoCredentials?.credentials[0]?.uaa); + const uri = `${htmlRepoCredentials?.credentials[0]?.uri}/applications/content/${appNameVersion}?pathSuffixFilter=manifest.json,xs-app.json`; const zip = await downloadZip(token, appHostId, uri); let admZip; @@ -137,6 +135,7 @@ export async function downloadAppContent( throw new Error(t('error.failedToParseManifestJson', { error: e.message })); } } catch (e) { + logger.error(e); throw new Error(t('error.failedToDownloadAppContent', { spaceGuid, appName, appHostId, error: e.message })); } } diff --git a/packages/adp-tooling/test/unit/cf/app/discovery.test.ts b/packages/adp-tooling/test/unit/cf/app/discovery.test.ts new file mode 100644 index 00000000000..14b6a3ccd71 --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/app/discovery.test.ts @@ -0,0 +1,300 @@ +import type { ToolsLogger } from '@sap-ux/logger'; + +import { initI18n, t } from '../../../../src/i18n'; +import { getFDCApps } from '../../../../src/cf/services/api'; +import { formatDiscovery, getAppHostIds, getCfApps } from '../../../../src/cf/app/discovery'; +import type { CFApp, CfConfig, CfCredentials, Organization, Space, Uaa } from '../../../../src/types'; + +jest.mock('../../../../src/cf/services/api', () => ({ + ...jest.requireActual('../../../../src/cf/services/api'), + getFDCApps: jest.fn() +})); + +const mockGetFDCApps = getFDCApps as jest.MockedFunction; + +const mockApps: CFApp[] = [ + { + appId: 'app-1', + appName: 'App 1', + appVersion: '1.0.0', + serviceName: 'service-1', + title: 'Test App 1', + appHostId: 'host-123' + }, + { + appId: 'app-2', + appName: 'App 2', + appVersion: '2.0.0', + serviceName: 'service-2', + title: 'Test App 2', + appHostId: 'host-456' + } +]; + +describe('CF App Discovery', () => { + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('formatDiscovery', () => { + test('should format app discovery string correctly', () => { + const result = formatDiscovery(mockApps[0]); + + expect(result).toBe('Test App 1 (app-1 1.0.0)'); + }); + }); + + describe('getAppHostIds', () => { + test('should extract single app host id from credentials', () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as any, + uri: 'test-uri', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-123' + } + } + ]; + + const result = getAppHostIds(credentials); + + expect(result).toEqual(['host-123']); + }); + + test('should extract multiple app host ids from single credential', () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as any, + uri: 'test-uri', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-123, host-456, host-789' + } + } + ]; + + const result = getAppHostIds(credentials); + + expect(result).toEqual(['host-123', 'host-456', 'host-789']); + }); + + test('should extract app host ids from multiple credentials', () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as any, + uri: 'test-uri-1', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-123, host-456' + } + }, + { + uaa: {} as any, + uri: 'test-uri-2', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-789' + } + } + ]; + + const result = getAppHostIds(credentials); + + expect(result).toEqual(['host-123', 'host-456', 'host-789']); + }); + + test('should handle credentials with spaces around app host ids', () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as any, + uri: 'test-uri', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: ' host-123 , host-456 , host-789 ' + } + } + ]; + + const result = getAppHostIds(credentials); + + expect(result).toEqual(['host-123', 'host-456', 'host-789']); + }); + + test('should remove duplicate app host ids', () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as any, + uri: 'test-uri-1', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-123, host-456' + } + }, + { + uaa: {} as any, + uri: 'test-uri-2', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-123, host-789' + } + } + ]; + + const result = getAppHostIds(credentials); + + expect(result).toEqual(['host-123', 'host-456', 'host-789']); + }); + + test('should handle credentials without html5-apps-repo', () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as any, + uri: 'test-uri', + endpoints: {} + } + ]; + + const result = getAppHostIds(credentials); + + expect(result).toEqual([]); + }); + + test('should handle credentials with empty html5-apps-repo', () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as any, + uri: 'test-uri', + endpoints: {}, + 'html5-apps-repo': {} + } + ]; + + const result = getAppHostIds(credentials); + + expect(result).toEqual([]); + }); + + test('should handle empty credentials array', () => { + const credentials: CfCredentials[] = []; + + const result = getAppHostIds(credentials); + + expect(result).toEqual([]); + }); + + test('should handle mixed credentials with and without app host ids', () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as any, + uri: 'test-uri-1', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-123' + } + }, + { + uaa: {} as any, + uri: 'test-uri-2', + endpoints: {} + }, + { + uaa: {} as any, + uri: 'test-uri-3', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-456, host-789' + } + } + ]; + + const result = getAppHostIds(credentials); + + expect(result).toEqual(['host-123', 'host-456', 'host-789']); + }); + }); + + describe('getCfApps', () => { + const mockLogger = { + log: jest.fn() + } as unknown as ToolsLogger; + + const mockCfConfig: CfConfig = { + org: {} as Organization, + space: {} as Space, + token: 'test-token', + url: 'https://test.cf.com' + }; + + test('should successfully discover apps with valid credentials', async () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as any, + uri: 'test-uri', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-123, host-456' + } + } + ]; + + mockGetFDCApps.mockResolvedValue(mockApps); + + const result = await getCfApps(credentials, mockCfConfig, mockLogger); + + expect(result).toEqual(mockApps); + expect(mockGetFDCApps).toHaveBeenCalledWith(['host-123', 'host-456'], mockCfConfig, mockLogger); + expect(mockLogger.log).toHaveBeenCalledWith('App Host Ids: ["host-123","host-456"]'); + }); + + test('should throw error when app host ids exceed 100', async () => { + const credentials: CfCredentials[] = Array.from({ length: 101 }, (_, i) => ({ + uaa: {} as Uaa, + uri: `test-uri-${i}`, + endpoints: {}, + 'html5-apps-repo': { + app_host_id: `host-${i}` + } + })); + + await expect(getCfApps(credentials, mockCfConfig, mockLogger)).rejects.toThrow( + t('error.tooManyAppHostIds', { appHostIdsLength: 101 }) + ); + expect(mockGetFDCApps).not.toHaveBeenCalled(); + }); + + test('should handle empty credentials array', async () => { + const credentials: CfCredentials[] = []; + + mockGetFDCApps.mockResolvedValue([]); + + const result = await getCfApps(credentials, mockCfConfig, mockLogger); + + expect(result).toEqual([]); + expect(mockGetFDCApps).toHaveBeenCalledWith([], mockCfConfig, mockLogger); + expect(mockLogger.log).toHaveBeenCalledWith('App Host Ids: []'); + }); + + test('should propagate errors from getFDCApps', async () => { + const credentials: CfCredentials[] = [ + { + uaa: {} as Uaa, + uri: 'test-uri', + endpoints: {}, + 'html5-apps-repo': { + app_host_id: 'host-123' + } + } + ]; + + const error = new Error('API Error'); + mockGetFDCApps.mockRejectedValue(error); + + await expect(getCfApps(credentials, mockCfConfig, mockLogger)).rejects.toThrow('API Error'); + }); + }); +}); diff --git a/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts b/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts new file mode 100644 index 00000000000..4e18687736a --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts @@ -0,0 +1,396 @@ +import axios from 'axios'; +import AdmZip from 'adm-zip'; + +import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; + +import { initI18n, t } from '../../../../src/i18n'; +import type { CfAppParams, ServiceKeys, Uaa } from '../../../../src/types'; +import { createService, getServiceInstanceKeys } from '../../../../src/cf/services/api'; +import { downloadAppContent, downloadZip, getHtml5RepoCredentials, getToken } from '../../../../src/cf/app/html5-repo'; + +// Mock dependencies +jest.mock('axios'); +jest.mock('adm-zip'); +jest.mock('../../../../src/cf/services/api', () => ({ + ...jest.requireActual('../../../../src/cf/services/api'), + createService: jest.fn(), + getServiceInstanceKeys: jest.fn() +})); + +const mockAxios = axios as jest.Mocked; +const mockAdmZip = AdmZip as jest.MockedClass; +const mockCreateService = createService as jest.MockedFunction; +const mockGetServiceInstanceKeys = getServiceInstanceKeys as jest.MockedFunction; + +describe('HTML5 Repository', () => { + const mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn() + } as unknown as ToolsLogger; + + const mockUaa: Uaa = { + clientid: 'test-client-id', + clientsecret: 'test-client-secret', + url: '/test-uaa' + }; + + const mockServiceKeys: ServiceKeys = { + credentials: [ + { + uaa: mockUaa, + uri: '/test-html5-repo', + endpoints: {} + } + ], + serviceInstance: { + guid: 'test-service-guid', + name: 'test-service' + } + }; + + const mockManifest: Manifest = { + 'sap.app': { + id: 'test-app', + title: 'Test App' + } + } as Manifest; + + const mockZipEntry = { + entryName: 'manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockManifest))) + }; + + const mockZipEntries = [ + mockZipEntry, + { + entryName: 'xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from('{}')) + } + ]; + + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getToken', () => { + test('should successfully get OAuth token', async () => { + const mockResponse = { + data: { + // eslint-disable-next-line @typescript-eslint/naming-convention + access_token: 'test-access-token' + } + }; + mockAxios.get.mockResolvedValue(mockResponse); + + const result = await getToken(mockUaa); + + expect(result).toBe('test-access-token'); + expect(mockAxios.get).toHaveBeenCalledWith('/test-uaa/oauth/token?grant_type=client_credentials', { + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Basic ' + Buffer.from('test-client-id:test-client-secret').toString('base64') + } + }); + }); + + test('should throw error when token request fails', async () => { + const error = new Error('Network error'); + mockAxios.get.mockRejectedValue(error); + + await expect(getToken(mockUaa)).rejects.toThrow(t('error.failedToGetAuthKey', { error: 'Network error' })); + }); + + test('should handle missing access_token in response', async () => { + const mockResponse = { + data: {} + }; + mockAxios.get.mockResolvedValue(mockResponse); + + const result = await getToken(mockUaa); + + expect(result).toBeUndefined(); + }); + }); + + describe('downloadZip', () => { + test('should successfully download zip file', async () => { + const mockBuffer = Buffer.from('test-zip-content'); + const mockResponse = { + data: mockBuffer + }; + mockAxios.get.mockResolvedValue(mockResponse); + + const result = await downloadZip('test-token', 'test-app-host-id', '/test-uri'); + + expect(result).toBe(mockBuffer); + expect(mockAxios.get).toHaveBeenCalledWith('/test-uri', { + responseType: 'arraybuffer', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test-token', + 'x-app-host-id': 'test-app-host-id' + } + }); + }); + + test('should throw error when download fails', async () => { + const error = new Error('Download failed'); + mockAxios.get.mockRejectedValue(error); + + await expect(downloadZip('test-token', 'test-app-host-id', '/test-uri')).rejects.toThrow( + t('error.failedToDownloadZipFromHtml5Repo', { error: 'Download failed' }) + ); + }); + }); + + describe('getHtml5RepoCredentials', () => { + test('should return existing service keys', async () => { + mockGetServiceInstanceKeys.mockResolvedValue(mockServiceKeys); + + const result = await getHtml5RepoCredentials('test-space-guid', mockLogger); + + expect(result).toBe(mockServiceKeys); + expect(mockGetServiceInstanceKeys).toHaveBeenCalledWith( + { + spaceGuids: ['test-space-guid'], + planNames: ['app-runtime'], + names: ['html5-apps-repo-runtime'] + }, + mockLogger + ); + expect(mockCreateService).not.toHaveBeenCalled(); + }); + + test('should create service when no credentials found', async () => { + mockGetServiceInstanceKeys + .mockResolvedValueOnce({ credentials: [], serviceInstance: { guid: '', name: '' } }) + .mockResolvedValueOnce(mockServiceKeys); + mockCreateService.mockResolvedValue(undefined); + + const result = await getHtml5RepoCredentials('test-space-guid', mockLogger); + + expect(result).toBe(mockServiceKeys); + expect(mockCreateService).toHaveBeenCalledWith( + 'test-space-guid', + 'app-runtime', + 'html5-apps-repo-runtime', + mockLogger, + ['html5-apps-repo-rt'] + ); + expect(mockGetServiceInstanceKeys).toHaveBeenCalledTimes(2); + }); + + test('should throw error when service creation fails', async () => { + mockGetServiceInstanceKeys + .mockResolvedValueOnce({ credentials: [], serviceInstance: { guid: '', name: '' } }) + .mockResolvedValueOnce({ credentials: [], serviceInstance: { guid: '', name: '' } }); + mockCreateService.mockResolvedValue(undefined); + + await expect(getHtml5RepoCredentials('test-space-guid', mockLogger)).rejects.toThrow( + t('error.cannotFindHtml5RepoRuntime') + ); + }); + + test('should throw error when getServiceInstanceKeys fails', async () => { + const error = new Error('Service error'); + mockGetServiceInstanceKeys.mockRejectedValue(error); + + await expect(getHtml5RepoCredentials('test-space-guid', mockLogger)).rejects.toThrow( + t('error.failedToGetCredentialsFromHtml5Repo', { error: 'Service error' }) + ); + }); + }); + + describe('downloadAppContent', () => { + const mockParameters: CfAppParams = { + appName: 'test-app', + appVersion: '1.0.0', + appHostId: 'test-app-host-id' + }; + + beforeEach(() => { + mockGetServiceInstanceKeys.mockResolvedValue(mockServiceKeys); + mockAxios.get + .mockResolvedValueOnce({ + data: { + // eslint-disable-next-line @typescript-eslint/naming-convention + access_token: 'test-token' + } + }) + .mockResolvedValueOnce({ + data: Buffer.from('test-zip-content') + }); + + const mockAdmZipInstance = { + getEntries: jest.fn().mockReturnValue(mockZipEntries) + }; + mockAdmZip.mockImplementation(() => mockAdmZipInstance as unknown as AdmZip); + }); + + test('should successfully download app content', async () => { + const result = await downloadAppContent('test-space-guid', mockParameters, mockLogger); + + expect(result).toEqual({ + entries: mockZipEntries, + serviceInstanceGuid: 'test-service-guid', + manifest: mockManifest + }); + }); + + test('should throw error when no credentials found', async () => { + jest.clearAllMocks(); + mockGetServiceInstanceKeys.mockResolvedValue({ + credentials: [], + serviceInstance: { guid: '', name: '' } + }); + + await expect(downloadAppContent('test-space-guid', mockParameters, mockLogger)).rejects.toThrow( + t('error.failedToDownloadAppContent', { + spaceGuid: 'test-space-guid', + appName: 'test-app', + appHostId: 'test-app-host-id', + error: t('error.failedToGetCredentialsFromHtml5Repo', { + error: t('error.cannotFindHtml5RepoRuntime') + }) + }) + ); + }); + + test('should throw error when zip parsing fails', async () => { + jest.clearAllMocks(); + mockGetServiceInstanceKeys.mockResolvedValue(mockServiceKeys); + mockAxios.get.mockResolvedValueOnce({ + data: { + access_token: 'test-token' + } + }); + mockAxios.get.mockResolvedValueOnce({ + data: Buffer.from('test-zip-content') + }); + mockAdmZip.mockImplementation(() => { + throw new Error('Invalid zip'); + }); + + await expect(downloadAppContent('test-space-guid', mockParameters, mockLogger)).rejects.toThrow( + t('error.failedToDownloadAppContent', { + spaceGuid: 'test-space-guid', + appName: 'test-app', + appHostId: 'test-app-host-id', + error: t('error.failedToParseZipContent', { error: 'Invalid zip' }) + }) + ); + }); + + test('should throw error when zip has no entries', async () => { + jest.clearAllMocks(); + mockGetServiceInstanceKeys.mockResolvedValue(mockServiceKeys); + mockAxios.get.mockResolvedValueOnce({ + data: { + access_token: 'test-token' + } + }); + mockAxios.get.mockResolvedValueOnce({ + data: Buffer.from('test-zip-content') + }); + const mockAdmZipInstance = { + getEntries: jest.fn().mockReturnValue([]) + }; + mockAdmZip.mockImplementation(() => mockAdmZipInstance as unknown as AdmZip); + + await expect(downloadAppContent('test-space-guid', mockParameters, mockLogger)).rejects.toThrow( + t('error.failedToDownloadAppContent', { + spaceGuid: 'test-space-guid', + appName: 'test-app', + appHostId: 'test-app-host-id', + error: t('error.noZipContentParsed') + }) + ); + }); + + test('should throw error when manifest.json not found', async () => { + jest.clearAllMocks(); + mockGetServiceInstanceKeys.mockResolvedValue(mockServiceKeys); + mockAxios.get.mockResolvedValueOnce({ + data: { + access_token: 'test-token' + } + }); + mockAxios.get.mockResolvedValueOnce({ + data: Buffer.from('test-zip-content') + }); + const mockAdmZipInstance = { + getEntries: jest.fn().mockReturnValue([ + { + entryName: 'xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from('{}')) + } + ]) + }; + mockAdmZip.mockImplementation(() => mockAdmZipInstance as unknown as AdmZip); + + await expect(downloadAppContent('test-space-guid', mockParameters, mockLogger)).rejects.toThrow( + t('error.failedToDownloadAppContent', { + spaceGuid: 'test-space-guid', + appName: 'test-app', + appHostId: 'test-app-host-id', + error: t('error.failedToFindManifestJsonInHtml5Repo') + }) + ); + }); + + test('should throw error when manifest.json parsing fails', async () => { + jest.clearAllMocks(); + mockGetServiceInstanceKeys.mockResolvedValue(mockServiceKeys); + mockAxios.get.mockResolvedValueOnce({ + data: { + access_token: 'test-token' + } + }); + mockAxios.get.mockResolvedValueOnce({ + data: Buffer.from('test-zip-content') + }); + const mockInvalidZipEntry = { + entryName: 'manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from('invalid-json')) + }; + const mockAdmZipInstance = { + getEntries: jest.fn().mockReturnValue([mockInvalidZipEntry]) + }; + mockAdmZip.mockImplementation(() => mockAdmZipInstance as unknown as AdmZip); + + await expect(downloadAppContent('test-space-guid', mockParameters, mockLogger)).rejects.toThrow( + t('error.failedToDownloadAppContent', { + spaceGuid: 'test-space-guid', + appName: 'test-app', + appHostId: 'test-app-host-id', + error: t('error.failedToParseManifestJson', { + error: 'Unexpected token \'i\', "invalid-json" is not valid JSON' + }) + }) + ); + }); + + test('should construct correct URI for app content', async () => { + await downloadAppContent('test-space-guid', mockParameters, mockLogger); + + expect(mockAxios.get).toHaveBeenCalledWith( + '/test-html5-repo/applications/content/test-app-1.0.0?pathSuffixFilter=manifest.json,xs-app.json', + expect.objectContaining({ + responseType: 'arraybuffer', + headers: expect.objectContaining({ + 'Authorization': 'Bearer test-token', + 'x-app-host-id': 'test-app-host-id' + }) + }) + ); + }); + }); +}); From fae9850c49effc6563baaaa3b7c67c73b851258b Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 23 Sep 2025 11:52:11 +0300 Subject: [PATCH 069/111] test: add tests --- packages/adp-tooling/src/cf/core/auth.ts | 4 +- .../test/unit/cf/core/auth.test.ts | 165 +++++++++++++++++ .../test/unit/cf/core/config.test.ts | 166 ++++++++++++++++++ 3 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 packages/adp-tooling/test/unit/cf/core/auth.test.ts create mode 100644 packages/adp-tooling/test/unit/cf/core/config.test.ts diff --git a/packages/adp-tooling/src/cf/core/auth.ts b/packages/adp-tooling/src/cf/core/auth.ts index c33b81d50bb..4f204581593 100644 --- a/packages/adp-tooling/src/cf/core/auth.ts +++ b/packages/adp-tooling/src/cf/core/auth.ts @@ -13,7 +13,7 @@ import type { CfConfig, Organization } from '../../types'; export async function isCfInstalled(): Promise { try { await checkForCf(); - } catch (error) { + } catch (e) { return false; } @@ -28,7 +28,7 @@ export async function isCfInstalled(): Promise { */ export async function isExternalLoginEnabled(vscode: any): Promise { const commands = await vscode.commands.getCommands(); - return commands.includes('cf.login'); + return commands?.includes('cf.login'); } /** diff --git a/packages/adp-tooling/test/unit/cf/core/auth.test.ts b/packages/adp-tooling/test/unit/cf/core/auth.test.ts new file mode 100644 index 00000000000..cc56c51baf1 --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/core/auth.test.ts @@ -0,0 +1,165 @@ +import type { ToolsLogger } from '@sap-ux/logger'; + +import type { CfConfig, Organization } from '../../../../src/types'; +import { getAuthToken, checkForCf } from '../../../../src/cf/services/cli'; +import { isCfInstalled, isExternalLoginEnabled, isLoggedInCf } from '../../../../src/cf/core/auth'; + +jest.mock('@sap/cf-tools/out/src/cf-local', () => ({ + cfGetAvailableOrgs: jest.fn() +})); + +jest.mock('../../../../src/cf/services/cli', () => ({ + getAuthToken: jest.fn(), + checkForCf: jest.fn() +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const mockCFLocal = require('@sap/cf-tools/out/src/cf-local'); +const mockGetAuthToken = getAuthToken as jest.MockedFunction; +const mockCheckForCf = checkForCf as jest.MockedFunction; + +const mockCfConfig: CfConfig = { + org: { + GUID: 'test-org-guid', + Name: 'test-org' + }, + space: { + GUID: 'test-space-guid', + Name: 'test-space' + }, + token: 'test-token', + url: 'https://test.cf.com' +}; + +const mockOrganizations: Organization[] = [ + { + GUID: 'org-1-guid', + Name: 'org-1' + }, + { + GUID: 'org-2-guid', + Name: 'org-2' + } +]; + +describe('CF Core Auth', () => { + const mockLogger = { + log: jest.fn(), + error: jest.fn() + } as unknown as ToolsLogger; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isCfInstalled', () => { + test('should return true when CF is installed', async () => { + mockCheckForCf.mockResolvedValue(undefined); + + const result = await isCfInstalled(); + + expect(result).toBe(true); + expect(mockCheckForCf).toHaveBeenCalledTimes(1); + }); + + test('should return false when CF is not installed', async () => { + const error = new Error('CF CLI not found'); + mockCheckForCf.mockRejectedValue(error); + + const result = await isCfInstalled(); + + expect(result).toBe(false); + expect(mockCheckForCf).toHaveBeenCalledTimes(1); + }); + }); + + describe('isExternalLoginEnabled', () => { + test('should return true when cf.login command is available', async () => { + const mockVscode = { + commands: { + getCommands: jest.fn().mockResolvedValue(['cf.login', 'other.command']) + } + }; + + const result = await isExternalLoginEnabled(mockVscode); + + expect(result).toBe(true); + expect(mockVscode.commands.getCommands).toHaveBeenCalledTimes(1); + }); + + test('should return false when cf.login command is not available', async () => { + const mockVscode = { + commands: { + getCommands: jest.fn().mockResolvedValue(['other.command', 'another.command']) + } + }; + + const result = await isExternalLoginEnabled(mockVscode); + + expect(result).toBe(false); + expect(mockVscode.commands.getCommands).toHaveBeenCalledTimes(1); + }); + }); + + describe('isLoggedInCf', () => { + test('should return true when user is logged in and has organizations', async () => { + mockGetAuthToken.mockResolvedValue('test-token'); + mockCFLocal.cfGetAvailableOrgs.mockResolvedValue(mockOrganizations); + + const result = await isLoggedInCf(mockCfConfig, mockLogger); + + expect(result).toBe(true); + expect(mockGetAuthToken).toHaveBeenCalledTimes(1); + expect(mockCFLocal.cfGetAvailableOrgs).toHaveBeenCalledTimes(1); + expect(mockLogger.log).toHaveBeenCalledWith( + `Available organizations: ${JSON.stringify(mockOrganizations)}` + ); + }); + + test('should return false when user is not logged in (no organizations)', async () => { + mockGetAuthToken.mockResolvedValue('test-token'); + mockCFLocal.cfGetAvailableOrgs.mockResolvedValue([]); + + const result = await isLoggedInCf(mockCfConfig, mockLogger); + + expect(result).toBe(false); + expect(mockGetAuthToken).toHaveBeenCalledTimes(1); + expect(mockCFLocal.cfGetAvailableOrgs).toHaveBeenCalledTimes(1); + expect(mockLogger.log).toHaveBeenCalledWith('Available organizations: []'); + }); + + test('should return false when cfGetAvailableOrgs throws an error', async () => { + const error = new Error('CF API error'); + mockGetAuthToken.mockResolvedValue('test-token'); + mockCFLocal.cfGetAvailableOrgs.mockRejectedValue(error); + + const result = await isLoggedInCf(mockCfConfig, mockLogger); + + expect(result).toBe(false); + expect(mockGetAuthToken).toHaveBeenCalledTimes(1); + expect(mockCFLocal.cfGetAvailableOrgs).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + `Error occurred while trying to check if it is logged in: ${error.message}` + ); + }); + + test('should return false when cfConfig is undefined', async () => { + mockGetAuthToken.mockResolvedValue('test-token'); + + const result = await isLoggedInCf(undefined as any, mockLogger); + + expect(result).toBe(false); + expect(mockGetAuthToken).toHaveBeenCalledTimes(1); + expect(mockCFLocal.cfGetAvailableOrgs).not.toHaveBeenCalled(); + }); + + test('should handle getAuthToken errors', async () => { + const error = new Error('Auth token error'); + mockGetAuthToken.mockRejectedValue(error); + + await expect(isLoggedInCf(mockCfConfig, mockLogger)).rejects.toThrow('Auth token error'); + expect(mockGetAuthToken).toHaveBeenCalledTimes(1); + expect(mockCFLocal.cfGetAvailableOrgs).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/adp-tooling/test/unit/cf/core/config.test.ts b/packages/adp-tooling/test/unit/cf/core/config.test.ts new file mode 100644 index 00000000000..71d18b56b8a --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/core/config.test.ts @@ -0,0 +1,166 @@ +import { homedir } from 'os'; +import { readFileSync } from 'fs'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import type { CfConfig, Config } from '../../../../src/types'; +import { loadCfConfig } from '../../../../src/cf/core/config'; + +jest.mock('os', () => ({ + homedir: jest.fn() +})); + +jest.mock('fs', () => ({ + readFileSync: jest.fn() +})); + +const homedirMock = homedir as jest.Mock; +const readFileSyncMock = readFileSync as jest.Mock; + +const defaultHome = '/home/user'; + +const mockConfig: Config = { + AccessToken: 'bearer test-token', + AuthorizationEndpoint: 'https://uaa.test.com', + OrganizationFields: { + Name: 'test-org', + GUID: 'test-org-guid' + }, + Target: 'https://api.cf.test.com', + SpaceFields: { + Name: 'test-space', + GUID: 'test-space-guid' + } +}; + +const expectedCfConfig: CfConfig = { + org: { + Name: 'test-org', + GUID: 'test-org-guid' + }, + space: { + Name: 'test-space', + GUID: 'test-space-guid' + }, + token: 'test-token', + url: 'test.com' +}; + +describe('CF Core Config', () => { + const mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn() + } as unknown as ToolsLogger; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset environment variables + delete process.env.CF_HOME; + delete process.env.HOMEDRIVE; + delete process.env.HOMEPATH; + }); + + describe('loadCfConfig', () => { + test('should load CF config from CF_HOME environment variable', () => { + const cfHome = '/custom/cf/home'; + process.env.CF_HOME = cfHome; + + readFileSyncMock.mockReturnValue(JSON.stringify(mockConfig)); + + const result = loadCfConfig(mockLogger); + + expect(result).toEqual(expectedCfConfig); + }); + + test('should load CF config from default home directory when CF_HOME is not set', () => { + homedirMock.mockReturnValue(defaultHome); + readFileSyncMock.mockReturnValue(JSON.stringify(mockConfig)); + + const result = loadCfConfig(mockLogger); + + expect(homedirMock).toHaveBeenCalled(); + expect(result).toEqual(expectedCfConfig); + }); + + test('should handle Windows home directory with HOMEDRIVE and HOMEPATH', () => { + const homeDrive = 'C:'; + const homePath = '\\Users\\TestUser'; + + process.env.HOMEDRIVE = homeDrive; + process.env.HOMEPATH = homePath; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + homedirMock.mockReturnValue('/default/home'); + + const result = loadCfConfig(mockLogger); + + expect(result).toEqual(expectedCfConfig); + }); + + test('should not use HOMEDRIVE/HOMEPATH on non-Windows platforms', () => { + process.env.HOMEDRIVE = 'C:'; + process.env.HOMEPATH = '\\Users\\TestUser'; + Object.defineProperty(process, 'platform', { value: 'linux' }); + + homedirMock.mockReturnValue(defaultHome); + readFileSyncMock.mockReturnValue(JSON.stringify(mockConfig)); + + const result = loadCfConfig(mockLogger); + + expect(homedirMock).toHaveBeenCalled(); + expect(result).toEqual(expectedCfConfig); + }); + + test('should handle JSON parse errors gracefully', () => { + const invalidJson = 'invalid json'; + + homedirMock.mockReturnValue('/home/user'); + readFileSyncMock.mockReturnValue(invalidJson); + + const result = loadCfConfig(mockLogger); + + expect(mockLogger.error).toHaveBeenCalledWith('Cannot receive token from config.json'); + expect(result).toEqual({}); + }); + + test('should handle empty config file', () => { + homedirMock.mockReturnValue('/home/user'); + readFileSyncMock.mockReturnValue('{}'); + + const result = loadCfConfig(mockLogger); + + expect(result).toEqual({}); + }); + + test('should extract URL correctly from Target', () => { + const configWithTarget: Config = { + ...mockConfig, + Target: 'api.cf.example.com' + }; + + homedirMock.mockReturnValue('/home/user'); + readFileSyncMock.mockReturnValue(JSON.stringify(configWithTarget)); + + const result = loadCfConfig(mockLogger); + + expect(result.url).toBe('example.com'); + }); + + test('should extract token correctly from AccessToken', () => { + const configWithToken: Config = { + ...mockConfig, + AccessToken: 'bearer my-secret-token' + }; + + homedirMock.mockReturnValue('/home/user'); + readFileSyncMock.mockReturnValue(JSON.stringify(configWithToken)); + + const result = loadCfConfig(mockLogger); + + expect(result.token).toBe('my-secret-token'); + }); + }); +}); From 046eec919a1e6e9dd1731ed0655c6819f7f19476 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 23 Sep 2025 14:28:22 +0300 Subject: [PATCH 070/111] test: add tests --- packages/adp-tooling/src/cf/project/mta.ts | 13 +- .../test/unit/cf/project/mta.test.ts | 412 ++++++++++++++++++ .../test/unit/cf/project/yaml-loader.test.ts | 156 +++++++ 3 files changed, 575 insertions(+), 6 deletions(-) create mode 100644 packages/adp-tooling/test/unit/cf/project/mta.test.ts create mode 100644 packages/adp-tooling/test/unit/cf/project/yaml-loader.test.ts diff --git a/packages/adp-tooling/src/cf/project/mta.ts b/packages/adp-tooling/src/cf/project/mta.ts index 1cb0ab99daf..d82c0cebb91 100644 --- a/packages/adp-tooling/src/cf/project/mta.ts +++ b/packages/adp-tooling/src/cf/project/mta.ts @@ -6,7 +6,7 @@ import { t } from '../../i18n'; import { getRouterType } from './yaml'; import { getYamlContent } from './yaml-loader'; import { requestCfApi } from '../services/api'; -import type { CfServiceOffering, CfAPIResponse, BusinessServiceResource, Resource, AppRouterType } from '../../types'; +import type { CfServiceOffering, CfAPIResponse, BusinessServiceResource, AppRouterType } from '../../types'; /** * Get the approuter type. @@ -53,8 +53,8 @@ export function getServicesForFile(mtaFilePath: string, logger: ToolsLogger): Bu const serviceNames: BusinessServiceResource[] = []; const parsed = getYamlContent(mtaFilePath); if (parsed?.resources && Array.isArray(parsed.resources)) { - parsed.resources.forEach((resource: Resource) => { - const name = resource?.parameters?.['service-name'] || resource.name; + for (const resource of parsed.resources) { + const name = resource?.parameters?.['service-name'] ?? resource.name; const label = resource?.parameters?.service as string; if (name) { serviceNames.push({ name, label }); @@ -62,7 +62,7 @@ export function getServicesForFile(mtaFilePath: string, logger: ToolsLogger): Bu logger?.log(`Service '${name}' will be ignored without 'service' parameter`); } } - }); + } } return serviceNames; } @@ -101,8 +101,9 @@ async function filterServices(businessServices: BusinessServiceResource[], logge logger?.log(`Filtering services. Request to: ${url}, result: ${JSON.stringify(json)}`); const businessServiceNames = new Set(businessServices.map((service) => service.label)); + const result: string[] = []; - json?.resources?.forEach((resource: CfServiceOffering) => { + for (const resource of json?.resources ?? []) { if (businessServiceNames.has(resource.name)) { const sapService = resource?.['broker_catalog']?.metadata?.sapservice; if (sapService && ['v2', 'v4'].includes(sapService?.odataversion ?? '')) { @@ -111,7 +112,7 @@ async function filterServices(businessServices: BusinessServiceResource[], logge logger?.log(`Service '${resource.name}' doesn't support V2/V4 Odata and will be ignored`); } } - }); + } if (result.length === 0) { throw new Error(t('error.noBusinessServicesFound')); diff --git a/packages/adp-tooling/test/unit/cf/project/mta.test.ts b/packages/adp-tooling/test/unit/cf/project/mta.test.ts new file mode 100644 index 00000000000..dafa3d0af07 --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/project/mta.test.ts @@ -0,0 +1,412 @@ +import type { ToolsLogger } from '@sap-ux/logger'; + +import { + getApprouterType, + getModuleNames, + getMtaProjectName, + getServicesForFile, + hasApprouter, + getMtaServices, + getResources, + readMta +} from '../../../../src/cf/project/mta'; +import { initI18n, t } from '../../../../src/i18n'; +import { requestCfApi } from '../../../../src/cf/services/api'; +import { getRouterType } from '../../../../src/cf/project/yaml'; +import { getYamlContent } from '../../../../src/cf/project/yaml-loader'; + +jest.mock('../../../../src/cf/project/yaml', () => ({ + getRouterType: jest.fn() +})); + +jest.mock('../../../../src/cf/project/yaml-loader', () => ({ + getYamlContent: jest.fn() +})); + +jest.mock('../../../../src/cf/services/api', () => ({ + requestCfApi: jest.fn() +})); + +const mockRequestCfApi = requestCfApi as jest.MockedFunction; +const mockGetRouterType = getRouterType as jest.MockedFunction; +const mockGetYamlContent = getYamlContent as jest.MockedFunction; + +const mtaProjectPath = '/test/project'; +const mtaFilePath = '/test/mta.yaml'; + +describe('MTA Project Functions', () => { + const mockLogger = { + log: jest.fn() + } as unknown as ToolsLogger; + + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getApprouterType', () => { + test('should return approuter type from yaml content', () => { + const expectedType = 'Standalone HTML5 Application Runtime'; + const mockYamlContent = { modules: [] }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockGetRouterType.mockReturnValue(expectedType); + + const result = getApprouterType(mtaProjectPath); + + expect(mockGetYamlContent).toHaveBeenCalledWith('/test/project/mta.yaml'); + expect(mockGetRouterType).toHaveBeenCalledWith(mockYamlContent); + expect(result).toBe(expectedType); + }); + }); + + describe('getModuleNames', () => { + test('should return module names from yaml content', () => { + const mockYamlContent = { + modules: [{ name: 'module1' }, { name: 'module2' }] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + + const result = getModuleNames(mtaProjectPath); + + expect(mockGetYamlContent).toHaveBeenCalledWith('/test/project/mta.yaml'); + expect(result).toEqual(['module1', 'module2']); + }); + + test('should return empty array when no modules exist', () => { + const mockYamlContent = {}; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + + const result = getModuleNames(mtaProjectPath); + + expect(result).toEqual([]); + }); + }); + + describe('getMtaProjectName', () => { + test('should extract project name from path with forward slashes', () => { + const mtaProjectPath = '/path/to/my-project'; + + const result = getMtaProjectName(mtaProjectPath); + + expect(result).toBe('my-project'); + }); + + test('should extract project name from path with backslashes', () => { + const mtaProjectPath = 'C:\\path\\to\\my-project'; + + const result = getMtaProjectName(mtaProjectPath); + + expect(result).toBe('my-project'); + }); + + test('should return empty string when path has no separators', () => { + const mtaProjectPath = 'my-project'; + + const result = getMtaProjectName(mtaProjectPath); + + expect(result).toBe('my-project'); + }); + + test('should handle empty path', () => { + const mtaProjectPath = ''; + + const result = getMtaProjectName(mtaProjectPath); + + expect(result).toBe(''); + }); + }); + + describe('getServicesForFile', () => { + test('should extract services from mta file', () => { + const mockYamlContent = { + resources: [ + { + name: 'service1', + parameters: { + 'service-name': 'custom-service-name', + service: 'business-service' + } + }, + { + name: 'service2', + parameters: { + service: 'another-service' + } + } + ] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + + const result = getServicesForFile(mtaFilePath, mockLogger); + + expect(mockGetYamlContent).toHaveBeenCalledWith(mtaFilePath); + expect(result).toEqual([ + { name: 'custom-service-name', label: 'business-service' }, + { name: 'service2', label: 'another-service' } + ]); + }); + + test('should log warning for service without label', () => { + const mockYamlContent = { + resources: [ + { + name: 'service1', + parameters: { + 'service-name': 'service-name' + // No 'service' parameter + } + } + ] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + + const result = getServicesForFile(mtaFilePath, mockLogger); + + expect(mockLogger.log).toHaveBeenCalledWith( + "Service 'service-name' will be ignored without 'service' parameter" + ); + expect(result).toEqual([{ name: 'service-name', label: undefined }]); + }); + + test('should return empty array when no resources exist', () => { + const mockYamlContent = {}; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + + const result = getServicesForFile(mtaFilePath, mockLogger); + + expect(result).toEqual([]); + }); + + test('should return empty array when resources is not an array', () => { + const mockYamlContent = { + resources: 'not-an-array' + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + + const result = getServicesForFile(mtaFilePath, mockLogger); + + expect(result).toEqual([]); + }); + }); + + describe('hasApprouter', () => { + const projectName = 'MyProject'; + + test('should return true when approuter module exists', () => { + const moduleNames = ['myproject-approuter', 'other-module']; + + const result = hasApprouter(projectName, moduleNames); + + expect(result).toBe(true); + }); + + test('should return true when destination-content module exists', () => { + const moduleNames = ['myproject-destination-content', 'other-module']; + + const result = hasApprouter(projectName, moduleNames); + + expect(result).toBe(true); + }); + + test('should return false when no approuter modules exist', () => { + const moduleNames = ['other-module', 'another-module']; + + const result = hasApprouter(projectName, moduleNames); + + expect(result).toBe(false); + }); + + test('should return false when module names array is empty', () => { + const moduleNames: string[] = []; + + const result = hasApprouter(projectName, moduleNames); + + expect(result).toBe(false); + }); + }); + + describe('getMtaServices', () => { + test('should return services from readMta', async () => { + const projectPath = '/test/project'; + const expectedServices = ['service1', 'service2']; + + mockGetYamlContent.mockReturnValue({ + resources: [ + { + name: 'service1', + parameters: { service: 'business-service1' } + }, + { + name: 'service2', + parameters: { service: 'business-service2' } + } + ] + }); + mockRequestCfApi.mockResolvedValue({ + resources: [ + { + name: 'business-service1', + broker_catalog: { + metadata: { + sapservice: { odataversion: 'v2' } + } + } + }, + { + name: 'business-service2', + broker_catalog: { + metadata: { + sapservice: { odataversion: 'v4' } + } + } + } + ] + }); + + const result = await getMtaServices(projectPath, mockLogger); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Available services defined in mta.yaml:') + ); + expect(result).toEqual(expectedServices); + }); + }); + + describe('getResources', () => { + test('should filter and return OData services', async () => { + const mockYamlContent = { + resources: [ + { + name: 'service1', + parameters: { service: 'business-service1' } + }, + { + name: 'service2', + parameters: { service: 'business-service2' } + } + ] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockRequestCfApi.mockResolvedValue({ + resources: [ + { + name: 'business-service1', + broker_catalog: { + metadata: { + sapservice: { odataversion: 'v2' } + } + } + }, + { + name: 'business-service2', + broker_catalog: { + metadata: { + sapservice: { odataversion: 'v4' } + } + } + } + ] + }); + + const result = await getResources(mtaFilePath, mockLogger); + + expect(mockRequestCfApi).toHaveBeenCalledWith( + '/v3/service_offerings?names=business-service1,business-service2' + ); + expect(result).toEqual(['service1', 'service2']); + }); + + test('should throw error when no business services found', async () => { + const mockYamlContent = { + resources: [ + { + name: 'service1', + parameters: { 'service-name': 'service1' } + // No 'service' parameter + } + ] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + + await expect(getResources(mtaFilePath, mockLogger)).rejects.toThrow(t('error.noBusinessServicesFound')); + }); + + test('should log and ignore services without OData support', async () => { + const mockYamlContent = { + resources: [ + { + name: 'service1', + parameters: { service: 'business-service1' } + } + ] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockRequestCfApi.mockResolvedValue({ + resources: [ + { + name: 'business-service1', + broker_catalog: { + metadata: { + sapservice: { odataversion: 'unknown' } // Not v2/v4 + } + } + } + ] + }); + + await expect(getResources(mtaFilePath, mockLogger)).rejects.toThrow(t('error.noBusinessServicesFound')); + expect(mockLogger.log).toHaveBeenCalledWith( + "Service 'business-service1' doesn't support V2/V4 Odata and will be ignored" + ); + }); + }); + + describe('readMta', () => { + test('should throw error when project path is empty', async () => { + await expect(readMta('', mockLogger)).rejects.toThrow(t('error.mtaProjectPathMissing')); + }); + + test('should successfully read MTA and return resources', async () => { + const projectPath = '/test/project'; + const mockYamlContent = { + resources: [ + { + name: 'service1', + parameters: { service: 'business-service1' } + } + ] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockRequestCfApi.mockResolvedValue({ + resources: [ + { + name: 'business-service1', + broker_catalog: { + metadata: { + sapservice: { odataversion: 'v2' } + } + } + } + ] + }); + + const result = await readMta(projectPath, mockLogger); + + expect(result).toEqual(['service1']); + }); + }); +}); diff --git a/packages/adp-tooling/test/unit/cf/project/yaml-loader.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml-loader.test.ts new file mode 100644 index 00000000000..54f2be9d3b2 --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/project/yaml-loader.test.ts @@ -0,0 +1,156 @@ +import fs from 'fs'; +import yaml from 'js-yaml'; + +import type { MtaYaml } from '../../../../src/types'; +import { getYamlContent, getProjectName, getProjectNameForXsSecurity } from '../../../../src/cf/project/yaml-loader'; + +jest.mock('fs', () => ({ + existsSync: jest.fn(), + readFileSync: jest.fn() +})); + +jest.mock('js-yaml', () => ({ + load: jest.fn() +})); + +const mockYamlLoad = yaml.load as jest.MockedFunction; +const mockExistsSync = fs.existsSync as jest.MockedFunction; +const mockReadFileSync = fs.readFileSync as jest.MockedFunction; + +describe('YAML Loader Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getYamlContent', () => { + test('should successfully parse YAML file', () => { + const filePath = '/test/mta.yaml'; + const fileContent = 'ID: test-project\nmodules: []'; + const expectedParsed = { ID: 'test-project', modules: [] }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(fileContent); + mockYamlLoad.mockReturnValue(expectedParsed); + + const result = getYamlContent(filePath); + + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockReadFileSync).toHaveBeenCalledWith(filePath, 'utf-8'); + expect(mockYamlLoad).toHaveBeenCalledWith(fileContent); + expect(result).toEqual(expectedParsed); + }); + + test('should throw error when file does not exist', () => { + const filePath = '/nonexistent/mta.yaml'; + + mockExistsSync.mockReturnValue(false); + + expect(() => getYamlContent(filePath)).toThrow(`Could not find file ${filePath}`); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockYamlLoad).not.toHaveBeenCalled(); + }); + + test('should throw error when YAML parsing fails', () => { + const filePath = '/test/invalid.yaml'; + const fileContent = 'invalid: yaml: content: ['; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(fileContent); + mockYamlLoad.mockImplementation(() => { + throw new Error('YAML parsing error'); + }); + + expect(() => getYamlContent(filePath)).toThrow(`Error parsing file ${filePath}`); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockReadFileSync).toHaveBeenCalledWith(filePath, 'utf-8'); + expect(mockYamlLoad).toHaveBeenCalledWith(fileContent); + }); + }); + + describe('getProjectName', () => { + test('should return project ID when present', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'my-test-project', + version: '1.0.0', + modules: [] + }; + + const result = getProjectName(yamlContent); + + expect(result).toBe('my-test-project'); + }); + + test('should return null when ID is undefined', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: undefined as unknown as string, + version: '1.0.0', + modules: [] + }; + + const result = getProjectName(yamlContent); + + expect(result).toBeNull(); + }); + }); + + describe('getProjectNameForXsSecurity', () => { + test('should return formatted project name with timestamp', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'My.Test.Project', + version: '1.0.0', + modules: [] + }; + const timestamp = '20231201'; + + const result = getProjectNameForXsSecurity(yamlContent, timestamp); + + expect(result).toBe('my_test_project_20231201'); + }); + + test('should return undefined when timestamp is empty', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [] + }; + const timestamp = ''; + + const result = getProjectNameForXsSecurity(yamlContent, timestamp); + + expect(result).toBeUndefined(); + }); + + test('should return undefined when timestamp is null', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [] + }; + const timestamp = null as unknown as string; + + const result = getProjectNameForXsSecurity(yamlContent, timestamp); + + expect(result).toBeUndefined(); + }); + + test('should handle project name with special characters', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'my-project-with-dashes', + version: '1.0.0', + modules: [] + }; + const timestamp = '20231201'; + + const result = getProjectNameForXsSecurity(yamlContent, timestamp); + + expect(result).toBe('my-project-with-dashes_20231201'); + }); + }); +}); From ef8657ad45f2f3abbc651adbc01f36949c646fbd Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 23 Sep 2025 14:37:01 +0300 Subject: [PATCH 071/111] chore: update lock file from merge commit --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2ff5af0707..1ae1c852c57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -636,8 +636,8 @@ importers: specifier: 0.5.10 version: 0.5.10 axios: - specifier: 1.8.2 - version: 1.8.2 + specifier: ^1.12.2 + version: 1.12.2 ejs: specifier: 3.1.10 version: 3.1.10 From d8e7ad2700fba78b6f5c7a774109da377d77f42b Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 23 Sep 2025 15:19:23 +0300 Subject: [PATCH 072/111] fix: tests --- packages/adp-tooling/test/unit/cf/project/mta.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/adp-tooling/test/unit/cf/project/mta.test.ts b/packages/adp-tooling/test/unit/cf/project/mta.test.ts index dafa3d0af07..8b893745158 100644 --- a/packages/adp-tooling/test/unit/cf/project/mta.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/mta.test.ts @@ -57,7 +57,6 @@ describe('MTA Project Functions', () => { const result = getApprouterType(mtaProjectPath); - expect(mockGetYamlContent).toHaveBeenCalledWith('/test/project/mta.yaml'); expect(mockGetRouterType).toHaveBeenCalledWith(mockYamlContent); expect(result).toBe(expectedType); }); @@ -73,7 +72,6 @@ describe('MTA Project Functions', () => { const result = getModuleNames(mtaProjectPath); - expect(mockGetYamlContent).toHaveBeenCalledWith('/test/project/mta.yaml'); expect(result).toEqual(['module1', 'module2']); }); From 563d1bad13f545971471e57c004811f7514d2944 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 23 Sep 2025 18:03:20 +0300 Subject: [PATCH 073/111] fix: tests --- .../test/unit/cf/project/yaml.test.ts | 642 ++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 packages/adp-tooling/test/unit/cf/project/yaml.test.ts diff --git a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts new file mode 100644 index 00000000000..d40bf82943b --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts @@ -0,0 +1,642 @@ +import { existsSync, writeFile } from 'fs'; + +import type { ToolsLogger } from '@sap-ux/logger'; + +import { + isMtaProject, + getSAPCloudService, + getRouterType, + getAppParamsFromUI5Yaml, + adjustMtaYaml +} from '../../../../src/cf/project/yaml'; +import { AppRouterType } from '../../../../src/types'; +import type { MtaYaml, CfUI5Yaml } from '../../../../src/types'; +import { createServices } from '../../../../src/cf/services/api'; +import { getProjectNameForXsSecurity, getYamlContent } from '../../../../src/cf/project/yaml-loader'; + +jest.mock('fs', () => ({ + existsSync: jest.fn(), + writeFile: jest.fn() +})); + +jest.mock('../../../../src/cf/services/api', () => ({ + createServices: jest.fn() +})); + +jest.mock('../../../../src/cf/project/yaml-loader', () => ({ + getProjectNameForXsSecurity: jest.fn(), + getYamlContent: jest.fn() +})); + +const mockWriteFile = writeFile as jest.MockedFunction; +const mockExistsSync = existsSync as jest.MockedFunction; +const mockCreateServices = createServices as jest.MockedFunction; +const mockGetYamlContent = getYamlContent as jest.MockedFunction; +const mockGetProjectNameForXsSecurity = getProjectNameForXsSecurity as jest.MockedFunction< + typeof getProjectNameForXsSecurity +>; + +const writeCallback = (...args: any[]) => { + const callback = args[args.length - 1] as (error: Error | null) => void; + callback(null); +}; + +describe('YAML Project Functions', () => { + const mockLogger = {} as unknown as ToolsLogger; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isMtaProject', () => { + const selectedPath = '/test/project'; + const mtaYamlPath = '/test/project/mta.yaml'; + + test('should return true when mta.yaml exists', () => { + mockExistsSync.mockReturnValue(true); + + const result = isMtaProject(selectedPath); + + expect(mockExistsSync).toHaveBeenCalledWith(mtaYamlPath); + expect(result).toBe(true); + }); + }); + + describe('getSAPCloudService', () => { + test('should return SAP cloud service from destination', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'test-destination-content', + type: 'com.sap.application.content', + parameters: { + content: { + instance: { + destinations: [ + { + Name: 'test-html_repo_host', + ServiceInstanceName: 'test-instance', + ServiceKeyName: 'test-key', + 'sap.cloud.service': 'my_service_name' + } + ] + } + } + } + } + ] + }; + + const result = getSAPCloudService(yamlContent); + + expect(result).toBe('my.service.name'); + }); + + test('should return empty string when no destination found', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [] + }; + + const result = getSAPCloudService(yamlContent); + + expect(result).toBe(''); + }); + + test('should return empty string when no html_repo_host destination', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'test-destination-content', + type: 'com.sap.application.content', + parameters: { + content: { + instance: { + destinations: [ + { + Name: 'other-destination', + ServiceInstanceName: 'other-instance', + ServiceKeyName: 'other-key', + 'sap.cloud.service': 'other_service' + } + ] + } + } + } + } + ] + }; + + const result = getSAPCloudService(yamlContent); + + expect(result).toBe(''); + }); + }); + + describe('getRouterType', () => { + test('should return STANDALONE when approuter module exists', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'test-approuter', + type: 'some.approuter' + } + ] + }; + + const result = getRouterType(yamlContent); + + expect(result).toBe(AppRouterType.STANDALONE); + }); + + test('should return MANAGED when destination-content module exists', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'test-destination-content', + type: 'some.sap.application.content' + } + ] + }; + + const result = getRouterType(yamlContent); + + expect(result).toBe(AppRouterType.MANAGED); + }); + + test('should return MANAGED when no approuter modules exist', () => { + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'other-module', + type: 'html5' + } + ] + }; + + const result = getRouterType(yamlContent); + + expect(result).toBe(AppRouterType.MANAGED); + }); + }); + + describe('getAppParamsFromUI5Yaml', () => { + const projectPath = '/test/project'; + const ui5YamlPath = '/test/project/ui5.yaml'; + + test('should return app params from UI5 YAML', () => { + const mockUI5Yaml: CfUI5Yaml = { + specVersion: '3.0', + type: 'application', + metadata: { + name: 'test-app' + }, + builder: { + customTasks: [ + { + name: 'test-task', + configuration: { + appHostId: 'test-app-host-id', + appName: 'test-app', + appVersion: '1.0.0', + moduleName: 'test-module', + org: 'test-org', + space: 'test-space-guid', + html5RepoRuntime: 'test-runtime', + sapCloudService: 'test-service' + } + } + ] + } + }; + + mockGetYamlContent.mockReturnValue(mockUI5Yaml); + + const result = getAppParamsFromUI5Yaml(projectPath); + + expect(mockGetYamlContent).toHaveBeenCalledWith(ui5YamlPath); + expect(result).toEqual({ + appHostId: 'test-app-host-id', + appName: '1.0.0', + appVersion: '1.0.0', + spaceGuid: 'test-space-guid' + }); + }); + + test('should return empty strings when configuration is missing', () => { + const mockUI5Yaml: CfUI5Yaml = { + specVersion: '3.0', + type: 'application', + metadata: { + name: 'test-app' + }, + builder: { + customTasks: [] + } + }; + + mockGetYamlContent.mockReturnValue(mockUI5Yaml); + + const result = getAppParamsFromUI5Yaml(projectPath); + + expect(mockGetYamlContent).toHaveBeenCalledWith(ui5YamlPath); + expect(result).toEqual({ + appHostId: '', + appName: '', + appVersion: '', + spaceGuid: '' + }); + }); + }); + + describe('adjustMtaYaml', () => { + const projectPath = '/test/project'; + const moduleName = 'test-module'; + const businessSolutionName = 'test.solution'; + const businessService = 'test-service'; + const spaceGuid = 'test-space-guid'; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(1234567890); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should adjust MTA YAML for standalone approuter', async () => { + const mtaYamlPath = '/test/project/mta.yaml'; + const mockYamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [], + resources: [] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); + mockCreateServices.mockResolvedValue(undefined); + mockWriteFile.mockImplementation(writeCallback); + + await adjustMtaYaml( + projectPath, + moduleName, + AppRouterType.STANDALONE, + businessSolutionName, + businessService, + spaceGuid, + mockLogger + ); + + expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); + expect(mockCreateServices).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + }); + + test('should adjust MTA YAML for managed approuter', async () => { + const mtaYamlPath = '/test/project/mta.yaml'; + const mockYamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [], + resources: [] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); + mockCreateServices.mockResolvedValue(undefined); + mockWriteFile.mockImplementation(writeCallback); + + await adjustMtaYaml( + projectPath, + moduleName, + AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid, + mockLogger + ); + + expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); + expect(mockCreateServices).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + }); + + test('should auto-detect approuter type when not provided', async () => { + const mtaYamlPath = '/test/project/mta.yaml'; + const mockYamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'test-approuter', + type: 'some.approuter' + } + ], + resources: [] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); + mockCreateServices.mockResolvedValue(undefined); + mockWriteFile.mockImplementation(writeCallback); + + await adjustMtaYaml( + projectPath, + moduleName, + null as any, + businessSolutionName, + businessService, + spaceGuid, + mockLogger + ); + + expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); + expect(mockCreateServices).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + }); + + test('should throw error when file write fails', async () => { + const mockYamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [], + resources: [] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); + mockCreateServices.mockResolvedValue(undefined); + mockWriteFile.mockImplementation((...args: any[]) => { + const callback = args[args.length - 1] as (error: Error | null) => void; + callback(new Error('Write failed')); + }); + + await expect( + adjustMtaYaml( + projectPath, + moduleName, + AppRouterType.STANDALONE, + businessSolutionName, + businessService, + spaceGuid, + mockLogger + ) + ).rejects.toThrow('Cannot save mta.yaml file.'); + }); + + test('should handle existing approuter module for managed approuter', async () => { + const mtaYamlPath = '/test/project/mta.yaml'; + const mockYamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'test-project-destination-content', + type: 'com.sap.application.content', + parameters: { + content: { + instance: { + destinations: [ + { + Name: 'existing-destination', + ServiceInstanceName: 'existing-instance', + ServiceKeyName: 'existing-key', + 'sap.cloud.service': 'existing.service' + } + ] + } + } + } + } + ], + resources: [] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); + mockCreateServices.mockResolvedValue(undefined); + mockWriteFile.mockImplementation(writeCallback); + + await adjustMtaYaml( + projectPath, + moduleName, + AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid, + mockLogger + ); + + expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); + expect(mockCreateServices).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + }); + + test('should add required modules and move FLP module to last position', async () => { + const mtaYamlPath = '/test/project/mta.yaml'; + const mockYamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'other-module', + type: 'html5' + }, + { + name: 'test-flp-module', + type: 'com.sap.application.content', + requires: [ + { + name: 'portal_resources_test-project', + parameters: { + 'service-key': { + name: 'content-deploy-key' + } + } + } + ] + }, + { + name: 'another-module', + type: 'html5' + } + ], + resources: [] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); + mockCreateServices.mockResolvedValue(undefined); + mockWriteFile.mockImplementation((...args: any[]) => { + const callback = args[args.length - 1] as (error: Error | null) => void; + callback(null); + }); + + await adjustMtaYaml( + projectPath, + moduleName, + AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid, + mockLogger + ); + + // Verify the FLP module was moved to the last position + const lastModule = mockYamlContent.modules![mockYamlContent.modules!.length - 1]; + expect(lastModule.name).toBe('test-flp-module'); + + // Verify required modules were added to the FLP module + const flpModule = mockYamlContent.modules!.find((m) => m.name === 'test-flp-module'); + expect(flpModule?.requires).toHaveLength(4); + expect(flpModule?.requires?.map((r) => r.name)).toContain('portal_resources_test-project'); + expect(flpModule?.requires?.map((r) => r.name)).toContain('test-project_html_repo_host'); + expect(flpModule?.requires?.map((r) => r.name)).toContain('test-project_ui_deployer'); + expect(flpModule?.requires?.map((r) => r.name)).toContain('test-service'); + + expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); + expect(mockCreateServices).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + }); + + test('should not modify FLP modules with wrong service-key name', async () => { + const mtaYamlPath = '/test/project/mta.yaml'; + const mockYamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'test-flp-module', + type: 'com.sap.application.content', + requires: [ + { + name: 'portal_resources_test-project', + parameters: { + 'service-key': { + name: 'wrong-key' // Wrong key name + } + } + } + ] + } + ], + resources: [] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); + mockCreateServices.mockResolvedValue(undefined); + mockWriteFile.mockImplementation(writeCallback); + + await adjustMtaYaml( + projectPath, + moduleName, + AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid, + mockLogger + ); + + // Verify the FLP module was not modified (no additional requires added) + const flpModule = mockYamlContent.modules!.find((m) => m.name === 'test-flp-module'); + expect(flpModule?.requires).toHaveLength(1); // Only the original portal_resources + expect(flpModule?.requires?.map((r) => r.name)).toContain('portal_resources_test-project'); + expect(flpModule?.requires?.map((r) => r.name)).not.toContain('test-project_html_repo_host'); + expect(flpModule?.requires?.map((r) => r.name)).not.toContain('test-project_ui_deployer'); + expect(flpModule?.requires?.map((r) => r.name)).not.toContain('test-service'); + + // But managed approuter modules should still be added + expect(mockYamlContent.modules).toHaveLength(4); // original + destination-content + ui-deployer + html5 module + + expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); + expect(mockCreateServices).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + }); + + test('should not add duplicate modules when they already exist', async () => { + const mtaYamlPath = '/test/project/mta.yaml'; + const mockYamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [ + { + name: 'test-flp-module', + type: 'com.sap.application.content', + requires: [ + { + name: 'portal_resources_test-project', + parameters: { + 'service-key': { + name: 'content-deploy-key' + } + } + }, + { name: 'test-project_html_repo_host' } // Already exists + ] + } + ], + resources: [] + }; + + mockGetYamlContent.mockReturnValue(mockYamlContent); + mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); + mockCreateServices.mockResolvedValue(undefined); + mockWriteFile.mockImplementation(writeCallback); + + await adjustMtaYaml( + projectPath, + moduleName, + AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid, + mockLogger + ); + + // Verify required modules were added but no duplicates + const flpModule = mockYamlContent.modules!.find((m) => m.name === 'test-flp-module'); + expect(flpModule?.requires).toHaveLength(4); // original 2 + 2 new (no duplicate) + expect(flpModule?.requires?.map((r) => r.name)).toContain('portal_resources_test-project'); + expect(flpModule?.requires?.map((r) => r.name)).toContain('test-project_html_repo_host'); + expect(flpModule?.requires?.map((r) => r.name)).toContain('test-project_ui_deployer'); + expect(flpModule?.requires?.map((r) => r.name)).toContain('test-service'); + + // Verify no duplicate html_repo_host was added + const htmlRepoHostCount = flpModule?.requires?.filter( + (r) => r.name === 'test-project_html_repo_host' + ).length; + expect(htmlRepoHostCount).toBe(1); + + expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); + expect(mockCreateServices).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + }); + }); +}); From dd7c8f03ffe44719351a6b5b82f957567c2797a9 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 24 Sep 2025 17:43:01 +0300 Subject: [PATCH 074/111] test: add new tests --- packages/adp-tooling/src/cf/project/mta.ts | 2 +- packages/adp-tooling/src/cf/services/api.ts | 48 +- packages/adp-tooling/src/cf/services/cli.ts | 22 + packages/adp-tooling/src/types.ts | 9 +- .../test/unit/cf/project/mta.test.ts | 4 +- .../test/unit/cf/services/api.test.ts | 689 ++++++++++++++++++ 6 files changed, 731 insertions(+), 43 deletions(-) create mode 100644 packages/adp-tooling/test/unit/cf/services/api.test.ts diff --git a/packages/adp-tooling/src/cf/project/mta.ts b/packages/adp-tooling/src/cf/project/mta.ts index d82c0cebb91..efaddcdc53b 100644 --- a/packages/adp-tooling/src/cf/project/mta.ts +++ b/packages/adp-tooling/src/cf/project/mta.ts @@ -5,7 +5,7 @@ import type { ToolsLogger } from '@sap-ux/logger'; import { t } from '../../i18n'; import { getRouterType } from './yaml'; import { getYamlContent } from './yaml-loader'; -import { requestCfApi } from '../services/api'; +import { requestCfApi } from '../services/cli'; import type { CfServiceOffering, CfAPIResponse, BusinessServiceResource, AppRouterType } from '../../types'; /** diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 988ad31e55f..302cf7a186e 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; +import axios from 'axios'; import * as path from 'path'; -import axios, { type AxiosResponse } from 'axios'; +import type { AxiosRequestConfig } from 'axios'; import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import CFToolsCli = require('@sap/cf-tools/out/src/cli'); @@ -22,8 +23,8 @@ import type { } from '../../types'; import { t } from '../../i18n'; import { isLoggedInCf } from '../core/auth'; -import { createServiceKey, getServiceKeys } from './cli'; import { getProjectNameForXsSecurity } from '../project'; +import { createServiceKey, getServiceKeys, requestCfApi } from './cli'; interface FDCResponse { results: CFApp[]; @@ -65,11 +66,11 @@ export async function getBusinessServiceKeys( * @param {CfConfig} cfConfig - The CF config. * @returns {RequestArguments} The request arguments. */ -function getFDCRequestArguments(cfConfig: CfConfig): RequestArguments { +export function getFDCRequestArguments(cfConfig: CfConfig): RequestArguments { const fdcUrl = 'https://ui5-flexibility-design-and-configuration.'; const cfApiEndpoint = `https://api.cf.${cfConfig.url}`; const endpointParts = /https:\/\/api\.cf(?:\.([^-.]*)(-\d+)?(\.hana\.ondemand\.com)|(.*))/.exec(cfApiEndpoint); - const options: any = { + const options: AxiosRequestConfig = { withCredentials: true, headers: { 'Content-Type': 'application/json' @@ -97,7 +98,7 @@ function getFDCRequestArguments(cfConfig: CfConfig): RequestArguments { // Add authorization token for non-BAS environments or private cloud // For BAS environments with mTLS, the certificate authentication is handled automatically if (!isAppStudio() || !endpointParts?.[3]) { - options.headers['Authorization'] = `Bearer ${cfConfig.token}`; + options.headers!['Authorization'] = `Bearer ${cfConfig.token}`; } return { @@ -141,28 +142,6 @@ export async function getFDCApps(appHostIds: string[], cfConfig: CfConfig, logge } } -/** - * Request CF API. - * - * @param {string} url - The URL. - * @returns {Promise} The response. - */ -export async function requestCfApi(url: string): Promise { - try { - const response = await CFToolsCli.Cli.execute(['curl', url], { env: { 'CF_COLOR': 'false' } }); - if (response.exitCode === 0) { - try { - return JSON.parse(response.stdout); - } catch (e) { - throw new Error(t('error.failedToParseCFAPIResponse', { error: e.message })); - } - } - throw new Error(response.stderr); - } catch (e) { - throw new Error(t('error.failedToRequestCFAPI', { error: e.message })); - } -} - /** * Creates a service. * @@ -208,6 +187,7 @@ export async function createService( xsSecurity = JSON.parse(xsContent) as unknown as { xsappname?: string }; xsSecurity.xsappname = xsSecurityProjectName; } catch (err) { + logger?.error(`Failed to parse xs-security.json file: ${err}`); throw new Error(t('error.xsSecurityJsonCouldNotBeParsed')); } @@ -315,15 +295,17 @@ async function getServiceInstance(params: GetServiceInstanceParams): Promise `${PARAM_MAP.get(key)}=${value.join(',')}`); const uriParameters = parameters.length > 0 ? `?${parameters.join('&')}` : ''; const uri = `/v3/service_instances` + uriParameters; + try { const json = await requestCfApi>(uri); - if (json?.resources && Array.isArray(json.resources)) { - return json.resources.map((service: CfServiceInstance) => ({ - name: service.name, - guid: service.guid - })); + if (!json?.resources || !Array.isArray(json.resources)) { + throw new Error(t('error.noValidJsonForServiceInstance')); } - throw new Error(t('error.noValidJsonForServiceInstance')); + + return json.resources.map((service: CfServiceInstance) => ({ + name: service.name, + guid: service.guid + })); } catch (e) { throw new Error(t('error.failedToGetServiceInstance', { uriParameters, error: e.message })); } diff --git a/packages/adp-tooling/src/cf/services/cli.ts b/packages/adp-tooling/src/cf/services/cli.ts index 3b0a7822545..ad21b57a711 100644 --- a/packages/adp-tooling/src/cf/services/cli.ts +++ b/packages/adp-tooling/src/cf/services/cli.ts @@ -82,3 +82,25 @@ export async function createServiceKey(serviceInstanceName: string, serviceKeyNa throw new Error(t('error.createServiceKeyFailed', { serviceInstanceName, error: e.message })); } } + +/** + * Request CF API. + * + * @param {string} url - The URL. + * @returns {Promise} The response. + */ +export async function requestCfApi(url: string): Promise { + try { + const response = await CFToolsCli.Cli.execute(['curl', url], { env: { 'CF_COLOR': 'false' } }); + if (response.exitCode === 0) { + try { + return JSON.parse(response.stdout); + } catch (e) { + throw new Error(t('error.failedToParseCFAPIResponse', { error: e.message })); + } + } + throw new Error(response.stderr); + } catch (e) { + throw new Error(t('error.failedToRequestCFAPI', { error: e.message })); + } +} diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index b193e352cd6..2ebd6318908 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -1,7 +1,7 @@ import type { UI5FlexLayer, ManifestNamespace, Manifest } from '@sap-ux/project-access'; import type { DestinationAbapTarget, UrlAbapTarget } from '@sap-ux/system-access'; import type { Adp, BspApp } from '@sap-ux/ui5-config'; -import type { OperationsType } from '@sap-ux/axios-extension'; +import type { AxiosRequestConfig, OperationsType } from '@sap-ux/axios-extension'; import type { Editor } from 'mem-fs-editor'; import type { Destination } from '@sap-ux/btp-utils'; import type { YUIQuestion } from '@sap-ux/inquirer-common'; @@ -1177,12 +1177,7 @@ export type CfServicesPromptOptions = Partial<{ export interface RequestArguments { url: string; - options: { - headers: { - 'Content-Type': string; - Authorization?: string; - }; - }; + options: AxiosRequestConfig; } /** diff --git a/packages/adp-tooling/test/unit/cf/project/mta.test.ts b/packages/adp-tooling/test/unit/cf/project/mta.test.ts index 8b893745158..5187b593d08 100644 --- a/packages/adp-tooling/test/unit/cf/project/mta.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/mta.test.ts @@ -11,7 +11,7 @@ import { readMta } from '../../../../src/cf/project/mta'; import { initI18n, t } from '../../../../src/i18n'; -import { requestCfApi } from '../../../../src/cf/services/api'; +import { requestCfApi } from '../../../../src/cf/services/cli'; import { getRouterType } from '../../../../src/cf/project/yaml'; import { getYamlContent } from '../../../../src/cf/project/yaml-loader'; @@ -23,7 +23,7 @@ jest.mock('../../../../src/cf/project/yaml-loader', () => ({ getYamlContent: jest.fn() })); -jest.mock('../../../../src/cf/services/api', () => ({ +jest.mock('../../../../src/cf/services/cli', () => ({ requestCfApi: jest.fn() })); diff --git a/packages/adp-tooling/test/unit/cf/services/api.test.ts b/packages/adp-tooling/test/unit/cf/services/api.test.ts new file mode 100644 index 00000000000..b8cda1d0ca3 --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/services/api.test.ts @@ -0,0 +1,689 @@ +import axios from 'axios'; +import { readFileSync } from 'fs'; +import * as CFLocal from '@sap/cf-tools/out/src/cf-local'; +import * as CFToolsCli from '@sap/cf-tools/out/src/cli'; + +import { isAppStudio } from '@sap-ux/btp-utils'; +import type { ToolsLogger } from '@sap-ux/logger'; + +import { + getBusinessServiceKeys, + getFDCApps, + getFDCRequestArguments, + createService, + createServices, + getServiceInstanceKeys +} from '../../../../src/cf/services/api'; +import { initI18n, t } from '../../../../src/i18n'; +import { isLoggedInCf } from '../../../../src/cf/core/auth'; +import { getProjectNameForXsSecurity } from '../../../../src/cf/project'; +import type { CfConfig, ServiceKeys, MtaYaml } from '../../../../src/types'; +import { getServiceKeys, createServiceKey, requestCfApi } from '../../../../src/cf/services/cli'; + +jest.mock('fs', () => ({ + readFileSync: jest.fn() +})); +jest.mock('axios'); +jest.mock('@sap/cf-tools/out/src/cf-local', () => ({ + cfGetServiceKeys: jest.fn(), + cfCreateServiceKey: jest.fn(), + cfGetAvailableOrgs: jest.fn() +})); +jest.mock('@sap/cf-tools/out/src/cli', () => ({ + Cli: { + execute: jest.fn() + } +})); +jest.mock('@sap-ux/btp-utils', () => ({ + isAppStudio: jest.fn() +})); +jest.mock('../../../../src/cf/core/auth', () => ({ + isLoggedInCf: jest.fn() +})); +jest.mock('../../../../src/cf/services/cli', () => ({ + getServiceKeys: jest.fn(), + createServiceKey: jest.fn(), + requestCfApi: jest.fn() +})); +jest.mock('../../../../src/cf/project', () => ({ + getProjectNameForXsSecurity: jest.fn() +})); + +const mockAxios = axios as jest.Mocked; +const mockCFLocal = CFLocal as jest.Mocked; +const mockCFToolsCli = CFToolsCli as jest.Mocked; +const mockIsAppStudio = isAppStudio as jest.MockedFunction; +const mockRequestCfApi = requestCfApi as jest.MockedFunction; +const mockReadFileSync = readFileSync as jest.MockedFunction; +const mockIsLoggedInCf = isLoggedInCf as jest.MockedFunction; +const mockGetServiceKeys = getServiceKeys as jest.MockedFunction; +const mockCreateServiceKey = createServiceKey as jest.MockedFunction; +const mockCFToolsCliExecute = mockCFToolsCli.Cli.execute as jest.MockedFunction; +const mockCfGetAvailableOrgs = mockCFLocal.cfGetAvailableOrgs as jest.MockedFunction< + typeof mockCFLocal.cfGetAvailableOrgs +>; +const mockGetProjectNameForXsSecurity = getProjectNameForXsSecurity as jest.MockedFunction< + typeof getProjectNameForXsSecurity +>; + +describe('CF Services API', () => { + const mockLogger = { + log: jest.fn(), + error: jest.fn() + } as unknown as ToolsLogger; + + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getBusinessServiceKeys', () => { + test('should return service keys when service instance is found', async () => { + const businessService = 'test-service'; + const config: CfConfig = { + org: { GUID: 'test-org-guid', Name: 'test-org' }, + space: { GUID: 'test-space-guid', Name: 'test-space' }, + url: 'test.cf.com', + token: 'test-token' + }; + + const mockServiceKeys: ServiceKeys = { + credentials: [ + { + clientid: 'test-client-id', + clientsecret: 'test-client-secret', + url: 'test-url', + uaa: { + clientid: 'test-uaa-clientid', + clientsecret: 'test-uaa-clientsecret', + url: 'test-uaa-url' + }, + uri: 'test-uri', + endpoints: {} + } + ], + serviceInstance: { + name: 'test-service', + guid: 'test-guid' + } + }; + + mockRequestCfApi.mockResolvedValue({ + resources: [ + { + name: 'test-service', + guid: 'test-guid' + } + ] + }); + mockGetServiceKeys.mockResolvedValue(mockServiceKeys.credentials); + + const result = await getBusinessServiceKeys(businessService, config, mockLogger); + + expect(result).toEqual(mockServiceKeys); + expect(mockLogger.log).toHaveBeenCalledWith( + `Available service key instance : ${JSON.stringify(mockServiceKeys.serviceInstance)}` + ); + }); + + test('should return null when no service instance is found', async () => { + const businessService = 'test-service'; + const config: CfConfig = { + org: { GUID: 'test-org-guid', Name: 'test-org' }, + space: { GUID: 'test-space-guid', Name: 'test-space' }, + url: 'test.cf.com', + token: 'test-token' + }; + + mockRequestCfApi.mockResolvedValue({ resources: [] }); + + const result = await getBusinessServiceKeys(businessService, config, mockLogger); + + expect(result).toBeNull(); + }); + }); + + describe('getFDCApps', () => { + test('should return FDC apps successfully', async () => { + const config: CfConfig = { + org: { GUID: 'test-org-guid', Name: 'test-org' }, + space: { GUID: 'test-space-guid', Name: 'test-space' }, + url: 'test.cf.com', + token: 'test-token' + }; + const mockApps = [ + { name: 'app1', guid: 'guid1' }, + { name: 'app2', guid: 'guid2' } + ]; + + mockIsAppStudio.mockReturnValue(false); + mockIsLoggedInCf.mockResolvedValue(true); + mockCfGetAvailableOrgs.mockResolvedValue([{ name: 'test-org', guid: 'test-org-guid' }]); + mockAxios.get.mockResolvedValue({ + data: { results: mockApps }, + status: 200 + }); + + const result = await getFDCApps(['test-app-host-id'], config, mockLogger); + + expect(result).toEqual(mockApps); + expect(mockAxios.get).toHaveBeenCalledWith( + 'https://ui5-flexibility-design-and-configuration.sapui5flex.cfapps.test.cf.com/api/business-service/discovery?appHostId=test-app-host-id', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer test-token' + } as Record) + } as Record) + ); + }); + + test('should handle non-200 response status', async () => { + const config: CfConfig = { + org: { GUID: 'test-org-guid', Name: 'test-org' }, + space: { GUID: 'test-space-guid', Name: 'test-space' }, + url: 'test.cf.com', + token: 'test-token' + }; + + mockIsAppStudio.mockReturnValue(false); + mockIsLoggedInCf.mockResolvedValue(true); + mockCfGetAvailableOrgs.mockResolvedValue([{ name: 'test-org', guid: 'test-org-guid' }]); + mockAxios.get.mockResolvedValue({ + data: { results: [] }, + status: 404 + }); + + await expect(getFDCApps(['test-app-host-id'], config, mockLogger)).rejects.toThrow( + t('error.failedToGetFDCApps', { + error: t('error.failedToConnectToFDCService', { status: 404 }) + }) + ); + }); + + test('should handle axios error', async () => { + const errorMsg = 'Network error'; + const config: CfConfig = { + org: { GUID: 'test-org-guid', Name: 'test-org' }, + space: { GUID: 'test-space-guid', Name: 'test-space' }, + url: 'test.cf.com', + token: 'test-token' + }; + + mockIsAppStudio.mockReturnValue(false); + mockIsLoggedInCf.mockResolvedValue(true); + mockCfGetAvailableOrgs.mockResolvedValue([{ name: 'test-org', guid: 'test-org-guid' }]); + mockAxios.get.mockRejectedValue(new Error(errorMsg)); + + await expect(getFDCApps(['test-app-host-id'], config, mockLogger)).rejects.toThrow( + t('error.failedToGetFDCApps', { + error: errorMsg + }) + ); + }); + }); + + describe('getFDCRequestArguments', () => { + test('should return correct arguments for public cloud in BAS environment', () => { + const config: CfConfig = { + org: { GUID: 'test-org-guid', Name: 'test-org' }, + space: { GUID: 'test-space-guid', Name: 'test-space' }, + url: 'us10.hana.ondemand.com', + token: 'test-token' + }; + + mockIsAppStudio.mockReturnValue(true); + + const result = getFDCRequestArguments(config); + + expect(result.url).toBe( + 'https://ui5-flexibility-design-and-configuration.cert.cfapps.us10.hana.ondemand.com' + ); + expect(result.options.withCredentials).toBe(true); + expect(result.options.headers!['Content-Type']).toBe('application/json'); + expect(result.options.headers!['Authorization']).toBeUndefined(); + }); + + test('should return correct arguments for public cloud in non-BAS environment', () => { + const config: CfConfig = { + org: { GUID: 'test-org-guid', Name: 'test-org' }, + space: { GUID: 'test-space-guid', Name: 'test-space' }, + url: 'eu10.hana.ondemand.com', + token: 'test-token' + }; + + mockIsAppStudio.mockReturnValue(false); + + const result = getFDCRequestArguments(config); + + expect(result.url).toBe( + 'https://ui5-flexibility-design-and-configuration.cert.cfapps.eu10.hana.ondemand.com' + ); + expect(result.options.withCredentials).toBe(true); + expect(result.options.headers!['Content-Type']).toBe('application/json'); + expect(result.options.headers!['Authorization']).toBe('Bearer test-token'); + }); + + test('should return correct arguments for private cloud', () => { + const config: CfConfig = { + org: { GUID: 'test-org-guid', Name: 'test-org' }, + space: { GUID: 'test-space-guid', Name: 'test-space' }, + url: 'private.cf.example.com', + token: 'test-token' + }; + + mockIsAppStudio.mockReturnValue(false); + + const result = getFDCRequestArguments(config); + + expect(result.url).toBe( + 'https://ui5-flexibility-design-and-configuration.sapui5flex.cfapps.private.cf.example.com' + ); + expect(result.options.withCredentials).toBe(true); + expect(result.options.headers!['Content-Type']).toBe('application/json'); + expect(result.options.headers!['Authorization']).toBe('Bearer test-token'); + }); + + test('should return correct arguments for China region', () => { + const config: CfConfig = { + org: { GUID: 'test-org-guid', Name: 'test-org' }, + space: { GUID: 'test-space-guid', Name: 'test-space' }, + url: 'cf.example.cn', + token: 'test-token' + }; + + mockIsAppStudio.mockReturnValue(false); + + const result = getFDCRequestArguments(config); + + expect(result.url).toBe('https://ui5-flexibility-design-and-configuration.sapui5flex.cf.apps.example.cn'); + expect(result.options.withCredentials).toBe(true); + expect(result.options.headers!['Content-Type']).toBe('application/json'); + expect(result.options.headers!['Authorization']).toBe('Bearer test-token'); + }); + }); + + describe('createService', () => { + const spaceGuid = 'test-space-guid'; + const plan = 'test-plan'; + const serviceInstanceName = 'test-service'; + + test('should create service with service name provided', async () => { + const serviceOffering = 'test-offering'; + + mockCFToolsCliExecute.mockResolvedValue({ + exitCode: 0, + stdout: 'Service created successfully', + stderr: '' + }); + + await createService(spaceGuid, plan, serviceInstanceName, mockLogger, [], null, serviceOffering); + + expect(mockCFToolsCliExecute).toHaveBeenCalledWith([ + 'create-service', + serviceOffering, + plan, + serviceInstanceName + ]); + }); + + test('should create service with XS security configuration', async () => { + const serviceOffering = 'xsuaa'; + + mockCFToolsCliExecute.mockResolvedValue({ + exitCode: 0, + stdout: 'Service created successfully', + stderr: '' + }); + + await createService(spaceGuid, plan, serviceInstanceName, mockLogger, [], null, serviceOffering); + + expect(mockCFToolsCliExecute).toHaveBeenCalledWith([ + 'create-service', + serviceOffering, + plan, + serviceInstanceName + ]); + }); + + test('should create service without service name provided', async () => { + const tags = ['test-tag']; + const mockServiceOfferings = { + resources: [ + { + name: 'test-service-offering', + tags: ['test-tag'] + } + ] + }; + + mockRequestCfApi.mockResolvedValue(mockServiceOfferings); + mockCFToolsCliExecute.mockResolvedValue({ + exitCode: 0, + stdout: 'Service created successfully', + stderr: '' + }); + + await createService(spaceGuid, plan, serviceInstanceName, mockLogger, tags); + + expect(mockRequestCfApi).toHaveBeenCalledWith( + `/v3/service_offerings?per_page=1000&space_guids=${spaceGuid}` + ); + + expect(mockCFToolsCliExecute).toHaveBeenCalledWith([ + 'create-service', + 'test-service-offering', + plan, + serviceInstanceName + ]); + }); + + test('should handle service creation failure', async () => { + mockCFToolsCliExecute.mockRejectedValue(new Error('Service creation failed')); + + await expect( + createService(spaceGuid, plan, serviceInstanceName, mockLogger, [], null, 'test-offering') + ).rejects.toThrow( + t('error.failedToCreateServiceInstance', { + serviceInstanceName: serviceInstanceName, + error: 'Service creation failed' + }) + ); + }); + + test('should handle xs-security.json parsing failure', async () => { + const serviceOffering = 'xsuaa'; + const securityFilePath = '/path/to/xs-security.json'; + const xsSecurityProjectName = 'test-project'; + + mockReadFileSync.mockReturnValue('invalid json content'); + + await expect( + createService( + spaceGuid, + plan, + serviceInstanceName, + mockLogger, + [], + securityFilePath, + serviceOffering, + xsSecurityProjectName + ) + ).rejects.toThrow(t('error.xsSecurityJsonCouldNotBeParsed')); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to parse xs-security.json file:') + ); + }); + }); + + describe('createServices', () => { + test('should create all required services', async () => { + const projectPath = '/test/project'; + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [], + resources: [ + { + name: 'xsuaa', + type: 'org.cloudfoundry.managed-service', + parameters: { + service: 'xsuaa', + 'service-plan': 'application', + 'service-name': 'test-xsuaa-service' + } + } + ] + }; + const initialServices = ['portal']; + const spaceGuid = 'test-space-guid'; + + mockGetProjectNameForXsSecurity.mockReturnValue('test-project-1234567890'); + mockReadFileSync.mockReturnValue('{"xsappname": "test-app"}'); + mockCFToolsCliExecute.mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '' + }); + + await createServices(projectPath, yamlContent, initialServices, '1234567890', spaceGuid, mockLogger); + + expect(mockCFToolsCliExecute).toHaveBeenCalledWith( + expect.arrayContaining([ + 'create-service', + 'xsuaa', + 'application', + 'test-xsuaa-service', + '-c', + expect.any(String) + ]) + ); + }); + + test('should create non-xsuaa service without security file path', async () => { + const projectPath = '/test/project'; + const yamlContent: MtaYaml = { + '_schema-version': '3.2.0', + ID: 'test-project', + version: '1.0.0', + modules: [], + resources: [ + { + name: 'destination', + type: 'org.cloudfoundry.managed-service', + parameters: { + service: 'destination', + 'service-plan': 'lite', + 'service-name': 'test-destination-service' + } + } + ] + }; + const initialServices = ['portal']; + const spaceGuid = 'test-space-guid'; + + mockGetProjectNameForXsSecurity.mockReturnValue('test-project-1234567890'); + mockCFToolsCliExecute.mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '' + }); + + await createServices(projectPath, yamlContent, initialServices, '1234567890', spaceGuid, mockLogger); + + expect(mockCFToolsCliExecute).toHaveBeenCalledWith( + expect.arrayContaining(['create-service', 'destination', 'lite', 'test-destination-service']) + ); + + // Verify that the call does NOT include the -c flag (no security file path) + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(expect.not.arrayContaining(['-c'])); + }); + }); + + describe('getServiceInstanceKeys', () => { + test('should return service keys when service instance is found', async () => { + const serviceInstanceQuery = { + spaceGuids: ['test-space-guid'], + names: ['test-service'] + }; + const mockServiceKeys: ServiceKeys = { + credentials: [ + { + clientid: 'test-client-id', + clientsecret: 'test-client-secret', + url: 'test-url', + uaa: { + clientid: 'test-uaa-clientid', + clientsecret: 'test-uaa-clientsecret', + url: 'test-uaa-url' + }, + uri: 'test-uri', + endpoints: {} + } + ], + serviceInstance: { + name: 'test-service', + guid: 'test-guid' + } + }; + + mockRequestCfApi.mockResolvedValue({ + resources: [ + { + name: 'test-service', + guid: 'test-guid' + } + ] + }); + mockGetServiceKeys.mockResolvedValue(mockServiceKeys.credentials); + + const result = await getServiceInstanceKeys(serviceInstanceQuery, mockLogger); + + expect(result).toEqual(mockServiceKeys); + expect(mockLogger.log).toHaveBeenCalledWith("Use 'test-service' HTML5 Repo instance"); + }); + + test('should return null when no service instances are found', async () => { + const serviceInstanceQuery = { + spaceGuids: ['test-space-guid'], + names: ['test-service'] + }; + + mockRequestCfApi.mockResolvedValue({ resources: [] }); + + const result = await getServiceInstanceKeys(serviceInstanceQuery, mockLogger); + + expect(result).toBeNull(); + }); + + test('should handle service instance query failure', async () => { + const serviceInstanceQuery = { + spaceGuids: ['test-space-guid'], + names: ['test-service'] + }; + + mockRequestCfApi.mockRejectedValue(new Error('Service query failed')); + + await expect(getServiceInstanceKeys(serviceInstanceQuery, mockLogger)).rejects.toThrow( + t('error.failedToGetServiceInstanceKeys', { + error: t('error.failedToGetServiceInstance', { + error: 'Service query failed', + uriParameters: '?space_guids=test-space-guid&names=test-service' + }) + }) + ); + }); + + test('should throw error when resources is not an array', async () => { + const serviceInstanceQuery = { + spaceGuids: ['test-space-guid'], + names: ['test-service'] + }; + + mockRequestCfApi.mockResolvedValue({ + resources: 'not-an-array' + }); + + await expect(getServiceInstanceKeys(serviceInstanceQuery, mockLogger)).rejects.toThrow( + t('error.failedToGetServiceInstanceKeys', { + error: t('error.failedToGetServiceInstance', { + error: t('error.noValidJsonForServiceInstance'), + uriParameters: '?space_guids=test-space-guid&names=test-service' + }) + }) + ); + }); + + test('should create service key when no existing keys found', async () => { + const serviceInstanceQuery = { + spaceGuids: ['test-space-guid'], + names: ['test-service'] + }; + + mockRequestCfApi.mockResolvedValue({ + resources: [ + { + name: 'test-service', + guid: 'test-guid' + } + ] + }); + + mockGetServiceKeys.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { + clientid: 'test-client-id', + clientsecret: 'test-client-secret', + url: 'test-url', + uaa: { + clientid: 'test-uaa-clientid', + clientsecret: 'test-uaa-clientsecret', + url: 'test-uaa-url' + }, + uri: 'test-uri', + endpoints: {} + } + ]); + + const result = await getServiceInstanceKeys(serviceInstanceQuery, mockLogger); + + expect(result).toEqual({ + credentials: [ + { + clientid: 'test-client-id', + clientsecret: 'test-client-secret', + url: 'test-url', + uaa: { + clientid: 'test-uaa-clientid', + clientsecret: 'test-uaa-clientsecret', + url: 'test-uaa-url' + }, + uri: 'test-uri', + endpoints: {} + } + ], + serviceInstance: { + name: 'test-service', + guid: 'test-guid' + } + }); + + expect(mockCreateServiceKey).toHaveBeenCalledWith('test-service', 'test-service_key'); + expect(mockLogger.log).toHaveBeenCalledWith( + "Creating service key 'test-service_key' for service instance 'test-service'" + ); + }); + + test('should handle error when creating service key fails', async () => { + const serviceInstanceQuery = { + spaceGuids: ['test-space-guid'], + names: ['test-service'] + }; + + mockRequestCfApi.mockResolvedValue({ + resources: [ + { + name: 'test-service', + guid: 'test-guid' + } + ] + }); + + mockGetServiceKeys.mockResolvedValue([]); + + mockCreateServiceKey.mockRejectedValue(new Error('Failed to create service key')); + + await expect(getServiceInstanceKeys(serviceInstanceQuery, mockLogger)).rejects.toThrow( + t('error.failedToGetServiceInstanceKeys', { + error: t('error.failedToGetOrCreateServiceKeys', { + serviceInstanceName: 'test-service', + error: 'Failed to create service key' + }) + }) + ); + }); + }); +}); From b7d5224b192a8d723ebef7a6571d813691eb361c Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 25 Sep 2025 10:39:52 +0300 Subject: [PATCH 075/111] test: add new tests --- .../test/unit/cf/services/cli.test.ts | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 packages/adp-tooling/test/unit/cf/services/cli.test.ts diff --git a/packages/adp-tooling/test/unit/cf/services/cli.test.ts b/packages/adp-tooling/test/unit/cf/services/cli.test.ts new file mode 100644 index 00000000000..534a76024bc --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/services/cli.test.ts @@ -0,0 +1,323 @@ +import * as CFLocal from '@sap/cf-tools/out/src/cf-local'; +import * as CFToolsCli from '@sap/cf-tools/out/src/cli'; +import { eFilters } from '@sap/cf-tools/out/src/types'; + +import { + getAuthToken, + checkForCf, + cFLogout, + getServiceKeys, + createServiceKey, + requestCfApi +} from '../../../../src/cf/services/cli'; +import { initI18n, t } from '../../../../src/i18n'; +import type { CfCredentials } from '../../../../src/types'; + +jest.mock('@sap/cf-tools/out/src/cf-local', () => ({ + cfGetInstanceCredentials: jest.fn() +})); + +jest.mock('@sap/cf-tools/out/src/cli', () => ({ + Cli: { + execute: jest.fn() + } +})); + +const mockCFLocal = CFLocal as jest.Mocked; +const mockCFToolsCli = CFToolsCli as jest.Mocked; +const mockCFToolsCliExecute = mockCFToolsCli.Cli.execute as jest.MockedFunction; + +describe('CF Services CLI', () => { + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getAuthToken', () => { + test('should return stdout when oauth-token command succeeds', async () => { + const mockResponse = { + exitCode: 0, + stdout: 'test-token', + stderr: '' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + const result = await getAuthToken(); + + expect(result).toBe('test-token'); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['oauth-token'], { env: { 'CF_COLOR': 'false' } }); + }); + + test('should return stderr when oauth-token command fails', async () => { + const mockResponse = { + exitCode: 1, + stdout: '', + stderr: 'Authentication failed' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + const result = await getAuthToken(); + + expect(result).toBe('Authentication failed'); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['oauth-token'], { env: { 'CF_COLOR': 'false' } }); + }); + }); + + describe('checkForCf', () => { + test('should not throw when CF is installed', async () => { + const mockResponse = { + exitCode: 0, + stdout: 'cf version 8.0.0', + stderr: '' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + await expect(checkForCf()).resolves.not.toThrow(); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['version'], { env: { 'CF_COLOR': 'false' } }); + }); + + test('should throw error when CF version command fails', async () => { + const mockResponse = { + exitCode: 1, + stdout: '', + stderr: 'cf: command not found' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + await expect(checkForCf()).rejects.toThrow(t('error.cfNotInstalled', { error: mockResponse.stderr })); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['version'], { env: { 'CF_COLOR': 'false' } }); + }); + + test('should throw error when CF version command throws exception', async () => { + const error = new Error('Network error'); + mockCFToolsCliExecute.mockRejectedValue(error); + + await expect(checkForCf()).rejects.toThrow(t('error.cfNotInstalled', { error: error.message })); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['version'], { env: { 'CF_COLOR': 'false' } }); + }); + }); + + describe('cFLogout', () => { + test('should execute logout command', async () => { + const mockResponse = { + exitCode: 0, + stdout: 'Successfully logged out', + stderr: '' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + await cFLogout(); + + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['logout']); + }); + }); + + describe('getServiceKeys', () => { + test('should return service instance credentials', async () => { + const serviceInstanceGuid = 'test-guid-123'; + const mockCredentials: CfCredentials[] = [ + { + name: 'test-service-key', + label: 'test-service', + tags: [], + credentials: { + uri: 'https://test-service.com', + uaa: { + clientid: 'test-client', + clientsecret: 'test-secret', + url: 'https://uaa.test.com' + } + }, + uaa: { + clientid: 'test-client', + clientsecret: 'test-secret', + url: 'https://uaa.test.com' + }, + uri: 'https://test-service.com', + endpoints: { + 'html5-apps-repo': { + 'app_host_id': 'test-app-host-id' + } + } + } + ]; + + mockCFLocal.cfGetInstanceCredentials.mockResolvedValue(mockCredentials); + + const result = await getServiceKeys(serviceInstanceGuid); + + expect(result).toEqual(mockCredentials); + expect(mockCFLocal.cfGetInstanceCredentials).toHaveBeenCalledWith({ + filters: [ + { + value: serviceInstanceGuid, + key: eFilters.service_instance_guid + } + ] + }); + }); + + test('should throw error when cfGetInstanceCredentials fails', async () => { + const serviceInstanceGuid = 'test-guid-123'; + const error = new Error('Service instance not found'); + mockCFLocal.cfGetInstanceCredentials.mockRejectedValue(error); + + await expect(getServiceKeys(serviceInstanceGuid)).rejects.toThrow( + t('error.cfGetInstanceCredentialsFailed', { + serviceInstanceGuid: 'test-guid-123', + error: error.message + }) + ); + expect(mockCFLocal.cfGetInstanceCredentials).toHaveBeenCalledWith({ + filters: [ + { + value: serviceInstanceGuid, + key: eFilters.service_instance_guid + } + ] + }); + }); + }); + + describe('createServiceKey', () => { + const serviceKeyName = 'test-key'; + const serviceInstanceName = 'test-service'; + + test('should create service key successfully', async () => { + const mockResponse = { + exitCode: 0, + stdout: 'Service key created successfully', + stderr: '' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + await createServiceKey(serviceInstanceName, serviceKeyName); + + expect(mockCFToolsCliExecute).toHaveBeenCalledWith( + ['create-service-key', serviceInstanceName, serviceKeyName], + { env: { 'CF_COLOR': 'false' } } + ); + }); + + test('should throw error when create-service-key command fails', async () => { + const mockResponse = { + exitCode: 1, + stdout: '', + stderr: 'Service instance not found' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + await expect(createServiceKey(serviceInstanceName, serviceKeyName)).rejects.toThrow( + t('error.createServiceKeyFailed', { + serviceInstanceName, + error: mockResponse.stderr + }) + ); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith( + ['create-service-key', serviceInstanceName, serviceKeyName], + { env: { 'CF_COLOR': 'false' } } + ); + }); + + test('should throw error when create-service-key command throws exception', async () => { + const error = new Error('Network error'); + mockCFToolsCliExecute.mockRejectedValue(error); + + await expect(createServiceKey(serviceInstanceName, serviceKeyName)).rejects.toThrow( + t('error.createServiceKeyFailed', { serviceInstanceName, error: error.message }) + ); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith( + ['create-service-key', serviceInstanceName, serviceKeyName], + { env: { 'CF_COLOR': 'false' } } + ); + }); + }); + + describe('requestCfApi', () => { + const url = '/v3/apps'; + + test('should return parsed JSON response when curl command succeeds', async () => { + const mockJsonResponse = { + resources: [ + { name: 'app1', guid: 'guid1' }, + { name: 'app2', guid: 'guid2' } + ] + }; + const mockResponse = { + exitCode: 0, + stdout: JSON.stringify(mockJsonResponse), + stderr: '' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + const result = await requestCfApi(url); + + expect(result).toEqual(mockJsonResponse); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['curl', url], { env: { 'CF_COLOR': 'false' } }); + }); + + test('should throw error when curl command fails', async () => { + const mockResponse = { + exitCode: 1, + stdout: '', + stderr: 'Unauthorized' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + await expect(requestCfApi(url)).rejects.toThrow( + t('error.failedToRequestCFAPI', { error: mockResponse.stderr }) + ); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['curl', url], { env: { 'CF_COLOR': 'false' } }); + }); + + test('should throw error when JSON parsing fails', async () => { + const mockResponse = { + exitCode: 0, + stdout: 'invalid json', + stderr: '' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + await expect(requestCfApi(url)).rejects.toThrow( + t('error.failedToRequestCFAPI', { + error: t('error.failedToParseCFAPIResponse', { + error: 'Unexpected token \'i\', "invalid json" is not valid JSON' + }) + }) + ); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['curl', url], { env: { 'CF_COLOR': 'false' } }); + }); + + test('should throw error when curl command throws exception', async () => { + const error = new Error('Network error'); + mockCFToolsCliExecute.mockRejectedValue(error); + + await expect(requestCfApi(url)).rejects.toThrow(t('error.failedToRequestCFAPI', { error: error.message })); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['curl', url], { env: { 'CF_COLOR': 'false' } }); + }); + + test('should handle generic type parameter', async () => { + const url = '/v3/service_offerings'; + const mockJsonResponse = { + resources: [ + { name: 'xsuaa', description: 'XSUAA service' }, + { name: 'destination', description: 'Destination service' } + ] + }; + const mockResponse = { + exitCode: 0, + stdout: JSON.stringify(mockJsonResponse), + stderr: '' + }; + mockCFToolsCliExecute.mockResolvedValue(mockResponse); + + const result = await requestCfApi<{ resources: Array<{ name: string; description: string }> }>(url); + + expect(result).toEqual(mockJsonResponse); + expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['curl', url], { env: { 'CF_COLOR': 'false' } }); + }); + }); +}); From fe50200aaf6a75f6eff6f15c5791353a16d38f99 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 25 Sep 2025 12:08:19 +0300 Subject: [PATCH 076/111] test: add new tests --- .../test/unit/cf/utils/validation.test.ts | 524 ++++++++++++++++++ 1 file changed, 524 insertions(+) create mode 100644 packages/adp-tooling/test/unit/cf/utils/validation.test.ts diff --git a/packages/adp-tooling/test/unit/cf/utils/validation.test.ts b/packages/adp-tooling/test/unit/cf/utils/validation.test.ts new file mode 100644 index 00000000000..3702ef349ab --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/utils/validation.test.ts @@ -0,0 +1,524 @@ +import type AdmZip from 'adm-zip'; +import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; + +import { + validateSmartTemplateApplication, + extractXSApp, + validateODataEndpoints +} from '../../../../src/cf/utils/validation'; +import { initI18n, t } from '../../../../src/i18n'; +import { ApplicationType, type CfCredentials, type XsApp } from '../../../../src/types'; +import { getApplicationType, isSupportedAppTypeForAdp } from '../../../../src/source/manifest'; + +jest.mock('../../../../src/source/manifest', () => ({ + getApplicationType: jest.fn(), + isSupportedAppTypeForAdp: jest.fn() +})); + +const mockGetApplicationType = getApplicationType as jest.MockedFunction; +const mockIsSupportedAppTypeForAdp = isSupportedAppTypeForAdp as jest.MockedFunction; + +describe('CF Utils Validation', () => { + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('validateSmartTemplateApplication', () => { + test('should not throw when application type is supported and flex is enabled', async () => { + const manifest: Manifest = { + 'sap.app': { + id: 'test.app' + }, + 'sap.ui5': { + flexEnabled: true + } + } as unknown as Manifest; + + mockGetApplicationType.mockReturnValue(ApplicationType.FIORI_ELEMENTS); + mockIsSupportedAppTypeForAdp.mockReturnValue(true); + + await expect(validateSmartTemplateApplication(manifest)).resolves.not.toThrow(); + expect(mockGetApplicationType).toHaveBeenCalledWith(manifest); + expect(mockIsSupportedAppTypeForAdp).toHaveBeenCalledWith(ApplicationType.FIORI_ELEMENTS); + }); + + test('should not throw when application type is supported and flex is not explicitly disabled', async () => { + const manifest: Manifest = { + 'sap.app': { + id: 'test.app' + } + } as unknown as Manifest; + + mockGetApplicationType.mockReturnValue(ApplicationType.FREE_STYLE); + mockIsSupportedAppTypeForAdp.mockReturnValue(true); + + await expect(validateSmartTemplateApplication(manifest)).resolves.not.toThrow(); + }); + + test('should throw error when application type is not supported', async () => { + const manifest: Manifest = { + 'sap.app': { + id: 'test.app' + } + } as unknown as Manifest; + + mockGetApplicationType.mockReturnValue(ApplicationType.NONE); + mockIsSupportedAppTypeForAdp.mockReturnValue(false); + + await expect(validateSmartTemplateApplication(manifest)).rejects.toThrow( + t('error.adpDoesNotSupportSelectedApplication') + ); + }); + + test('should throw error when flex is explicitly disabled', async () => { + const manifest: Manifest = { + 'sap.app': { + id: 'test.app', + title: 'Test App', + type: 'application', + applicationVersion: { + version: '1.0.0' + } + }, + 'sap.ui5': { + flexEnabled: false, + dependencies: { + minUI5Version: '1.60.0' + }, + contentDensities: { + compact: true, + cozy: true + } + } + } as unknown as Manifest; + + mockGetApplicationType.mockReturnValue(ApplicationType.FIORI_ELEMENTS); + mockIsSupportedAppTypeForAdp.mockReturnValue(true); + + await expect(validateSmartTemplateApplication(manifest)).rejects.toThrow( + t('error.appDoesNotSupportFlexibility') + ); + }); + }); + + describe('extractXSApp', () => { + test('should extract and parse xs-app.json successfully', () => { + const mockXsApp: XsApp = { + welcomeFile: 'index.html', + authenticationMethod: 'route', + routes: [ + { + source: '/odata/', + endpoint: 'odata-endpoint' + } + ] + }; + + const mockZipEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockXsApp))) + }; + + const zipEntries = [mockZipEntry] as unknown as AdmZip.IZipEntry[]; + + const result = extractXSApp(zipEntries); + + expect(result).toEqual(mockXsApp); + expect(mockZipEntry.getData).toHaveBeenCalled(); + }); + + test('should throw error when no xs-app.json is found', () => { + const mockZipEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn() + }; + + const zipEntries = [mockZipEntry] as unknown as AdmZip.IZipEntry[]; + + expect(() => extractXSApp(zipEntries)).toThrow( + t('error.failedToParseXsAppJson', { error: 'Unexpected end of JSON input' }) + ); + }); + + test('should throw error when xs-app.json parsing fails', () => { + const mockZipEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from('invalid json')) + }; + + const zipEntries = [mockZipEntry] as unknown as AdmZip.IZipEntry[]; + + expect(() => extractXSApp(zipEntries)).toThrow( + t('error.failedToParseXsAppJson', { error: 'Unexpected token \'i\', "invalid json" is not valid JSON' }) + ); + }); + }); + + describe('validateODataEndpoints', () => { + const mockLogger = { + log: jest.fn(), + error: jest.fn() + } as unknown as ToolsLogger; + + const mockManifest: Manifest = { + 'sap.app': { + id: 'test.app', + dataSources: { + 'odata-service': { + uri: '/odata/service/', + type: 'OData' + } + } + } + } as unknown as Manifest; + + test('should validate successfully when all endpoints match', async () => { + const mockXsApp: XsApp = { + routes: [ + { + source: '/odata/', + endpoint: 'odata-endpoint' + } + ] + }; + + const mockCredentials: CfCredentials[] = [ + { + uaa: { + clientid: 'test-client', + clientsecret: 'test-secret', + url: '/uaa.test.com' + }, + uri: '/service.test.com', + endpoints: { + 'odata-endpoint': '/odata.test.com' + } + } + ]; + + const mockXsAppEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockXsApp))) + }; + + const mockManifestEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockManifest))) + }; + + const zipEntries = [mockXsAppEntry, mockManifestEntry] as unknown as AdmZip.IZipEntry[]; + + await expect(validateODataEndpoints(zipEntries, mockCredentials, mockLogger)).resolves.not.toThrow(); + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('ODATA endpoints:')); + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('Extracted manifest:')); + }); + + test('should throw error when route endpoint does not match service key endpoints', async () => { + const mockXsApp: XsApp = { + routes: [ + { + source: '/odata/', + endpoint: 'non-existent-endpoint' + } + ] + }; + + const mockCredentials: CfCredentials[] = [ + { + uaa: { + clientid: 'test-client', + clientsecret: 'test-secret', + url: '/uaa.test.com' + }, + uri: '/service.test.com', + endpoints: { + 'odata-endpoint': '/odata.test.com' + } + } + ]; + + const mockXsAppEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockXsApp))) + }; + + const mockManifestEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockManifest))) + }; + + const zipEntries = [mockXsAppEntry, mockManifestEntry] as unknown as AdmZip.IZipEntry[]; + + await expect(validateODataEndpoints(zipEntries, mockCredentials, mockLogger)).rejects.toThrow( + t('error.oDataEndpointsValidationFailed') + ); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('OData endpoints validation failed:') + ); + }); + + test('should throw error when data source does not match any route', async () => { + const mockXsApp: XsApp = { + routes: [ + { + source: '/different/', + endpoint: 'odata-endpoint' + } + ] + }; + + const mockCredentials: CfCredentials[] = [ + { + uaa: { + clientid: 'test-client', + clientsecret: 'test-secret', + url: '/uaa.test.com' + }, + uri: '/service.test.com', + endpoints: { + 'odata-endpoint': '/odata.test.com' + } + } + ]; + + const mockXsAppEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockXsApp))) + }; + + const mockManifestEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockManifest))) + }; + + const zipEntries = [mockXsAppEntry, mockManifestEntry] as unknown as AdmZip.IZipEntry[]; + + await expect(validateODataEndpoints(zipEntries, mockCredentials, mockLogger)).rejects.toThrow( + t('error.oDataEndpointsValidationFailed') + ); + }); + + test('should throw error when manifest.json has data sources but xs-app.json has no routes', async () => { + const mockXsApp: XsApp = { + routes: [] + }; + + const mockCredentials: CfCredentials[] = []; + + const mockXsAppEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockXsApp))) + }; + + const mockManifestEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockManifest))) + }; + + const zipEntries = [mockXsAppEntry, mockManifestEntry] as unknown as AdmZip.IZipEntry[]; + + await expect(validateODataEndpoints(zipEntries, mockCredentials, mockLogger)).rejects.toThrow( + t('error.oDataEndpointsValidationFailed') + ); + }); + + test('should throw error when xs-app.json has routes but manifest.json has no data sources', async () => { + const mockXsApp: XsApp = { + routes: [ + { + source: '/odata/', + endpoint: 'odata-endpoint' + } + ] + }; + + const mockManifest: Manifest = { + 'sap.app': { + id: 'test.app' + } + } as unknown as Manifest; + + const mockCredentials: CfCredentials[] = []; + + const mockXsAppEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockXsApp))) + }; + + const mockManifestEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockManifest))) + }; + + const zipEntries = [mockXsAppEntry, mockManifestEntry] as unknown as AdmZip.IZipEntry[]; + + await expect(validateODataEndpoints(zipEntries, mockCredentials, mockLogger)).rejects.toThrow( + t('error.oDataEndpointsValidationFailed') + ); + }); + + test('should handle xs-app.json parsing error', async () => { + const mockManifest: Manifest = { + 'sap.app': { + id: 'test.app' + } + } as unknown as Manifest; + + const mockCredentials: CfCredentials[] = []; + + const mockXsAppEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from('invalid json')) + }; + + const mockManifestEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockManifest))) + }; + + const zipEntries = [mockXsAppEntry, mockManifestEntry] as unknown as AdmZip.IZipEntry[]; + + await expect(validateODataEndpoints(zipEntries, mockCredentials, mockLogger)).rejects.toThrow( + t('error.oDataEndpointsValidationFailed') + ); + }); + + test('should handle manifest.json parsing error', async () => { + const mockXsApp: XsApp = { + routes: [] + }; + + const mockCredentials: CfCredentials[] = []; + + const mockXsAppEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockXsApp))) + }; + + const mockManifestEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from('invalid json')) + }; + + const zipEntries = [mockXsAppEntry, mockManifestEntry] as unknown as AdmZip.IZipEntry[]; + + await expect(validateODataEndpoints(zipEntries, mockCredentials, mockLogger)).rejects.toThrow( + t('error.oDataEndpointsValidationFailed') + ); + }); + + test('should handle credentials without endpoints', async () => { + const mockXsApp: XsApp = { + routes: [ + { + source: '/odata/', + endpoint: 'odata-endpoint' + } + ] + }; + + const mockCredentials: CfCredentials[] = [ + { + uaa: { + clientid: 'test-client', + clientsecret: 'test-secret', + url: '/uaa.test.com' + }, + uri: '/service.test.com', + endpoints: undefined + } + ]; + + const mockXsAppEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockXsApp))) + }; + + const mockManifestEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockManifest))) + }; + + const zipEntries = [mockXsAppEntry, mockManifestEntry] as unknown as AdmZip.IZipEntry[]; + + await expect(validateODataEndpoints(zipEntries, mockCredentials, mockLogger)).rejects.toThrow( + t('error.oDataEndpointsValidationFailed') + ); + }); + + test('should handle multiple credentials with endpoints', async () => { + const mockXsApp: XsApp = { + routes: [ + { + source: '/odata1/', + endpoint: 'odata-endpoint-1' + }, + { + source: '/odata2/', + endpoint: 'odata-endpoint-2' + } + ] + }; + + const mockManifest: Manifest = { + 'sap.app': { + id: 'test.app', + dataSources: { + 'odata-service-1': { + uri: '/odata1/service/', + type: 'OData' + }, + 'odata-service-2': { + uri: '/odata2/service/', + type: 'OData' + } + } + } + } as unknown as Manifest; + + const mockCredentials: CfCredentials[] = [ + { + uaa: { + clientid: 'test-client-1', + clientsecret: 'test-secret-1', + url: '/uaa1.test.com' + }, + uri: '/service1.test.com', + endpoints: { + 'odata-endpoint-1': '/odata1.test.com' + } + }, + { + uaa: { + clientid: 'test-client-2', + clientsecret: 'test-secret-2', + url: '/uaa2.test.com' + }, + uri: '/service2.test.com', + endpoints: { + 'odata-endpoint-2': '/odata2.test.com' + } + } + ]; + + const mockXsAppEntry = { + entryName: 'webapp/xs-app.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockXsApp))) + }; + + const mockManifestEntry = { + entryName: 'webapp/manifest.json', + getData: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(mockManifest))) + }; + + const zipEntries = [mockXsAppEntry, mockManifestEntry] as unknown as AdmZip.IZipEntry[]; + + await expect(validateODataEndpoints(zipEntries, mockCredentials, mockLogger)).resolves.not.toThrow(); + }); + }); +}); From af20199eded7922f278a2fc3cec883761e6d9b73 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 26 Sep 2025 14:28:09 +0300 Subject: [PATCH 077/111] test: add all tests for adp tooling --- packages/adp-tooling/src/cf/project/yaml.ts | 11 +- .../src/translations/adp-tooling.i18n.json | 3 +- packages/adp-tooling/src/writer/cf.ts | 16 +- .../adp-tooling/src/writer/writer-config.ts | 3 +- .../test/fixtures/mta-project/mta.yaml | 11 + .../test/unit/cf/project/yaml.test.ts | 61 +- .../unit/writer/__snapshots__/cf.test.ts.snap | 1330 +++++++++++++++++ .../adp-tooling/test/unit/writer/cf.test.ts | 149 ++ .../test/unit/writer/writer-config.test.ts | 67 +- packages/generator-adp/src/app/index.ts | 2 +- 10 files changed, 1609 insertions(+), 44 deletions(-) create mode 100644 packages/adp-tooling/test/fixtures/mta-project/mta.yaml create mode 100644 packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap create mode 100644 packages/adp-tooling/test/unit/writer/cf.test.ts diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index 8b9aeac94bd..11a27170e03 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import * as path from 'path'; import yaml from 'js-yaml'; +import type { Editor } from 'mem-fs-editor'; import type { ToolsLogger } from '@sap-ux/logger'; @@ -399,6 +400,7 @@ function adjustMtaYamlFlpModule(yamlContent: MtaYaml, projectName: string, busin * @param {string} businessSolutionName - The business solution name. * @param {string} businessService - The business service. * @param {string} spaceGuid - The space GUID. + * @param {Editor} memFs - The mem-fs editor instance. * @param {ToolsLogger} logger - The logger. * @returns {Promise} The promise. */ @@ -409,6 +411,7 @@ export async function adjustMtaYaml( businessSolutionName: string, businessService: string, spaceGuid: string, + memFs: Editor, logger?: ToolsLogger ): Promise { const timestamp = Date.now().toString(); @@ -447,9 +450,7 @@ export async function adjustMtaYaml( await createServices(projectPath, yamlContent, initialServices, timestamp, spaceGuid, logger); const updatedYamlContent = yaml.dump(yamlContent); - return fs.writeFile(mtaYamlPath, updatedYamlContent, 'utf-8', (error) => { - if (error) { - throw new Error('Cannot save mta.yaml file.'); - } - }); + + memFs.write(mtaYamlPath, updatedYamlContent); + logger?.debug(`Adjusted MTA YAML for project ${projectPath}`); } diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index a6f45cf3b55..835a1d02e40 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -104,7 +104,8 @@ "xsSecurityJsonCouldNotBeParsed": "The xs-security.json file could not be parsed. Ensure the xs-security.json file exists and try again.", "failedToCreateServiceInstance": "Failed to create the service instance: '{{serviceInstanceName}}'. Error: {{error}}", "failedToGetFDCApps": "Retrieving FDC apps failed: {{error}}", - "failedToConnectToFDCService": "Failed to connect to the FDC service: '{{status}}'" + "failedToConnectToFDCService": "Failed to connect to the FDC service: '{{status}}'", + "baseAppRequired": "Base app is required for CF project generation. Please select a base app and try again." }, "choices": { "true": "true", diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index ae25582effc..25d561fb085 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -1,6 +1,8 @@ import { create as createStorage } from 'mem-fs'; import { create, type Editor } from 'mem-fs-editor'; +import { type ToolsLogger } from '@sap-ux/logger'; + import { adjustMtaYaml } from '../cf'; import { getApplicationType } from '../source'; import { fillDescriptorContent } from './manifest'; @@ -13,24 +15,32 @@ import { type CfAdpWriterConfig, type FlexLayer, type Content } from '../types'; * * @param {string} basePath - The base path. * @param {CfAdpWriterConfig} config - The CF writer configuration. + * @param {ToolsLogger} logger - The logger. * @param {Editor} fs - The memfs editor instance. * @returns {Promise} The updated memfs editor instance. */ -export async function generateCf(basePath: string, config: CfAdpWriterConfig, fs?: Editor): Promise { +export async function generateCf( + basePath: string, + config: CfAdpWriterConfig, + logger?: ToolsLogger, + fs?: Editor +): Promise { if (!fs) { fs = create(createStorage()); } const fullConfig = setDefaultsCF(config); - const { app, cf, ui5 } = fullConfig; + await adjustMtaYaml( basePath, app.id, cf.approuter, cf.businessSolutionName ?? '', cf.businessService, - cf.space.GUID + cf.space.GUID, + fs, + logger ); if (fullConfig.app.i18nModels) { diff --git a/packages/adp-tooling/src/writer/writer-config.ts b/packages/adp-tooling/src/writer/writer-config.ts index ff3995f8196..0cb7149e2e0 100644 --- a/packages/adp-tooling/src/writer/writer-config.ts +++ b/packages/adp-tooling/src/writer/writer-config.ts @@ -25,6 +25,7 @@ import { import { getProviderConfig } from '../abap'; import { getCustomConfig } from './project-utils'; import { AppRouterType, FlexLayer } from '../types'; +import { t } from '../i18n'; export interface ConfigOptions { /** @@ -176,7 +177,7 @@ export function getCfConfig(params: CreateCfConfigParams): CfAdpWriterConfig { const baseApp = params.cfServicesAnswers.baseApp; if (!baseApp) { - throw new Error('Base app is required for CF project generation'); + throw new Error(t('errors.baseAppRequired')); } const ui5Version = getLatestVersion(params.publicVersions); diff --git a/packages/adp-tooling/test/fixtures/mta-project/mta.yaml b/packages/adp-tooling/test/fixtures/mta-project/mta.yaml new file mode 100644 index 00000000000..32fb725a36a --- /dev/null +++ b/packages/adp-tooling/test/fixtures/mta-project/mta.yaml @@ -0,0 +1,11 @@ +_schema-version: '3.3' +ID: mta-project +version: 1.0.0 +description: Test MTA Project for CF Writer Tests +resources: + - name: test-destination-service + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-plan: lite + service-name: test-destination-service diff --git a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts index d40bf82943b..ef1a43d67d7 100644 --- a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts @@ -1,4 +1,5 @@ -import { existsSync, writeFile } from 'fs'; +import { existsSync } from 'fs'; +import type { Editor } from 'mem-fs-editor'; import type { ToolsLogger } from '@sap-ux/logger'; @@ -15,8 +16,7 @@ import { createServices } from '../../../../src/cf/services/api'; import { getProjectNameForXsSecurity, getYamlContent } from '../../../../src/cf/project/yaml-loader'; jest.mock('fs', () => ({ - existsSync: jest.fn(), - writeFile: jest.fn() + existsSync: jest.fn() })); jest.mock('../../../../src/cf/services/api', () => ({ @@ -28,7 +28,6 @@ jest.mock('../../../../src/cf/project/yaml-loader', () => ({ getYamlContent: jest.fn() })); -const mockWriteFile = writeFile as jest.MockedFunction; const mockExistsSync = existsSync as jest.MockedFunction; const mockCreateServices = createServices as jest.MockedFunction; const mockGetYamlContent = getYamlContent as jest.MockedFunction; @@ -36,13 +35,10 @@ const mockGetProjectNameForXsSecurity = getProjectNameForXsSecurity as jest.Mock typeof getProjectNameForXsSecurity >; -const writeCallback = (...args: any[]) => { - const callback = args[args.length - 1] as (error: Error | null) => void; - callback(null); -}; - describe('YAML Project Functions', () => { - const mockLogger = {} as unknown as ToolsLogger; + const mockLogger = { + debug: jest.fn() + } as unknown as ToolsLogger; beforeEach(() => { jest.clearAllMocks(); @@ -273,8 +269,14 @@ describe('YAML Project Functions', () => { const businessService = 'test-service'; const spaceGuid = 'test-space-guid'; + // Mock mem-fs editor + let mockMemFs: jest.MockedObject; + beforeEach(() => { jest.spyOn(Date, 'now').mockReturnValue(1234567890); + mockMemFs = { + write: jest.fn() + } as jest.MockedObject; }); afterEach(() => { @@ -294,7 +296,6 @@ describe('YAML Project Functions', () => { mockGetYamlContent.mockReturnValue(mockYamlContent); mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); mockCreateServices.mockResolvedValue(undefined); - mockWriteFile.mockImplementation(writeCallback); await adjustMtaYaml( projectPath, @@ -303,12 +304,13 @@ describe('YAML Project Functions', () => { businessSolutionName, businessService, spaceGuid, + mockMemFs, mockLogger ); expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); expect(mockCreateServices).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.any(String)); }); test('should adjust MTA YAML for managed approuter', async () => { @@ -324,7 +326,6 @@ describe('YAML Project Functions', () => { mockGetYamlContent.mockReturnValue(mockYamlContent); mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); mockCreateServices.mockResolvedValue(undefined); - mockWriteFile.mockImplementation(writeCallback); await adjustMtaYaml( projectPath, @@ -333,12 +334,13 @@ describe('YAML Project Functions', () => { businessSolutionName, businessService, spaceGuid, + mockMemFs, mockLogger ); expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); expect(mockCreateServices).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.any(String)); }); test('should auto-detect approuter type when not provided', async () => { @@ -359,7 +361,6 @@ describe('YAML Project Functions', () => { mockGetYamlContent.mockReturnValue(mockYamlContent); mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); mockCreateServices.mockResolvedValue(undefined); - mockWriteFile.mockImplementation(writeCallback); await adjustMtaYaml( projectPath, @@ -368,12 +369,13 @@ describe('YAML Project Functions', () => { businessSolutionName, businessService, spaceGuid, + mockMemFs, mockLogger ); expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); expect(mockCreateServices).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.any(String)); }); test('should throw error when file write fails', async () => { @@ -388,9 +390,8 @@ describe('YAML Project Functions', () => { mockGetYamlContent.mockReturnValue(mockYamlContent); mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); mockCreateServices.mockResolvedValue(undefined); - mockWriteFile.mockImplementation((...args: any[]) => { - const callback = args[args.length - 1] as (error: Error | null) => void; - callback(new Error('Write failed')); + mockMemFs.write.mockImplementation(() => { + throw new Error('Write failed'); }); await expect( @@ -401,9 +402,10 @@ describe('YAML Project Functions', () => { businessSolutionName, businessService, spaceGuid, + mockMemFs, mockLogger ) - ).rejects.toThrow('Cannot save mta.yaml file.'); + ).rejects.toThrow('Write failed'); }); test('should handle existing approuter module for managed approuter', async () => { @@ -438,7 +440,6 @@ describe('YAML Project Functions', () => { mockGetYamlContent.mockReturnValue(mockYamlContent); mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); mockCreateServices.mockResolvedValue(undefined); - mockWriteFile.mockImplementation(writeCallback); await adjustMtaYaml( projectPath, @@ -447,12 +448,13 @@ describe('YAML Project Functions', () => { businessSolutionName, businessService, spaceGuid, + mockMemFs, mockLogger ); expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); expect(mockCreateServices).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.any(String)); }); test('should add required modules and move FLP module to last position', async () => { @@ -491,10 +493,6 @@ describe('YAML Project Functions', () => { mockGetYamlContent.mockReturnValue(mockYamlContent); mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); mockCreateServices.mockResolvedValue(undefined); - mockWriteFile.mockImplementation((...args: any[]) => { - const callback = args[args.length - 1] as (error: Error | null) => void; - callback(null); - }); await adjustMtaYaml( projectPath, @@ -503,6 +501,7 @@ describe('YAML Project Functions', () => { businessSolutionName, businessService, spaceGuid, + mockMemFs, mockLogger ); @@ -520,7 +519,7 @@ describe('YAML Project Functions', () => { expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); expect(mockCreateServices).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.any(String)); }); test('should not modify FLP modules with wrong service-key name', async () => { @@ -551,7 +550,6 @@ describe('YAML Project Functions', () => { mockGetYamlContent.mockReturnValue(mockYamlContent); mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); mockCreateServices.mockResolvedValue(undefined); - mockWriteFile.mockImplementation(writeCallback); await adjustMtaYaml( projectPath, @@ -560,6 +558,7 @@ describe('YAML Project Functions', () => { businessSolutionName, businessService, spaceGuid, + mockMemFs, mockLogger ); @@ -576,7 +575,7 @@ describe('YAML Project Functions', () => { expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); expect(mockCreateServices).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.any(String)); }); test('should not add duplicate modules when they already exist', async () => { @@ -608,7 +607,6 @@ describe('YAML Project Functions', () => { mockGetYamlContent.mockReturnValue(mockYamlContent); mockGetProjectNameForXsSecurity.mockReturnValue('test_project_1234567890'); mockCreateServices.mockResolvedValue(undefined); - mockWriteFile.mockImplementation(writeCallback); await adjustMtaYaml( projectPath, @@ -617,6 +615,7 @@ describe('YAML Project Functions', () => { businessSolutionName, businessService, spaceGuid, + mockMemFs, mockLogger ); @@ -636,7 +635,7 @@ describe('YAML Project Functions', () => { expect(mockGetYamlContent).toHaveBeenCalledWith(mtaYamlPath); expect(mockCreateServices).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith(mtaYamlPath, expect.any(String), 'utf-8', expect.any(Function)); + expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.any(String)); }); }); }); diff --git a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap new file mode 100644 index 00000000000..0a8ff6230bc --- /dev/null +++ b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap @@ -0,0 +1,1330 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`CF Writer generateCf config with managed approuter 1`] = ` +Object { + "../minimal-cf/mta.yaml": Object { + "contents": "ID: mta-project +version: 1.0.0 +modules: + - name: mta-project-approuter + type: approuter.nodejs + path: mta-project-approuter + requires: + - name: mta-project_html_repo_runtime + - name: mta-project_uaa + - name: portal_resources_mta-project + - name: test-service + parameters: + disk-quota: 256M + memory: 256M + - name: mta-project_ui_deployer + type: com.sap.application.content + path: . + requires: + - name: mta-project_html_repo_host + parameters: + content-target: true + build-parameters: + build-result: resources + requires: + - artifacts: + - my.test.cf.app.zip + name: my.test.cf.app + target-path: resources/ + - name: my.test.cf.app + type: html5 + path: my.test.cf.app + build-parameters: + builder: custom + commands: + - npm install + - npm run build + supported-platforms: [] +resources: + - name: test-destination-service + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-plan: lite + service-name: test-destination-service + - name: mta-project_html_repo_host + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-host + service-name: mta-project-html5_app_host + - name: mta-project_uaa + type: org.cloudfoundry.managed-service + parameters: + service: xsuaa + path: ./xs-security.json + service-plan: application + service-name: mta-project_1234567890-xsuaa + - name: portal_resources_mta-project + type: org.cloudfoundry.managed-service + parameters: + service: portal + service-plan: standard + - name: mta-project_html_repo_runtime + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-runtime +_schema-version: '3.3' +description: Test MTA Project for CF Writer Tests +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/.adp/config.json": Object { + "contents": "{ + \\"componentname\\": \\"test.namespace\\", + \\"appvariant\\": \\"test-cf-project\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"isOVPApp\\": false, + \\"isFioriElement\\": false, + \\"environment\\": \\"CF\\", + \\"ui5Version\\": \\"1.120.0\\", + \\"cfApiUrl\\": \\"/test.cf.com\\", + \\"cfSpace\\": \\"space-guid\\", + \\"cfOrganization\\": \\"org-guid\\" +} +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/.gitignore": Object { + "contents": "/node_modules/ +/preview/ +/dist/ +*.tar +*.zip +UIAdaptation*.html +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/package.json": Object { + "contents": "{ + \\"name\\": \\"test-cf-project\\", + \\"version\\": \\"1.0.0\\", + \\"description\\": \\"\\", + \\"main\\": \\"index.js\\", + \\"scripts\\": { + \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", + \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", + \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" + }, + \\"repository\\": { + \\"type\\": \\"git\\", + \\"build\\": \\"ui5.js build --verbose --include-task generateCachebusterInfo\\" + }, + \\"ui5\\": { + \\"dependencies\\": [ + \\"@sap/ui5-builder-webide-extension\\", + \\"@ui5/task-adaptation\\" + ] + }, + \\"devDependencies\\": { + \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", + \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@ui5/task-adaptation\\": \\"^1.0.x\\", + \\"bestzip\\": \\"2.1.4\\", + \\"rimraf\\": \\"3.0.2\\" + } +} +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +builder: + customTasks: + - name: app-variant-bundler-build + beforeTask: escapeNonAsciiCharacters + configuration: + appHostId: app-host-id + appName: Base App + appVersion: 1.0.0 + moduleName: test-cf-project + org: org-guid + space: space-guid + html5RepoRuntime: runtime-guid + sapCloudService: test-solution +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/webapp/i18n/i18n.properties": Object { + "contents": "#Make sure you provide a unique prefix to the newly added keys in this file, to avoid overriding of SAP Fiori application keys. +#XTIT: Application name +test.namespace_sap.app.title=Test CF App", + "state": "modified", + }, + "../minimal-cf/test-cf-project/webapp/manifest.appdescr_variant": Object { + "contents": "{ + \\"fileName\\": \\"manifest\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"fileType\\": \\"appdescr_variant\\", + \\"reference\\": \\"my.test.cf.app\\", + \\"id\\": \\"test.namespace\\", + \\"namespace\\": \\"apps/my.test.cf.app/appVariants/test.namespace/\\", + \\"version\\": \\"0.1.0\\", + \\"content\\": [ + { + \\"changeType\\": \\"appdescr_ui5_setMinUI5Version\\", + \\"content\\": { + \\"minUI5Version\\": \\"1.120.0\\" + } + }, + { + \\"changeType\\": \\"appdescr_app_setTitle\\", + \\"content\\": {}, + \\"texts\\": { + \\"i18n\\": \\"i18n/i18n.properties\\" + } + } + ] +} +", + "state": "modified", + }, + "../minimal-cf/xs-security.json": Object { + "contents": "{ + \\"xsappname\\": \\"test-cf-project\\", + \\"tenant-mode\\": \\"dedicated\\", + \\"description\\": \\"Security profile of called application\\", + \\"scopes\\": [], + \\"role-templates\\": [] +} +", + "state": "modified", + }, + "mta.yaml": Object { + "contents": "ID: mta-project +version: 1.0.0 +modules: + - name: mta-project-destination-content + type: com.sap.application.content + requires: + - name: mta-project_uaa + parameters: + service-key: + name: mta-project-uaa-key + - name: mta-project_html_repo_host + parameters: + service-key: + name: mta-project-html_repo_host-key + - name: mta-project-destination + parameters: + content-target: true + - name: test-service + parameters: + service-key: + name: test-service-key + build-parameters: + no-source: true + parameters: + content: + instance: + destinations: + - Name: test-solution-mta-project-html_repo_host + ServiceInstanceName: mta-project-html5_app_host + ServiceKeyName: mta-project-html_repo_host-key + sap.cloud.service: test-solution + - Name: test-solution-uaa-mta-project + ServiceInstanceName: mta-project-xsuaa + ServiceKeyName: mta-project_uaa-key + Authentication: OAuth2UserTokenExchange + sap.cloud.service: test-solution + - Name: test-service-service_instance_name + Authentication: OAuth2UserTokenExchange + ServiceInstanceName: test-service + ServiceKeyName: test-service-key + existing_destinations_policy: update + - name: mta-project_ui_deployer + type: com.sap.application.content + path: . + requires: + - name: mta-project_html_repo_host + parameters: + content-target: true + build-parameters: + build-result: resources + requires: + - artifacts: + - my.test.cf.app.zip + name: my.test.cf.app + target-path: resources/ + - name: my.test.cf.app + type: html5 + path: my.test.cf.app + build-parameters: + builder: custom + commands: + - npm install + - npm run build + supported-platforms: [] +resources: + - name: test-destination-service + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-plan: lite + service-name: test-destination-service + - name: mta-project_html_repo_host + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-host + service-name: mta-project-html5_app_host + - name: mta-project_uaa + type: org.cloudfoundry.managed-service + parameters: + service: xsuaa + path: ./xs-security.json + service-plan: application + service-name: mta-project_1234567890-xsuaa + - name: mta-project-destination + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-name: mta-project-destination + service-plan: lite + config: + HTML5Runtime_enabled: true + version: 1.0.0 +_schema-version: '3.3' +description: Test MTA Project for CF Writer Tests +", + "state": "modified", + }, + "test-cf-project/.adp/config.json": Object { + "contents": "{ + \\"componentname\\": \\"test.namespace\\", + \\"appvariant\\": \\"test-cf-project\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"isOVPApp\\": false, + \\"isFioriElement\\": false, + \\"environment\\": \\"CF\\", + \\"ui5Version\\": \\"1.120.0\\", + \\"cfApiUrl\\": \\"/test.cf.com\\", + \\"cfSpace\\": \\"space-guid\\", + \\"cfOrganization\\": \\"org-guid\\" +} +", + "state": "modified", + }, + "test-cf-project/.gitignore": Object { + "contents": "/node_modules/ +/preview/ +/dist/ +*.tar +*.zip +UIAdaptation*.html +", + "state": "modified", + }, + "test-cf-project/package.json": Object { + "contents": "{ + \\"name\\": \\"test-cf-project\\", + \\"version\\": \\"1.0.0\\", + \\"description\\": \\"\\", + \\"main\\": \\"index.js\\", + \\"scripts\\": { + \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", + \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", + \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" + }, + \\"repository\\": { + \\"type\\": \\"git\\", + \\"build\\": \\"ui5.js build --verbose --include-task generateCachebusterInfo\\" + }, + \\"ui5\\": { + \\"dependencies\\": [ + \\"@sap/ui5-builder-webide-extension\\", + \\"@ui5/task-adaptation\\" + ] + }, + \\"devDependencies\\": { + \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", + \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@ui5/task-adaptation\\": \\"^1.0.x\\", + \\"bestzip\\": \\"2.1.4\\", + \\"rimraf\\": \\"3.0.2\\" + } +} +", + "state": "modified", + }, + "test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +builder: + customTasks: + - name: app-variant-bundler-build + beforeTask: escapeNonAsciiCharacters + configuration: + appHostId: app-host-id + appName: Base App + appVersion: 1.0.0 + moduleName: test-cf-project + org: org-guid + space: space-guid + html5RepoRuntime: runtime-guid + sapCloudService: test-solution +", + "state": "modified", + }, + "test-cf-project/webapp/i18n/i18n.properties": Object { + "contents": "#Make sure you provide a unique prefix to the newly added keys in this file, to avoid overriding of SAP Fiori application keys. +#XTIT: Application name +test.namespace_sap.app.title=Test CF App", + "state": "modified", + }, + "test-cf-project/webapp/manifest.appdescr_variant": Object { + "contents": "{ + \\"fileName\\": \\"manifest\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"fileType\\": \\"appdescr_variant\\", + \\"reference\\": \\"my.test.cf.app\\", + \\"id\\": \\"test.namespace\\", + \\"namespace\\": \\"apps/my.test.cf.app/appVariants/test.namespace/\\", + \\"version\\": \\"0.1.0\\", + \\"content\\": [ + { + \\"changeType\\": \\"appdescr_ui5_setMinUI5Version\\", + \\"content\\": { + \\"minUI5Version\\": \\"1.120.0\\" + } + }, + { + \\"changeType\\": \\"appdescr_app_setTitle\\", + \\"content\\": {}, + \\"texts\\": { + \\"i18n\\": \\"i18n/i18n.properties\\" + } + } + ] +} +", + "state": "modified", + }, + "xs-security.json": Object { + "contents": "{ + \\"xsappname\\": \\"test-cf-project\\", + \\"tenant-mode\\": \\"dedicated\\", + \\"description\\": \\"Security profile of called application\\", + \\"scopes\\": [], + \\"role-templates\\": [] +} +", + "state": "modified", + }, +} +`; + +exports[`CF Writer generateCf config with options 1`] = ` +Object { + "../managed-approuter/mta.yaml": Object { + "contents": "ID: mta-project +version: 1.0.0 +modules: + - name: mta-project-destination-content + type: com.sap.application.content + requires: + - name: mta-project_uaa + parameters: + service-key: + name: mta-project-uaa-key + - name: mta-project_html_repo_host + parameters: + service-key: + name: mta-project-html_repo_host-key + - name: mta-project-destination + parameters: + content-target: true + - name: test-service + parameters: + service-key: + name: test-service-key + build-parameters: + no-source: true + parameters: + content: + instance: + destinations: + - Name: test-solution-mta-project-html_repo_host + ServiceInstanceName: mta-project-html5_app_host + ServiceKeyName: mta-project-html_repo_host-key + sap.cloud.service: test-solution + - Name: test-solution-uaa-mta-project + ServiceInstanceName: mta-project-xsuaa + ServiceKeyName: mta-project_uaa-key + Authentication: OAuth2UserTokenExchange + sap.cloud.service: test-solution + - Name: test-service-service_instance_name + Authentication: OAuth2UserTokenExchange + ServiceInstanceName: test-service + ServiceKeyName: test-service-key + existing_destinations_policy: update + - name: mta-project_ui_deployer + type: com.sap.application.content + path: . + requires: + - name: mta-project_html_repo_host + parameters: + content-target: true + build-parameters: + build-result: resources + requires: + - artifacts: + - my.test.cf.app.zip + name: my.test.cf.app + target-path: resources/ + - name: my.test.cf.app + type: html5 + path: my.test.cf.app + build-parameters: + builder: custom + commands: + - npm install + - npm run build + supported-platforms: [] +resources: + - name: test-destination-service + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-plan: lite + service-name: test-destination-service + - name: mta-project_html_repo_host + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-host + service-name: mta-project-html5_app_host + - name: mta-project_uaa + type: org.cloudfoundry.managed-service + parameters: + service: xsuaa + path: ./xs-security.json + service-plan: application + service-name: mta-project_1234567890-xsuaa + - name: mta-project-destination + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-name: mta-project-destination + service-plan: lite + config: + HTML5Runtime_enabled: true + version: 1.0.0 +_schema-version: '3.3' +description: Test MTA Project for CF Writer Tests +", + "state": "modified", + }, + "../managed-approuter/test-cf-project/.adp/config.json": Object { + "contents": "{ + \\"componentname\\": \\"test.namespace\\", + \\"appvariant\\": \\"test-cf-project\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"isOVPApp\\": false, + \\"isFioriElement\\": false, + \\"environment\\": \\"CF\\", + \\"ui5Version\\": \\"1.120.0\\", + \\"cfApiUrl\\": \\"/test.cf.com\\", + \\"cfSpace\\": \\"space-guid\\", + \\"cfOrganization\\": \\"org-guid\\" +} +", + "state": "modified", + }, + "../managed-approuter/test-cf-project/.gitignore": Object { + "contents": "/node_modules/ +/preview/ +/dist/ +*.tar +*.zip +UIAdaptation*.html +", + "state": "modified", + }, + "../managed-approuter/test-cf-project/package.json": Object { + "contents": "{ + \\"name\\": \\"test-cf-project\\", + \\"version\\": \\"1.0.0\\", + \\"description\\": \\"\\", + \\"main\\": \\"index.js\\", + \\"scripts\\": { + \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", + \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", + \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" + }, + \\"repository\\": { + \\"type\\": \\"git\\", + \\"build\\": \\"ui5.js build --verbose --include-task generateCachebusterInfo\\" + }, + \\"ui5\\": { + \\"dependencies\\": [ + \\"@sap/ui5-builder-webide-extension\\", + \\"@ui5/task-adaptation\\" + ] + }, + \\"devDependencies\\": { + \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", + \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@ui5/task-adaptation\\": \\"^1.0.x\\", + \\"bestzip\\": \\"2.1.4\\", + \\"rimraf\\": \\"3.0.2\\" + } +} +", + "state": "modified", + }, + "../managed-approuter/test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +builder: + customTasks: + - name: app-variant-bundler-build + beforeTask: escapeNonAsciiCharacters + configuration: + appHostId: app-host-id + appName: Base App + appVersion: 1.0.0 + moduleName: test-cf-project + org: org-guid + space: space-guid + html5RepoRuntime: runtime-guid + sapCloudService: test-solution +", + "state": "modified", + }, + "../managed-approuter/test-cf-project/webapp/i18n/i18n.properties": Object { + "contents": "#Make sure you provide a unique prefix to the newly added keys in this file, to avoid overriding of SAP Fiori application keys. +#XTIT: Application name +test.namespace_sap.app.title=Test CF App", + "state": "modified", + }, + "../managed-approuter/test-cf-project/webapp/manifest.appdescr_variant": Object { + "contents": "{ + \\"fileName\\": \\"manifest\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"fileType\\": \\"appdescr_variant\\", + \\"reference\\": \\"my.test.cf.app\\", + \\"id\\": \\"test.namespace\\", + \\"namespace\\": \\"apps/my.test.cf.app/appVariants/test.namespace/\\", + \\"version\\": \\"0.1.0\\", + \\"content\\": [ + { + \\"changeType\\": \\"appdescr_ui5_setMinUI5Version\\", + \\"content\\": { + \\"minUI5Version\\": \\"1.120.0\\" + } + }, + { + \\"changeType\\": \\"appdescr_app_setTitle\\", + \\"content\\": {}, + \\"texts\\": { + \\"i18n\\": \\"i18n/i18n.properties\\" + } + } + ] +} +", + "state": "modified", + }, + "../managed-approuter/xs-security.json": Object { + "contents": "{ + \\"xsappname\\": \\"test-cf-project\\", + \\"tenant-mode\\": \\"dedicated\\", + \\"description\\": \\"Security profile of called application\\", + \\"scopes\\": [], + \\"role-templates\\": [] +} +", + "state": "modified", + }, + "../minimal-cf/mta.yaml": Object { + "contents": "ID: mta-project +version: 1.0.0 +modules: + - name: mta-project-approuter + type: approuter.nodejs + path: mta-project-approuter + requires: + - name: mta-project_html_repo_runtime + - name: mta-project_uaa + - name: portal_resources_mta-project + - name: test-service + parameters: + disk-quota: 256M + memory: 256M + - name: mta-project_ui_deployer + type: com.sap.application.content + path: . + requires: + - name: mta-project_html_repo_host + parameters: + content-target: true + build-parameters: + build-result: resources + requires: + - artifacts: + - my.test.cf.app.zip + name: my.test.cf.app + target-path: resources/ + - name: my.test.cf.app + type: html5 + path: my.test.cf.app + build-parameters: + builder: custom + commands: + - npm install + - npm run build + supported-platforms: [] +resources: + - name: test-destination-service + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-plan: lite + service-name: test-destination-service + - name: mta-project_html_repo_host + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-host + service-name: mta-project-html5_app_host + - name: mta-project_uaa + type: org.cloudfoundry.managed-service + parameters: + service: xsuaa + path: ./xs-security.json + service-plan: application + service-name: mta-project_1234567890-xsuaa + - name: portal_resources_mta-project + type: org.cloudfoundry.managed-service + parameters: + service: portal + service-plan: standard + - name: mta-project_html_repo_runtime + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-runtime +_schema-version: '3.3' +description: Test MTA Project for CF Writer Tests +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/.adp/config.json": Object { + "contents": "{ + \\"componentname\\": \\"test.namespace\\", + \\"appvariant\\": \\"test-cf-project\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"isOVPApp\\": false, + \\"isFioriElement\\": false, + \\"environment\\": \\"CF\\", + \\"ui5Version\\": \\"1.120.0\\", + \\"cfApiUrl\\": \\"/test.cf.com\\", + \\"cfSpace\\": \\"space-guid\\", + \\"cfOrganization\\": \\"org-guid\\" +} +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/.gitignore": Object { + "contents": "/node_modules/ +/preview/ +/dist/ +*.tar +*.zip +UIAdaptation*.html +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/package.json": Object { + "contents": "{ + \\"name\\": \\"test-cf-project\\", + \\"version\\": \\"1.0.0\\", + \\"description\\": \\"\\", + \\"main\\": \\"index.js\\", + \\"scripts\\": { + \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", + \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", + \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" + }, + \\"repository\\": { + \\"type\\": \\"git\\", + \\"build\\": \\"ui5.js build --verbose --include-task generateCachebusterInfo\\" + }, + \\"ui5\\": { + \\"dependencies\\": [ + \\"@sap/ui5-builder-webide-extension\\", + \\"@ui5/task-adaptation\\" + ] + }, + \\"devDependencies\\": { + \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", + \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@ui5/task-adaptation\\": \\"^1.0.x\\", + \\"bestzip\\": \\"2.1.4\\", + \\"rimraf\\": \\"3.0.2\\" + } +} +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +builder: + customTasks: + - name: app-variant-bundler-build + beforeTask: escapeNonAsciiCharacters + configuration: + appHostId: app-host-id + appName: Base App + appVersion: 1.0.0 + moduleName: test-cf-project + org: org-guid + space: space-guid + html5RepoRuntime: runtime-guid + sapCloudService: test-solution +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/webapp/i18n/i18n.properties": Object { + "contents": "#Make sure you provide a unique prefix to the newly added keys in this file, to avoid overriding of SAP Fiori application keys. +#XTIT: Application name +test.namespace_sap.app.title=Test CF App", + "state": "modified", + }, + "../minimal-cf/test-cf-project/webapp/manifest.appdescr_variant": Object { + "contents": "{ + \\"fileName\\": \\"manifest\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"fileType\\": \\"appdescr_variant\\", + \\"reference\\": \\"my.test.cf.app\\", + \\"id\\": \\"test.namespace\\", + \\"namespace\\": \\"apps/my.test.cf.app/appVariants/test.namespace/\\", + \\"version\\": \\"0.1.0\\", + \\"content\\": [ + { + \\"changeType\\": \\"appdescr_ui5_setMinUI5Version\\", + \\"content\\": { + \\"minUI5Version\\": \\"1.120.0\\" + } + }, + { + \\"changeType\\": \\"appdescr_app_setTitle\\", + \\"content\\": {}, + \\"texts\\": { + \\"i18n\\": \\"i18n/i18n.properties\\" + } + } + ] +} +", + "state": "modified", + }, + "../minimal-cf/xs-security.json": Object { + "contents": "{ + \\"xsappname\\": \\"test-cf-project\\", + \\"tenant-mode\\": \\"dedicated\\", + \\"description\\": \\"Security profile of called application\\", + \\"scopes\\": [], + \\"role-templates\\": [] +} +", + "state": "modified", + }, + "mta.yaml": Object { + "contents": "ID: mta-project +version: 1.0.0 +modules: + - name: mta-project-destination-content + type: com.sap.application.content + requires: + - name: mta-project_uaa + parameters: + service-key: + name: mta-project-uaa-key + - name: mta-project_html_repo_host + parameters: + service-key: + name: mta-project-html_repo_host-key + - name: mta-project-destination + parameters: + content-target: true + - name: test-service + parameters: + service-key: + name: test-service-key + build-parameters: + no-source: true + parameters: + content: + instance: + destinations: + - Name: test-solution-mta-project-html_repo_host + ServiceInstanceName: mta-project-html5_app_host + ServiceKeyName: mta-project-html_repo_host-key + sap.cloud.service: test-solution + - Name: test-solution-uaa-mta-project + ServiceInstanceName: mta-project-xsuaa + ServiceKeyName: mta-project_uaa-key + Authentication: OAuth2UserTokenExchange + sap.cloud.service: test-solution + - Name: test-service-service_instance_name + Authentication: OAuth2UserTokenExchange + ServiceInstanceName: test-service + ServiceKeyName: test-service-key + existing_destinations_policy: update + - name: mta-project_ui_deployer + type: com.sap.application.content + path: . + requires: + - name: mta-project_html_repo_host + parameters: + content-target: true + build-parameters: + build-result: resources + requires: + - artifacts: + - my.test.cf.app.zip + name: my.test.cf.app + target-path: resources/ + - name: my.test.cf.app + type: html5 + path: my.test.cf.app + build-parameters: + builder: custom + commands: + - npm install + - npm run build + supported-platforms: [] +resources: + - name: test-destination-service + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-plan: lite + service-name: test-destination-service + - name: mta-project_html_repo_host + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-host + service-name: mta-project-html5_app_host + - name: mta-project_uaa + type: org.cloudfoundry.managed-service + parameters: + service: xsuaa + path: ./xs-security.json + service-plan: application + service-name: mta-project_1234567890-xsuaa + - name: mta-project-destination + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-name: mta-project-destination + service-plan: lite + config: + HTML5Runtime_enabled: true + version: 1.0.0 +_schema-version: '3.3' +description: Test MTA Project for CF Writer Tests +", + "state": "modified", + }, + "test-cf-project-approuter/package.json": Object { + "contents": "{ + \\"name\\": \\"test-cf-project-approuter\\", + \\"description\\": \\"Node.js based application router service for html5-apps\\", + \\"engines\\": { + \\"node\\": \\"^10.0.0\\" + }, + \\"dependencies\\": { + \\"@sap/approuter\\": \\"^7.0.0\\" + }, + \\"scripts\\": { + \\"start\\": \\"node node_modules/@sap/approuter/approuter.js\\" + } +} +", + "state": "modified", + }, + "test-cf-project-approuter/xs-app.json": Object { + "contents": "{ + \\"welcomeFile\\": \\"/cp.portal\\", + \\"authenticationMethod\\": \\"route\\", + \\"logout\\": { + \\"logoutEndpoint\\": \\"/do/logout\\" + }, + \\"routes\\": [ + { + \\"source\\": \\"^(.*)$\\", + \\"target\\": \\"$1\\", + \\"service\\": \\"html5-apps-repo-rt\\", + \\"authenticationType\\": \\"xsuaa\\" + } + ] +} +", + "state": "modified", + }, + "test-cf-project/.adp/config.json": Object { + "contents": "{ + \\"componentname\\": \\"test.namespace\\", + \\"appvariant\\": \\"test-cf-project\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"isOVPApp\\": false, + \\"isFioriElement\\": false, + \\"environment\\": \\"CF\\", + \\"ui5Version\\": \\"1.120.0\\", + \\"cfApiUrl\\": \\"/test.cf.com\\", + \\"cfSpace\\": \\"space-guid\\", + \\"cfOrganization\\": \\"org-guid\\" +} +", + "state": "modified", + }, + "test-cf-project/.gitignore": Object { + "contents": "/node_modules/ +/preview/ +/dist/ +*.tar +*.zip +UIAdaptation*.html +", + "state": "modified", + }, + "test-cf-project/package.json": Object { + "contents": "{ + \\"name\\": \\"test-cf-project\\", + \\"version\\": \\"1.0.0\\", + \\"description\\": \\"\\", + \\"main\\": \\"index.js\\", + \\"scripts\\": { + \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", + \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", + \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" + }, + \\"repository\\": { + \\"type\\": \\"git\\", + \\"build\\": \\"ui5.js build --verbose --include-task generateCachebusterInfo\\" + }, + \\"ui5\\": { + \\"dependencies\\": [ + \\"@sap/ui5-builder-webide-extension\\", + \\"@ui5/task-adaptation\\" + ] + }, + \\"devDependencies\\": { + \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", + \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@ui5/task-adaptation\\": \\"^1.0.x\\", + \\"bestzip\\": \\"2.1.4\\", + \\"rimraf\\": \\"3.0.2\\" + } +} +", + "state": "modified", + }, + "test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +builder: + customTasks: + - name: app-variant-bundler-build + beforeTask: escapeNonAsciiCharacters + configuration: + appHostId: app-host-id + appName: Base App + appVersion: 1.0.0 + moduleName: test-cf-project + org: org-guid + space: space-guid + html5RepoRuntime: runtime-guid + sapCloudService: test-solution +", + "state": "modified", + }, + "test-cf-project/webapp/i18n/i18n.properties": Object { + "contents": "#Make sure you provide a unique prefix to the newly added keys in this file, to avoid overriding of SAP Fiori application keys. +#XTIT: Application name +test.namespace_sap.app.title=Test CF App", + "state": "modified", + }, + "test-cf-project/webapp/manifest.appdescr_variant": Object { + "contents": "{ + \\"fileName\\": \\"manifest\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"fileType\\": \\"appdescr_variant\\", + \\"reference\\": \\"my.test.cf.app\\", + \\"id\\": \\"test.namespace\\", + \\"namespace\\": \\"apps/my.test.cf.app/appVariants/test.namespace/\\", + \\"version\\": \\"0.1.0\\", + \\"content\\": [ + { + \\"changeType\\": \\"appdescr_ui5_setMinUI5Version\\", + \\"content\\": { + \\"minUI5Version\\": \\"1.120.0\\" + } + }, + { + \\"changeType\\": \\"appdescr_app_setTitle\\", + \\"content\\": {}, + \\"texts\\": { + \\"i18n\\": \\"i18n/i18n.properties\\" + } + } + ] +} +", + "state": "modified", + }, + "xs-security.json": Object { + "contents": "{ + \\"xsappname\\": \\"test-cf-project\\", + \\"tenant-mode\\": \\"dedicated\\", + \\"description\\": \\"Security profile of called application\\", + \\"scopes\\": [], + \\"role-templates\\": [] +} +", + "state": "modified", + }, +} +`; + +exports[`CF Writer generateCf minimal config 1`] = ` +Object { + "mta.yaml": Object { + "contents": "ID: mta-project +version: 1.0.0 +modules: + - name: mta-project-approuter + type: approuter.nodejs + path: mta-project-approuter + requires: + - name: mta-project_html_repo_runtime + - name: mta-project_uaa + - name: portal_resources_mta-project + - name: test-service + parameters: + disk-quota: 256M + memory: 256M + - name: mta-project_ui_deployer + type: com.sap.application.content + path: . + requires: + - name: mta-project_html_repo_host + parameters: + content-target: true + build-parameters: + build-result: resources + requires: + - artifacts: + - my.test.cf.app.zip + name: my.test.cf.app + target-path: resources/ + - name: my.test.cf.app + type: html5 + path: my.test.cf.app + build-parameters: + builder: custom + commands: + - npm install + - npm run build + supported-platforms: [] +resources: + - name: test-destination-service + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-plan: lite + service-name: test-destination-service + - name: mta-project_html_repo_host + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-host + service-name: mta-project-html5_app_host + - name: mta-project_uaa + type: org.cloudfoundry.managed-service + parameters: + service: xsuaa + path: ./xs-security.json + service-plan: application + service-name: mta-project_1234567890-xsuaa + - name: portal_resources_mta-project + type: org.cloudfoundry.managed-service + parameters: + service: portal + service-plan: standard + - name: mta-project_html_repo_runtime + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-runtime +_schema-version: '3.3' +description: Test MTA Project for CF Writer Tests +", + "state": "modified", + }, + "test-cf-project/.adp/config.json": Object { + "contents": "{ + \\"componentname\\": \\"test.namespace\\", + \\"appvariant\\": \\"test-cf-project\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"isOVPApp\\": false, + \\"isFioriElement\\": false, + \\"environment\\": \\"CF\\", + \\"ui5Version\\": \\"1.120.0\\", + \\"cfApiUrl\\": \\"/test.cf.com\\", + \\"cfSpace\\": \\"space-guid\\", + \\"cfOrganization\\": \\"org-guid\\" +} +", + "state": "modified", + }, + "test-cf-project/.gitignore": Object { + "contents": "/node_modules/ +/preview/ +/dist/ +*.tar +*.zip +UIAdaptation*.html +", + "state": "modified", + }, + "test-cf-project/package.json": Object { + "contents": "{ + \\"name\\": \\"test-cf-project\\", + \\"version\\": \\"1.0.0\\", + \\"description\\": \\"\\", + \\"main\\": \\"index.js\\", + \\"scripts\\": { + \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", + \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", + \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" + }, + \\"repository\\": { + \\"type\\": \\"git\\", + \\"build\\": \\"ui5.js build --verbose --include-task generateCachebusterInfo\\" + }, + \\"ui5\\": { + \\"dependencies\\": [ + \\"@sap/ui5-builder-webide-extension\\", + \\"@ui5/task-adaptation\\" + ] + }, + \\"devDependencies\\": { + \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", + \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@ui5/task-adaptation\\": \\"^1.0.x\\", + \\"bestzip\\": \\"2.1.4\\", + \\"rimraf\\": \\"3.0.2\\" + } +} +", + "state": "modified", + }, + "test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +builder: + customTasks: + - name: app-variant-bundler-build + beforeTask: escapeNonAsciiCharacters + configuration: + appHostId: app-host-id + appName: Base App + appVersion: 1.0.0 + moduleName: test-cf-project + org: org-guid + space: space-guid + html5RepoRuntime: runtime-guid + sapCloudService: test-solution +", + "state": "modified", + }, + "test-cf-project/webapp/i18n/i18n.properties": Object { + "contents": "#Make sure you provide a unique prefix to the newly added keys in this file, to avoid overriding of SAP Fiori application keys. +#XTIT: Application name +test.namespace_sap.app.title=Test CF App", + "state": "modified", + }, + "test-cf-project/webapp/manifest.appdescr_variant": Object { + "contents": "{ + \\"fileName\\": \\"manifest\\", + \\"layer\\": \\"CUSTOMER_BASE\\", + \\"fileType\\": \\"appdescr_variant\\", + \\"reference\\": \\"my.test.cf.app\\", + \\"id\\": \\"test.namespace\\", + \\"namespace\\": \\"apps/my.test.cf.app/appVariants/test.namespace/\\", + \\"version\\": \\"0.1.0\\", + \\"content\\": [ + { + \\"changeType\\": \\"appdescr_ui5_setMinUI5Version\\", + \\"content\\": { + \\"minUI5Version\\": \\"1.120.0\\" + } + }, + { + \\"changeType\\": \\"appdescr_app_setTitle\\", + \\"content\\": {}, + \\"texts\\": { + \\"i18n\\": \\"i18n/i18n.properties\\" + } + } + ] +} +", + "state": "modified", + }, + "xs-security.json": Object { + "contents": "{ + \\"xsappname\\": \\"test-cf-project\\", + \\"tenant-mode\\": \\"dedicated\\", + \\"description\\": \\"Security profile of called application\\", + \\"scopes\\": [], + \\"role-templates\\": [] +} +", + "state": "modified", + }, +} +`; diff --git a/packages/adp-tooling/test/unit/writer/cf.test.ts b/packages/adp-tooling/test/unit/writer/cf.test.ts new file mode 100644 index 00000000000..85837785ccb --- /dev/null +++ b/packages/adp-tooling/test/unit/writer/cf.test.ts @@ -0,0 +1,149 @@ +import { join } from 'path'; +import { rimraf } from 'rimraf'; +import { create } from 'mem-fs-editor'; +import { create as createStorage } from 'mem-fs'; +import { writeFileSync, mkdirSync } from 'fs'; + +import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; + +import { generateCf } from '../../../src/writer/cf'; +import { AppRouterType, FlexLayer, type CfAdpWriterConfig } from '../../../src/types'; + +jest.mock('../../../src/cf/services/api', () => ({ + createServices: jest.fn().mockResolvedValue(undefined) +})); + +const config: CfAdpWriterConfig = { + app: { + id: 'my.test.cf.app', + title: 'Test CF App', + layer: FlexLayer.CUSTOMER_BASE, + namespace: 'test.namespace', + manifest: { + 'sap.app': { + id: 'my.test.cf.app', + title: 'Test CF App' + }, + 'sap.ui5': { + flexEnabled: true + } + } as unknown as Manifest + }, + baseApp: { + appId: 'base-app-id', + appName: 'Base App', + appVersion: '1.0.0', + appHostId: 'app-host-id', + serviceName: 'base-service', + title: 'Base App Title' + }, + cf: { + url: '/test.cf.com', + org: { GUID: 'org-guid', Name: 'test-org' }, + space: { GUID: 'space-guid', Name: 'test-space' }, + html5RepoRuntimeGuid: 'runtime-guid', + approuter: AppRouterType.STANDALONE, + businessService: 'test-service', + businessSolutionName: 'test-solution' + }, + project: { + name: 'test-cf-project', + path: '/test/cf/path', + folder: '' // This will be set in each test + }, + ui5: { + version: '1.120.0' + }, + options: { + addStandaloneApprouter: false, + addSecurity: false + } +}; + +function createConfigWithProjectPath(projectDir: string): CfAdpWriterConfig { + return { + ...config, + project: { + ...config.project, + folder: join(projectDir, 'test-cf-project') + } + }; +} + +describe('CF Writer', () => { + const fs = create(createStorage()); + const debug = true || !!process.env['UX_DEBUG']; + const outputDir = join(__dirname, '../../fixtures/test-output'); + + describe('generateCf', () => { + const mtaProjectDir = join(__dirname, '../../fixtures/mta-project'); + const originalMtaYaml = fs.read(join(mtaProjectDir, 'mta.yaml')); + + const mockLogger = { + debug: jest.fn() + } as unknown as ToolsLogger; + + beforeAll(async () => { + await rimraf(outputDir); + }, 10000); + + afterAll(() => { + return new Promise((resolve) => { + // write out the files for debugging + if (debug) { + fs.commit(resolve); + } else { + resolve(true); + } + }); + }); + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(1234567890); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('minimal config', async () => { + const projectDir = join(outputDir, 'minimal-cf'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'mta.yaml'), originalMtaYaml); + + await generateCf(projectDir, createConfigWithProjectPath(projectDir), mockLogger, fs); + + expect(fs.dump(projectDir)).toMatchSnapshot(); + }); + + test('config with managed approuter', async () => { + const projectDir = join(outputDir, 'managed-approuter'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'mta.yaml'), originalMtaYaml); + + const customConfig = createConfigWithProjectPath(projectDir); + customConfig.cf.approuter = AppRouterType.MANAGED; + + await generateCf(projectDir, customConfig, mockLogger, fs); + + expect(fs.dump(projectDir)).toMatchSnapshot(); + }); + + test('config with options', async () => { + const projectDir = join(outputDir, 'options'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'mta.yaml'), originalMtaYaml); + + const customConfig = createConfigWithProjectPath(projectDir); + customConfig.options = { + addStandaloneApprouter: true, + addSecurity: true + }; + + await generateCf(projectDir, customConfig, mockLogger, fs); + + expect(fs.dump(projectDir)).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/adp-tooling/test/unit/writer/writer-config.test.ts b/packages/adp-tooling/test/unit/writer/writer-config.test.ts index 82e01efc118..7b626cacf06 100644 --- a/packages/adp-tooling/test/unit/writer/writer-config.test.ts +++ b/packages/adp-tooling/test/unit/writer/writer-config.test.ts @@ -2,10 +2,9 @@ import { join } from 'path'; import { readFileSync } from 'fs'; import type { ToolsLogger } from '@sap-ux/logger'; -import type { Package } from '@sap-ux/project-access'; +import type { Manifest, Package } from '@sap-ux/project-access'; import { type AbapServiceProvider, AdaptationProjectType } from '@sap-ux/axios-extension'; -import { FlexLayer, getProviderConfig, getConfig } from '../../../src'; import type { AttributesAnswers, ConfigAnswers, @@ -15,6 +14,10 @@ import type { VersionDetail } from '../../../src'; import { t } from '../../../src/i18n'; +import { AppRouterType } from '../../../src/types'; +import { getCfConfig } from '../../../src/writer/writer-config'; +import { FlexLayer, getProviderConfig, getConfig } from '../../../src'; +import type { CfConfig, CfServicesAnswers, CreateCfConfigParams } from '../../../src/types'; const basePath = join(__dirname, '../../fixtures/base-app/manifest.json'); const manifest = JSON.parse(readFileSync(basePath, 'utf-8')); @@ -115,3 +118,63 @@ describe('getConfig', () => { }); }); }); + +describe('getCfConfig', () => { + const baseParams: CreateCfConfigParams = { + projectPath: '/test/project', + layer: FlexLayer.CUSTOMER_BASE, + attributeAnswers: { + title: 'Test App', + namespace: 'test.namespace', + projectName: 'test-project' + } as AttributesAnswers, + manifest: { + 'sap.app': { + id: 'test.app', + title: 'Test App' + } + } as Manifest, + cfConfig: { + url: '/test.cf.com', + org: { GUID: 'org-guid', Name: 'test-org' }, + space: { GUID: 'space-guid', Name: 'test-space' }, + token: 'test-token' + } as CfConfig, + cfServicesAnswers: { + baseApp: { + appId: 'base-app-id', + appName: 'Base App', + appVersion: '1.0.0', + appHostId: 'app-host-id', + serviceName: 'base-service', + title: 'Base App Title' + }, + approuter: AppRouterType.MANAGED, + businessService: 'test-service', + businessSolutionName: 'test-solution' + } as CfServicesAnswers, + html5RepoRuntimeGuid: 'runtime-guid', + publicVersions: { latest: { version: '1.135.0' } as VersionDetail } + }; + + test('should create CF config with managed approuter', () => { + const result = getCfConfig(baseParams); + + expect(result.cf.approuter).toBe(AppRouterType.MANAGED); + expect(result.options?.addStandaloneApprouter).toBe(false); + expect(result.app.id).toBe('base-app-id'); + expect(result.project.folder).toBe('/test/project/test-project'); + }); + + test('should throw error when baseApp is missing', () => { + const params = { + ...baseParams, + cfServicesAnswers: { + ...baseParams.cfServicesAnswers, + baseApp: undefined + } + }; + + expect(() => getCfConfig(params)).toThrow(t('errors.baseAppRequired')); + }); +}); diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index e9c0d9017b3..1058b005da9 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -569,7 +569,7 @@ export default class extends Generator { publicVersions }); - await generateCf(projectPath, cfConfig, this.fs); + await generateCf(projectPath, cfConfig, this.logger, this.fs); } /** From af9eed6eb9771c0603c5bcfc0dade9296ef3a0f7 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 26 Sep 2025 15:03:29 +0300 Subject: [PATCH 078/111] test: fix windows tests --- .../test/unit/cf/project/yaml.test.ts | 19 ++++++++++--------- .../test/unit/writer/writer-config.test.ts | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts index ef1a43d67d7..5a242db7805 100644 --- a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts @@ -14,6 +14,7 @@ import { AppRouterType } from '../../../../src/types'; import type { MtaYaml, CfUI5Yaml } from '../../../../src/types'; import { createServices } from '../../../../src/cf/services/api'; import { getProjectNameForXsSecurity, getYamlContent } from '../../../../src/cf/project/yaml-loader'; +import { join } from 'path'; jest.mock('fs', () => ({ existsSync: jest.fn() @@ -46,7 +47,7 @@ describe('YAML Project Functions', () => { describe('isMtaProject', () => { const selectedPath = '/test/project'; - const mtaYamlPath = '/test/project/mta.yaml'; + const mtaYamlPath = join(selectedPath, 'mta.yaml'); test('should return true when mta.yaml exists', () => { mockExistsSync.mockReturnValue(true); @@ -195,7 +196,7 @@ describe('YAML Project Functions', () => { describe('getAppParamsFromUI5Yaml', () => { const projectPath = '/test/project'; - const ui5YamlPath = '/test/project/ui5.yaml'; + const ui5YamlPath = join(projectPath, 'ui5.yaml'); test('should return app params from UI5 YAML', () => { const mockUI5Yaml: CfUI5Yaml = { @@ -284,7 +285,7 @@ describe('YAML Project Functions', () => { }); test('should adjust MTA YAML for standalone approuter', async () => { - const mtaYamlPath = '/test/project/mta.yaml'; + const mtaYamlPath = join(projectPath, 'mta.yaml'); const mockYamlContent: MtaYaml = { '_schema-version': '3.2.0', ID: 'test-project', @@ -314,7 +315,7 @@ describe('YAML Project Functions', () => { }); test('should adjust MTA YAML for managed approuter', async () => { - const mtaYamlPath = '/test/project/mta.yaml'; + const mtaYamlPath = join(projectPath, 'mta.yaml'); const mockYamlContent: MtaYaml = { '_schema-version': '3.2.0', ID: 'test-project', @@ -344,7 +345,7 @@ describe('YAML Project Functions', () => { }); test('should auto-detect approuter type when not provided', async () => { - const mtaYamlPath = '/test/project/mta.yaml'; + const mtaYamlPath = join(projectPath, 'mta.yaml'); const mockYamlContent: MtaYaml = { '_schema-version': '3.2.0', ID: 'test-project', @@ -409,7 +410,7 @@ describe('YAML Project Functions', () => { }); test('should handle existing approuter module for managed approuter', async () => { - const mtaYamlPath = '/test/project/mta.yaml'; + const mtaYamlPath = join(projectPath, 'mta.yaml'); const mockYamlContent: MtaYaml = { '_schema-version': '3.2.0', ID: 'test-project', @@ -458,7 +459,7 @@ describe('YAML Project Functions', () => { }); test('should add required modules and move FLP module to last position', async () => { - const mtaYamlPath = '/test/project/mta.yaml'; + const mtaYamlPath = join(projectPath, 'mta.yaml'); const mockYamlContent: MtaYaml = { '_schema-version': '3.2.0', ID: 'test-project', @@ -523,7 +524,7 @@ describe('YAML Project Functions', () => { }); test('should not modify FLP modules with wrong service-key name', async () => { - const mtaYamlPath = '/test/project/mta.yaml'; + const mtaYamlPath = join(projectPath, 'mta.yaml'); const mockYamlContent: MtaYaml = { '_schema-version': '3.2.0', ID: 'test-project', @@ -579,7 +580,7 @@ describe('YAML Project Functions', () => { }); test('should not add duplicate modules when they already exist', async () => { - const mtaYamlPath = '/test/project/mta.yaml'; + const mtaYamlPath = join(projectPath, 'mta.yaml'); const mockYamlContent: MtaYaml = { '_schema-version': '3.2.0', ID: 'test-project', diff --git a/packages/adp-tooling/test/unit/writer/writer-config.test.ts b/packages/adp-tooling/test/unit/writer/writer-config.test.ts index 7b626cacf06..8e852c9f970 100644 --- a/packages/adp-tooling/test/unit/writer/writer-config.test.ts +++ b/packages/adp-tooling/test/unit/writer/writer-config.test.ts @@ -163,7 +163,7 @@ describe('getCfConfig', () => { expect(result.cf.approuter).toBe(AppRouterType.MANAGED); expect(result.options?.addStandaloneApprouter).toBe(false); expect(result.app.id).toBe('base-app-id'); - expect(result.project.folder).toBe('/test/project/test-project'); + expect(result.project.folder).toBe(join('/test/project', 'test-project')); }); test('should throw error when baseApp is missing', () => { From 3ce18dc8c89cf19b68f68ccac3fb2f1414c1fc7d Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 26 Sep 2025 15:55:02 +0300 Subject: [PATCH 079/111] fix: sonar issues --- packages/adp-tooling/src/cf/core/auth.ts | 17 +-- .../adp-tooling/src/cf/project/yaml-loader.ts | 2 +- packages/adp-tooling/src/cf/project/yaml.ts | 23 ++-- packages/adp-tooling/src/cf/services/cli.ts | 11 +- packages/adp-tooling/src/writer/cf.ts | 14 ++- .../test/unit/cf/core/auth.test.ts | 29 +---- .../test/unit/cf/project/yaml-loader.test.ts | 2 +- .../test/unit/cf/project/yaml.test.ts | 112 ++++++++++-------- .../test/unit/cf/services/cli.test.ts | 31 +++-- packages/generator-adp/src/app/index.ts | 2 +- 10 files changed, 122 insertions(+), 121 deletions(-) diff --git a/packages/adp-tooling/src/cf/core/auth.ts b/packages/adp-tooling/src/cf/core/auth.ts index 4f204581593..66d84323311 100644 --- a/packages/adp-tooling/src/cf/core/auth.ts +++ b/packages/adp-tooling/src/cf/core/auth.ts @@ -2,24 +2,9 @@ import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import type { ToolsLogger } from '@sap-ux/logger'; -import { getAuthToken, checkForCf } from '../services/cli'; +import { getAuthToken } from '../services/cli'; import type { CfConfig, Organization } from '../../types'; -/** - * Check if CF is installed. - * - * @returns {Promise} True if CF is installed, false otherwise. - */ -export async function isCfInstalled(): Promise { - try { - await checkForCf(); - } catch (e) { - return false; - } - - return true; -} - /** * Check if the external login is enabled. * diff --git a/packages/adp-tooling/src/cf/project/yaml-loader.ts b/packages/adp-tooling/src/cf/project/yaml-loader.ts index 0eecde3a290..6dae88ab2a4 100644 --- a/packages/adp-tooling/src/cf/project/yaml-loader.ts +++ b/packages/adp-tooling/src/cf/project/yaml-loader.ts @@ -20,7 +20,7 @@ export function getYamlContent(filePath: string): T { parsed = yaml.load(content) as T; return parsed; } catch (e) { - throw new Error(`Error parsing file ${filePath}`); + throw new Error(`Error parsing file ${filePath}: ${e.message}`); } } diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index 11a27170e03..656c160da47 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -22,6 +22,15 @@ const CF_MANAGED_SERVICE = 'org.cloudfoundry.managed-service'; const HTML5_APPS_REPO = 'html5-apps-repo'; const SAP_APPLICATION_CONTENT = 'com.sap.application.content'; +interface AdjustMtaYamlParams { + projectPath: string; + moduleName: string; + appRouterType: AppRouterType; + businessSolutionName: string; + businessService: string; + spaceGuid: string; +} + /** * Checks if the selected path is a MTA project. * @@ -394,23 +403,13 @@ function adjustMtaYamlFlpModule(yamlContent: MtaYaml, projectName: string, busin /** * Adjusts the MTA YAML. * - * @param {string} projectPath - The project path. - * @param {string} moduleName - The module name. - * @param {AppRouterType} appRouterType - The app router type. - * @param {string} businessSolutionName - The business solution name. - * @param {string} businessService - The business service. - * @param {string} spaceGuid - The space GUID. + * @param {AdjustMtaYamlParams} params - The parameters. * @param {Editor} memFs - The mem-fs editor instance. * @param {ToolsLogger} logger - The logger. * @returns {Promise} The promise. */ export async function adjustMtaYaml( - projectPath: string, - moduleName: string, - appRouterType: AppRouterType, - businessSolutionName: string, - businessService: string, - spaceGuid: string, + { projectPath, moduleName, appRouterType, businessSolutionName, businessService, spaceGuid }: AdjustMtaYamlParams, memFs: Editor, logger?: ToolsLogger ): Promise { diff --git a/packages/adp-tooling/src/cf/services/cli.ts b/packages/adp-tooling/src/cf/services/cli.ts index ad21b57a711..0b08344828c 100644 --- a/packages/adp-tooling/src/cf/services/cli.ts +++ b/packages/adp-tooling/src/cf/services/cli.ts @@ -2,6 +2,8 @@ import CFLocal = require('@sap/cf-tools/out/src/cf-local'); import CFToolsCli = require('@sap/cf-tools/out/src/cli'); import { eFilters } from '@sap/cf-tools/out/src/types'; +import type { ToolsLogger } from '@sap-ux/logger'; + import { t } from '../../i18n'; import type { CfCredentials } from '../../types'; @@ -22,15 +24,20 @@ export async function getAuthToken(): Promise { /** * Checks if Cloud Foundry is installed. + * + * @param {ToolsLogger} logger - The logger. + * @returns {Promise} True if CF is installed, false otherwise. */ -export async function checkForCf(): Promise { +export async function isCfInstalled(logger: ToolsLogger): Promise { try { const response = await CFToolsCli.Cli.execute(['version'], ENV); if (response.exitCode !== 0) { throw new Error(response.stderr); } + return true; } catch (e) { - throw new Error(t('error.cfNotInstalled', { error: e.message })); + logger.error(t('error.cfNotInstalled', { error: e.message })); + return false; } } diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 25d561fb085..c4ac32077b6 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -33,12 +33,14 @@ export async function generateCf( const { app, cf, ui5 } = fullConfig; await adjustMtaYaml( - basePath, - app.id, - cf.approuter, - cf.businessSolutionName ?? '', - cf.businessService, - cf.space.GUID, + { + projectPath: basePath, + moduleName: app.id, + appRouterType: cf.approuter, + businessSolutionName: cf.businessSolutionName ?? '', + businessService: cf.businessService, + spaceGuid: cf.space.GUID + }, fs, logger ); diff --git a/packages/adp-tooling/test/unit/cf/core/auth.test.ts b/packages/adp-tooling/test/unit/cf/core/auth.test.ts index cc56c51baf1..c18574190af 100644 --- a/packages/adp-tooling/test/unit/cf/core/auth.test.ts +++ b/packages/adp-tooling/test/unit/cf/core/auth.test.ts @@ -1,22 +1,20 @@ import type { ToolsLogger } from '@sap-ux/logger'; +import { getAuthToken } from '../../../../src/cf/services/cli'; import type { CfConfig, Organization } from '../../../../src/types'; -import { getAuthToken, checkForCf } from '../../../../src/cf/services/cli'; -import { isCfInstalled, isExternalLoginEnabled, isLoggedInCf } from '../../../../src/cf/core/auth'; +import { isExternalLoginEnabled, isLoggedInCf } from '../../../../src/cf/core/auth'; jest.mock('@sap/cf-tools/out/src/cf-local', () => ({ cfGetAvailableOrgs: jest.fn() })); jest.mock('../../../../src/cf/services/cli', () => ({ - getAuthToken: jest.fn(), - checkForCf: jest.fn() + getAuthToken: jest.fn() })); // eslint-disable-next-line @typescript-eslint/no-var-requires const mockCFLocal = require('@sap/cf-tools/out/src/cf-local'); const mockGetAuthToken = getAuthToken as jest.MockedFunction; -const mockCheckForCf = checkForCf as jest.MockedFunction; const mockCfConfig: CfConfig = { org: { @@ -52,27 +50,6 @@ describe('CF Core Auth', () => { jest.clearAllMocks(); }); - describe('isCfInstalled', () => { - test('should return true when CF is installed', async () => { - mockCheckForCf.mockResolvedValue(undefined); - - const result = await isCfInstalled(); - - expect(result).toBe(true); - expect(mockCheckForCf).toHaveBeenCalledTimes(1); - }); - - test('should return false when CF is not installed', async () => { - const error = new Error('CF CLI not found'); - mockCheckForCf.mockRejectedValue(error); - - const result = await isCfInstalled(); - - expect(result).toBe(false); - expect(mockCheckForCf).toHaveBeenCalledTimes(1); - }); - }); - describe('isExternalLoginEnabled', () => { test('should return true when cf.login command is available', async () => { const mockVscode = { diff --git a/packages/adp-tooling/test/unit/cf/project/yaml-loader.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml-loader.test.ts index 54f2be9d3b2..460dd4c8a17 100644 --- a/packages/adp-tooling/test/unit/cf/project/yaml-loader.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/yaml-loader.test.ts @@ -61,7 +61,7 @@ describe('YAML Loader Functions', () => { throw new Error('YAML parsing error'); }); - expect(() => getYamlContent(filePath)).toThrow(`Error parsing file ${filePath}`); + expect(() => getYamlContent(filePath)).toThrow(`Error parsing file ${filePath}: YAML parsing error`); expect(mockExistsSync).toHaveBeenCalledWith(filePath); expect(mockReadFileSync).toHaveBeenCalledWith(filePath, 'utf-8'); expect(mockYamlLoad).toHaveBeenCalledWith(fileContent); diff --git a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts index 5a242db7805..f255eb9a47d 100644 --- a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts @@ -299,12 +299,14 @@ describe('YAML Project Functions', () => { mockCreateServices.mockResolvedValue(undefined); await adjustMtaYaml( - projectPath, - moduleName, - AppRouterType.STANDALONE, - businessSolutionName, - businessService, - spaceGuid, + { + projectPath, + moduleName, + appRouterType: AppRouterType.STANDALONE, + businessSolutionName, + businessService, + spaceGuid + }, mockMemFs, mockLogger ); @@ -329,12 +331,14 @@ describe('YAML Project Functions', () => { mockCreateServices.mockResolvedValue(undefined); await adjustMtaYaml( - projectPath, - moduleName, - AppRouterType.MANAGED, - businessSolutionName, - businessService, - spaceGuid, + { + projectPath, + moduleName, + appRouterType: AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid + }, mockMemFs, mockLogger ); @@ -364,12 +368,14 @@ describe('YAML Project Functions', () => { mockCreateServices.mockResolvedValue(undefined); await adjustMtaYaml( - projectPath, - moduleName, - null as any, - businessSolutionName, - businessService, - spaceGuid, + { + projectPath, + moduleName, + appRouterType: null as unknown as AppRouterType, + businessSolutionName, + businessService, + spaceGuid + }, mockMemFs, mockLogger ); @@ -397,12 +403,14 @@ describe('YAML Project Functions', () => { await expect( adjustMtaYaml( - projectPath, - moduleName, - AppRouterType.STANDALONE, - businessSolutionName, - businessService, - spaceGuid, + { + projectPath, + moduleName, + appRouterType: AppRouterType.STANDALONE, + businessSolutionName, + businessService, + spaceGuid + }, mockMemFs, mockLogger ) @@ -443,12 +451,14 @@ describe('YAML Project Functions', () => { mockCreateServices.mockResolvedValue(undefined); await adjustMtaYaml( - projectPath, - moduleName, - AppRouterType.MANAGED, - businessSolutionName, - businessService, - spaceGuid, + { + projectPath, + moduleName, + appRouterType: AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid + }, mockMemFs, mockLogger ); @@ -496,12 +506,14 @@ describe('YAML Project Functions', () => { mockCreateServices.mockResolvedValue(undefined); await adjustMtaYaml( - projectPath, - moduleName, - AppRouterType.MANAGED, - businessSolutionName, - businessService, - spaceGuid, + { + projectPath, + moduleName, + appRouterType: AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid + }, mockMemFs, mockLogger ); @@ -553,12 +565,14 @@ describe('YAML Project Functions', () => { mockCreateServices.mockResolvedValue(undefined); await adjustMtaYaml( - projectPath, - moduleName, - AppRouterType.MANAGED, - businessSolutionName, - businessService, - spaceGuid, + { + projectPath, + moduleName, + appRouterType: AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid + }, mockMemFs, mockLogger ); @@ -610,12 +624,14 @@ describe('YAML Project Functions', () => { mockCreateServices.mockResolvedValue(undefined); await adjustMtaYaml( - projectPath, - moduleName, - AppRouterType.MANAGED, - businessSolutionName, - businessService, - spaceGuid, + { + projectPath, + moduleName, + appRouterType: AppRouterType.MANAGED, + businessSolutionName, + businessService, + spaceGuid + }, mockMemFs, mockLogger ); diff --git a/packages/adp-tooling/test/unit/cf/services/cli.test.ts b/packages/adp-tooling/test/unit/cf/services/cli.test.ts index 534a76024bc..dcea9c82089 100644 --- a/packages/adp-tooling/test/unit/cf/services/cli.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/cli.test.ts @@ -2,9 +2,11 @@ import * as CFLocal from '@sap/cf-tools/out/src/cf-local'; import * as CFToolsCli from '@sap/cf-tools/out/src/cli'; import { eFilters } from '@sap/cf-tools/out/src/types'; +import type { ToolsLogger } from '@sap-ux/logger'; + import { getAuthToken, - checkForCf, + isCfInstalled, cFLogout, getServiceKeys, createServiceKey, @@ -66,8 +68,12 @@ describe('CF Services CLI', () => { }); }); - describe('checkForCf', () => { - test('should not throw when CF is installed', async () => { + describe('isCfInstalled', () => { + const mockLogger = { + error: jest.fn() + } as unknown as ToolsLogger; + + test('should return true when CF is installed', async () => { const mockResponse = { exitCode: 0, stdout: 'cf version 8.0.0', @@ -75,11 +81,14 @@ describe('CF Services CLI', () => { }; mockCFToolsCliExecute.mockResolvedValue(mockResponse); - await expect(checkForCf()).resolves.not.toThrow(); + const result = await isCfInstalled(mockLogger); + + expect(result).toBe(true); expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['version'], { env: { 'CF_COLOR': 'false' } }); + expect(mockLogger.error).not.toHaveBeenCalled(); }); - test('should throw error when CF version command fails', async () => { + test('should return false and log error when CF version command fails', async () => { const mockResponse = { exitCode: 1, stdout: '', @@ -87,15 +96,21 @@ describe('CF Services CLI', () => { }; mockCFToolsCliExecute.mockResolvedValue(mockResponse); - await expect(checkForCf()).rejects.toThrow(t('error.cfNotInstalled', { error: mockResponse.stderr })); + const result = await isCfInstalled(mockLogger); + + expect(result).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith(t('error.cfNotInstalled', { error: mockResponse.stderr })); expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['version'], { env: { 'CF_COLOR': 'false' } }); }); - test('should throw error when CF version command throws exception', async () => { + test('should return false and log error when CF version command throws exception', async () => { const error = new Error('Network error'); mockCFToolsCliExecute.mockRejectedValue(error); - await expect(checkForCf()).rejects.toThrow(t('error.cfNotInstalled', { error: error.message })); + const result = await isCfInstalled(mockLogger); + + expect(result).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith(t('error.cfNotInstalled', { error: error.message })); expect(mockCFToolsCliExecute).toHaveBeenCalledWith(['version'], { env: { 'CF_COLOR': 'false' } }); }); }); diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 1058b005da9..b7ef1f6b45f 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -232,7 +232,7 @@ export default class extends Generator { this.isCustomerBase = this.layer === FlexLayer.CUSTOMER_BASE; this.systemLookup = new SystemLookup(this.logger); - this.cfInstalled = await isCfInstalled(); + this.cfInstalled = await isCfInstalled(this.logger); this.cfConfig = loadCfConfig(this.logger); this.isCfLoggedIn = await isLoggedInCf(this.cfConfig, this.logger); this.logger.info(`isCfInstalled: ${this.cfInstalled}`); From 78b158a1e40b6a76c6356480cf05dd3763f554da Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 26 Sep 2025 15:57:52 +0300 Subject: [PATCH 080/111] fix: sonar issues --- packages/adp-tooling/src/cf/services/api.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 302cf7a186e..5ee5da0a3a8 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -83,16 +83,13 @@ export function getFDCRequestArguments(cfConfig: CfConfig): RequestArguments { // Public cloud - use mTLS enabled domain with "cert" prefix const region = endpointParts[1]; url = `${fdcUrl}cert.cfapps.${region}.hana.ondemand.com`; + } else if (endpointParts?.[4]?.endsWith('.cn')) { + // China has a special URL pattern + const parts = endpointParts[4].split('.'); + parts.splice(2, 0, 'apps'); + url = `${fdcUrl}sapui5flex${parts.join('.')}`; } else { - // Private cloud or other environments - if (endpointParts?.[4]?.endsWith('.cn')) { - // China has a special URL pattern - const parts = endpointParts[4].split('.'); - parts.splice(2, 0, 'apps'); - url = `${fdcUrl}sapui5flex${parts.join('.')}`; - } else { - url = `${fdcUrl}sapui5flex.cfapps${endpointParts?.[4]}`; - } + url = `${fdcUrl}sapui5flex.cfapps${endpointParts?.[4]}`; } // Add authorization token for non-BAS environments or private cloud From 80cfcc1ac41565b0116562068ff66cb198ed203b Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 26 Sep 2025 16:29:25 +0300 Subject: [PATCH 081/111] fix: sonar issues --- packages/adp-tooling/src/cf/app/html5-repo.ts | 10 +++++- packages/adp-tooling/src/cf/services/api.ts | 34 ++++++++++--------- .../test/unit/cf/app/html5-repo.test.ts | 6 ++-- .../test/unit/cf/services/api.test.ts | 13 ++++--- 4 files changed, 37 insertions(+), 26 deletions(-) diff --git a/packages/adp-tooling/src/cf/app/html5-repo.ts b/packages/adp-tooling/src/cf/app/html5-repo.ts index 5f9d18dbae3..b68b21268e5 100644 --- a/packages/adp-tooling/src/cf/app/html5-repo.ts +++ b/packages/adp-tooling/src/cf/app/html5-repo.ts @@ -75,7 +75,15 @@ export async function getHtml5RepoCredentials(spaceGuid: string, logger: ToolsLo logger ); if (!serviceKeys?.credentials?.length) { - await createService(spaceGuid, 'app-runtime', HTML5_APPS_REPO_RUNTIME, logger, ['html5-apps-repo-rt']); + await createService( + spaceGuid, + 'app-runtime', + HTML5_APPS_REPO_RUNTIME, + ['html5-apps-repo-rt'], + undefined, + undefined, + logger + ); serviceKeys = await getServiceInstanceKeys({ names: [HTML5_APPS_REPO_RUNTIME] }, logger); if (!serviceKeys?.credentials?.length) { logger.debug(t('error.noUaaCredentialsFoundForHtml5Repo')); diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 5ee5da0a3a8..1bd2eda9cf5 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -145,23 +145,27 @@ export async function getFDCApps(appHostIds: string[], cfConfig: CfConfig, logge * @param {string} spaceGuid - The space GUID. * @param {string} plan - The plan. * @param {string} serviceInstanceName - The service instance name. - * @param {ToolsLogger} logger - The logger. * @param {string[]} tags - The tags. - * @param {string | null} securityFilePath - The security file path. - * @param {string | null} serviceName - The service name. - * @param {string} [xsSecurityProjectName] - The project name for XS security. + * @param {string} [serviceName] - The service name. + * @param {object} [security] - Security configuration. + * @param {string | null} security.filePath - The security file path. + * @param {string} [security.xsappname] - The XS app name. + * @param {ToolsLogger} [logger] - The logger. */ export async function createService( spaceGuid: string, plan: string, serviceInstanceName: string, - logger?: ToolsLogger, tags: string[] = [], - securityFilePath: string | null = null, - serviceName: string | undefined = undefined, - xsSecurityProjectName?: string + serviceName: string | undefined, + security?: { + filePath: string | null; + xsappname?: string; + }, + logger?: ToolsLogger ): Promise { try { + const { filePath, xsappname } = security ?? {}; if (!serviceName) { const json: CfAPIResponse = await requestCfApi>( `/v3/service_offerings?per_page=1000&space_guids=${spaceGuid}` @@ -176,13 +180,13 @@ export async function createService( ); const commandParameters: string[] = ['create-service', serviceName ?? '', plan, serviceInstanceName]; - if (securityFilePath) { + if (filePath) { let xsSecurity = null; try { const filePath = path.resolve(__dirname, '../../../templates/cf/xs-security.json'); const xsContent = fs.readFileSync(filePath, 'utf-8'); xsSecurity = JSON.parse(xsContent) as unknown as { xsappname?: string }; - xsSecurity.xsappname = xsSecurityProjectName; + xsSecurity.xsappname = xsappname; } catch (err) { logger?.error(`Failed to parse xs-security.json file: ${err}`); throw new Error(t('error.xsSecurityJsonCouldNotBeParsed')); @@ -229,22 +233,20 @@ export async function createServices( spaceGuid, resource.parameters['service-plan'] ?? '', resource.parameters['service-name'] ?? '', - logger, [], - xsSecurityPath, resource.parameters.service, - xsSecurityProjectName + { filePath: xsSecurityPath, xsappname: xsSecurityProjectName }, + logger ); } else { await createService( spaceGuid, resource.parameters['service-plan'] ?? '', resource.parameters['service-name'] ?? '', - logger, [], - '', resource.parameters.service, - xsSecurityProjectName + { filePath: null, xsappname: xsSecurityProjectName }, + logger ); } } diff --git a/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts b/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts index 4e18687736a..ce837c5faff 100644 --- a/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts +++ b/packages/adp-tooling/test/unit/cf/app/html5-repo.test.ts @@ -182,8 +182,10 @@ describe('HTML5 Repository', () => { 'test-space-guid', 'app-runtime', 'html5-apps-repo-runtime', - mockLogger, - ['html5-apps-repo-rt'] + ['html5-apps-repo-rt'], + undefined, + undefined, + mockLogger ); expect(mockGetServiceInstanceKeys).toHaveBeenCalledTimes(2); }); diff --git a/packages/adp-tooling/test/unit/cf/services/api.test.ts b/packages/adp-tooling/test/unit/cf/services/api.test.ts index b8cda1d0ca3..5c17716236b 100644 --- a/packages/adp-tooling/test/unit/cf/services/api.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/api.test.ts @@ -319,7 +319,7 @@ describe('CF Services API', () => { stderr: '' }); - await createService(spaceGuid, plan, serviceInstanceName, mockLogger, [], null, serviceOffering); + await createService(spaceGuid, plan, serviceInstanceName, [], serviceOffering, undefined, mockLogger); expect(mockCFToolsCliExecute).toHaveBeenCalledWith([ 'create-service', @@ -338,7 +338,7 @@ describe('CF Services API', () => { stderr: '' }); - await createService(spaceGuid, plan, serviceInstanceName, mockLogger, [], null, serviceOffering); + await createService(spaceGuid, plan, serviceInstanceName, [], serviceOffering, undefined, mockLogger); expect(mockCFToolsCliExecute).toHaveBeenCalledWith([ 'create-service', @@ -366,7 +366,7 @@ describe('CF Services API', () => { stderr: '' }); - await createService(spaceGuid, plan, serviceInstanceName, mockLogger, tags); + await createService(spaceGuid, plan, serviceInstanceName, tags, undefined, undefined, mockLogger); expect(mockRequestCfApi).toHaveBeenCalledWith( `/v3/service_offerings?per_page=1000&space_guids=${spaceGuid}` @@ -384,7 +384,7 @@ describe('CF Services API', () => { mockCFToolsCliExecute.mockRejectedValue(new Error('Service creation failed')); await expect( - createService(spaceGuid, plan, serviceInstanceName, mockLogger, [], null, 'test-offering') + createService(spaceGuid, plan, serviceInstanceName, [], 'test-offering', undefined, mockLogger) ).rejects.toThrow( t('error.failedToCreateServiceInstance', { serviceInstanceName: serviceInstanceName, @@ -405,11 +405,10 @@ describe('CF Services API', () => { spaceGuid, plan, serviceInstanceName, - mockLogger, [], - securityFilePath, serviceOffering, - xsSecurityProjectName + { filePath: securityFilePath, xsappname: xsSecurityProjectName }, + mockLogger ) ).rejects.toThrow(t('error.xsSecurityJsonCouldNotBeParsed')); From c6c8e4c5c2268cc575cf8a689152e441aac0879d Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 26 Sep 2025 17:46:48 +0300 Subject: [PATCH 082/111] fix: sonar issues --- .../src/app/questions/helper/validators.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/generator-adp/src/app/questions/helper/validators.ts b/packages/generator-adp/src/app/questions/helper/validators.ts index 535e0ea82c4..3b382a3ca22 100644 --- a/packages/generator-adp/src/app/questions/helper/validators.ts +++ b/packages/generator-adp/src/app/questions/helper/validators.ts @@ -127,12 +127,6 @@ export async function validateProjectPath(projectPath: string, logger: ToolsLogg return validationResult; } - try { - fs.realpathSync(projectPath, 'utf-8'); - } catch (e) { - return t('error.projectDoesNotExist'); - } - if (!fs.existsSync(projectPath)) { return t('error.projectDoesNotExist'); } @@ -141,14 +135,13 @@ export async function validateProjectPath(projectPath: string, logger: ToolsLogg return t('error.projectDoesNotExistMta'); } - let services: string[]; try { - services = await getMtaServices(projectPath, logger); - } catch (err) { - services = []; - } - - if (services.length < 1) { + const services = await getMtaServices(projectPath, logger); + if (services.length < 1) { + return t('error.noAdaptableBusinessServiceFoundInMta'); + } + } catch (e) { + logger?.error(`Failed to get MTA services: ${e.message}`); return t('error.noAdaptableBusinessServiceFoundInMta'); } From 6f9cbf35dbf83c708b7354ac42b7f8c58859b38f Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 26 Sep 2025 17:47:36 +0300 Subject: [PATCH 083/111] fix: sonar issues --- packages/adp-tooling/src/cf/services/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 1bd2eda9cf5..66765f430bc 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -156,7 +156,7 @@ export async function createService( spaceGuid: string, plan: string, serviceInstanceName: string, - tags: string[] = [], + tags: string[], serviceName: string | undefined, security?: { filePath: string | null; From 657e99a5c50c832c1a3e3cdbbbb828c37244a206 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Fri, 26 Sep 2025 17:53:46 +0300 Subject: [PATCH 084/111] fix: sonar issues --- packages/adp-tooling/src/cf/utils/validation.ts | 3 +-- packages/adp-tooling/src/writer/cf.ts | 5 ++--- packages/generator-adp/src/app/index.ts | 7 ++----- .../src/app/questions/cf-services.ts | 16 +++++----------- .../src/app/questions/target-env.ts | 4 ++-- 5 files changed, 12 insertions(+), 23 deletions(-) diff --git a/packages/adp-tooling/src/cf/utils/validation.ts b/packages/adp-tooling/src/cf/utils/validation.ts index 67b60651ca0..308877a05b0 100644 --- a/packages/adp-tooling/src/cf/utils/validation.ts +++ b/packages/adp-tooling/src/cf/utils/validation.ts @@ -5,8 +5,7 @@ import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; import { t } from '../../i18n'; import type { CfCredentials, XsApp, XsAppRoute } from '../../types'; -import { getApplicationType } from '../../source/manifest'; -import { isSupportedAppTypeForAdp } from '../../source/manifest'; +import { getApplicationType, isSupportedAppTypeForAdp } from '../../source/manifest'; /** * Normalize the xs-app route regex. diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index c4ac32077b6..72736a1f561 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -6,9 +6,9 @@ import { type ToolsLogger } from '@sap-ux/logger'; import { adjustMtaYaml } from '../cf'; import { getApplicationType } from '../source'; import { fillDescriptorContent } from './manifest'; +import type { CfAdpWriterConfig, Content } from '../types'; import { getCfVariant, writeCfTemplates } from './project-utils'; import { getI18nDescription, getI18nModels, writeI18nModels } from './i18n'; -import { type CfAdpWriterConfig, type FlexLayer, type Content } from '../types'; /** * Writes the CF adp-project template to the mem-fs-editor instance. @@ -70,8 +70,7 @@ function setDefaultsCF(config: CfAdpWriterConfig): CfAdpWriterConfig { ...config.app, appType: config.app.appType ?? getApplicationType(config.app.manifest), i18nModels: config.app.i18nModels ?? getI18nModels(config.app.manifest, config.app.layer, config.app.id), - i18nDescription: - config.app.i18nDescription ?? getI18nDescription(config.app.layer as FlexLayer, config.app.title) + i18nDescription: config.app.i18nDescription ?? getI18nDescription(config.app.layer, config.app.title) }, options: { addStandaloneApprouter: false, diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index b7ef1f6b45f..9372440000c 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -19,10 +19,7 @@ import { isLoggedInCf, isMtaProject, loadApps, - loadCfConfig, - type AttributesAnswers, - type ConfigAnswers, - type UI5Version + loadCfConfig } from '@sap-ux/adp-tooling'; import { TelemetryHelper, @@ -34,8 +31,8 @@ import { import { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import type { AbapServiceProvider } from '@sap-ux/axios-extension'; -import type { CfConfig, CfServicesAnswers } from '@sap-ux/adp-tooling'; import { isInternalFeaturesSettingEnabled } from '@sap-ux/feature-toggle'; +import type { CfConfig, CfServicesAnswers, AttributesAnswers, ConfigAnswers, UI5Version } from '@sap-ux/adp-tooling'; import { EventName } from '../telemetryEvents'; import { cacheClear, cacheGet, cachePut, initCache } from '../utils/appWizardCache'; diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index 524a2643bd1..ee30cec41bf 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -3,7 +3,9 @@ import type { CFServicesQuestion, CfServicesPromptOptions, AppRouterType, - CfConfig + CfConfig, + CFApp, + ServiceKeys } from '@sap-ux/adp-tooling'; import { cfServicesPromptNames, @@ -16,13 +18,13 @@ import { downloadAppContent, validateSmartTemplateApplication, validateODataEndpoints, - getMtaProjectName + getMtaProjectName, + getBusinessServiceKeys } from '@sap-ux/adp-tooling'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import { validateEmptyString } from '@sap-ux/project-input-validator'; import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; -import { getBusinessServiceKeys, type CFApp, type ServiceKeys } from '@sap-ux/adp-tooling'; import { t } from '../../utils/i18n'; import { validateBusinessSolutionName } from './helper/validators'; @@ -49,10 +51,6 @@ export class CFServicesPrompter { * The business services available. */ private businessServices: string[] = []; - /** - * The name of the cached business service. - */ - private cachedServiceName: string | undefined; /** * The keys of the business service. */ @@ -61,10 +59,6 @@ export class CFServicesPrompter { * The base apps available. */ private apps: CFApp[] = []; - /** - * The error message when choosing a base app. - */ - private baseAppOnChoiceError: string | null = null; /** * The service instance GUID. */ diff --git a/packages/generator-adp/src/app/questions/target-env.ts b/packages/generator-adp/src/app/questions/target-env.ts index 94c649dd70a..01d525190ec 100644 --- a/packages/generator-adp/src/app/questions/target-env.ts +++ b/packages/generator-adp/src/app/questions/target-env.ts @@ -7,10 +7,10 @@ import { getDefaultTargetFolder } from '@sap-ux/fiori-generator-shared'; import type { InputQuestion, ListQuestion, YUIQuestion } from '@sap-ux/inquirer-common'; import { t } from '../../utils/i18n'; -import type { ProjectLocationAnswers } from '../types'; +import { TargetEnv } from '../types'; import { getTargetEnvAdditionalMessages } from './helper/additional-messages'; import { validateEnvironment, validateProjectPath } from './helper/validators'; -import { TargetEnv, type TargetEnvAnswers, type TargetEnvQuestion } from '../types'; +import type { ProjectLocationAnswers, TargetEnvAnswers, TargetEnvQuestion } from '../types'; type EnvironmentChoice = { name: string; value: TargetEnv }; From 1e91c0265f074be0121366e680ef1a8e364bba2c Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 29 Sep 2025 11:34:22 +0300 Subject: [PATCH 085/111] test: add new tests --- .../test/unit/questions/target-env.test.ts | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 packages/generator-adp/test/unit/questions/target-env.test.ts diff --git a/packages/generator-adp/test/unit/questions/target-env.test.ts b/packages/generator-adp/test/unit/questions/target-env.test.ts new file mode 100644 index 00000000000..96cf7a7bfa5 --- /dev/null +++ b/packages/generator-adp/test/unit/questions/target-env.test.ts @@ -0,0 +1,188 @@ +import { MessageType } from '@sap-devx/yeoman-ui-types'; +import type { AppWizard } from '@sap-devx/yeoman-ui-types'; + +import type { ToolsLogger } from '@sap-ux/logger'; +import type { CfConfig } from '@sap-ux/adp-tooling'; +import type { ListQuestion } from '@sap-ux/inquirer-common'; +import { getDefaultTargetFolder } from '@sap-ux/fiori-generator-shared'; + +import { initI18n, t } from '../../../src/utils/i18n'; +import { TargetEnv, type TargetEnvAnswers } from '../../../src/app/types'; +import { getTargetEnvAdditionalMessages } from '../../../src/app/questions/helper/additional-messages'; +import { validateEnvironment, validateProjectPath } from '../../../src/app/questions/helper/validators'; +import { getTargetEnvPrompt, getEnvironments, getProjectPathPrompt } from '../../../src/app/questions/target-env'; + +jest.mock('@sap-ux/fiori-generator-shared', () => ({ + getDefaultTargetFolder: jest.fn() +})); + +jest.mock('../../../src/app/questions/helper/additional-messages', () => ({ + getTargetEnvAdditionalMessages: jest.fn() +})); + +jest.mock('../../../src/app/questions/helper/validators', () => ({ + validateEnvironment: jest.fn(), + validateProjectPath: jest.fn() +})); + +const mockValidateEnvironment = validateEnvironment as jest.MockedFunction; +const mockValidateProjectPath = validateProjectPath as jest.MockedFunction; +const mockGetDefaultTargetFolder = getDefaultTargetFolder as jest.MockedFunction; +const mockGetTargetEnvAdditionalMessages = getTargetEnvAdditionalMessages as jest.MockedFunction< + typeof getTargetEnvAdditionalMessages +>; + +describe('Target Environment', () => { + const mockAppWizard: AppWizard = { + showInformation: jest.fn() + } as unknown as AppWizard; + + const mockLogger: ToolsLogger = {} as unknown as ToolsLogger; + + const mockCfConfig: CfConfig = { + org: { GUID: 'org-guid', Name: 'test-org' }, + space: { GUID: 'space-guid', Name: 'test-space' }, + token: 'test-token', + url: '/test.cf.com' + }; + + const mockVscode = { + workspace: { + workspaceFolders: [{ uri: { fsPath: '/test/workspace' } }] + } + }; + + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getTargetEnvPrompt', () => { + test('should create target environment prompt with correct structure', () => { + const prompt = getTargetEnvPrompt(mockAppWizard, true, true, mockCfConfig, mockVscode); + + expect(prompt.type).toBe('list'); + expect(prompt.name).toBe('targetEnv'); + expect(prompt.message).toBe(t('prompts.targetEnvLabel')); + expect(prompt.guiOptions).toEqual({ + mandatory: true, + hint: t('prompts.targetEnvTooltip'), + breadcrumb: t('prompts.targetEnvBreadcrumb') + }); + }); + + test('should have choices function that calls getEnvironments', () => { + const envPrompt = getTargetEnvPrompt( + mockAppWizard, + true, + true, + mockCfConfig, + mockVscode + ) as ListQuestion; + + const choicesFn = envPrompt!.choices; + expect(typeof choicesFn).toBe('function'); + + const choices = (choicesFn as () => Promise)(); + expect(choices).toEqual([ + { name: 'ABAP', value: TargetEnv.ABAP }, + { name: 'Cloud Foundry', value: TargetEnv.CF } + ]); + }); + + test('should have default function that calls getEnvironments', () => { + const envPrompt = getTargetEnvPrompt( + mockAppWizard, + true, + true, + mockCfConfig, + mockVscode + ) as ListQuestion; + + const defaultFn = envPrompt!.default; + expect(typeof defaultFn).toBe('function'); + + const defaultChoice = (defaultFn as () => Promise)(); + expect(defaultChoice).toEqual(TargetEnv.ABAP); + }); + + test('should set up validation function', () => { + const prompt = getTargetEnvPrompt(mockAppWizard, true, true, mockCfConfig, mockVscode); + + const validateResult = prompt.validate!('ABAP'); + expect(mockValidateEnvironment).toHaveBeenCalledWith('ABAP', true, mockVscode); + expect(validateResult).toBeUndefined(); + }); + + test('should set up additional messages function', () => { + const prompt = getTargetEnvPrompt(mockAppWizard, true, true, mockCfConfig, mockVscode); + + const additionalMessages = prompt.additionalMessages!('ABAP'); + expect(mockGetTargetEnvAdditionalMessages).toHaveBeenCalledWith('ABAP', true, mockCfConfig); + expect(additionalMessages).toBeUndefined(); + }); + }); + + describe('getEnvironments', () => { + test('should return ABAP and CF choices when CF is installed', () => { + const choices = getEnvironments(mockAppWizard, true); + + expect(choices).toHaveLength(2); + expect(choices[0]).toEqual({ name: 'ABAP', value: TargetEnv.ABAP }); + expect(choices[1]).toEqual({ name: 'Cloud Foundry', value: TargetEnv.CF }); + expect(mockAppWizard.showInformation).not.toHaveBeenCalled(); + }); + + test('should return only ABAP choice when CF is not installed', () => { + const choices = getEnvironments(mockAppWizard, false); + + expect(choices).toHaveLength(1); + expect(choices[0]).toEqual({ name: 'ABAP', value: TargetEnv.ABAP }); + expect(mockAppWizard.showInformation).toHaveBeenCalledWith(t('error.cfNotInstalled'), MessageType.prompt); + }); + + test('should show information message when CF is not installed', () => { + getEnvironments(mockAppWizard, false); + + expect(mockAppWizard.showInformation).toHaveBeenCalledWith(t('error.cfNotInstalled'), MessageType.prompt); + }); + }); + + describe('getProjectPathPrompt', () => { + test('should create project path prompt with correct structure', () => { + const prompt = getProjectPathPrompt(mockLogger, mockVscode); + + expect(prompt.type).toBe('input'); + expect(prompt.name).toBe('projectLocation'); + expect(prompt.message).toBe(t('prompts.projectLocationLabel')); + expect(prompt.guiOptions).toEqual({ + type: 'folder-browser', + mandatory: true, + hint: t('prompts.projectLocationTooltip'), + breadcrumb: t('prompts.projectLocationBreadcrumb') + }); + }); + + test('should set up validation function', () => { + const prompt = getProjectPathPrompt(mockLogger, mockVscode); + + const validateResult = prompt.validate!('/test/path'); + expect(mockValidateProjectPath).toHaveBeenCalledWith('/test/path', mockLogger); + expect(validateResult).toBeUndefined(); + }); + + test('should set up default function', () => { + const mockDefaultPath = '/default/path'; + mockGetDefaultTargetFolder.mockReturnValue(mockDefaultPath); + + const prompt = getProjectPathPrompt(mockLogger, mockVscode); + + const defaultPath = prompt.default!(); + expect(mockGetDefaultTargetFolder).toHaveBeenCalledWith(mockVscode); + expect(defaultPath).toBe(mockDefaultPath); + }); + }); +}); From 725d5a4ab08073a76eaab15e705fecb2940c88bf Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 29 Sep 2025 15:44:43 +0300 Subject: [PATCH 086/111] test: add new tests --- .../test/unit/questions/cf-services.test.ts | 477 ++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 packages/generator-adp/test/unit/questions/cf-services.test.ts diff --git a/packages/generator-adp/test/unit/questions/cf-services.test.ts b/packages/generator-adp/test/unit/questions/cf-services.test.ts new file mode 100644 index 00000000000..fb3020b35a8 --- /dev/null +++ b/packages/generator-adp/test/unit/questions/cf-services.test.ts @@ -0,0 +1,477 @@ +import { + getModuleNames, + getApprouterType, + hasApprouter, + isLoggedInCf, + getMtaServices, + getCfApps, + AppRouterType, + downloadAppContent, + validateSmartTemplateApplication, + validateODataEndpoints, + getMtaProjectName, + getBusinessServiceKeys, + cfServicesPromptNames +} from '@sap-ux/adp-tooling'; +import type { ToolsLogger } from '@sap-ux/logger'; +import type { Manifest } from '@sap-ux/project-access'; +import type { ListQuestion } from '@sap-ux/inquirer-common'; +import type { CfConfig, CFApp, ServiceKeys, CfServicesAnswers } from '@sap-ux/adp-tooling'; + +import { initI18n, t } from '../../../src/utils/i18n'; +import { CFServicesPrompter } from '../../../src/app/questions/cf-services'; +import { validateBusinessSolutionName } from '../../../src/app/questions/helper/validators'; +import { showBusinessSolutionNameQuestion } from '../../../src/app/questions/helper/conditions'; +import { getAppRouterChoices, getCFAppChoices } from '../../../src/app/questions/helper/choices'; + +jest.mock('../../../src/app/questions/helper/validators', () => ({ + ...jest.requireActual('../../../src/app/questions/helper/validators'), + validateBusinessSolutionName: jest.fn() +})); + +jest.mock('../../../src/app/questions/helper/choices', () => ({ + ...jest.requireActual('../../../src/app/questions/helper/choices'), + getAppRouterChoices: jest.fn(), + getCFAppChoices: jest.fn() +})); + +jest.mock('../../../src/app/questions/helper/conditions', () => ({ + ...jest.requireActual('../../../src/app/questions/helper/conditions'), + showBusinessSolutionNameQuestion: jest.fn() +})); + +jest.mock('@sap-ux/adp-tooling', () => ({ + ...jest.requireActual('@sap-ux/adp-tooling'), + getModuleNames: jest.fn(), + getApprouterType: jest.fn(), + hasApprouter: jest.fn(), + isLoggedInCf: jest.fn(), + getMtaServices: jest.fn(), + getCfApps: jest.fn(), + downloadAppContent: jest.fn(), + validateSmartTemplateApplication: jest.fn(), + validateODataEndpoints: jest.fn(), + getMtaProjectName: jest.fn(), + getBusinessServiceKeys: jest.fn() +})); + +const mockValidateBusinessSolutionName = validateBusinessSolutionName as jest.MockedFunction< + typeof validateBusinessSolutionName +>; +const mockGetAppRouterChoices = getAppRouterChoices as jest.MockedFunction; +const mockGetCFAppChoices = getCFAppChoices as jest.MockedFunction; +const mockShowBusinessSolutionNameQuestion = showBusinessSolutionNameQuestion as jest.MockedFunction< + typeof showBusinessSolutionNameQuestion +>; +const mockGetModuleNames = getModuleNames as jest.MockedFunction; +const mockGetApprouterType = getApprouterType as jest.MockedFunction; +const mockHasApprouter = hasApprouter as jest.MockedFunction; +const mockIsLoggedInCf = isLoggedInCf as jest.MockedFunction; +const mockGetMtaServices = getMtaServices as jest.MockedFunction; +const mockGetCfApps = getCfApps as jest.MockedFunction; +const mockDownloadAppContent = downloadAppContent as jest.MockedFunction; +const mockValidateSmartTemplateApplication = validateSmartTemplateApplication as jest.MockedFunction< + typeof validateSmartTemplateApplication +>; +const mockValidateODataEndpoints = validateODataEndpoints as jest.MockedFunction; +const mockGetMtaProjectName = getMtaProjectName as jest.MockedFunction; +const mockGetBusinessServiceKeys = getBusinessServiceKeys as jest.MockedFunction; + +const mockCfConfig: CfConfig = { + org: { GUID: 'org-guid', Name: 'test-org' }, + space: { GUID: 'space-guid', Name: 'test-space' }, + token: 'test-token', + url: '/test.cf.com' +}; + +const mockManifest: Manifest = { + _version: '1.32.0', + 'sap.app': { + id: 'test.app', + title: 'Test App', + type: 'application', + applicationVersion: { + version: '1.0.0' + } + }, + 'sap.ui': { + technology: 'UI5' + } +} as Manifest; + +const mockServiceKeys: ServiceKeys = { + credentials: [ + { + url: '/test.service.com', + clientid: 'test-client', + clientsecret: 'test-secret', + uaa: { + url: '/test.uaa.com', + clientid: 'test-client', + clientsecret: 'test-secret' + }, + uri: '/test.service.com', + endpoints: { + 'test-endpoint': '/test.endpoint.com' + } + } + ], + serviceInstance: { + guid: 'test-instance-guid', + name: 'test-instance' + } +}; + +const mockCFApp: CFApp = { + appId: 'test-app-id', + appName: 'Test App', + appVersion: '1.0.0', + appHostId: 'test-host-id', + serviceName: 'test-service', + title: 'Test App Title' +}; + +describe('CFServicesPrompter', () => { + const mockLogger: ToolsLogger = { + log: jest.fn(), + error: jest.fn() + } as unknown as ToolsLogger; + + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getPrompts', () => { + test('should return all prompts when no options provided', async () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + mockGetMtaServices.mockResolvedValue(['service1', 'service2']); + + const prompts = await prompter.getPrompts('/test/path', mockCfConfig); + + expect(prompts).toHaveLength(4); + expect(prompts.map((p) => p.name)).toEqual([ + cfServicesPromptNames.approuter, + cfServicesPromptNames.businessService, + cfServicesPromptNames.businessSolutionName, + cfServicesPromptNames.baseApp + ]); + expect(mockGetMtaServices).toHaveBeenCalledWith('/test/path', mockLogger); + }); + + test('should filter hidden prompts', async () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + const promptOptions = { + [cfServicesPromptNames.approuter]: { hide: true }, + [cfServicesPromptNames.businessService]: { hide: false } + }; + + const prompts = await prompter.getPrompts('/test/path', mockCfConfig, promptOptions); + + expect(prompts).toHaveLength(3); + expect(prompts.map((p) => p.name)).not.toContain(cfServicesPromptNames.approuter); + }); + + test('should not load business services when not logged in', async () => { + const prompter = new CFServicesPrompter(false, false, mockLogger); + + await prompter.getPrompts('/test/path', mockCfConfig); + + expect(mockGetMtaServices).not.toHaveBeenCalled(); + }); + }); + + describe('getBusinessSolutionNamePrompt', () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + + test('should create business solution name prompt', () => { + mockShowBusinessSolutionNameQuestion.mockReturnValue(true); + mockValidateBusinessSolutionName.mockReturnValue(true); + + const prompt = prompter['getBusinessSolutionNamePrompt'](); + + expect(prompt.type).toBe('input'); + expect(prompt.name).toBe(cfServicesPromptNames.businessSolutionName); + expect(prompt.message).toBe(t('prompts.businessSolutionNameLabel')); + expect((prompt as any).store).toBe(false); + expect(prompt.guiOptions).toEqual({ + mandatory: true, + hint: t('prompts.businessSolutionNameTooltip'), + breadcrumb: t('prompts.businessSolutionBreadcrumb') + }); + }); + + test('should call showBusinessSolutionNameQuestion for when condition', () => { + const answers = { businessService: 'test-service' }; + + const prompt = prompter['getBusinessSolutionNamePrompt'](); + const whenFn = prompt.when as (answers: CfServicesAnswers) => boolean; + whenFn(answers); + + expect(mockShowBusinessSolutionNameQuestion).toHaveBeenCalledWith(answers, true, false, 'test-service'); + }); + + test('should call validateBusinessSolutionName for validation', () => { + mockValidateBusinessSolutionName.mockReturnValue(true); + + const prompt = prompter['getBusinessSolutionNamePrompt'](); + const result = prompt.validate!('test-solution'); + + expect(mockValidateBusinessSolutionName).toHaveBeenCalledWith('test-solution'); + expect(result).toBe(true); + }); + }); + + describe('getAppRouterPrompt', () => { + beforeEach(() => { + mockGetModuleNames.mockReturnValue(['module1', 'module2']); + mockGetMtaProjectName.mockReturnValue('test-project'); + }); + + test('should create approuter prompt', () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + mockHasApprouter.mockReturnValue(false); + mockGetAppRouterChoices.mockReturnValue([ + { name: AppRouterType.STANDALONE, value: AppRouterType.STANDALONE }, + { name: AppRouterType.MANAGED, value: AppRouterType.MANAGED } + ]); + + const prompt = prompter['getAppRouterPrompt']( + '/test/path', + mockCfConfig + ) as ListQuestion; + + expect(prompt.type).toBe('list'); + expect(prompt.name).toBe(cfServicesPromptNames.approuter); + expect(prompt.message).toBe(t('prompts.approuterLabel')); + expect(prompt.choices).toEqual([ + { name: AppRouterType.STANDALONE, value: AppRouterType.STANDALONE }, + { name: AppRouterType.MANAGED, value: AppRouterType.MANAGED } + ]); + }); + + test('should set approuter type when router exists', () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + mockHasApprouter.mockReturnValue(true); + mockGetApprouterType.mockReturnValue(AppRouterType.STANDALONE); + + const prompt = prompter['getAppRouterPrompt']('/test/path', mockCfConfig); + const whenFn = prompt.when as () => boolean; + whenFn(); + + expect(mockGetApprouterType).toHaveBeenCalledWith('/test/path'); + }); + + test('should show prompt when not logged in and no router', () => { + const prompter = new CFServicesPrompter(false, false, mockLogger); + mockHasApprouter.mockReturnValue(false); + + const prompt = prompter['getAppRouterPrompt']('/test/path', mockCfConfig); + const whenFn = prompt.when as () => boolean; + const shouldShow = whenFn(); + + expect(shouldShow).toBe(false); + }); + + test('should show prompt when logged in and no router', () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + mockHasApprouter.mockReturnValue(false); + + const prompt = prompter['getAppRouterPrompt']('/test/path', mockCfConfig); + const whenFn = prompt.when as () => boolean; + const shouldShow = whenFn(); + + expect(shouldShow).toBe(true); + expect(prompter['showSolutionNamePrompt']).toBe(true); + }); + + test('should validate CF login status', async () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + mockIsLoggedInCf.mockResolvedValue(false); + + const prompt = prompter['getAppRouterPrompt']('/test/path', mockCfConfig); + const result = await prompt.validate!('STANDALONE'); + + expect(mockIsLoggedInCf).toHaveBeenCalledWith(mockCfConfig, mockLogger); + expect(result).toBe(t('error.cfNotLoggedIn')); + }); + + test('should validate empty string', async () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + mockIsLoggedInCf.mockResolvedValue(true); + + const prompt = prompter['getAppRouterPrompt']('/test/path', mockCfConfig); + const result = await prompt.validate!(''); + + expect(result).toBe('The input cannot be empty.'); + }); + + test('should return true for a valid approuter type', async () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + mockIsLoggedInCf.mockResolvedValue(true); + + const prompt = prompter['getAppRouterPrompt']('/test/path', mockCfConfig); + const result = await prompt.validate!(AppRouterType.STANDALONE); + + expect(result).toBe(true); + }); + }); + + describe('getBaseAppPrompt', () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + + test('should create base app prompt', () => { + mockGetCFAppChoices.mockReturnValue([ + { name: 'App 1', value: mockCFApp }, + { name: 'App 2', value: mockCFApp } + ]); + + const prompt = prompter['getBaseAppPrompt'](mockCfConfig); + + expect(prompt.type).toBe('list'); + expect(prompt.name).toBe(cfServicesPromptNames.baseApp); + expect(prompt.message).toBe(t('prompts.baseAppLabel')); + }); + + test('should return choices for base apps', () => { + const choices = [ + { name: 'App 1', value: mockCFApp }, + { name: 'App 2', value: mockCFApp } + ]; + mockGetCFAppChoices.mockReturnValue(choices); + + const prompt = prompter['getBaseAppPrompt'](mockCfConfig) as ListQuestion; + const choicesFn = prompt.choices as (answers: CfServicesAnswers) => { name: string; value: CFApp }[]; + const result = choicesFn({ businessService: 'test-service' }); + + expect(result).toBe(choices); + }); + + test('should validate app selection', async () => { + mockDownloadAppContent.mockResolvedValue({ + entries: [], + serviceInstanceGuid: 'test-guid', + manifest: mockManifest + }); + mockValidateSmartTemplateApplication.mockResolvedValue(undefined); + mockValidateODataEndpoints.mockResolvedValue(undefined); + + prompter['businessServiceKeys'] = mockServiceKeys; + + const prompt = prompter['getBaseAppPrompt'](mockCfConfig); + const result = await prompt.validate!(mockCFApp); + + expect(mockDownloadAppContent).toHaveBeenCalledWith(mockCfConfig.space.GUID, mockCFApp, mockLogger); + expect(mockValidateSmartTemplateApplication).toHaveBeenCalledWith(mockManifest); + expect(mockValidateODataEndpoints).toHaveBeenCalledWith([], mockServiceKeys.credentials, mockLogger); + expect(result).toBe(true); + expect(prompter['manifest']).toBe(mockManifest); + expect(prompter['serviceInstanceGuid']).toBe('test-guid'); + }); + + test('should return error when app is not selected', async () => { + const prompt = prompter['getBaseAppPrompt'](mockCfConfig); + const result = await prompt.validate!(null); + + expect(result).toBe(t('error.baseAppHasToBeSelected')); + }); + + test('should handle validation errors', async () => { + const error = new Error('Validation failed'); + mockDownloadAppContent.mockRejectedValue(error); + + const prompt = prompter['getBaseAppPrompt'](mockCfConfig); + const result = await prompt.validate!(mockCFApp); + + expect(result).toBe('Validation failed'); + }); + + test('should show prompt when conditions are met', () => { + prompter['apps'] = [mockCFApp]; + + const prompt = prompter['getBaseAppPrompt'](mockCfConfig); + const whenFn = prompt.when as (answers: CfServicesAnswers) => boolean; + const shouldShow = whenFn({ businessService: 'test-service' }); + + expect(shouldShow).toBe(true); + }); + }); + + describe('getBusinessServicesPrompt', () => { + const prompter = new CFServicesPrompter(false, true, mockLogger); + + test('should create business services prompt', () => { + prompter['businessServices'] = ['service1', 'service2']; + + const prompt = prompter['getBusinessServicesPrompt'](mockCfConfig) as ListQuestion; + + expect(prompt.type).toBe('list'); + expect(prompt.name).toBe(cfServicesPromptNames.businessService); + expect(prompt.message).toBe(t('prompts.businessServiceLabel')); + expect(prompt.choices).toEqual(['service1', 'service2']); + }); + + test('should set default value when only one service', () => { + prompter['businessServices'] = ['single-service']; + + const prompt = prompter['getBusinessServicesPrompt'](mockCfConfig) as ListQuestion; + const defaultValue = prompt.default({}); + + expect(defaultValue).toBe('single-service'); + }); + + test('should validate business service selection', async () => { + mockGetBusinessServiceKeys.mockResolvedValue(mockServiceKeys); + mockGetCfApps.mockResolvedValue([mockCFApp]); + + const prompt = prompter['getBusinessServicesPrompt'](mockCfConfig) as ListQuestion; + const result = await prompt.validate!('test-service'); + + expect(mockGetBusinessServiceKeys).toHaveBeenCalledWith('test-service', mockCfConfig, mockLogger); + expect(mockGetCfApps).toHaveBeenCalledWith(mockServiceKeys.credentials, mockCfConfig, mockLogger); + expect(result).toBe(true); + }); + + test('should handle empty string for business service', async () => { + mockGetBusinessServiceKeys.mockResolvedValue(null); + + const prompt = prompter['getBusinessServicesPrompt'](mockCfConfig); + const result = await prompt.validate!(''); + + expect(result).toBe(t('error.businessServiceHasToBeSelected')); + }); + + test('should handle business service not found', async () => { + mockGetBusinessServiceKeys.mockResolvedValue(null); + + const prompt = prompter['getBusinessServicesPrompt'](mockCfConfig); + const result = await prompt.validate!('test-service'); + + expect(result).toBe(t('error.businessServiceDoesNotExist')); + }); + + test('should handle errors during validation', async () => { + const error = new Error('Service error'); + mockGetBusinessServiceKeys.mockRejectedValue(error); + + const prompt = prompter['getBusinessServicesPrompt'](mockCfConfig); + const result = await prompt.validate!('test-service'); + + expect(result).toBe('Service error'); + expect(mockLogger.error).toHaveBeenCalledWith('Failed to get available applications: Service error'); + }); + + test('should show prompt when logged in and approuter selected', () => { + prompter['approuter'] = AppRouterType.STANDALONE; + + const prompt = prompter['getBusinessServicesPrompt'](mockCfConfig) as ListQuestion; + const whenFn = prompt.when as (answers: CfServicesAnswers) => boolean; + const shouldShow = whenFn({ approuter: AppRouterType.STANDALONE }); + + expect(shouldShow).toBe(AppRouterType.STANDALONE); + }); + }); +}); From 68c5d6c326dc70c7a0c9aba761b60c2d9f39e158 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 29 Sep 2025 16:27:48 +0300 Subject: [PATCH 087/111] test: add new tests --- .../src/app/questions/helper/choices.ts | 2 +- .../unit/questions/helper/choices.test.ts | 208 ++++++++++++------ 2 files changed, 146 insertions(+), 64 deletions(-) diff --git a/packages/generator-adp/src/app/questions/helper/choices.ts b/packages/generator-adp/src/app/questions/helper/choices.ts index 5595e534b17..4f75e6e020d 100644 --- a/packages/generator-adp/src/app/questions/helper/choices.ts +++ b/packages/generator-adp/src/app/questions/helper/choices.ts @@ -35,7 +35,7 @@ export const getApplicationChoices = (apps: SourceApplication[]): Choice[] => { */ export const getCFAppChoices = (apps: CFApp[]): { name: string; value: CFApp }[] => { return apps.map((result: CFApp) => ({ - name: formatDiscovery(result) ?? `${result.title} (${result.appId}, ${result.appVersion})`, + name: formatDiscovery(result), value: result })); }; diff --git a/packages/generator-adp/test/unit/questions/helper/choices.test.ts b/packages/generator-adp/test/unit/questions/helper/choices.test.ts index 1801825dc16..b437d7a6566 100644 --- a/packages/generator-adp/test/unit/questions/helper/choices.test.ts +++ b/packages/generator-adp/test/unit/questions/helper/choices.test.ts @@ -1,75 +1,157 @@ -import type { SourceApplication } from '@sap-ux/adp-tooling'; +import { AppRouterType } from '@sap-ux/adp-tooling'; +import type { CFApp, SourceApplication } from '@sap-ux/adp-tooling'; -import { getApplicationChoices } from '../../../../src/app/questions/helper/choices'; +import { + getApplicationChoices, + getCFAppChoices, + getAppRouterChoices +} from '../../../../src/app/questions/helper/choices'; + +describe('Choices Helper Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); -describe('choices', () => { describe('getApplicationChoices', () => { - it('should transform an application with a title', () => { - const apps: SourceApplication[] = [ - { - id: '1', - title: 'Test App', - ach: 'AchValue', - registrationIds: ['ID1', 'ID2'], - bspName: '', - bspUrl: '', - fileType: '' - } - ]; - const choices = getApplicationChoices(apps); - expect(choices).toEqual([ - { - value: apps[0], - name: 'Test App (1, ID1,ID2, AchValue)' - } - ]); + const mockSourceApp: SourceApplication = { + id: 'test-app-id', + title: 'Test Application', + registrationIds: ['REG123'], + ach: 'ACH456', + fileType: 'application', + bspUrl: '/test.bsp.com', + bspName: 'test-bsp' + }; + + const mockSourceAppWithoutTitle: SourceApplication = { + id: 'test-app-id-2', + title: '', + registrationIds: ['REG456'], + ach: 'ACH789', + fileType: 'application', + bspUrl: '/test2.bsp.com', + bspName: 'test-bsp-2' + }; + + test('should create choices from applications with title', () => { + const apps = [mockSourceApp]; + const result = getApplicationChoices(apps); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + value: mockSourceApp, + name: 'Test Application (test-app-id, REG123, ACH456)' + }); }); - it('should transform an application without a title', () => { - const apps: SourceApplication[] = [ - { - id: '2', - title: '', - ach: 'Ach1', - registrationIds: ['Reg1'], - bspName: '', - bspUrl: '', - fileType: '' - } - ]; - const choices = getApplicationChoices(apps); - expect(choices).toEqual([ - { - value: apps[0], - name: '2 (Reg1, Ach1)' - } - ]); + test('should create choices from applications without title', () => { + const apps = [mockSourceAppWithoutTitle]; + const result = getApplicationChoices(apps); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + value: mockSourceAppWithoutTitle, + name: 'test-app-id-2 (REG456, ACH789)' + }); }); - it('should clean up extra punctuation when registrationIds and ach are empty', () => { - const apps: SourceApplication[] = [ - { - id: '3', - title: 'Empty Fields', - ach: '', - registrationIds: [], - bspName: '', - bspUrl: '', - fileType: '' - } - ]; - const choices = getApplicationChoices(apps); - expect(choices).toEqual([ - { - value: apps[0], - name: 'Empty Fields (3)' - } - ]); + test('should handle multiple applications', () => { + const apps = [mockSourceApp, mockSourceAppWithoutTitle]; + const result = getApplicationChoices(apps); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Test Application (test-app-id, REG123, ACH456)'); + expect(result[1].name).toBe('test-app-id-2 (REG456, ACH789)'); }); - it('should return the input if it is not an array', () => { - const notAnArray = { foo: 'bar' }; - expect(getApplicationChoices(notAnArray as any)).toEqual(notAnArray); + test('should handle empty array', () => { + const result = getApplicationChoices([]); + expect(result).toHaveLength(0); + }); + + test('should handle non-array input', () => { + const nonArray = 'not an array'; + const result = getApplicationChoices(nonArray as any); + expect(result).toBe(nonArray); + }); + }); + + describe('getCFAppChoices', () => { + const mockCFApp: CFApp = { + appId: 'test-app-id', + appName: 'Test App', + appVersion: '1.0.0', + appHostId: 'host-123', + serviceName: 'test-service', + title: 'Test Application' + }; + + const mockCFApp2: CFApp = { + appId: 'test-app-id-2', + appName: 'Test App 2', + appVersion: '2.0.0', + appHostId: 'host-456', + serviceName: 'test-service-2', + title: 'Test Application 2' + }; + + test('should create choices using formatDiscovery when available', () => { + const apps = [mockCFApp]; + + const result = getCFAppChoices(apps); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + value: mockCFApp, + name: 'Test Application (test-app-id 1.0.0)' + }); + }); + + test('should handle multiple apps', () => { + const apps = [mockCFApp, mockCFApp2]; + + const result = getCFAppChoices(apps); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + value: mockCFApp, + name: 'Test Application (test-app-id 1.0.0)' + }); + expect(result[1]).toEqual({ + value: mockCFApp2, + name: 'Test Application 2 (test-app-id-2 2.0.0)' + }); + }); + + test('should handle empty array', () => { + const result = getCFAppChoices([]); + expect(result).toHaveLength(0); + }); + }); + + describe('getAppRouterChoices', () => { + test('should return only MANAGED option when isInternalUsage is false', () => { + const result = getAppRouterChoices(false); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: AppRouterType.MANAGED, + value: AppRouterType.MANAGED + }); + }); + + test('should return both MANAGED and STANDALONE options when isInternalUsage is true', () => { + const result = getAppRouterChoices(true); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: AppRouterType.MANAGED, + value: AppRouterType.MANAGED + }); + expect(result[1]).toEqual({ + name: AppRouterType.STANDALONE, + value: AppRouterType.STANDALONE + }); }); }); }); From b897ca2dc485a32bbeef37f9340940d50fea1f4e Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 29 Sep 2025 17:55:31 +0300 Subject: [PATCH 088/111] test: add new tests --- .../helper/additional-messages.test.ts | 30 ++- .../unit/questions/helper/conditions.test.ts | 51 ++++- .../unit/questions/helper/validators.test.ts | 206 ++++++++++++++++-- 3 files changed, 270 insertions(+), 17 deletions(-) diff --git a/packages/generator-adp/test/unit/questions/helper/additional-messages.test.ts b/packages/generator-adp/test/unit/questions/helper/additional-messages.test.ts index 80da8486271..011a13aa5da 100644 --- a/packages/generator-adp/test/unit/questions/helper/additional-messages.test.ts +++ b/packages/generator-adp/test/unit/questions/helper/additional-messages.test.ts @@ -6,7 +6,8 @@ import { AdaptationProjectType } from '@sap-ux/axios-extension'; import { getAppAdditionalMessages, getSystemAdditionalMessages, - getVersionAdditionalMessages + getVersionAdditionalMessages, + getTargetEnvAdditionalMessages } from '../../../../src/app/questions/helper/additional-messages'; import { t } from '../../../../src/utils/i18n'; @@ -137,5 +138,32 @@ describe('additional-messages', () => { severity: Severity.warning }); }); + + it('should return undefined when version is detected', () => { + const result = getVersionAdditionalMessages(true); + expect(result).toBeUndefined(); + }); + }); + + describe('getTargetEnvAdditionalMessages', () => { + const mockCfConfig = { + url: 'https://test.cf.com', + org: { Name: 'test-org' }, + space: { Name: 'test-space' } + }; + + it('should return information when CF selected and logged in', () => { + const result = getTargetEnvAdditionalMessages('CF', true, mockCfConfig); + + expect(result).toEqual({ + message: 'You are logged in to Cloud Foundry: https://test.cf.com / test-org / test-space.', + severity: Severity.information + }); + }); + + it('should return undefined when not CF environment', () => { + const result = getTargetEnvAdditionalMessages('ABAP', true, mockCfConfig); + expect(result).toBeUndefined(); + }); }); }); diff --git a/packages/generator-adp/test/unit/questions/helper/conditions.test.ts b/packages/generator-adp/test/unit/questions/helper/conditions.test.ts index 0733bf08ddb..ff8506d46d2 100644 --- a/packages/generator-adp/test/unit/questions/helper/conditions.test.ts +++ b/packages/generator-adp/test/unit/questions/helper/conditions.test.ts @@ -1,10 +1,13 @@ import { isAppStudio } from '@sap-ux/btp-utils'; -import type { ConfigAnswers, SourceApplication } from '@sap-ux/adp-tooling'; +import type { ConfigAnswers, SourceApplication, CfServicesAnswers } from '@sap-ux/adp-tooling'; +import { AppRouterType } from '@sap-ux/adp-tooling'; import { showApplicationQuestion, showCredentialQuestion, - showExtensionProjectQuestion + showExtensionProjectQuestion, + showInternalQuestions, + showBusinessSolutionNameQuestion } from '../../../../src/app/questions/helper/conditions'; jest.mock('@sap-ux/btp-utils', () => ({ @@ -155,3 +158,47 @@ describe('showExtensionProjectQuestion', () => { expect(result).toBe(false); }); }); + +describe('showInternalQuestions', () => { + it('should return true when all conditions are met', () => { + const answers = { + system: 'TestSystem', + application: { id: '1', title: 'Test App' } + } as ConfigAnswers; + const result = showInternalQuestions(answers, false, true); + expect(result).toBe(true); + }); + + it('should return false when application is not supported', () => { + const answers = { + system: 'TestSystem', + application: { id: '1', title: 'Test App' } + } as ConfigAnswers; + const result = showInternalQuestions(answers, false, false); + expect(result).toBe(false); + }); +}); + +describe('showBusinessSolutionNameQuestion', () => { + it('should return true when all conditions are met', () => { + const answers = { + approuter: AppRouterType.MANAGED, + businessService: 'test-service', + businessSolutionName: '', + baseApp: undefined + } as CfServicesAnswers; + const result = showBusinessSolutionNameQuestion(answers, true, true, 'test-service'); + expect(result).toBe(true); + }); + + it('should return false when businessService is undefined', () => { + const answers = { + approuter: AppRouterType.MANAGED, + businessService: 'test-service', + businessSolutionName: '', + baseApp: undefined + } as CfServicesAnswers; + const result = showBusinessSolutionNameQuestion(answers, true, true, undefined); + expect(result).toBe(false); + }); +}); diff --git a/packages/generator-adp/test/unit/questions/helper/validators.test.ts b/packages/generator-adp/test/unit/questions/helper/validators.test.ts index 818c22d8dcf..c4d87c83796 100644 --- a/packages/generator-adp/test/unit/questions/helper/validators.test.ts +++ b/packages/generator-adp/test/unit/questions/helper/validators.test.ts @@ -1,15 +1,43 @@ +import { existsSync } from 'fs'; + +import { isAppStudio } from '@sap-ux/btp-utils'; +import type { ToolsLogger } from '@sap-ux/logger'; import type { SystemLookup } from '@sap-ux/adp-tooling'; -import { validateNamespaceAdp, validateProjectName } from '@sap-ux/project-input-validator'; +import { isExternalLoginEnabled, isMtaProject, getMtaServices } from '@sap-ux/adp-tooling'; +import { validateNamespaceAdp, validateProjectName, validateEmptyString } from '@sap-ux/project-input-validator'; -import { t } from '../../../../src/utils/i18n'; -import { validateJsonInput } from '../../../../src/app/questions/helper/validators'; -import { validateExtensibilityExtension } from '../../../../src/app/questions/helper/validators'; +import { + validateJsonInput, + validateExtensibilityExtension, + validateEnvironment, + validateProjectPath, + validateBusinessSolutionName +} from '../../../../src/app/questions/helper/validators'; +import { initI18n, t } from '../../../../src/utils/i18n'; jest.mock('@sap-ux/project-input-validator', () => ({ + ...jest.requireActual('@sap-ux/project-input-validator'), validateProjectName: jest.fn(), validateNamespaceAdp: jest.fn() })); +jest.mock('@sap-ux/btp-utils', () => ({ + ...jest.requireActual('@sap-ux/btp-utils'), + isAppStudio: jest.fn() +})); + +jest.mock('@sap-ux/adp-tooling', () => ({ + ...jest.requireActual('@sap-ux/adp-tooling'), + isExternalLoginEnabled: jest.fn(), + isMtaProject: jest.fn(), + getMtaServices: jest.fn() +})); + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn() +})); + const availableSystem = 'systemA'; const nonExistingSystem = 'systemB'; @@ -20,10 +48,19 @@ const jsonInput = { system: availableSystem }; -const validateProjectNameMock = validateProjectName as jest.Mock; -const validateNamespaceAdpMock = validateNamespaceAdp as jest.Mock; +const mockExistsSync = existsSync as jest.MockedFunction; +const mockIsAppStudio = isAppStudio as jest.MockedFunction; +const mockIsMtaProject = isMtaProject as jest.MockedFunction; +const mockGetMtaServices = getMtaServices as jest.MockedFunction; +const mockValidateProjectName = validateProjectName as jest.MockedFunction; +const mockValidateNamespaceAdp = validateNamespaceAdp as jest.MockedFunction; +const mockIsExternalLoginEnabled = isExternalLoginEnabled as jest.MockedFunction; describe('validateExtensibilityGenerator', () => { + beforeAll(async () => { + await initI18n(); + }); + beforeEach(() => { jest.clearAllMocks(); }); @@ -110,34 +147,175 @@ describe('validateJsonInput', () => { }); it('should resolve the returned promise when all data is valid', async () => { - validateProjectNameMock.mockReturnValue(true); - validateNamespaceAdpMock.mockReturnValue(true); + mockValidateProjectName.mockReturnValue(true); + mockValidateNamespaceAdp.mockReturnValue(true); await expect(validateJsonInput(systemLookup, true, jsonInput)).resolves.not.toThrow(); expect(systemLookup.getSystemByName).toHaveBeenCalledWith(availableSystem); }); it('should throw an error when the project name is NOT valid', async () => { const invalidProjectNameMessage = 'invalid project name'; - validateProjectNameMock.mockReturnValue(invalidProjectNameMessage); - validateNamespaceAdpMock.mockReturnValue(true); + mockValidateProjectName.mockReturnValue(invalidProjectNameMessage); + mockValidateNamespaceAdp.mockReturnValue(true); await expect(validateJsonInput(systemLookup, true, jsonInput)).rejects.toThrow(invalidProjectNameMessage); expect(systemLookup.getSystemByName).not.toHaveBeenCalled(); }); it('should throw an error when the namespace is NOT valid', async () => { const invalidNamespaceMessage = 'invalid namespace'; - validateNamespaceAdpMock.mockReturnValue(invalidNamespaceMessage); - validateProjectNameMock.mockReturnValue(true); + mockValidateNamespaceAdp.mockReturnValue(invalidNamespaceMessage); + mockValidateProjectName.mockReturnValue(true); await expect(validateJsonInput(systemLookup, true, jsonInput)).rejects.toThrow(invalidNamespaceMessage); expect(systemLookup.getSystemByName).not.toHaveBeenCalled(); }); it('should throw an error when the system is NOT found', async () => { - validateNamespaceAdpMock.mockReturnValue(true); - validateProjectNameMock.mockReturnValue(true); + mockValidateNamespaceAdp.mockReturnValue(true); + mockValidateProjectName.mockReturnValue(true); await expect( validateJsonInput(systemLookup, true, { ...jsonInput, system: nonExistingSystem }) ).rejects.toThrow(t('error.systemNotFound', { system: nonExistingSystem })); expect(systemLookup.getSystemByName).toHaveBeenCalledWith(nonExistingSystem); }); }); + +describe('validateEnvironment', () => { + const mockVscode = {}; + + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return true for ABAP environment', async () => { + const result = await validateEnvironment('ABAP', false, mockVscode); + expect(result).toBe(true); + }); + + test('should return true for CF when logged in', async () => { + mockIsAppStudio.mockReturnValue(false); + mockIsExternalLoginEnabled.mockResolvedValue(true); + + const result = await validateEnvironment('CF', true, mockVscode); + expect(result).toBe(true); + }); + + test('should return error when CF selected but not logged in', async () => { + const result = await validateEnvironment('CF', false, mockVscode); + expect(result).toBe(t('error.cfNotLoggedIn')); + }); + + test('should return true for CF when logged in and in AppStudio', async () => { + mockIsAppStudio.mockReturnValue(true); + + const result = await validateEnvironment('CF', true, mockVscode); + expect(result).toBe(true); + }); + + test('should check external login when CF selected and not in AppStudio', async () => { + mockIsAppStudio.mockReturnValue(false); + mockIsExternalLoginEnabled.mockResolvedValue(true); + + const result = await validateEnvironment('CF', true, mockVscode); + expect(result).toBe(true); + expect(mockIsExternalLoginEnabled).toHaveBeenCalledWith(mockVscode); + }); + + test('should return error when external login not enabled', async () => { + mockIsAppStudio.mockReturnValue(false); + mockIsExternalLoginEnabled.mockResolvedValue(false); + + const result = await validateEnvironment('CF', true, mockVscode); + expect(result).toBe(t('error.cfLoginCannotBeDetected')); + }); +}); + +describe('validateProjectPath', () => { + const mockLogger = { + error: jest.fn() + } as unknown as ToolsLogger; + + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return true for valid project path', async () => { + mockExistsSync.mockReturnValue(true); + mockIsMtaProject.mockReturnValue(true); + mockGetMtaServices.mockResolvedValue(['service1', 'service2']); + + const result = await validateProjectPath('/test/project', mockLogger); + expect(result).toBe(true); + }); + + test('should return error for empty string', async () => { + const result = await validateProjectPath('', mockLogger); + expect(result).toBe('The input cannot be empty.'); + }); + + test('should return error when project does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await validateProjectPath('/nonexistent/project', mockLogger); + expect(result).toBe(t('error.projectDoesNotExist')); + }); + + test('should return error when not an MTA project', async () => { + mockExistsSync.mockReturnValue(true); + mockIsMtaProject.mockReturnValue(false); + + const result = await validateProjectPath('/test/project', mockLogger); + expect(result).toBe(t('error.projectDoesNotExistMta')); + }); + + test('should return error when no services found', async () => { + mockExistsSync.mockReturnValue(true); + mockIsMtaProject.mockReturnValue(true); + mockGetMtaServices.mockResolvedValue([]); + + const result = await validateProjectPath('/test/project', mockLogger); + expect(result).toBe(t('error.noAdaptableBusinessServiceFoundInMta')); + }); + + test('should return error when getMtaServices throws exception', async () => { + mockExistsSync.mockReturnValue(true); + mockIsMtaProject.mockReturnValue(true); + mockGetMtaServices.mockRejectedValue(new Error('Service error')); + + const result = await validateProjectPath('/test/project', mockLogger); + expect(result).toBe(t('error.noAdaptableBusinessServiceFoundInMta')); + expect(mockLogger.error).toHaveBeenCalledWith('Failed to get MTA services: Service error'); + }); +}); + +describe('validateBusinessSolutionName', () => { + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return true for valid business solution name', () => { + const result = validateBusinessSolutionName('test.solution'); + expect(result).toBe(true); + }); + + test('should return error for empty string', () => { + const result = validateBusinessSolutionName(''); + expect(result).toBe('The input cannot be empty.'); + }); + + test('should return error for single part name', () => { + const result = validateBusinessSolutionName('test'); + expect(result).toBe(t('error.businessSolutionNameInvalid')); + }); +}); From 914b6d3d137404beb6843a3c7f5cda58b598207b Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 30 Sep 2025 15:36:05 +0300 Subject: [PATCH 089/111] test: add integration tests for cf flow --- packages/generator-adp/package.json | 2 + packages/generator-adp/src/app/index.ts | 37 +- .../src/app/questions/cf-services.ts | 4 +- .../src/app/questions/helper/conditions.ts | 14 +- .../test/__snapshots__/app.test.ts.snap | 201 +++++- packages/generator-adp/test/app.test.ts | 585 +++++++++++------- .../test/fixtures/mta-project/mta.yaml | 11 + 7 files changed, 605 insertions(+), 249 deletions(-) create mode 100644 packages/generator-adp/test/fixtures/mta-project/mta.yaml diff --git a/packages/generator-adp/package.json b/packages/generator-adp/package.json index 7e92b663345..52854427387 100644 --- a/packages/generator-adp/package.json +++ b/packages/generator-adp/package.json @@ -22,6 +22,8 @@ "lint:fix": "eslint . --ext .ts --fix", "test": "jest --ci --forceExit --detectOpenHandles --colors", "test-u": "jest --ci --forceExit --detectOpenHandles --colors -u", + "test:abap": "jest --ci --forceExit --detectOpenHandles --colors test/app.test.ts --testNamePattern=\"ABAP Environment\"", + "test:cf": "jest --ci --forceExit --detectOpenHandles --colors test/app.test.ts --testNamePattern=\"CF Environment\"", "link": "pnpm link --global", "unlink": "pnpm unlink --global" }, diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 9372440000c..7dc782936b1 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -14,7 +14,6 @@ import { getConfig, getConfiguredProvider, getYamlContent, - isCFEnvironment, isCfInstalled, isLoggedInCf, isMtaProject, @@ -162,14 +161,6 @@ export default class extends Generator { * Indicates if the current project is an MTA project. */ private readonly isMtaYamlFound: boolean; - /** - * Project location. - */ - private projectLocation: string; - /** - * CF project destination path. - */ - private cfProjectDestinationPath: string; /** * CF services answers. */ @@ -199,8 +190,7 @@ export default class extends Generator { this.options = opts; this.isMtaYamlFound = isMtaProject(process.cwd()) as boolean; - // TODO: Remove this once the PR is ready. - this.isExtensionInstalled = true; // isExtensionInstalled(opts.vscode, 'SAP.adp-ve-bas-ext'); + this.isExtensionInstalled = isExtensionInstalled(opts.vscode, 'SAP.adp-ve-bas-ext'); const jsonInputString = getFirstArgAsString(args); this.jsonInput = parseJsonInput(jsonInputString, this.logger); @@ -415,7 +405,7 @@ export default class extends Generator { }); } - if (isCFEnvironment(projectPath) || this.isCli) { + if (this.isCli) { return; } @@ -440,14 +430,12 @@ export default class extends Generator { private async _promptForCfProjectPath(): Promise { if (!this.isMtaYamlFound) { const pathAnswers = await this.prompt([getProjectPathPrompt(this.logger, this.vscode)]); - this.projectLocation = pathAnswers.projectLocation; - this.projectLocation = fs.realpathSync(this.projectLocation, 'utf-8'); - this.cfProjectDestinationPath = this.destinationRoot(this.projectLocation); - this.logger.log(`Project path information: ${this.projectLocation}`); + const path = this.destinationRoot(fs.realpathSync(pathAnswers.projectLocation, 'utf-8')); + this.logger.log(`Project path information: ${path}`); } else { - this.cfProjectDestinationPath = this.destinationRoot(process.cwd()); - getYamlContent(join(this.cfProjectDestinationPath, 'mta.yaml')); - this.logger.log(`Project path information: ${this.cfProjectDestinationPath}`); + const path = this.destinationRoot(process.cwd()); + getYamlContent(join(path, 'mta.yaml')); + this.logger.log(`Project path information: ${path}`); } } @@ -499,8 +487,9 @@ export default class extends Generator { addDeployConfig: { hide: true } }; + const projectPath = this.destinationPath(); const attributesQuestions = getPrompts( - this.destinationPath(), + projectPath, { ui5Versions: [], isVersionDetected: false, @@ -515,7 +504,7 @@ export default class extends Generator { this.attributeAnswers = await this.prompt(attributesQuestions); this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); - const cfServicesQuestions = await this.cfPrompter.getPrompts(this.cfProjectDestinationPath, this.cfConfig); + const cfServicesQuestions = await this.cfPrompter.getPrompts(projectPath, this.cfConfig); this.cfServicesAnswers = await this.prompt(cfServicesQuestions); this.logger.info(`CF Services Answers: ${JSON.stringify(this.cfServicesAnswers, null, 2)}`); } @@ -540,12 +529,6 @@ export default class extends Generator { * Generates the ADP project artifacts for the CF environment. */ private async _generateAdpProjectArtifactsCF(): Promise { - const { baseApp } = this.cfServicesAnswers; - - if (!baseApp) { - throw new Error('Base app is required for CF project generation. Please select a base app and try again.'); - } - const projectPath = this.isMtaYamlFound ? process.cwd() : this.destinationPath(); const publicVersions = await fetchPublicVersions(this.logger); diff --git a/packages/generator-adp/src/app/questions/cf-services.ts b/packages/generator-adp/src/app/questions/cf-services.ts index ee30cec41bf..9af56eb1c1e 100644 --- a/packages/generator-adp/src/app/questions/cf-services.ts +++ b/packages/generator-adp/src/app/questions/cf-services.ts @@ -29,7 +29,7 @@ import type { InputQuestion, ListQuestion } from '@sap-ux/inquirer-common'; import { t } from '../../utils/i18n'; import { validateBusinessSolutionName } from './helper/validators'; import { getAppRouterChoices, getCFAppChoices } from './helper/choices'; -import { showBusinessSolutionNameQuestion } from './helper/conditions'; +import { shouldShowBaseAppPrompt, showBusinessSolutionNameQuestion } from './helper/conditions'; /** * Prompter for CF services. @@ -244,7 +244,7 @@ export class CFServicesPrompter { return true; }, - when: (answers: CfServicesAnswers) => this.isCfLoggedIn && answers.businessService && !!this.apps.length, + when: (answers: CfServicesAnswers) => shouldShowBaseAppPrompt(answers, this.isCfLoggedIn, this.apps), guiOptions: { hint: t('prompts.baseAppTooltip'), breadcrumb: true diff --git a/packages/generator-adp/src/app/questions/helper/conditions.ts b/packages/generator-adp/src/app/questions/helper/conditions.ts index 2d0ff36c23b..4c5a6d68ad5 100644 --- a/packages/generator-adp/src/app/questions/helper/conditions.ts +++ b/packages/generator-adp/src/app/questions/helper/conditions.ts @@ -1,6 +1,6 @@ import { isAppStudio } from '@sap-ux/btp-utils'; import { AppRouterType } from '@sap-ux/adp-tooling'; -import type { ConfigAnswers, FlexUISupportedSystem, CfServicesAnswers } from '@sap-ux/adp-tooling'; +import type { ConfigAnswers, FlexUISupportedSystem, CfServicesAnswers, CFApp } from '@sap-ux/adp-tooling'; /** * Determines if a credential question should be shown. @@ -99,3 +99,15 @@ export function showBusinessSolutionNameQuestion( ): boolean { return isCFLoggedIn && answers.approuter === AppRouterType.MANAGED && showSolutionNamePrompt && !!businessService; } + +/** + * Determines if the base app prompt should be shown. + * + * @param {CfServicesAnswers} answers - The user-provided answers containing application details. + * @param {boolean} isCFLoggedIn - A flag indicating whether the user is logged in to Cloud Foundry. + * @param {CFApp[]} apps - The base apps available. + * @returns {boolean} True if the base app prompt should be shown, otherwise false. + */ +export function shouldShowBaseAppPrompt(answers: CfServicesAnswers, isCFLoggedIn: boolean, apps: CFApp[]): boolean { + return isCFLoggedIn && !!answers.businessService && !!apps.length; +} diff --git a/packages/generator-adp/test/__snapshots__/app.test.ts.snap b/packages/generator-adp/test/__snapshots__/app.test.ts.snap index c8e92e7bfa8..8f02e342b23 100644 --- a/packages/generator-adp/test/__snapshots__/app.test.ts.snap +++ b/packages/generator-adp/test/__snapshots__/app.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Adaptation Project Generator Integration Test should create adaptation project from json correctly 1`] = ` +exports[`Adaptation Project Generator Integration Test ABAP Environment should create adaptation project from json correctly 1`] = ` "{ "fileName": "manifest", "layer": "CUSTOMER_BASE", @@ -22,7 +22,7 @@ exports[`Adaptation Project Generator Integration Test should create adaptation " `; -exports[`Adaptation Project Generator Integration Test should create adaptation project from json correctly 2`] = ` +exports[`Adaptation Project Generator Integration Test ABAP Environment should create adaptation project from json correctly 2`] = ` "#Make sure you provide a unique prefix to the newly added keys in this file, to avoid overriding of SAP Fiori application keys. #XTIT: Application name @@ -30,7 +30,7 @@ customer.my.app_sap.app.title=My app title " `; -exports[`Adaptation Project Generator Integration Test should create adaptation project from json correctly 3`] = ` +exports[`Adaptation Project Generator Integration Test ABAP Environment should create adaptation project from json correctly 3`] = ` "# yaml-language-server: $schema=https://sap.github.io/ui5-tooling/schema/ui5.yaml.json specVersion: "3.1" @@ -79,7 +79,7 @@ server: " `; -exports[`Adaptation Project Generator Integration Test should generate an onPremise adaptation project successfully 1`] = ` +exports[`Adaptation Project Generator Integration Test ABAP Environment should generate an onPremise adaptation project successfully 1`] = ` "{ "fileName": "manifest", "layer": "CUSTOMER_BASE", @@ -107,7 +107,7 @@ exports[`Adaptation Project Generator Integration Test should generate an onPrem " `; -exports[`Adaptation Project Generator Integration Test should generate an onPremise adaptation project successfully 2`] = ` +exports[`Adaptation Project Generator Integration Test ABAP Environment should generate an onPremise adaptation project successfully 2`] = ` "#Make sure you provide a unique prefix to the newly added keys in this file, to avoid overriding of SAP Fiori application keys. #XTIT: Application name @@ -115,7 +115,7 @@ customer.app.variant_sap.app.title=App Title " `; -exports[`Adaptation Project Generator Integration Test should generate an onPremise adaptation project successfully 3`] = ` +exports[`Adaptation Project Generator Integration Test ABAP Environment should generate an onPremise adaptation project successfully 3`] = ` "# yaml-language-server: $schema=https://sap.github.io/ui5-tooling/schema/ui5.yaml.json specVersion: "3.1" @@ -163,3 +163,192 @@ server: version: 1.134.1 " `; + +exports[`Adaptation Project Generator Integration Test CF Environment should generate an adaptation project successfully 1`] = ` +"{ + "fileName": "manifest", + "layer": "CUSTOMER_BASE", + "fileType": "appdescr_variant", + "reference": "test-app-id", + "id": "customer.app.variant", + "namespace": "apps/test-app-id/appVariants/customer.app.variant/", + "version": "0.1.0", + "content": [ + { + "changeType": "appdescr_ui5_setMinUI5Version", + "content": { + "minUI5Version": "1.134.1" + } + }, + { + "changeType": "appdescr_app_setTitle", + "content": {}, + "texts": { + "i18n": "i18n/i18n.properties" + } + } + ] +} +" +`; + +exports[`Adaptation Project Generator Integration Test CF Environment should generate an adaptation project successfully 2`] = ` +"#Make sure you provide a unique prefix to the newly added keys in this file, to avoid overriding of SAP Fiori application keys. +#XTIT: Application name +customer.app.variant_sap.app.title=App Title" +`; + +exports[`Adaptation Project Generator Integration Test CF Environment should generate an adaptation project successfully 3`] = ` +"--- +specVersion: "2.2" +type: application +metadata: + name: app.variant +builder: + customTasks: + - name: app-variant-bundler-build + beforeTask: escapeNonAsciiCharacters + configuration: + appHostId: test-app-host-id + appName: test-app-name + appVersion: test-app-version + moduleName: app.variant + org: org-guid + space: space-guid + html5RepoRuntime: test-guid + sapCloudService: test-solution +" +`; + +exports[`Adaptation Project Generator Integration Test CF Environment should generate an adaptation project successfully 4`] = ` +"ID: mta-project +version: 1.0.0 +modules: + - name: mta-project-destination-content + type: com.sap.application.content + requires: + - name: mta-project_uaa + parameters: + service-key: + name: mta-project-uaa-key + - name: mta-project_html_repo_host + parameters: + service-key: + name: mta-project-html_repo_host-key + - name: mta-project-destination + parameters: + content-target: true + - name: test-service + parameters: + service-key: + name: test-service-key + build-parameters: + no-source: true + parameters: + content: + instance: + destinations: + - Name: test-solution-mta-project-html_repo_host + ServiceInstanceName: mta-project-html5_app_host + ServiceKeyName: mta-project-html_repo_host-key + sap.cloud.service: test-solution + - Name: test-solution-uaa-mta-project + ServiceInstanceName: mta-project-xsuaa + ServiceKeyName: mta-project_uaa-key + Authentication: OAuth2UserTokenExchange + sap.cloud.service: test-solution + - Name: test-service-service_instance_name + Authentication: OAuth2UserTokenExchange + ServiceInstanceName: test-service + ServiceKeyName: test-service-key + existing_destinations_policy: update + - name: mta-project_ui_deployer + type: com.sap.application.content + path: . + requires: + - name: mta-project_html_repo_host + parameters: + content-target: true + build-parameters: + build-result: resources + requires: + - artifacts: + - test-app-id.zip + name: test-app-id + target-path: resources/ + - name: test-app-id + type: html5 + path: test-app-id + build-parameters: + builder: custom + commands: + - npm install + - npm run build + supported-platforms: [] +resources: + - name: test-destination-service + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-plan: lite + service-name: test-destination-service + - name: mta-project_html_repo_host + type: org.cloudfoundry.managed-service + parameters: + service: html5-apps-repo + service-plan: app-host + service-name: mta-project-html5_app_host + - name: mta-project_uaa + type: org.cloudfoundry.managed-service + parameters: + service: xsuaa + path: ./xs-security.json + service-plan: application + service-name: mta-project_1234567890-xsuaa + - name: mta-project-destination + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-name: mta-project-destination + service-plan: lite + config: + HTML5Runtime_enabled: true + version: 1.0.0 +_schema-version: '3.3' +description: Test MTA Project for CF Writer Tests +" +`; + +exports[`Adaptation Project Generator Integration Test CF Environment should generate an adaptation project successfully 5`] = ` +"{ + "name": "app.variant", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip", + "zip": "cd dist && npx bestzip ../app.variant.zip *", + "clean": "npx rimraf app.variant.zip dist", + "build-ui5": "npm explore @ui5/task-adaptation -- npm run rollup" + }, + "repository": { + "type": "git", + "build": "ui5.js build --verbose --include-task generateCachebusterInfo" + }, + "ui5": { + "dependencies": [ + "@sap/ui5-builder-webide-extension", + "@ui5/task-adaptation" + ] + }, + "devDependencies": { + "@sap/ui5-builder-webide-extension": "1.0.x", + "@sapui5/ts-types": "^1.85.1", + "@ui5/cli": "^3.0.0", + "@ui5/task-adaptation": "^1.0.x", + "bestzip": "2.1.4", + "rimraf": "3.0.2" + } +} +" +`; diff --git a/packages/generator-adp/test/app.test.ts b/packages/generator-adp/test/app.test.ts index ec78b8413bd..036ab4b020c 100644 --- a/packages/generator-adp/test/app.test.ts +++ b/packages/generator-adp/test/app.test.ts @@ -5,20 +5,38 @@ import { rimraf } from 'rimraf'; import Generator from 'yeoman-generator'; import yeomanTest from 'yeoman-test'; -import type { AttributesAnswers, ConfigAnswers, Language, SourceApplication, VersionDetail } from '@sap-ux/adp-tooling'; +import type { + AttributesAnswers, + CFApp, + CfConfig, + CfServicesAnswers, + ConfigAnswers, + Language, + SourceApplication, + VersionDetail +} from '@sap-ux/adp-tooling'; import { + AppRouterType, FlexLayer, SourceManifest, SystemLookup, + createServices, fetchPublicVersions, + getApprouterType, getConfiguredProvider, + getModuleNames, + getMtaServices, getProviderConfig, + hasApprouter, + isCfInstalled, + isLoggedInCf, loadApps, + loadCfConfig, validateUI5VersionExists } from '@sap-ux/adp-tooling'; import { type AbapServiceProvider, AdaptationProjectType } from '@sap-ux/axios-extension'; import { isAppStudio } from '@sap-ux/btp-utils'; -import { isCli, sendTelemetry } from '@sap-ux/fiori-generator-shared'; +import { isCli, isExtensionInstalled, sendTelemetry } from '@sap-ux/fiori-generator-shared'; import type { ToolsLogger } from '@sap-ux/logger'; import * as Logger from '@sap-ux/logger'; import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; @@ -28,7 +46,7 @@ import type { AdpGeneratorOptions } from '../src/app'; import adpGenerator from '../src/app'; import { ConfigPrompter } from '../src/app/questions/configuration'; import { getDefaultProjectName } from '../src/app/questions/helper/default-values'; -import type { JsonInput } from '../src/app/types'; +import { TargetEnv, type JsonInput, type TargetEnvAnswers } from '../src/app/types'; import { EventName } from '../src/telemetryEvents'; import { initI18n, t } from '../src/utils/i18n'; import * as subgenHelpers from '../src/utils/subgenHelpers'; @@ -38,6 +56,7 @@ import { showWorkspaceFolderWarning, workspaceChoices } from '../src/utils/workspace'; +import { CFServicesPrompter } from '../src/app/questions/cf-services'; jest.mock('@sap-ux/feature-toggle', () => ({ isInternalFeaturesSettingEnabled: jest.fn().mockReturnValue(false) @@ -61,7 +80,8 @@ jest.mock('../src/app/extension-project/index.ts', () => ({ jest.mock('../src/app/questions/helper/conditions', () => ({ ...jest.requireActual('../src/app/questions/helper/conditions'), showApplicationQuestion: jest.fn().mockReturnValue(true), - showExtensionProjectQuestion: jest.fn().mockReturnValue(true) + showExtensionProjectQuestion: jest.fn().mockReturnValue(true), + shouldShowBaseAppPrompt: jest.fn().mockReturnValue(true) })); jest.mock('@sap-ux/system-access', () => ({ @@ -81,7 +101,15 @@ jest.mock('@sap-ux/adp-tooling', () => ({ getProviderConfig: jest.fn(), validateUI5VersionExists: jest.fn(), fetchPublicVersions: jest.fn(), - isCFEnvironment: jest.fn().mockReturnValue(false) + isCFEnvironment: jest.fn().mockReturnValue(false), + isCfInstalled: jest.fn(), + loadCfConfig: jest.fn(), + isLoggedInCf: jest.fn(), + getMtaServices: jest.fn(), + getModuleNames: jest.fn(), + getApprouterType: jest.fn(), + hasApprouter: jest.fn(), + createServices: jest.fn() })); jest.mock('../src/utils/deps.ts', () => ({ @@ -101,7 +129,7 @@ jest.mock('@sap-ux/fiori-generator-shared', () => ({ Platform: 'testPlatform' }) }, - isExtensionInstalled: jest.fn().mockReturnValue(true), + isExtensionInstalled: jest.fn(), getHostEnvironment: jest.fn(), isCli: jest.fn(), getDefaultTargetFolder: jest.fn().mockReturnValue(undefined) @@ -156,6 +184,36 @@ const answers: ConfigAnswers & AttributesAnswers = { addFlpConfig: false }; +const baseApp: CFApp = { + appId: 'test-app-id', + appName: 'test-app-name', + appVersion: 'test-app-version', + serviceName: 'test-service-name', + appHostId: 'test-app-host-id', + title: 'test-app-title' +}; + +const answersCf: CfServicesAnswers & AttributesAnswers & TargetEnvAnswers = { + targetEnv: TargetEnv.CF, + projectName: 'app.variant', + namespace: 'customer.app.variant', + title: 'App Title', + ui5Version: '1.134.1', + targetFolder: testOutputDir, + enableTypeScript: false, + baseApp, + approuter: AppRouterType.MANAGED, + businessService: 'test-service', + businessSolutionName: 'test-solution' +}; + +const cfConfig: CfConfig = { + url: '/api.cf.example.com', + token: 'test-token', + org: { GUID: 'org-guid', Name: 'test-org' }, + space: { GUID: 'space-guid', Name: 'test-space' } +}; + const inbounds = { 'display-bank': { semanticObject: 'test', @@ -234,242 +292,343 @@ const getProviderConfigMock = getProviderConfig as jest.Mock; const fetchPublicVersionsMock = fetchPublicVersions as jest.Mock; const sendTelemetryMock = sendTelemetry as jest.Mock; const existsInWorkspaceMock = existsInWorkspace as jest.Mock; +const isExtensionInstalledMock = isExtensionInstalled as jest.Mock; const showWorkspaceFolderWarningMock = showWorkspaceFolderWarning as jest.Mock; const handleWorkspaceFolderChoiceMock = handleWorkspaceFolderChoice as jest.Mock; const getDefaultProjectNameMock = getDefaultProjectName as jest.Mock; const getConfiguredProviderMock = getConfiguredProvider as jest.Mock; const getCredentialsFromStoreMock = getCredentialsFromStore as jest.Mock; const validateUI5VersionExistsMock = validateUI5VersionExists as jest.Mock; +const isCfInstalledMock = isCfInstalled as jest.MockedFunction; +const loadCfConfigMock = loadCfConfig as jest.MockedFunction; +const isLoggedInCfMock = isLoggedInCf as jest.MockedFunction; +const mockGetModuleNames = getModuleNames as jest.MockedFunction; +const mockGetApprouterType = getApprouterType as jest.MockedFunction; +const mockHasApprouter = hasApprouter as jest.MockedFunction; +const mockGetMtaServices = getMtaServices as jest.MockedFunction; +const createServicesMock = createServices as jest.MockedFunction; describe('Adaptation Project Generator Integration Test', () => { jest.setTimeout(60000); - beforeEach(() => { - fs.mkdirSync(testOutputDir, { recursive: true }); + beforeAll(async () => { + await initI18n(); + }); - loadAppsMock.mockResolvedValue(apps); - jest.spyOn(ConfigPrompter.prototype, 'provider', 'get').mockReturnValue(dummyProvider); - jest.spyOn(ConfigPrompter.prototype, 'ui5', 'get').mockReturnValue({ - publicVersions, - ui5Versions: ['1.134.1 (latest)', '1.134.0'], - systemVersion: '1.136.0' + describe('ABAP Environment', () => { + beforeEach(() => { + fs.mkdirSync(testOutputDir, { recursive: true }); + isExtensionInstalledMock.mockReturnValueOnce(false).mockReturnValueOnce(true); + loadAppsMock.mockResolvedValue(apps); + jest.spyOn(ConfigPrompter.prototype, 'provider', 'get').mockReturnValue(dummyProvider); + jest.spyOn(ConfigPrompter.prototype, 'ui5', 'get').mockReturnValue({ + publicVersions, + ui5Versions: ['1.134.1 (latest)', '1.134.0'], + systemVersion: '1.136.0' + }); + jest.spyOn(ConfigPrompter.prototype, 'manifest', 'get').mockReturnValue(mockManifest); + jest.spyOn(SourceManifest.prototype, 'getManifest').mockResolvedValue(mockManifest); + validateUI5VersionExistsMock.mockReturnValue(true); + jest.spyOn(SystemLookup.prototype, 'getSystems').mockResolvedValue(endpoints); + jest.spyOn(SystemLookup.prototype, 'getSystemRequiresAuth').mockResolvedValue(false); + getConfiguredProviderMock.mockResolvedValue(dummyProvider); + execMock.mockImplementation((_: string, callback: Function) => { + callback(null, { stdout: 'ok', stderr: '' }); + }); + isCliMock.mockReturnValue(false); + getProviderConfigMock.mockResolvedValue({ url: 'urlA', client: '010' }); + isAbapCloudMock.mockResolvedValue(false); + getAtoInfoMock.mockResolvedValue({ operationsType: 'P' }); + + getDefaultProjectNameMock.mockReturnValue('app.variant1'); + getCredentialsFromStoreMock.mockResolvedValue(undefined); + + isCfInstalledMock.mockResolvedValue(false); + loadCfConfigMock.mockReturnValue({} as CfConfig); + isLoggedInCfMock.mockResolvedValue(false); + + fetchPublicVersionsMock.mockResolvedValue(publicVersions); + existsInWorkspaceMock.mockReturnValue(true); + showWorkspaceFolderWarningMock.mockResolvedValue(workspaceChoices.OPEN_FOLDER); + handleWorkspaceFolderChoiceMock.mockResolvedValue(undefined); }); - jest.spyOn(ConfigPrompter.prototype, 'manifest', 'get').mockReturnValue(mockManifest); - jest.spyOn(SourceManifest.prototype, 'getManifest').mockResolvedValue(mockManifest); - validateUI5VersionExistsMock.mockReturnValue(true); - jest.spyOn(SystemLookup.prototype, 'getSystems').mockResolvedValue(endpoints); - jest.spyOn(SystemLookup.prototype, 'getSystemRequiresAuth').mockResolvedValue(false); - getConfiguredProviderMock.mockResolvedValue(dummyProvider); - execMock.mockImplementation((_: string, callback: Function) => { - callback(null, { stdout: 'ok', stderr: '' }); + + afterAll(async () => { + process.chdir(originalCwd); + rimraf.sync(testOutputDir); }); - isCliMock.mockReturnValue(false); - getProviderConfigMock.mockResolvedValue({ url: 'urlA', client: '010' }); - isAbapCloudMock.mockResolvedValue(false); - getAtoInfoMock.mockResolvedValue({ operationsType: 'P' }); - - getDefaultProjectNameMock.mockReturnValue('app.variant1'); - getCredentialsFromStoreMock.mockResolvedValue(undefined); - - fetchPublicVersionsMock.mockResolvedValue(publicVersions); - existsInWorkspaceMock.mockReturnValue(true); - showWorkspaceFolderWarningMock.mockResolvedValue(workspaceChoices.OPEN_FOLDER); - handleWorkspaceFolderChoiceMock.mockResolvedValue(undefined); - }); - beforeAll(async () => { - await initI18n(); - }); + afterEach(() => { + jest.clearAllMocks(); + }); - afterEach(() => { - jest.clearAllMocks(); - }); + it('should throw error when writing phase fails', async () => { + const error = new Error('Test error'); + mockIsAppStudio.mockReturnValue(false); + getAtoInfoMock.mockRejectedValueOnce(error); - afterAll(async () => { - process.chdir(originalCwd); - rimraf.sync(testOutputDir); - }); + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withOptions({ shouldInstallDeps: false } as AdpGeneratorOptions) + .withPrompts(answers); - it('should throw error when writing phase fails', async () => { - const error = new Error('Test error'); - mockIsAppStudio.mockReturnValue(false); - getAtoInfoMock.mockRejectedValueOnce(error); + await expect(runContext.run()).rejects.toThrow(t('error.updatingApp')); + }); - const runContext = yeomanTest - .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) - .withOptions({ shouldInstallDeps: false } as AdpGeneratorOptions) - .withPrompts(answers); + it('should call composeWith to generate an extension project in case the application is not supported', async () => { + mockIsAppStudio.mockReturnValue(false); + jest.spyOn(Generator.prototype, 'composeWith'); + const addExtProjectGenSpy = jest.spyOn(subgenHelpers, 'addExtProjectGen').mockResolvedValue(); + + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withOptions({ shouldInstallDeps: false, vscode: vscodeMock } as AdpGeneratorOptions) + .withPrompts({ ...answers, shouldCreateExtProject: true }); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(addExtProjectGenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributeAnswers: { + namespace: 'customer.app.variant', + projectName: 'app.variant', + title: 'App Title', + ui5Version: '1.134.1' + }, + configAnswers: { + application: apps[0], + shouldCreateExtProject: true, + system: 'urlA' + }, + systemLookup: expect.any(Object) + }), + expect.any(Function), + expect.any(Object), + expect.any(Object) + ); + expect(sendTelemetryMock).toHaveBeenCalledTimes(0); + expect(executeCommandSpy).toHaveBeenCalledTimes(0); + expect(showWorkspaceFolderWarningMock).toHaveBeenCalledTimes(0); + }); - await expect(runContext.run()).rejects.toThrow(t('error.updatingApp')); - }); + it('should call composeWith for FLP and Deploy sub-generators and generate a cloud project successfully', async () => { + mockIsAppStudio.mockReturnValue(false); + existsInWorkspaceMock.mockReturnValue(false); + jest.spyOn(ConfigPrompter.prototype, 'isCloud', 'get').mockReturnValue(true); + jest.spyOn(ConfigPrompter.prototype, 'baseAppInbounds', 'get').mockReturnValue(inbounds); + jest.spyOn(Generator.prototype, 'composeWith').mockReturnValue([]); - it('should call composeWith to generate an extension project in case the application is not supported', async () => { - mockIsAppStudio.mockReturnValue(false); - jest.spyOn(Generator.prototype, 'composeWith'); - const addExtProjectGenSpy = jest.spyOn(subgenHelpers, 'addExtProjectGen').mockResolvedValue(); + const addDeployGenSpy = jest.spyOn(subgenHelpers, 'addDeployGen').mockReturnValue(); + const addFlpGenSpy = jest.spyOn(subgenHelpers, 'addFlpGen').mockReturnValue(); - const runContext = yeomanTest - .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) - .withOptions({ shouldInstallDeps: false, vscode: vscodeMock } as AdpGeneratorOptions) - .withPrompts({ ...answers, shouldCreateExtProject: true }); + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withOptions({ shouldInstallDeps: false, vscode: vscodeMock } as AdpGeneratorOptions) + .withPrompts({ ...answers, addDeployConfig: true, addFlpConfig: true }); - await expect(runContext.run()).resolves.not.toThrow(); + await expect(runContext.run()).resolves.not.toThrow(); - expect(addExtProjectGenSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributeAnswers: { - namespace: 'customer.app.variant', + expect(addDeployGenSpy).toHaveBeenCalledWith( + { + connectedSystem: 'urlA', projectName: 'app.variant', - title: 'App Title', - ui5Version: '1.134.1' + projectPath: testOutputDir, + system: { + Name: 'SystemA', + Client: '010', + Url: 'urlA' + } }, - configAnswers: { - application: apps[0], - shouldCreateExtProject: true, - system: 'urlA' + expect.any(Function), + expect.any(Object), + expect.any(Object) + ); + + expect(addFlpGenSpy).toHaveBeenCalledWith( + { + inbounds: inbounds, + projectRootPath: join(testOutputDir, answers.projectName), + layer: FlexLayer.CUSTOMER_BASE, + vscode: vscodeMock }, - systemLookup: expect.any(Object) - }), - expect.any(Function), - expect.any(Object), - expect.any(Object) - ); - expect(sendTelemetryMock).toHaveBeenCalledTimes(0); - expect(executeCommandSpy).toHaveBeenCalledTimes(0); - expect(showWorkspaceFolderWarningMock).toHaveBeenCalledTimes(0); - }); + expect.any(Function), + expect.any(Object), + expect.any(Object) + ); - it('should call composeWith for FLP and Deploy sub-generators and generate a cloud project successfully', async () => { - mockIsAppStudio.mockReturnValue(false); - existsInWorkspaceMock.mockReturnValue(false); - jest.spyOn(ConfigPrompter.prototype, 'isCloud', 'get').mockReturnValue(true); - jest.spyOn(ConfigPrompter.prototype, 'baseAppInbounds', 'get').mockReturnValue(inbounds); - jest.spyOn(Generator.prototype, 'composeWith').mockReturnValue([]); - - const addDeployGenSpy = jest.spyOn(subgenHelpers, 'addDeployGen').mockReturnValue(); - const addFlpGenSpy = jest.spyOn(subgenHelpers, 'addFlpGen').mockReturnValue(); - - const runContext = yeomanTest - .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) - .withOptions({ shouldInstallDeps: false, vscode: vscodeMock } as AdpGeneratorOptions) - .withPrompts({ ...answers, addDeployConfig: true, addFlpConfig: true }); - - await expect(runContext.run()).resolves.not.toThrow(); - - expect(addDeployGenSpy).toHaveBeenCalledWith( - { - connectedSystem: 'urlA', - projectName: 'app.variant', - projectPath: testOutputDir, - system: { - Name: 'SystemA', - Client: '010', - Url: 'urlA' - } - }, - expect.any(Function), - expect.any(Object), - expect.any(Object) - ); - - expect(addFlpGenSpy).toHaveBeenCalledWith( - { - inbounds: inbounds, - projectRootPath: join(testOutputDir, answers.projectName), - layer: FlexLayer.CUSTOMER_BASE, - vscode: vscodeMock - }, - expect.any(Function), - expect.any(Object), - expect.any(Object) - ); - - expect(executeCommandSpy).toHaveBeenCalledTimes(0); - expect(showWorkspaceFolderWarningMock).toHaveBeenCalledTimes(1); - expect(handleWorkspaceFolderChoiceMock).toHaveBeenCalledTimes(1); - - const generatedDirs = fs.readdirSync(testOutputDir); - expect(generatedDirs).toContain(answers.projectName); - }); + expect(executeCommandSpy).toHaveBeenCalledTimes(0); + expect(showWorkspaceFolderWarningMock).toHaveBeenCalledTimes(1); + expect(handleWorkspaceFolderChoiceMock).toHaveBeenCalledTimes(1); + + const generatedDirs = fs.readdirSync(testOutputDir); + expect(generatedDirs).toContain(answers.projectName); + }); + + it('should generate an onPremise adaptation project successfully', async () => { + mockIsAppStudio.mockReturnValue(false); + + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withOptions({ shouldInstallDeps: true, vscode: vscodeMock } as AdpGeneratorOptions) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(executeCommandSpy).toHaveBeenCalledTimes(1); + + const generatedDirs = fs.readdirSync(testOutputDir); + expect(generatedDirs).toContain(answers.projectName); + const projectFolder = join(testOutputDir, answers.projectName); + + const manifestPath = join(projectFolder, 'webapp', 'manifest.appdescr_variant'); + const i18nPath = join(projectFolder, 'webapp', 'i18n', 'i18n.properties'); + const ui5Yaml = join(projectFolder, 'ui5.yaml'); + + expect(fs.existsSync(manifestPath)).toBe(true); + expect(fs.existsSync(i18nPath)).toBe(true); + expect(fs.existsSync(ui5Yaml)).toBe(true); + + const manifestContent = fs.readFileSync(manifestPath, 'utf8'); + const i18nContent = fs.readFileSync(i18nPath, 'utf8'); + const ui5Content = fs.readFileSync(ui5Yaml, 'utf8'); + expect(manifestContent).toMatchSnapshot(); + expect(i18nContent).toMatchSnapshot(); + expect(ui5Content).toMatchSnapshot(); + + expect(sendTelemetryMock).toHaveBeenCalledWith( + EventName.ADAPTATION_PROJECT_CREATED, + expect.objectContaining({ + OperatingSystem: 'testOS', + Platform: 'testPlatform' + }), + projectFolder + ); + }); - it('should generate an onPremise adaptation project successfully', async () => { - mockIsAppStudio.mockReturnValue(false); - - const runContext = yeomanTest - .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) - .withOptions({ shouldInstallDeps: true, vscode: vscodeMock } as AdpGeneratorOptions) - .withPrompts(answers); - - await expect(runContext.run()).resolves.not.toThrow(); - - expect(executeCommandSpy).toHaveBeenCalledTimes(1); - - const generatedDirs = fs.readdirSync(testOutputDir); - expect(generatedDirs).toContain(answers.projectName); - const projectFolder = join(testOutputDir, answers.projectName); - - const manifestPath = join(projectFolder, 'webapp', 'manifest.appdescr_variant'); - const i18nPath = join(projectFolder, 'webapp', 'i18n', 'i18n.properties'); - const ui5Yaml = join(projectFolder, 'ui5.yaml'); - - expect(fs.existsSync(manifestPath)).toBe(true); - expect(fs.existsSync(i18nPath)).toBe(true); - expect(fs.existsSync(ui5Yaml)).toBe(true); - - const manifestContent = fs.readFileSync(manifestPath, 'utf8'); - const i18nContent = fs.readFileSync(i18nPath, 'utf8'); - const ui5Content = fs.readFileSync(ui5Yaml, 'utf8'); - expect(manifestContent).toMatchSnapshot(); - expect(i18nContent).toMatchSnapshot(); - expect(ui5Content).toMatchSnapshot(); - - expect(sendTelemetryMock).toHaveBeenCalledWith( - EventName.ADAPTATION_PROJECT_CREATED, - expect.objectContaining({ - OperatingSystem: 'testOS', - Platform: 'testPlatform' - }), - projectFolder - ); + it('should create adaptation project from json correctly', async () => { + const jsonInput: JsonInput = { + system: 'urlA', + username: 'user1', + password: 'pass1', + client: '010', + application: 'sap.ui.demoapps.f1', + projectName: 'my.app', + namespace: 'customer.my.app', + applicationTitle: 'My app title', + targetFolder: testOutputDir + }; + const jsonInputString = JSON.stringify(jsonInput); + + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withArguments([jsonInputString]); + + await expect(runContext.run()).resolves.not.toThrow(); + + const generatedDirs = fs.readdirSync(testOutputDir); + expect(generatedDirs).toContain(jsonInput.projectName); + const projectFolder = join(testOutputDir, jsonInput.projectName!); + + const manifestPath = join(projectFolder, 'webapp', 'manifest.appdescr_variant'); + const i18nPath = join(projectFolder, 'webapp', 'i18n', 'i18n.properties'); + const ui5Yaml = join(projectFolder, 'ui5.yaml'); + + expect(fs.existsSync(manifestPath)).toBe(true); + expect(fs.existsSync(i18nPath)).toBe(true); + expect(fs.existsSync(ui5Yaml)).toBe(true); + + const manifestContent = fs.readFileSync(manifestPath, 'utf8'); + const i18nContent = fs.readFileSync(i18nPath, 'utf8'); + const ui5Content = fs.readFileSync(ui5Yaml, 'utf8'); + expect(manifestContent).toMatchSnapshot(); + expect(i18nContent).toMatchSnapshot(); + expect(ui5Content).toMatchSnapshot(); + }); }); - it('should create adaptation project from json correctly', async () => { - const jsonInput: JsonInput = { - system: 'urlA', - username: 'user1', - password: 'pass1', - client: '010', - application: 'sap.ui.demoapps.f1', - projectName: 'my.app', - namespace: 'customer.my.app', - applicationTitle: 'My app title', - targetFolder: testOutputDir - }; - const jsonInputString = JSON.stringify(jsonInput); - - const runContext = yeomanTest - .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) - .withArguments([jsonInputString]); - - await expect(runContext.run()).resolves.not.toThrow(); - - const generatedDirs = fs.readdirSync(testOutputDir); - expect(generatedDirs).toContain(jsonInput.projectName); - const projectFolder = join(testOutputDir, jsonInput.projectName!); - - const manifestPath = join(projectFolder, 'webapp', 'manifest.appdescr_variant'); - const i18nPath = join(projectFolder, 'webapp', 'i18n', 'i18n.properties'); - const ui5Yaml = join(projectFolder, 'ui5.yaml'); - - expect(fs.existsSync(manifestPath)).toBe(true); - expect(fs.existsSync(i18nPath)).toBe(true); - expect(fs.existsSync(ui5Yaml)).toBe(true); - - const manifestContent = fs.readFileSync(manifestPath, 'utf8'); - const i18nContent = fs.readFileSync(i18nPath, 'utf8'); - const ui5Content = fs.readFileSync(ui5Yaml, 'utf8'); - expect(manifestContent).toMatchSnapshot(); - expect(i18nContent).toMatchSnapshot(); - expect(ui5Content).toMatchSnapshot(); + describe('CF Environment', () => { + beforeEach(() => { + fs.mkdirSync(testOutputDir, { recursive: true }); + + const mtaYamlSource = join(__dirname, 'fixtures', 'mta-project', 'mta.yaml'); + const mtaYamlTarget = join(testOutputDir, 'mta.yaml'); + fs.copyFileSync(mtaYamlSource, mtaYamlTarget); + + jest.spyOn(Date, 'now').mockReturnValue(1234567890); + isExtensionInstalledMock.mockReturnValue(true); + loadAppsMock.mockResolvedValue(apps); + jest.spyOn(CFServicesPrompter.prototype, 'manifest', 'get').mockReturnValue(mockManifest); + jest.spyOn(CFServicesPrompter.prototype, 'serviceInstanceGuid', 'get').mockReturnValue('test-guid'); + + isCliMock.mockReturnValue(false); + getDefaultProjectNameMock.mockReturnValue('app.variant1'); + getCredentialsFromStoreMock.mockResolvedValue(undefined); + createServicesMock.mockResolvedValue(undefined); + + isCfInstalledMock.mockResolvedValue(true); + loadCfConfigMock.mockReturnValue(cfConfig); + isLoggedInCfMock.mockResolvedValue(true); + mockGetModuleNames.mockReturnValue(['module1', 'module2']); + mockGetMtaServices.mockResolvedValue(['service1', 'service2']); + mockGetApprouterType.mockReturnValue(AppRouterType.STANDALONE); + mockHasApprouter.mockReturnValue(false); + + fetchPublicVersionsMock.mockResolvedValue(publicVersions); + }); + + afterAll(async () => { + process.chdir(originalCwd); + rimraf.sync(testOutputDir); + }); + + afterEach(() => { + const mtaYamlPath = join(testOutputDir, 'mta.yaml'); + if (fs.existsSync(mtaYamlPath)) { + fs.unlinkSync(mtaYamlPath); + } + + jest.clearAllMocks(); + }); + + it('should generate an adaptation project successfully', async () => { + mockIsAppStudio.mockReturnValue(true); + + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withOptions({ shouldInstallDeps: true, vscode: vscodeMock } as AdpGeneratorOptions) + .withPrompts({ ...answersCf, projectLocation: testOutputDir }); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(executeCommandSpy).not.toHaveBeenCalled(); + expect(sendTelemetryMock).not.toHaveBeenCalled(); + + const generatedDirs = fs.readdirSync(testOutputDir); + expect(generatedDirs).toContain(answers.projectName); + const projectFolder = join(testOutputDir, answers.projectName); + + const manifestPath = join(projectFolder, 'webapp', 'manifest.appdescr_variant'); + const i18nPath = join(projectFolder, 'webapp', 'i18n', 'i18n.properties'); + const ui5Yaml = join(projectFolder, 'ui5.yaml'); + const mtaYaml = join(testOutputDir, 'mta.yaml'); + const packageJson = join(projectFolder, 'package.json'); + + expect(fs.existsSync(manifestPath)).toBe(true); + expect(fs.existsSync(i18nPath)).toBe(true); + expect(fs.existsSync(ui5Yaml)).toBe(true); + expect(fs.existsSync(mtaYaml)).toBe(true); + expect(fs.existsSync(packageJson)).toBe(true); + + const manifestContent = fs.readFileSync(manifestPath, 'utf8'); + const i18nContent = fs.readFileSync(i18nPath, 'utf8'); + const ui5Content = fs.readFileSync(ui5Yaml, 'utf8'); + const mtaContent = fs.readFileSync(mtaYaml, 'utf8'); + const packageJsonContent = fs.readFileSync(packageJson, 'utf8'); + expect(manifestContent).toMatchSnapshot(); + expect(i18nContent).toMatchSnapshot(); + expect(ui5Content).toMatchSnapshot(); + expect(mtaContent).toMatchSnapshot(); + expect(packageJsonContent).toMatchSnapshot(); + }); }); }); diff --git a/packages/generator-adp/test/fixtures/mta-project/mta.yaml b/packages/generator-adp/test/fixtures/mta-project/mta.yaml new file mode 100644 index 00000000000..32fb725a36a --- /dev/null +++ b/packages/generator-adp/test/fixtures/mta-project/mta.yaml @@ -0,0 +1,11 @@ +_schema-version: '3.3' +ID: mta-project +version: 1.0.0 +description: Test MTA Project for CF Writer Tests +resources: + - name: test-destination-service + type: org.cloudfoundry.managed-service + parameters: + service: destination + service-plan: lite + service-name: test-destination-service From 7f42cc1c1fcc178e768e3bf3b6c3cff515a9fec1 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 30 Sep 2025 16:04:59 +0300 Subject: [PATCH 090/111] fix: lint errors --- packages/adp-tooling/src/cf/app/discovery.ts | 4 +- packages/adp-tooling/src/cf/project/mta.ts | 2 +- .../adp-tooling/src/cf/project/yaml-loader.ts | 2 +- packages/adp-tooling/src/cf/project/yaml.ts | 43 +++++++++++-------- packages/adp-tooling/src/cf/services/api.ts | 7 ++- .../adp-tooling/src/cf/utils/validation.ts | 8 ++-- 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/packages/adp-tooling/src/cf/app/discovery.ts b/packages/adp-tooling/src/cf/app/discovery.ts index bc0558d61c5..8e1854a824d 100644 --- a/packages/adp-tooling/src/cf/app/discovery.ts +++ b/packages/adp-tooling/src/cf/app/discovery.ts @@ -23,14 +23,14 @@ export function formatDiscovery(app: CFApp): string { export function getAppHostIds(credentials: CfCredentials[]): string[] { const appHostIds: string[] = []; - credentials.forEach((credential) => { + for (const credential of credentials) { const appHostId = credential['html5-apps-repo']?.app_host_id; if (appHostId) { // There might be multiple appHostIds separated by comma const ids = appHostId.split(',').map((item: string) => item.trim()); appHostIds.push(...ids); } - }); + } return [...new Set(appHostIds)]; } diff --git a/packages/adp-tooling/src/cf/project/mta.ts b/packages/adp-tooling/src/cf/project/mta.ts index efaddcdc53b..138bee04af6 100644 --- a/packages/adp-tooling/src/cf/project/mta.ts +++ b/packages/adp-tooling/src/cf/project/mta.ts @@ -90,7 +90,7 @@ export function hasApprouter(projectName: string, moduleNames: string[]): boolea * @returns {Promise} The filtered services. */ async function filterServices(businessServices: BusinessServiceResource[], logger: ToolsLogger): Promise { - const serviceLabels = businessServices.map((service) => service.label).filter((label) => label); + const serviceLabels = businessServices.map((service) => service.label).filter(Boolean); if (serviceLabels.length === 0) { throw new Error(t('error.noBusinessServicesFound')); diff --git a/packages/adp-tooling/src/cf/project/yaml-loader.ts b/packages/adp-tooling/src/cf/project/yaml-loader.ts index 6dae88ab2a4..bef81e0e1dd 100644 --- a/packages/adp-tooling/src/cf/project/yaml-loader.ts +++ b/packages/adp-tooling/src/cf/project/yaml-loader.ts @@ -46,5 +46,5 @@ export function getProjectNameForXsSecurity(yamlContent: MtaYaml, timestamp: str if (!projectName || !timestamp) { return undefined; } - return `${projectName.toLowerCase().replace(/\./g, '_')}_${timestamp}`; + return `${projectName.toLowerCase().replaceAll('.', '_')}_${timestamp}`; } diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index 656c160da47..2347b5113e1 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -53,7 +53,7 @@ export function getSAPCloudService(yamlContent: MtaYaml): string { const mtaDestination = destinations?.find((destination: MtaDestination) => destination.Name.includes('html_repo_host') ); - const sapCloudService = mtaDestination?.['sap.cloud.service']?.replace(/_/g, '.') ?? ''; + const sapCloudService = mtaDestination?.['sap.cloud.service']?.replaceAll('_', '.') ?? ''; return sapCloudService; } @@ -120,16 +120,18 @@ function adjustMtaYamlStandaloneApprouter(yamlContent: MtaYaml, projectName: str }; yamlContent.modules?.push(appRouter); } + const requires = [ `${projectName}_html_repo_runtime`, `${projectName}_uaa`, `portal_resources_${projectName}` ].concat(businessService); - requires.forEach((name) => { + + for (const name of requires) { if (appRouter.requires?.every((existing: { name: string }) => existing.name !== name)) { appRouter.requires?.push({ name }); } - }); + } } /** @@ -196,14 +198,14 @@ function adjustMtaYamlManagedApprouter( Name: `${businessSolution}-${projectName}-html_repo_host`, ServiceInstanceName: `${projectName}-html5_app_host`, ServiceKeyName: `${projectName}-html_repo_host-key`, - 'sap.cloud.service': businessSolution.replace(/_/g, '.') + 'sap.cloud.service': businessSolution.replaceAll('_', '.') }, { Name: `${businessSolution}-uaa-${projectName}`, ServiceInstanceName: `${projectName}-xsuaa`, ServiceKeyName: `${projectName}_uaa-key`, Authentication: 'OAuth2UserTokenExchange', - 'sap.cloud.service': businessSolution.replace(/_/g, '.') + 'sap.cloud.service': businessSolution.replaceAll('_', '.') }, { Name: `${businessService}-service_instance_name`, @@ -334,11 +336,11 @@ function adjustMtaYamlResources( ); } - resources.forEach((resource) => { + for (const resource of resources) { if (yamlContent.resources?.every((existing: MtaResource) => existing.name !== resource.name)) { yamlContent.resources?.push(resource); } - }); + } } /** @@ -384,20 +386,23 @@ function addModuleIfNotExists(requires: MtaRequire[], name: string): void { * @param {string} businessService - The business service. */ function adjustMtaYamlFlpModule(yamlContent: MtaYaml, projectName: string, businessService: string): void { - yamlContent.modules?.forEach((module, index) => { - if (module.type === SAP_APPLICATION_CONTENT && module.requires) { - const portalResources = module.requires.find( - (require: MtaRequire) => require.name === `portal_resources_${projectName}` - ); - if (portalResources?.parameters?.['service-key']?.name === 'content-deploy-key') { - addModuleIfNotExists(module.requires, `${projectName}_html_repo_host`); - addModuleIfNotExists(module.requires, `${projectName}_ui_deployer`); - addModuleIfNotExists(module.requires, businessService); - // move flp module to last position - yamlContent.modules?.push(yamlContent.modules.splice(index, 1)[0]); + for (const module of yamlContent.modules ?? []) { + const moduleIndex = yamlContent.modules?.indexOf(module); + if (moduleIndex !== undefined) { + if (module.type === SAP_APPLICATION_CONTENT && module.requires) { + const portalResources = module.requires.find( + (require: MtaRequire) => require.name === `portal_resources_${projectName}` + ); + if (portalResources?.parameters?.['service-key']?.name === 'content-deploy-key') { + addModuleIfNotExists(module.requires, `${projectName}_html_repo_host`); + addModuleIfNotExists(module.requires, `${projectName}_ui_deployer`); + addModuleIfNotExists(module.requires, businessService); + // Move FLP module to last position + yamlContent.modules?.push(yamlContent.modules.splice(moduleIndex, 1)[0]); + } } } - }); + } } /** diff --git a/packages/adp-tooling/src/cf/services/api.ts b/packages/adp-tooling/src/cf/services/api.ts index 66765f430bc..1f13148e36b 100644 --- a/packages/adp-tooling/src/cf/services/api.ts +++ b/packages/adp-tooling/src/cf/services/api.ts @@ -192,8 +192,7 @@ export async function createService( throw new Error(t('error.xsSecurityJsonCouldNotBeParsed')); } - commandParameters.push('-c'); - commandParameters.push(JSON.stringify(xsSecurity)); + commandParameters.push('-c', JSON.stringify(xsSecurity)); } await CFToolsCli.Cli.execute(commandParameters); @@ -223,11 +222,11 @@ export async function createServices( spaceGuid: string, logger?: ToolsLogger ): Promise { - const excludeServices = initialServices.concat(['portal', 'html5-apps-repo']); + const excludeServices = new Set([...initialServices, 'portal', 'html5-apps-repo']); const xsSecurityPath = path.join(projectPath, 'xs-security.json'); const xsSecurityProjectName = getProjectNameForXsSecurity(yamlContent, timestamp); for (const resource of yamlContent.resources ?? []) { - if (!excludeServices.includes(resource?.parameters?.service ?? '')) { + if (!excludeServices.has(resource?.parameters?.service ?? '')) { if (resource?.parameters?.service === 'xsuaa') { await createService( spaceGuid, diff --git a/packages/adp-tooling/src/cf/utils/validation.ts b/packages/adp-tooling/src/cf/utils/validation.ts index 308877a05b0..c004399eda1 100644 --- a/packages/adp-tooling/src/cf/utils/validation.ts +++ b/packages/adp-tooling/src/cf/utils/validation.ts @@ -66,13 +66,13 @@ function matchRoutesAndDatasources( serviceKeyEndpoints: string[] ): string[] { const messages: string[] = []; - routes.forEach((route: XsAppRoute) => { + for (const route of routes) { if (route.endpoint && !serviceKeyEndpoints.includes(route.endpoint)) { messages.push(`Route endpoint '${route.endpoint}' doesn't match a corresponding OData endpoint`); } - }); + } - Object.keys(dataSources ?? {}).forEach((dataSourceName) => { + for (const dataSourceName of Object.keys(dataSources ?? {})) { if ( !routes.some((route: XsAppRoute) => dataSources?.[dataSourceName].uri?.match(normalizeRouteRegex(route.source)) @@ -80,7 +80,7 @@ function matchRoutesAndDatasources( ) { messages.push(`Data source '${dataSourceName}' doesn't match a corresponding route in xs-app.json routes`); } - }); + } return messages; } From fb875cb07f96afe691f33131152f506ae642ea9f Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 30 Sep 2025 16:07:04 +0300 Subject: [PATCH 091/111] fix: lint errors --- packages/generator-adp/src/app/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 7dc782936b1..51c98e8e747 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -168,7 +168,7 @@ export default class extends Generator { /** * Indicates if the extension is installed. */ - private isExtensionInstalled: boolean; + private readonly isExtensionInstalled: boolean; /** * Indicates if CF is installed. */ From 5869400e38d3045493a7ba2a206c9aca13b5e91b Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 30 Sep 2025 16:25:54 +0300 Subject: [PATCH 092/111] chore: add cset --- .changeset/tender-suns-relate.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/tender-suns-relate.md diff --git a/.changeset/tender-suns-relate.md b/.changeset/tender-suns-relate.md new file mode 100644 index 00000000000..593bf0eedc7 --- /dev/null +++ b/.changeset/tender-suns-relate.md @@ -0,0 +1,7 @@ +--- +'@sap-ux/generator-adp': minor +'@sap-ux/adp-tooling': minor +'@sap-ux/project-input-validator': patch +--- + +feat: Add ADP Generator Cloud Foundry prompting code From bb7500635c7871125dd2513126b59b776f9e1a2f Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 2 Oct 2025 14:50:34 +0300 Subject: [PATCH 093/111] fix(wip): change incorrect property setting for the yaml file --- packages/adp-tooling/src/cf/project/yaml.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index 2347b5113e1..c9ef138614d 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -141,13 +141,16 @@ function adjustMtaYamlStandaloneApprouter(yamlContent: MtaYaml, projectName: str * @param {string} projectName - The project name. * @param {string} businessSolution - The business solution. * @param {string} businessService - The business service. + * @param {string} timestamp - The timestamp. */ function adjustMtaYamlManagedApprouter( yamlContent: MtaYaml, projectName: string, businessSolution: string, - businessService: string + businessService: string, + timestamp: string ): void { + const projectNameForXsSecurity = getProjectNameForXsSecurity(yamlContent, timestamp); const appRouterName = `${projectName}-destination-content`; let appRouter = yamlContent.modules?.find((module: MtaModule) => module.name === appRouterName); if (appRouter == null) { @@ -202,7 +205,7 @@ function adjustMtaYamlManagedApprouter( }, { Name: `${businessSolution}-uaa-${projectName}`, - ServiceInstanceName: `${projectName}-xsuaa`, + ServiceInstanceName: `${projectNameForXsSecurity}-xsuaa`, ServiceKeyName: `${projectName}_uaa-key`, Authentication: 'OAuth2UserTokenExchange', 'sap.cloud.service': businessSolution.replaceAll('_', '.') @@ -443,7 +446,7 @@ export async function adjustMtaYaml( if (isStandaloneApprouter) { adjustMtaYamlStandaloneApprouter(yamlContent, projectName, businessService); } else { - adjustMtaYamlManagedApprouter(yamlContent, projectName, businessSolutionName, businessService); + adjustMtaYamlManagedApprouter(yamlContent, projectName, businessSolutionName, businessService, timestamp); } adjustMtaYamlUDeployer(yamlContent, projectName, moduleName); adjustMtaYamlResources(yamlContent, projectName, timestamp, !isStandaloneApprouter); From a9533354d4f0028a51b599c3c2a9c5250aa64b2a Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 6 Oct 2025 10:20:12 +0300 Subject: [PATCH 094/111] fix: add missing properties to configuration files in CF scenario --- packages/adp-tooling/src/writer/cf.ts | 4 ++-- .../adp-tooling/src/writer/project-utils.ts | 3 ++- packages/adp-tooling/templates/cf/ui5.yaml | 1 + .../unit/writer/__snapshots__/cf.test.ts.snap | 18 +++++++++--------- .../test/__snapshots__/app.test.ts.snap | 4 ++-- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 72736a1f561..c1121f3e2bf 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -29,7 +29,7 @@ export async function generateCf( fs = create(createStorage()); } - const fullConfig = setDefaultsCF(config); + const fullConfig = setDefaults(config); const { app, cf, ui5 } = fullConfig; await adjustMtaYaml( @@ -63,7 +63,7 @@ export async function generateCf( * @param {CfAdpWriterConfig} config - The CF configuration provided by the calling middleware. * @returns {CfAdpWriterConfig} The enhanced configuration with default values. */ -function setDefaultsCF(config: CfAdpWriterConfig): CfAdpWriterConfig { +function setDefaults(config: CfAdpWriterConfig): CfAdpWriterConfig { const configWithDefaults: CfAdpWriterConfig = { ...config, app: { diff --git a/packages/adp-tooling/src/writer/project-utils.ts b/packages/adp-tooling/src/writer/project-utils.ts index 48fc05aaaba..81808a47df6 100644 --- a/packages/adp-tooling/src/writer/project-utils.ts +++ b/packages/adp-tooling/src/writer/project-utils.ts @@ -270,7 +270,8 @@ export async function writeCfTemplates( html5RepoRuntime: cf.html5RepoRuntimeGuid, org: cf.org.GUID, space: cf.space.GUID, - sapCloudService: cf.businessSolutionName ?? '' + sapCloudService: cf.businessSolutionName ?? '', + instanceName: cf.businessService }); fs.writeJSON(join(project.folder, '.adp/config.json'), getCfAdpConfig(config)); diff --git a/packages/adp-tooling/templates/cf/ui5.yaml b/packages/adp-tooling/templates/cf/ui5.yaml index 8da08cb423d..9cd4fca23e9 100644 --- a/packages/adp-tooling/templates/cf/ui5.yaml +++ b/packages/adp-tooling/templates/cf/ui5.yaml @@ -16,3 +16,4 @@ builder: space: <%= space %> html5RepoRuntime: <%= html5RepoRuntime %> sapCloudService: <%= sapCloudService %> + serviceInstanceName: <%= instanceName %> \ No newline at end of file diff --git a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap index 0a8ff6230bc..9d1a30a5136 100644 --- a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap +++ b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap @@ -154,7 +154,7 @@ builder: space: space-guid html5RepoRuntime: runtime-guid sapCloudService: test-solution -", + serviceInstanceName: test-service", "state": "modified", }, "../minimal-cf/test-cf-project/webapp/i18n/i18n.properties": Object { @@ -235,7 +235,7 @@ modules: ServiceKeyName: mta-project-html_repo_host-key sap.cloud.service: test-solution - Name: test-solution-uaa-mta-project - ServiceInstanceName: mta-project-xsuaa + ServiceInstanceName: mta-project_1234567890-xsuaa ServiceKeyName: mta-project_uaa-key Authentication: OAuth2UserTokenExchange sap.cloud.service: test-solution @@ -380,7 +380,7 @@ builder: space: space-guid html5RepoRuntime: runtime-guid sapCloudService: test-solution -", + serviceInstanceName: test-service", "state": "modified", }, "test-cf-project/webapp/i18n/i18n.properties": Object { @@ -466,7 +466,7 @@ modules: ServiceKeyName: mta-project-html_repo_host-key sap.cloud.service: test-solution - Name: test-solution-uaa-mta-project - ServiceInstanceName: mta-project-xsuaa + ServiceInstanceName: mta-project_1234567890-xsuaa ServiceKeyName: mta-project_uaa-key Authentication: OAuth2UserTokenExchange sap.cloud.service: test-solution @@ -611,7 +611,7 @@ builder: space: space-guid html5RepoRuntime: runtime-guid sapCloudService: test-solution -", + serviceInstanceName: test-service", "state": "modified", }, "../managed-approuter/test-cf-project/webapp/i18n/i18n.properties": Object { @@ -811,7 +811,7 @@ builder: space: space-guid html5RepoRuntime: runtime-guid sapCloudService: test-solution -", + serviceInstanceName: test-service", "state": "modified", }, "../minimal-cf/test-cf-project/webapp/i18n/i18n.properties": Object { @@ -892,7 +892,7 @@ modules: ServiceKeyName: mta-project-html_repo_host-key sap.cloud.service: test-solution - Name: test-solution-uaa-mta-project - ServiceInstanceName: mta-project-xsuaa + ServiceInstanceName: mta-project_1234567890-xsuaa ServiceKeyName: mta-project_uaa-key Authentication: OAuth2UserTokenExchange sap.cloud.service: test-solution @@ -1073,7 +1073,7 @@ builder: space: space-guid html5RepoRuntime: runtime-guid sapCloudService: test-solution -", + serviceInstanceName: test-service", "state": "modified", }, "test-cf-project/webapp/i18n/i18n.properties": Object { @@ -1278,7 +1278,7 @@ builder: space: space-guid html5RepoRuntime: runtime-guid sapCloudService: test-solution -", + serviceInstanceName: test-service", "state": "modified", }, "test-cf-project/webapp/i18n/i18n.properties": Object { diff --git a/packages/generator-adp/test/__snapshots__/app.test.ts.snap b/packages/generator-adp/test/__snapshots__/app.test.ts.snap index 8f02e342b23..4518a53da7a 100644 --- a/packages/generator-adp/test/__snapshots__/app.test.ts.snap +++ b/packages/generator-adp/test/__snapshots__/app.test.ts.snap @@ -217,7 +217,7 @@ builder: space: space-guid html5RepoRuntime: test-guid sapCloudService: test-solution -" + serviceInstanceName: test-service" `; exports[`Adaptation Project Generator Integration Test CF Environment should generate an adaptation project successfully 4`] = ` @@ -253,7 +253,7 @@ modules: ServiceKeyName: mta-project-html_repo_host-key sap.cloud.service: test-solution - Name: test-solution-uaa-mta-project - ServiceInstanceName: mta-project-xsuaa + ServiceInstanceName: mta-project_1234567890-xsuaa ServiceKeyName: mta-project_uaa-key Authentication: OAuth2UserTokenExchange sap.cloud.service: test-solution From 14646e9f67c12dad39b976933608a794d73b5e2c Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 6 Oct 2025 10:40:13 +0300 Subject: [PATCH 095/111] fix: negated conditions --- packages/generator-adp/src/app/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 51c98e8e747..1ec3bd15228 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -249,7 +249,9 @@ export default class extends Generator { await this._determineTargetEnv(); - if (!this.isCfEnv) { + if (this.isCfEnv) { + await this._promptForCfEnvironment(); + } else { const isExtensibilityExtInstalled = isExtensionInstalled(this.vscode, 'SAP.vscode-bas-extensibility'); const configQuestions = this.prompter.getPrompts({ appValidationCli: { hide: !this.isCli }, @@ -316,8 +318,6 @@ export default class extends Generator { this.appWizard ); } - } else { - await this._promptForCfEnvironment(); } } @@ -428,14 +428,14 @@ export default class extends Generator { * Prompts the user for the CF project path. */ private async _promptForCfProjectPath(): Promise { - if (!this.isMtaYamlFound) { - const pathAnswers = await this.prompt([getProjectPathPrompt(this.logger, this.vscode)]); - const path = this.destinationRoot(fs.realpathSync(pathAnswers.projectLocation, 'utf-8')); - this.logger.log(`Project path information: ${path}`); - } else { + if (this.isMtaYamlFound) { const path = this.destinationRoot(process.cwd()); getYamlContent(join(path, 'mta.yaml')); this.logger.log(`Project path information: ${path}`); + } else { + const pathAnswers = await this.prompt([getProjectPathPrompt(this.logger, this.vscode)]); + const path = this.destinationRoot(fs.realpathSync(pathAnswers.projectLocation, 'utf-8')); + this.logger.log(`Project path information: ${path}`); } } From b4e76c527761af1eb6c5109511a9a68e5c29ba42 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 6 Oct 2025 11:42:19 +0300 Subject: [PATCH 096/111] test: improve coverage --- .../test/unit/cf/utils/validation.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/adp-tooling/test/unit/cf/utils/validation.test.ts b/packages/adp-tooling/test/unit/cf/utils/validation.test.ts index 3702ef349ab..8ea2b9c29df 100644 --- a/packages/adp-tooling/test/unit/cf/utils/validation.test.ts +++ b/packages/adp-tooling/test/unit/cf/utils/validation.test.ts @@ -9,15 +9,14 @@ import { } from '../../../../src/cf/utils/validation'; import { initI18n, t } from '../../../../src/i18n'; import { ApplicationType, type CfCredentials, type XsApp } from '../../../../src/types'; -import { getApplicationType, isSupportedAppTypeForAdp } from '../../../../src/source/manifest'; +import { getApplicationType } from '../../../../src/source/manifest'; jest.mock('../../../../src/source/manifest', () => ({ - getApplicationType: jest.fn(), - isSupportedAppTypeForAdp: jest.fn() + ...jest.requireActual('../../../../src/source/manifest'), + getApplicationType: jest.fn() })); const mockGetApplicationType = getApplicationType as jest.MockedFunction; -const mockIsSupportedAppTypeForAdp = isSupportedAppTypeForAdp as jest.MockedFunction; describe('CF Utils Validation', () => { beforeAll(async () => { @@ -40,11 +39,9 @@ describe('CF Utils Validation', () => { } as unknown as Manifest; mockGetApplicationType.mockReturnValue(ApplicationType.FIORI_ELEMENTS); - mockIsSupportedAppTypeForAdp.mockReturnValue(true); await expect(validateSmartTemplateApplication(manifest)).resolves.not.toThrow(); expect(mockGetApplicationType).toHaveBeenCalledWith(manifest); - expect(mockIsSupportedAppTypeForAdp).toHaveBeenCalledWith(ApplicationType.FIORI_ELEMENTS); }); test('should not throw when application type is supported and flex is not explicitly disabled', async () => { @@ -55,7 +52,6 @@ describe('CF Utils Validation', () => { } as unknown as Manifest; mockGetApplicationType.mockReturnValue(ApplicationType.FREE_STYLE); - mockIsSupportedAppTypeForAdp.mockReturnValue(true); await expect(validateSmartTemplateApplication(manifest)).resolves.not.toThrow(); }); @@ -68,7 +64,6 @@ describe('CF Utils Validation', () => { } as unknown as Manifest; mockGetApplicationType.mockReturnValue(ApplicationType.NONE); - mockIsSupportedAppTypeForAdp.mockReturnValue(false); await expect(validateSmartTemplateApplication(manifest)).rejects.toThrow( t('error.adpDoesNotSupportSelectedApplication') @@ -98,7 +93,6 @@ describe('CF Utils Validation', () => { } as unknown as Manifest; mockGetApplicationType.mockReturnValue(ApplicationType.FIORI_ELEMENTS); - mockIsSupportedAppTypeForAdp.mockReturnValue(true); await expect(validateSmartTemplateApplication(manifest)).rejects.toThrow( t('error.appDoesNotSupportFlexibility') From 7429248d7d713dff4fee9f2506cee630ce8aa0f8 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 6 Oct 2025 17:00:49 +0300 Subject: [PATCH 097/111] feat: add new structure to the cf project --- packages/adp-tooling/src/writer/cf.ts | 3 +- packages/adp-tooling/src/writer/options.ts | 60 +++++++++++++++- .../adp-tooling/src/writer/project-utils.ts | 72 +++++++++---------- .../adp-tooling/templates/cf/package.json | 4 +- packages/adp-tooling/templates/cf/ui5.yaml | 14 ---- 5 files changed, 97 insertions(+), 56 deletions(-) diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index c1121f3e2bf..ade1da31e31 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -7,7 +7,7 @@ import { adjustMtaYaml } from '../cf'; import { getApplicationType } from '../source'; import { fillDescriptorContent } from './manifest'; import type { CfAdpWriterConfig, Content } from '../types'; -import { getCfVariant, writeCfTemplates } from './project-utils'; +import { getCfVariant, writeCfTemplates, writeUI5YamlCf } from './project-utils'; import { getI18nDescription, getI18nModels, writeI18nModels } from './i18n'; /** @@ -53,6 +53,7 @@ export async function generateCf( fillDescriptorContent(variant.content as Content[], app.appType, ui5.version, app.i18nModels); await writeCfTemplates(basePath, variant, fullConfig, fs); + await writeUI5YamlCf(fullConfig.project.folder, fullConfig, fs); return fs; } diff --git a/packages/adp-tooling/src/writer/options.ts b/packages/adp-tooling/src/writer/options.ts index b0d0bbdc53b..ad8687202b2 100644 --- a/packages/adp-tooling/src/writer/options.ts +++ b/packages/adp-tooling/src/writer/options.ts @@ -15,7 +15,8 @@ import type { CloudApp, InternalInboundNavigation, CloudCustomTaskConfig, - CloudCustomTaskConfigTarget + CloudCustomTaskConfigTarget, + CfAdpWriterConfig } from '../types'; const VSCODE_URL = 'https://REQUIRED_FOR_VSCODE.example'; @@ -346,3 +347,60 @@ export function enhanceManifestChangeContentWithFlpConfig( manifestChangeContent.push(removeOtherInboundsChange); } } + +/** + * Generate custom configuration required for the ui5.yaml. + * + * @param {UI5Config} ui5Config - Configuration representing the ui5.yaml. + * @param {CfAdpWriterConfig} config - Full project configuration. + */ +export function enhanceUI5YamlWithCfCustomTask(ui5Config: UI5Config, config: CfAdpWriterConfig): void { + const { baseApp, cf, project } = config; + ui5Config.addCustomTasks([ + { + name: 'app-variant-bundler-build', + beforeTask: 'escapeNonAsciiCharacters', + configuration: { + module: project.name, + appHostId: baseApp.appHostId, + appName: baseApp.appName, + appVersion: baseApp.appVersion, + html5RepoRuntime: cf.html5RepoRuntimeGuid, + org: cf.org.GUID, + space: cf.space.GUID, + sapCloudService: cf.businessSolutionName ?? '', + instanceName: cf.businessService + } + } + ]); +} + +/** + * Generate custom configuration required for the ui5.yaml. + * + * @param {UI5Config} ui5Config - Configuration representing the ui5.yaml. + */ +export function enhanceUI5YamlWithCfCustomMiddleware(ui5Config: UI5Config): void { + const ui5ConfigOptions: Partial = { + url: 'https://ui5.sap.com' + }; + + ui5Config.addFioriToolsProxyMiddleware( + { + ui5: ui5ConfigOptions, + backend: [] + }, + 'compression' + ); + ui5Config.addCustomMiddleware([ + { + name: 'fiori-tools-preview', + afterMiddleware: 'fiori-tools-proxy', + configuration: { + flp: { + theme: 'sap_horizon' + } + } + } + ]); +} diff --git a/packages/adp-tooling/src/writer/project-utils.ts b/packages/adp-tooling/src/writer/project-utils.ts index 81808a47df6..eb8390bf551 100644 --- a/packages/adp-tooling/src/writer/project-utils.ts +++ b/packages/adp-tooling/src/writer/project-utils.ts @@ -8,8 +8,7 @@ import { type CustomConfig, type TypesConfig, type CfAdpWriterConfig, - type DescriptorVariant, - ApplicationType + type DescriptorVariant } from '../types'; import { enhanceUI5DeployYaml, @@ -17,7 +16,9 @@ import { hasDeployConfig, enhanceUI5YamlWithCustomConfig, enhanceUI5YamlWithCustomTask, - enhanceUI5YamlWithTranspileMiddleware + enhanceUI5YamlWithTranspileMiddleware, + enhanceUI5YamlWithCfCustomTask, + enhanceUI5YamlWithCfCustomMiddleware } from './options'; import type { Package } from '@sap-ux/project-access'; @@ -125,30 +126,6 @@ export function getCfVariant(config: CfAdpWriterConfig): DescriptorVariant { return variant; } -/** - * Get the ADP config for the CF project. - * - * @param {CfAdpWriterConfig} config - The CF configuration. - * @returns {Record} The ADP config for the CF project. - */ -export function getCfAdpConfig(config: CfAdpWriterConfig): Record { - const { app, project, ui5, cf } = config; - const configJson = { - componentname: app.namespace, - appvariant: project.name, - layer: app.layer, - isOVPApp: app.appType === ApplicationType.FIORI_ELEMENTS_OVP, - isFioriElement: app.appType === ApplicationType.FIORI_ELEMENTS, - environment: 'CF', - ui5Version: ui5.version, - cfApiUrl: cf.url, - cfSpace: cf.space.GUID, - cfOrganization: cf.org.GUID - }; - - return configJson; -} - /** * Writes a given project template files within a specified folder in the project directory. * @@ -212,6 +189,33 @@ export async function writeUI5Yaml(projectPath: string, data: AdpWriterConfig, f } } +/** + * Writes a ui5.yaml file for CF project within a specified folder in the project directory. + * + * @param {string} projectPath - The root path of the project. + * @param {AdpWriterConfig} data - The data to be populated in the template file. + * @param {Editor} fs - The `mem-fs-editor` instance used for file operations. + * @returns {void} + */ +export async function writeUI5YamlCf(projectPath: string, data: CfAdpWriterConfig, fs: Editor): Promise { + try { + const ui5ConfigPath = join(projectPath, 'ui5.yaml'); + const baseUi5ConfigContent = fs.read(ui5ConfigPath); + const ui5Config = await UI5Config.newInstance(baseUi5ConfigContent); + ui5Config.setConfiguration({ propertiesFileSourceEncoding: 'UTF-8', paths: { webapp: 'dist' } }); + + /** Builder task */ + enhanceUI5YamlWithCfCustomTask(ui5Config, data); + + /** Middlewares */ + enhanceUI5YamlWithCfCustomMiddleware(ui5Config); + + fs.write(ui5ConfigPath, ui5Config.toString()); + } catch (e) { + throw new Error(`Could not write ui5.yaml file. Reason: ${e.message}`); + } +} + /** * Writes a ui5-deploy.yaml file within a specified folder in the project directory. * @@ -250,7 +254,7 @@ export async function writeCfTemplates( fs: Editor ): Promise { const baseTmplPath = join(__dirname, '../../templates'); - const { app, baseApp, cf, project, options } = config; + const { app, project, options } = config; fs.copyTpl( join(baseTmplPath, 'project/webapp/manifest.appdescr_variant'), @@ -263,19 +267,9 @@ export async function writeCfTemplates( }); fs.copyTpl(join(baseTmplPath, 'cf/ui5.yaml'), join(project.folder, 'ui5.yaml'), { - appHostId: baseApp.appHostId, - appName: baseApp.appName, - appVersion: baseApp.appVersion, - module: project.name, - html5RepoRuntime: cf.html5RepoRuntimeGuid, - org: cf.org.GUID, - space: cf.space.GUID, - sapCloudService: cf.businessSolutionName ?? '', - instanceName: cf.businessService + module: project.name }); - fs.writeJSON(join(project.folder, '.adp/config.json'), getCfAdpConfig(config)); - fs.copyTpl(join(baseTmplPath, 'cf/i18n/i18n.properties'), join(project.folder, 'webapp/i18n/i18n.properties'), { module: project.name, moduleTitle: app.title, diff --git a/packages/adp-tooling/templates/cf/package.json b/packages/adp-tooling/templates/cf/package.json index 1a40fa16c9e..7a156576789 100644 --- a/packages/adp-tooling/templates/cf/package.json +++ b/packages/adp-tooling/templates/cf/package.json @@ -4,6 +4,7 @@ "description": "", "main": "index.js", "scripts": { + "start": "fiori run --open /test/flp.html#app-preview", "build": "npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip", "zip": "cd dist && npx bestzip ../<%= module %>.zip *", "clean": "npx rimraf <%= module %>.zip dist", @@ -22,7 +23,8 @@ "devDependencies": { "@sap/ui5-builder-webide-extension": "1.0.x", "@sapui5/ts-types": "^1.85.1", - "@ui5/cli": "^3.0.0", + "@sap/ux-ui5-tooling": "1", + "@ui5/cli": "^4.0.16", "@ui5/task-adaptation": "^1.0.x", "bestzip": "2.1.4", "rimraf": "3.0.2" diff --git a/packages/adp-tooling/templates/cf/ui5.yaml b/packages/adp-tooling/templates/cf/ui5.yaml index 9cd4fca23e9..a38bf6790e9 100644 --- a/packages/adp-tooling/templates/cf/ui5.yaml +++ b/packages/adp-tooling/templates/cf/ui5.yaml @@ -3,17 +3,3 @@ specVersion: "2.2" type: application metadata: name: <%= module %> -builder: - customTasks: - - name: app-variant-bundler-build - beforeTask: escapeNonAsciiCharacters - configuration: - appHostId: <%= appHostId %> - appName: <%= appName %> - appVersion: <%= appVersion %> - moduleName: <%= module %> - org: <%= org %> - space: <%= space %> - html5RepoRuntime: <%= html5RepoRuntime %> - sapCloudService: <%= sapCloudService %> - serviceInstanceName: <%= instanceName %> \ No newline at end of file From c17c5d5446b524dbdba04e5dd3d83accd2c74d0f Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 7 Oct 2025 12:24:30 +0300 Subject: [PATCH 098/111] test: improve coverage --- .../unit/writer/__snapshots__/cf.test.ts.snap | 288 ++++++++++-------- .../test/unit/writer/project-utils.test.ts | 91 +++++- .../test/__snapshots__/app.test.ts.snap | 32 +- 3 files changed, 286 insertions(+), 125 deletions(-) diff --git a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap index 9d1a30a5136..59d217f425f 100644 --- a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap +++ b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap @@ -72,22 +72,6 @@ resources: service-plan: app-runtime _schema-version: '3.3' description: Test MTA Project for CF Writer Tests -", - "state": "modified", - }, - "../minimal-cf/test-cf-project/.adp/config.json": Object { - "contents": "{ - \\"componentname\\": \\"test.namespace\\", - \\"appvariant\\": \\"test-cf-project\\", - \\"layer\\": \\"CUSTOMER_BASE\\", - \\"isOVPApp\\": false, - \\"isFioriElement\\": false, - \\"environment\\": \\"CF\\", - \\"ui5Version\\": \\"1.120.0\\", - \\"cfApiUrl\\": \\"/test.cf.com\\", - \\"cfSpace\\": \\"space-guid\\", - \\"cfOrganization\\": \\"org-guid\\" -} ", "state": "modified", }, @@ -108,6 +92,7 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", @@ -126,7 +111,8 @@ UIAdaptation*.html \\"devDependencies\\": { \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", \\"@sapui5/ts-types\\": \\"^1.85.1\\", - \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@sap/ux-ui5-tooling\\": \\"1\\", + \\"@ui5/cli\\": \\"^4.0.16\\", \\"@ui5/task-adaptation\\": \\"^1.0.x\\", \\"bestzip\\": \\"2.1.4\\", \\"rimraf\\": \\"3.0.2\\" @@ -141,20 +127,42 @@ specVersion: \\"2.2\\" type: application metadata: name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist builder: customTasks: - name: app-variant-bundler-build beforeTask: escapeNonAsciiCharacters configuration: + module: test-cf-project appHostId: app-host-id appName: Base App appVersion: 1.0.0 - moduleName: test-cf-project + html5RepoRuntime: runtime-guid org: org-guid space: space-guid - html5RepoRuntime: runtime-guid sapCloudService: test-solution - serviceInstanceName: test-service", + instanceName: test-service +server: + customMiddleware: + - name: fiori-tools-proxy + afterMiddleware: compression + configuration: + ignoreCertErrors: false # If set to true, certificate errors will be ignored. E.g. self-signed certificates will be accepted + ui5: + path: + - /resources + - /test-resources + url: https://ui5.sap.com + - name: fiori-tools-preview + afterMiddleware: fiori-tools-proxy + configuration: + flp: + theme: sap_horizon +", "state": "modified", }, "../minimal-cf/test-cf-project/webapp/i18n/i18n.properties": Object { @@ -298,22 +306,6 @@ resources: version: 1.0.0 _schema-version: '3.3' description: Test MTA Project for CF Writer Tests -", - "state": "modified", - }, - "test-cf-project/.adp/config.json": Object { - "contents": "{ - \\"componentname\\": \\"test.namespace\\", - \\"appvariant\\": \\"test-cf-project\\", - \\"layer\\": \\"CUSTOMER_BASE\\", - \\"isOVPApp\\": false, - \\"isFioriElement\\": false, - \\"environment\\": \\"CF\\", - \\"ui5Version\\": \\"1.120.0\\", - \\"cfApiUrl\\": \\"/test.cf.com\\", - \\"cfSpace\\": \\"space-guid\\", - \\"cfOrganization\\": \\"org-guid\\" -} ", "state": "modified", }, @@ -334,6 +326,7 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", @@ -352,7 +345,8 @@ UIAdaptation*.html \\"devDependencies\\": { \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", \\"@sapui5/ts-types\\": \\"^1.85.1\\", - \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@sap/ux-ui5-tooling\\": \\"1\\", + \\"@ui5/cli\\": \\"^4.0.16\\", \\"@ui5/task-adaptation\\": \\"^1.0.x\\", \\"bestzip\\": \\"2.1.4\\", \\"rimraf\\": \\"3.0.2\\" @@ -367,20 +361,42 @@ specVersion: \\"2.2\\" type: application metadata: name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist builder: customTasks: - name: app-variant-bundler-build beforeTask: escapeNonAsciiCharacters configuration: + module: test-cf-project appHostId: app-host-id appName: Base App appVersion: 1.0.0 - moduleName: test-cf-project + html5RepoRuntime: runtime-guid org: org-guid space: space-guid - html5RepoRuntime: runtime-guid sapCloudService: test-solution - serviceInstanceName: test-service", + instanceName: test-service +server: + customMiddleware: + - name: fiori-tools-proxy + afterMiddleware: compression + configuration: + ignoreCertErrors: false # If set to true, certificate errors will be ignored. E.g. self-signed certificates will be accepted + ui5: + path: + - /resources + - /test-resources + url: https://ui5.sap.com + - name: fiori-tools-preview + afterMiddleware: fiori-tools-proxy + configuration: + flp: + theme: sap_horizon +", "state": "modified", }, "test-cf-project/webapp/i18n/i18n.properties": Object { @@ -529,22 +545,6 @@ resources: version: 1.0.0 _schema-version: '3.3' description: Test MTA Project for CF Writer Tests -", - "state": "modified", - }, - "../managed-approuter/test-cf-project/.adp/config.json": Object { - "contents": "{ - \\"componentname\\": \\"test.namespace\\", - \\"appvariant\\": \\"test-cf-project\\", - \\"layer\\": \\"CUSTOMER_BASE\\", - \\"isOVPApp\\": false, - \\"isFioriElement\\": false, - \\"environment\\": \\"CF\\", - \\"ui5Version\\": \\"1.120.0\\", - \\"cfApiUrl\\": \\"/test.cf.com\\", - \\"cfSpace\\": \\"space-guid\\", - \\"cfOrganization\\": \\"org-guid\\" -} ", "state": "modified", }, @@ -565,6 +565,7 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", @@ -583,7 +584,8 @@ UIAdaptation*.html \\"devDependencies\\": { \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", \\"@sapui5/ts-types\\": \\"^1.85.1\\", - \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@sap/ux-ui5-tooling\\": \\"1\\", + \\"@ui5/cli\\": \\"^4.0.16\\", \\"@ui5/task-adaptation\\": \\"^1.0.x\\", \\"bestzip\\": \\"2.1.4\\", \\"rimraf\\": \\"3.0.2\\" @@ -598,20 +600,42 @@ specVersion: \\"2.2\\" type: application metadata: name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist builder: customTasks: - name: app-variant-bundler-build beforeTask: escapeNonAsciiCharacters configuration: + module: test-cf-project appHostId: app-host-id appName: Base App appVersion: 1.0.0 - moduleName: test-cf-project + html5RepoRuntime: runtime-guid org: org-guid space: space-guid - html5RepoRuntime: runtime-guid sapCloudService: test-solution - serviceInstanceName: test-service", + instanceName: test-service +server: + customMiddleware: + - name: fiori-tools-proxy + afterMiddleware: compression + configuration: + ignoreCertErrors: false # If set to true, certificate errors will be ignored. E.g. self-signed certificates will be accepted + ui5: + path: + - /resources + - /test-resources + url: https://ui5.sap.com + - name: fiori-tools-preview + afterMiddleware: fiori-tools-proxy + configuration: + flp: + theme: sap_horizon +", "state": "modified", }, "../managed-approuter/test-cf-project/webapp/i18n/i18n.properties": Object { @@ -729,22 +753,6 @@ resources: service-plan: app-runtime _schema-version: '3.3' description: Test MTA Project for CF Writer Tests -", - "state": "modified", - }, - "../minimal-cf/test-cf-project/.adp/config.json": Object { - "contents": "{ - \\"componentname\\": \\"test.namespace\\", - \\"appvariant\\": \\"test-cf-project\\", - \\"layer\\": \\"CUSTOMER_BASE\\", - \\"isOVPApp\\": false, - \\"isFioriElement\\": false, - \\"environment\\": \\"CF\\", - \\"ui5Version\\": \\"1.120.0\\", - \\"cfApiUrl\\": \\"/test.cf.com\\", - \\"cfSpace\\": \\"space-guid\\", - \\"cfOrganization\\": \\"org-guid\\" -} ", "state": "modified", }, @@ -765,6 +773,7 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", @@ -783,7 +792,8 @@ UIAdaptation*.html \\"devDependencies\\": { \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", \\"@sapui5/ts-types\\": \\"^1.85.1\\", - \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@sap/ux-ui5-tooling\\": \\"1\\", + \\"@ui5/cli\\": \\"^4.0.16\\", \\"@ui5/task-adaptation\\": \\"^1.0.x\\", \\"bestzip\\": \\"2.1.4\\", \\"rimraf\\": \\"3.0.2\\" @@ -798,20 +808,42 @@ specVersion: \\"2.2\\" type: application metadata: name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist builder: customTasks: - name: app-variant-bundler-build beforeTask: escapeNonAsciiCharacters configuration: + module: test-cf-project appHostId: app-host-id appName: Base App appVersion: 1.0.0 - moduleName: test-cf-project + html5RepoRuntime: runtime-guid org: org-guid space: space-guid - html5RepoRuntime: runtime-guid sapCloudService: test-solution - serviceInstanceName: test-service", + instanceName: test-service +server: + customMiddleware: + - name: fiori-tools-proxy + afterMiddleware: compression + configuration: + ignoreCertErrors: false # If set to true, certificate errors will be ignored. E.g. self-signed certificates will be accepted + ui5: + path: + - /resources + - /test-resources + url: https://ui5.sap.com + - name: fiori-tools-preview + afterMiddleware: fiori-tools-proxy + configuration: + flp: + theme: sap_horizon +", "state": "modified", }, "../minimal-cf/test-cf-project/webapp/i18n/i18n.properties": Object { @@ -991,22 +1023,6 @@ description: Test MTA Project for CF Writer Tests } ] } -", - "state": "modified", - }, - "test-cf-project/.adp/config.json": Object { - "contents": "{ - \\"componentname\\": \\"test.namespace\\", - \\"appvariant\\": \\"test-cf-project\\", - \\"layer\\": \\"CUSTOMER_BASE\\", - \\"isOVPApp\\": false, - \\"isFioriElement\\": false, - \\"environment\\": \\"CF\\", - \\"ui5Version\\": \\"1.120.0\\", - \\"cfApiUrl\\": \\"/test.cf.com\\", - \\"cfSpace\\": \\"space-guid\\", - \\"cfOrganization\\": \\"org-guid\\" -} ", "state": "modified", }, @@ -1027,6 +1043,7 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", @@ -1045,7 +1062,8 @@ UIAdaptation*.html \\"devDependencies\\": { \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", \\"@sapui5/ts-types\\": \\"^1.85.1\\", - \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@sap/ux-ui5-tooling\\": \\"1\\", + \\"@ui5/cli\\": \\"^4.0.16\\", \\"@ui5/task-adaptation\\": \\"^1.0.x\\", \\"bestzip\\": \\"2.1.4\\", \\"rimraf\\": \\"3.0.2\\" @@ -1060,20 +1078,42 @@ specVersion: \\"2.2\\" type: application metadata: name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist builder: customTasks: - name: app-variant-bundler-build beforeTask: escapeNonAsciiCharacters configuration: + module: test-cf-project appHostId: app-host-id appName: Base App appVersion: 1.0.0 - moduleName: test-cf-project + html5RepoRuntime: runtime-guid org: org-guid space: space-guid - html5RepoRuntime: runtime-guid sapCloudService: test-solution - serviceInstanceName: test-service", + instanceName: test-service +server: + customMiddleware: + - name: fiori-tools-proxy + afterMiddleware: compression + configuration: + ignoreCertErrors: false # If set to true, certificate errors will be ignored. E.g. self-signed certificates will be accepted + ui5: + path: + - /resources + - /test-resources + url: https://ui5.sap.com + - name: fiori-tools-preview + afterMiddleware: fiori-tools-proxy + configuration: + flp: + theme: sap_horizon +", "state": "modified", }, "test-cf-project/webapp/i18n/i18n.properties": Object { @@ -1196,22 +1236,6 @@ resources: service-plan: app-runtime _schema-version: '3.3' description: Test MTA Project for CF Writer Tests -", - "state": "modified", - }, - "test-cf-project/.adp/config.json": Object { - "contents": "{ - \\"componentname\\": \\"test.namespace\\", - \\"appvariant\\": \\"test-cf-project\\", - \\"layer\\": \\"CUSTOMER_BASE\\", - \\"isOVPApp\\": false, - \\"isFioriElement\\": false, - \\"environment\\": \\"CF\\", - \\"ui5Version\\": \\"1.120.0\\", - \\"cfApiUrl\\": \\"/test.cf.com\\", - \\"cfSpace\\": \\"space-guid\\", - \\"cfOrganization\\": \\"org-guid\\" -} ", "state": "modified", }, @@ -1232,6 +1256,7 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", @@ -1250,7 +1275,8 @@ UIAdaptation*.html \\"devDependencies\\": { \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", \\"@sapui5/ts-types\\": \\"^1.85.1\\", - \\"@ui5/cli\\": \\"^3.0.0\\", + \\"@sap/ux-ui5-tooling\\": \\"1\\", + \\"@ui5/cli\\": \\"^4.0.16\\", \\"@ui5/task-adaptation\\": \\"^1.0.x\\", \\"bestzip\\": \\"2.1.4\\", \\"rimraf\\": \\"3.0.2\\" @@ -1265,20 +1291,42 @@ specVersion: \\"2.2\\" type: application metadata: name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist builder: customTasks: - name: app-variant-bundler-build beforeTask: escapeNonAsciiCharacters configuration: + module: test-cf-project appHostId: app-host-id appName: Base App appVersion: 1.0.0 - moduleName: test-cf-project + html5RepoRuntime: runtime-guid org: org-guid space: space-guid - html5RepoRuntime: runtime-guid sapCloudService: test-solution - serviceInstanceName: test-service", + instanceName: test-service +server: + customMiddleware: + - name: fiori-tools-proxy + afterMiddleware: compression + configuration: + ignoreCertErrors: false # If set to true, certificate errors will be ignored. E.g. self-signed certificates will be accepted + ui5: + path: + - /resources + - /test-resources + url: https://ui5.sap.com + - name: fiori-tools-preview + afterMiddleware: fiori-tools-proxy + configuration: + flp: + theme: sap_horizon +", "state": "modified", }, "test-cf-project/webapp/i18n/i18n.properties": Object { diff --git a/packages/adp-tooling/test/unit/writer/project-utils.test.ts b/packages/adp-tooling/test/unit/writer/project-utils.test.ts index 3c36050133f..07191c88e60 100644 --- a/packages/adp-tooling/test/unit/writer/project-utils.test.ts +++ b/packages/adp-tooling/test/unit/writer/project-utils.test.ts @@ -4,11 +4,12 @@ import type { Editor } from 'mem-fs-editor'; import { getTypesPackage, getTypesVersion, getEsmTypesVersion, UI5_DEFAULT } from '@sap-ux/ui5-config'; -import type { AdpWriterConfig } from '../../../src'; +import { type AdpWriterConfig, AppRouterType, FlexLayer } from '../../../src'; import { writeTemplateToFolder, writeUI5Yaml, writeUI5DeployYaml, + writeUI5YamlCf, getPackageJSONInfo, getTypes } from '../../../src/writer/project-utils'; @@ -254,4 +255,92 @@ describe('Project Utils', () => { } }); }); + + describe('writeUI5YamlCf', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const projectPath = 'project'; + const cfData = { + app: { + id: 'my.test.cf.app', + title: 'My Test CF App', + layer: FlexLayer.CUSTOMER_BASE, + namespace: 'my.test.cf.app', + manifest: {} as any + }, + baseApp: { + appId: 'the.original.app', + appName: 'Original App', + appVersion: '1.0.0', + appHostId: 'host123', + serviceName: 'service123', + title: 'Original App Title' + }, + cf: { + url: 'https://cf.example.com', + org: { Name: 'test-org', GUID: 'org-guid' }, + space: { Name: 'test-space', GUID: 'space-guid' }, + html5RepoRuntimeGuid: 'runtime-guid', + approuter: AppRouterType.MANAGED, + businessService: 'business-service' + }, + project: { + name: 'my-test-cf-project', + path: '/test/path', + folder: '/test/path/my-test-cf-project' + }, + ui5: { + version: '1.133.1' + }, + options: { + addStandaloneApprouter: false + } + }; + + const ui5YamlContent = `# yaml-language-server: $schema=https://sap.github.io/ui5-tooling/schema/ui5.yaml.json +specVersion: "3.0" +metadata: + name: ${cfData.app.id} + type: application`; + + const writeFilesSpy = jest.fn(); + const mockFs = { + write: writeFilesSpy, + read: jest.fn().mockReturnValue(ui5YamlContent) + }; + + it('should write ui5.yaml for CF project to the specified folder', async () => { + await writeUI5YamlCf(projectPath, cfData, mockFs as unknown as Editor); + + expect(mockFs.read).toHaveBeenCalledWith(path.join(projectPath, 'ui5.yaml')); + expect(writeFilesSpy).toHaveBeenCalledWith( + path.join(projectPath, 'ui5.yaml'), + expect.stringContaining('propertiesFileSourceEncoding: UTF-8') + ); + expect(writeFilesSpy).toHaveBeenCalledWith( + path.join(projectPath, 'ui5.yaml'), + expect.stringContaining('paths:') + ); + expect(writeFilesSpy).toHaveBeenCalledWith( + path.join(projectPath, 'ui5.yaml'), + expect.stringContaining('webapp: dist') + ); + }); + + it('should throw error when reading ui5.yaml fails', async () => { + const errMsg = 'File not found'; + mockFs.read.mockImplementation(() => { + throw new Error(errMsg); + }); + + try { + await writeUI5YamlCf(projectPath, cfData, mockFs as unknown as Editor); + fail('Expected error to be thrown'); + } catch (error) { + expect(error.message).toBe(`Could not write ui5.yaml file. Reason: ${errMsg}`); + } + }); + }); }); diff --git a/packages/generator-adp/test/__snapshots__/app.test.ts.snap b/packages/generator-adp/test/__snapshots__/app.test.ts.snap index 4518a53da7a..7e610a90da5 100644 --- a/packages/generator-adp/test/__snapshots__/app.test.ts.snap +++ b/packages/generator-adp/test/__snapshots__/app.test.ts.snap @@ -204,20 +204,42 @@ specVersion: "2.2" type: application metadata: name: app.variant +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist builder: customTasks: - name: app-variant-bundler-build beforeTask: escapeNonAsciiCharacters configuration: + module: app.variant appHostId: test-app-host-id appName: test-app-name appVersion: test-app-version - moduleName: app.variant + html5RepoRuntime: test-guid org: org-guid space: space-guid - html5RepoRuntime: test-guid sapCloudService: test-solution - serviceInstanceName: test-service" + instanceName: test-service +server: + customMiddleware: + - name: fiori-tools-proxy + afterMiddleware: compression + configuration: + ignoreCertErrors: false # If set to true, certificate errors will be ignored. E.g. self-signed certificates will be accepted + ui5: + path: + - /resources + - /test-resources + url: https://ui5.sap.com + - name: fiori-tools-preview + afterMiddleware: fiori-tools-proxy + configuration: + flp: + theme: sap_horizon +" `; exports[`Adaptation Project Generator Integration Test CF Environment should generate an adaptation project successfully 4`] = ` @@ -326,6 +348,7 @@ exports[`Adaptation Project Generator Integration Test CF Environment should gen "description": "", "main": "index.js", "scripts": { + "start": "fiori run --open /test/flp.html#app-preview", "build": "npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip", "zip": "cd dist && npx bestzip ../app.variant.zip *", "clean": "npx rimraf app.variant.zip dist", @@ -344,7 +367,8 @@ exports[`Adaptation Project Generator Integration Test CF Environment should gen "devDependencies": { "@sap/ui5-builder-webide-extension": "1.0.x", "@sapui5/ts-types": "^1.85.1", - "@ui5/cli": "^3.0.0", + "@sap/ux-ui5-tooling": "1", + "@ui5/cli": "^4.0.16", "@ui5/task-adaptation": "^1.0.x", "bestzip": "2.1.4", "rimraf": "3.0.2" From 7abbae86c7855e51fd40f67703bf424f934582f1 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 7 Oct 2025 12:25:10 +0300 Subject: [PATCH 099/111] chore: add cset --- .changeset/eighty-coins-sip.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/eighty-coins-sip.md diff --git a/.changeset/eighty-coins-sip.md b/.changeset/eighty-coins-sip.md new file mode 100644 index 00000000000..982a9abcf8a --- /dev/null +++ b/.changeset/eighty-coins-sip.md @@ -0,0 +1,7 @@ +--- +'@sap-ux/project-input-validator': patch +'@sap-ux/generator-adp': patch +'@sap-ux/adp-tooling': patch +--- + +feat: Adapt CF projects' structure to work with preview-middleware From 6e638d668938d9c25168b3e724ff83b0a6dea307 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Thu, 9 Oct 2025 09:08:54 +0300 Subject: [PATCH 100/111] chore: update cset --- .changeset/eighty-coins-sip.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/eighty-coins-sip.md b/.changeset/eighty-coins-sip.md index 982a9abcf8a..1c78d71ba97 100644 --- a/.changeset/eighty-coins-sip.md +++ b/.changeset/eighty-coins-sip.md @@ -1,5 +1,4 @@ --- -'@sap-ux/project-input-validator': patch '@sap-ux/generator-adp': patch '@sap-ux/adp-tooling': patch --- From a6018e829683757110fa3008137a1db89c248072 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 14 Oct 2025 08:37:50 +0300 Subject: [PATCH 101/111] chore: remove extra cset --- .changeset/tender-suns-relate.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .changeset/tender-suns-relate.md diff --git a/.changeset/tender-suns-relate.md b/.changeset/tender-suns-relate.md deleted file mode 100644 index 593bf0eedc7..00000000000 --- a/.changeset/tender-suns-relate.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@sap-ux/generator-adp': minor -'@sap-ux/adp-tooling': minor -'@sap-ux/project-input-validator': patch ---- - -feat: Add ADP Generator Cloud Foundry prompting code From 4bdd950dd094e9d170760d4279058840945b0804 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 14 Oct 2025 08:39:53 +0300 Subject: [PATCH 102/111] refactor: change i18n --- packages/adp-tooling/src/translations/adp-tooling.i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index 70f51a28af6..482df7ff73b 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -76,7 +76,7 @@ }, "error": { "appDoesNotSupportFlexibility": "The selected application does not support flexibility because it has `flexEnabled=false`. SAPUI5 Adaptation Project only supports applications that support flexibility. Please select a different application.", - "failedToParseXsAppJson": "Failed to parse `xs-app.json`. Error: {{error}}", + "failedToParseXsAppJson": "Failed to parse the `xs-app.json` file. Error: {{error}}", "failedToParseManifestJson": "Failed to parse the `manifest.json` file. Error: {{error}}", "oDataEndpointsValidationFailed": "Validation for the OData endpoints has failed. For more information, check the logs.", "adpDoesNotSupportSelectedApp": "Adaptation project doesn't support the selected application. Please select a different application.", From d06b55b25eaf092e223c65acb6c44c9e98038650 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 14 Oct 2025 15:17:09 +0300 Subject: [PATCH 103/111] fix: incorrect property --- packages/adp-tooling/src/writer/options.ts | 2 +- .../test/unit/writer/__snapshots__/cf.test.ts.snap | 12 ++++++------ .../test/__snapshots__/app.test.ts.snap | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/adp-tooling/src/writer/options.ts b/packages/adp-tooling/src/writer/options.ts index ad8687202b2..4278420ba84 100644 --- a/packages/adp-tooling/src/writer/options.ts +++ b/packages/adp-tooling/src/writer/options.ts @@ -369,7 +369,7 @@ export function enhanceUI5YamlWithCfCustomTask(ui5Config: UI5Config, config: CfA org: cf.org.GUID, space: cf.space.GUID, sapCloudService: cf.businessSolutionName ?? '', - instanceName: cf.businessService + serviceInstanceName: cf.businessService } } ]); diff --git a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap index 59d217f425f..23bd5c0291b 100644 --- a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap +++ b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap @@ -145,7 +145,7 @@ builder: org: org-guid space: space-guid sapCloudService: test-solution - instanceName: test-service + serviceInstanceName: test-service server: customMiddleware: - name: fiori-tools-proxy @@ -379,7 +379,7 @@ builder: org: org-guid space: space-guid sapCloudService: test-solution - instanceName: test-service + serviceInstanceName: test-service server: customMiddleware: - name: fiori-tools-proxy @@ -618,7 +618,7 @@ builder: org: org-guid space: space-guid sapCloudService: test-solution - instanceName: test-service + serviceInstanceName: test-service server: customMiddleware: - name: fiori-tools-proxy @@ -826,7 +826,7 @@ builder: org: org-guid space: space-guid sapCloudService: test-solution - instanceName: test-service + serviceInstanceName: test-service server: customMiddleware: - name: fiori-tools-proxy @@ -1096,7 +1096,7 @@ builder: org: org-guid space: space-guid sapCloudService: test-solution - instanceName: test-service + serviceInstanceName: test-service server: customMiddleware: - name: fiori-tools-proxy @@ -1309,7 +1309,7 @@ builder: org: org-guid space: space-guid sapCloudService: test-solution - instanceName: test-service + serviceInstanceName: test-service server: customMiddleware: - name: fiori-tools-proxy diff --git a/packages/generator-adp/test/__snapshots__/app.test.ts.snap b/packages/generator-adp/test/__snapshots__/app.test.ts.snap index 7e610a90da5..02c4a845d84 100644 --- a/packages/generator-adp/test/__snapshots__/app.test.ts.snap +++ b/packages/generator-adp/test/__snapshots__/app.test.ts.snap @@ -222,7 +222,7 @@ builder: org: org-guid space: space-guid sapCloudService: test-solution - instanceName: test-service + serviceInstanceName: test-service server: customMiddleware: - name: fiori-tools-proxy From e68f8770b50f8f6aee7f7331e53230648c17f4ab Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 20 Oct 2025 09:24:33 +0300 Subject: [PATCH 104/111] feat: add ui5 build yaml --- packages/adp-tooling/src/writer/cf.ts | 5 +-- .../adp-tooling/src/writer/project-utils.ts | 33 ++++++++++++++++--- .../adp-tooling/templates/cf/package.json | 3 +- .../adp-tooling/templates/cf/ui5-build.yaml | 5 +++ 4 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 packages/adp-tooling/templates/cf/ui5-build.yaml diff --git a/packages/adp-tooling/src/writer/cf.ts b/packages/adp-tooling/src/writer/cf.ts index 55ca0e9a642..574aae249f0 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -7,7 +7,7 @@ import { adjustMtaYaml } from '../cf'; import { getApplicationType } from '../source'; import { fillDescriptorContent } from './manifest'; import type { CfAdpWriterConfig, Content } from '../types'; -import { getCfVariant, writeCfTemplates, writeUI5YamlCf } from './project-utils'; +import { getCfVariant, writeCfTemplates, writeCfUI5Yaml, writeCfUI5BuildYaml } from './project-utils'; import { getI18nDescription, getI18nModels, writeI18nModels } from './i18n'; /** @@ -54,7 +54,8 @@ export async function generateCf( fillDescriptorContent(variant.content as Content[], app.appType, ui5.version, app.i18nModels); await writeCfTemplates(basePath, variant, fullConfig, fs); - await writeUI5YamlCf(fullConfig.project.folder, fullConfig, fs); + await writeCfUI5Yaml(fullConfig.project.folder, fullConfig, fs); + await writeCfUI5BuildYaml(fullConfig.project.folder, fullConfig, fs); return fs; } diff --git a/packages/adp-tooling/src/writer/project-utils.ts b/packages/adp-tooling/src/writer/project-utils.ts index b577300a7f2..18370f69eec 100644 --- a/packages/adp-tooling/src/writer/project-utils.ts +++ b/packages/adp-tooling/src/writer/project-utils.ts @@ -197,16 +197,13 @@ export async function writeUI5Yaml(projectPath: string, data: AdpWriterConfig, f * @param {Editor} fs - The `mem-fs-editor` instance used for file operations. * @returns {void} */ -export async function writeUI5YamlCf(projectPath: string, data: CfAdpWriterConfig, fs: Editor): Promise { +export async function writeCfUI5Yaml(projectPath: string, data: CfAdpWriterConfig, fs: Editor): Promise { try { const ui5ConfigPath = join(projectPath, 'ui5.yaml'); const baseUi5ConfigContent = fs.read(ui5ConfigPath); const ui5Config = await UI5Config.newInstance(baseUi5ConfigContent); ui5Config.setConfiguration({ propertiesFileSourceEncoding: 'UTF-8', paths: { webapp: 'dist' } }); - /** Builder task */ - enhanceUI5YamlWithCfCustomTask(ui5Config, data); - /** Middlewares */ enhanceUI5YamlWithCfCustomMiddleware(ui5Config); @@ -216,6 +213,30 @@ export async function writeUI5YamlCf(projectPath: string, data: CfAdpWriterConfi } } +/** + * Writes a ui5.yaml file for CF project within a specified folder in the project directory. + * + * @param {string} projectPath - The root path of the project. + * @param {AdpWriterConfig} data - The data to be populated in the template file. + * @param {Editor} fs - The `mem-fs-editor` instance used for file operations. + * @returns {void} + */ +export async function writeCfUI5BuildYaml(projectPath: string, data: CfAdpWriterConfig, fs: Editor): Promise { + try { + const ui5ConfigPath = join(projectPath, 'ui5-build.yaml'); + const baseUi5ConfigContent = fs.read(ui5ConfigPath); + const ui5Config = await UI5Config.newInstance(baseUi5ConfigContent); + ui5Config.setConfiguration({ propertiesFileSourceEncoding: 'UTF-8' }); + + /** Builder task */ + enhanceUI5YamlWithCfCustomTask(ui5Config, data); + + fs.write(ui5ConfigPath, ui5Config.toString()); + } catch (e) { + throw new Error(`Could not write ui5-build.yaml file. Reason: ${e.message}`); + } +} + /** * Writes a ui5-deploy.yaml file within a specified folder in the project directory. * @@ -271,6 +292,10 @@ export async function writeCfTemplates( module: project.name }); + fs.copyTpl(join(templatePath, 'cf/ui5-build.yaml'), join(project.folder, 'ui5-build.yaml'), { + module: project.name + }); + fs.copyTpl(join(templatePath, 'cf/i18n/i18n.properties'), join(project.folder, 'webapp/i18n/i18n.properties'), { module: project.name, moduleTitle: app.title, diff --git a/packages/adp-tooling/templates/cf/package.json b/packages/adp-tooling/templates/cf/package.json index 7a156576789..f6a30171068 100644 --- a/packages/adp-tooling/templates/cf/package.json +++ b/packages/adp-tooling/templates/cf/package.json @@ -4,8 +4,9 @@ "description": "", "main": "index.js", "scripts": { + "prestart": "npm run build", "start": "fiori run --open /test/flp.html#app-preview", - "build": "npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip", + "build": "npm run clean && ui5 build --config ui5-build.yaml --include-task=generateCachebusterInfo && npm run zip", "zip": "cd dist && npx bestzip ../<%= module %>.zip *", "clean": "npx rimraf <%= module %>.zip dist", "build-ui5": "npm explore @ui5/task-adaptation -- npm run rollup" diff --git a/packages/adp-tooling/templates/cf/ui5-build.yaml b/packages/adp-tooling/templates/cf/ui5-build.yaml new file mode 100644 index 00000000000..a38bf6790e9 --- /dev/null +++ b/packages/adp-tooling/templates/cf/ui5-build.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.2" +type: application +metadata: + name: <%= module %> From 6e34d8ed6036c9b92c0085abc35b16f49806813b Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Mon, 20 Oct 2025 11:12:18 +0300 Subject: [PATCH 105/111] test: add new tests and fix existing ones --- .../adp-tooling/src/writer/project-utils.ts | 6 +- .../unit/writer/__snapshots__/cf.test.ts.snap | 126 ++++++++++++++---- .../test/unit/writer/project-utils.test.ts | 126 ++++++++++++------ 3 files changed, 191 insertions(+), 67 deletions(-) diff --git a/packages/adp-tooling/src/writer/project-utils.ts b/packages/adp-tooling/src/writer/project-utils.ts index 18370f69eec..a928fa8366a 100644 --- a/packages/adp-tooling/src/writer/project-utils.ts +++ b/packages/adp-tooling/src/writer/project-utils.ts @@ -193,7 +193,7 @@ export async function writeUI5Yaml(projectPath: string, data: AdpWriterConfig, f * Writes a ui5.yaml file for CF project within a specified folder in the project directory. * * @param {string} projectPath - The root path of the project. - * @param {AdpWriterConfig} data - The data to be populated in the template file. + * @param {CfAdpWriterConfig} data - The data to be populated in the template file. * @param {Editor} fs - The `mem-fs-editor` instance used for file operations. * @returns {void} */ @@ -214,10 +214,10 @@ export async function writeCfUI5Yaml(projectPath: string, data: CfAdpWriterConfi } /** - * Writes a ui5.yaml file for CF project within a specified folder in the project directory. + * Writes a ui5-build.yaml file for CF project within a specified folder in the project directory. * * @param {string} projectPath - The root path of the project. - * @param {AdpWriterConfig} data - The data to be populated in the template file. + * @param {CfAdpWriterConfig} data - The data to be populated in the template file. * @param {Editor} fs - The `mem-fs-editor` instance used for file operations. * @returns {void} */ diff --git a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap index 23bd5c0291b..f6174f412f6 100644 --- a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap +++ b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap @@ -92,8 +92,9 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"prestart\\": \\"npm run build\\", \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", - \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"build\\": \\"npm run clean && ui5 build --config ui5-build.yaml --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" @@ -121,7 +122,7 @@ UIAdaptation*.html ", "state": "modified", }, - "../minimal-cf/test-cf-project/ui5.yaml": Object { + "../minimal-cf/test-cf-project/ui5-build.yaml": Object { "contents": "--- specVersion: \\"2.2\\" type: application @@ -130,8 +131,6 @@ metadata: resources: configuration: propertiesFileSourceEncoding: UTF-8 - paths: - webapp: dist builder: customTasks: - name: app-variant-bundler-build @@ -146,6 +145,20 @@ builder: space: space-guid sapCloudService: test-solution serviceInstanceName: test-service +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist server: customMiddleware: - name: fiori-tools-proxy @@ -326,8 +339,9 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"prestart\\": \\"npm run build\\", \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", - \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"build\\": \\"npm run clean && ui5 build --config ui5-build.yaml --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" @@ -355,7 +369,7 @@ UIAdaptation*.html ", "state": "modified", }, - "test-cf-project/ui5.yaml": Object { + "test-cf-project/ui5-build.yaml": Object { "contents": "--- specVersion: \\"2.2\\" type: application @@ -364,8 +378,6 @@ metadata: resources: configuration: propertiesFileSourceEncoding: UTF-8 - paths: - webapp: dist builder: customTasks: - name: app-variant-bundler-build @@ -380,6 +392,20 @@ builder: space: space-guid sapCloudService: test-solution serviceInstanceName: test-service +", + "state": "modified", + }, + "test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist server: customMiddleware: - name: fiori-tools-proxy @@ -565,8 +591,9 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"prestart\\": \\"npm run build\\", \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", - \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"build\\": \\"npm run clean && ui5 build --config ui5-build.yaml --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" @@ -594,7 +621,7 @@ UIAdaptation*.html ", "state": "modified", }, - "../managed-approuter/test-cf-project/ui5.yaml": Object { + "../managed-approuter/test-cf-project/ui5-build.yaml": Object { "contents": "--- specVersion: \\"2.2\\" type: application @@ -603,8 +630,6 @@ metadata: resources: configuration: propertiesFileSourceEncoding: UTF-8 - paths: - webapp: dist builder: customTasks: - name: app-variant-bundler-build @@ -619,6 +644,20 @@ builder: space: space-guid sapCloudService: test-solution serviceInstanceName: test-service +", + "state": "modified", + }, + "../managed-approuter/test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist server: customMiddleware: - name: fiori-tools-proxy @@ -773,8 +812,9 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"prestart\\": \\"npm run build\\", \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", - \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"build\\": \\"npm run clean && ui5 build --config ui5-build.yaml --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" @@ -802,7 +842,7 @@ UIAdaptation*.html ", "state": "modified", }, - "../minimal-cf/test-cf-project/ui5.yaml": Object { + "../minimal-cf/test-cf-project/ui5-build.yaml": Object { "contents": "--- specVersion: \\"2.2\\" type: application @@ -811,8 +851,6 @@ metadata: resources: configuration: propertiesFileSourceEncoding: UTF-8 - paths: - webapp: dist builder: customTasks: - name: app-variant-bundler-build @@ -827,6 +865,20 @@ builder: space: space-guid sapCloudService: test-solution serviceInstanceName: test-service +", + "state": "modified", + }, + "../minimal-cf/test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist server: customMiddleware: - name: fiori-tools-proxy @@ -1043,8 +1095,9 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"prestart\\": \\"npm run build\\", \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", - \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"build\\": \\"npm run clean && ui5 build --config ui5-build.yaml --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" @@ -1072,7 +1125,7 @@ UIAdaptation*.html ", "state": "modified", }, - "test-cf-project/ui5.yaml": Object { + "test-cf-project/ui5-build.yaml": Object { "contents": "--- specVersion: \\"2.2\\" type: application @@ -1081,8 +1134,6 @@ metadata: resources: configuration: propertiesFileSourceEncoding: UTF-8 - paths: - webapp: dist builder: customTasks: - name: app-variant-bundler-build @@ -1097,6 +1148,20 @@ builder: space: space-guid sapCloudService: test-solution serviceInstanceName: test-service +", + "state": "modified", + }, + "test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist server: customMiddleware: - name: fiori-tools-proxy @@ -1256,8 +1321,9 @@ UIAdaptation*.html \\"description\\": \\"\\", \\"main\\": \\"index.js\\", \\"scripts\\": { + \\"prestart\\": \\"npm run build\\", \\"start\\": \\"fiori run --open /test/flp.html#app-preview\\", - \\"build\\": \\"npm run clean && ui5 build --include-task=generateCachebusterInfo && npm run zip\\", + \\"build\\": \\"npm run clean && ui5 build --config ui5-build.yaml --include-task=generateCachebusterInfo && npm run zip\\", \\"zip\\": \\"cd dist && npx bestzip ../test-cf-project.zip *\\", \\"clean\\": \\"npx rimraf test-cf-project.zip dist\\", \\"build-ui5\\": \\"npm explore @ui5/task-adaptation -- npm run rollup\\" @@ -1285,7 +1351,7 @@ UIAdaptation*.html ", "state": "modified", }, - "test-cf-project/ui5.yaml": Object { + "test-cf-project/ui5-build.yaml": Object { "contents": "--- specVersion: \\"2.2\\" type: application @@ -1294,8 +1360,6 @@ metadata: resources: configuration: propertiesFileSourceEncoding: UTF-8 - paths: - webapp: dist builder: customTasks: - name: app-variant-bundler-build @@ -1310,6 +1374,20 @@ builder: space: space-guid sapCloudService: test-solution serviceInstanceName: test-service +", + "state": "modified", + }, + "test-cf-project/ui5.yaml": Object { + "contents": "--- +specVersion: \\"2.2\\" +type: application +metadata: + name: test-cf-project +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 + paths: + webapp: dist server: customMiddleware: - name: fiori-tools-proxy diff --git a/packages/adp-tooling/test/unit/writer/project-utils.test.ts b/packages/adp-tooling/test/unit/writer/project-utils.test.ts index d952ba67197..6c9f8e9fc34 100644 --- a/packages/adp-tooling/test/unit/writer/project-utils.test.ts +++ b/packages/adp-tooling/test/unit/writer/project-utils.test.ts @@ -9,7 +9,8 @@ import { writeTemplateToFolder, writeUI5Yaml, writeUI5DeployYaml, - writeUI5YamlCf, + writeCfUI5Yaml, + writeCfUI5BuildYaml, getPackageJSONInfo, getTypes } from '../../../src/writer/project-utils'; @@ -34,6 +35,43 @@ const mockedGetTypesPackage = getTypesPackage as jest.Mock; const mockedGetTypesVersion = getTypesVersion as jest.Mock; const mockedGetEsmTypesVersion = getEsmTypesVersion as jest.Mock; +const cfData = { + app: { + id: 'my.test.cf.app', + title: 'My Test CF App', + layer: FlexLayer.CUSTOMER_BASE, + namespace: 'my.test.cf.app', + manifest: {} as any + }, + baseApp: { + appId: 'the.original.app', + appName: 'Original App', + appVersion: '1.0.0', + appHostId: 'host123', + serviceName: 'service123', + title: 'Original App Title' + }, + cf: { + url: 'https://cf.example.com', + org: { Name: 'test-org', GUID: 'org-guid' }, + space: { Name: 'test-space', GUID: 'space-guid' }, + html5RepoRuntimeGuid: 'runtime-guid', + approuter: AppRouterType.MANAGED, + businessService: 'business-service' + }, + project: { + name: 'my-test-cf-project', + path: '/test/path', + folder: '/test/path/my-test-cf-project' + }, + ui5: { + version: '1.133.1' + }, + options: { + addStandaloneApprouter: false + } +}; + describe('Project Utils', () => { const data: AdpWriterConfig = { app: { @@ -256,48 +294,12 @@ describe('Project Utils', () => { }); }); - describe('writeUI5YamlCf', () => { + describe('writeCfUI5Yaml', () => { beforeEach(() => { jest.clearAllMocks(); }); const projectPath = 'project'; - const cfData = { - app: { - id: 'my.test.cf.app', - title: 'My Test CF App', - layer: FlexLayer.CUSTOMER_BASE, - namespace: 'my.test.cf.app', - manifest: {} as any - }, - baseApp: { - appId: 'the.original.app', - appName: 'Original App', - appVersion: '1.0.0', - appHostId: 'host123', - serviceName: 'service123', - title: 'Original App Title' - }, - cf: { - url: 'https://cf.example.com', - org: { Name: 'test-org', GUID: 'org-guid' }, - space: { Name: 'test-space', GUID: 'space-guid' }, - html5RepoRuntimeGuid: 'runtime-guid', - approuter: AppRouterType.MANAGED, - businessService: 'business-service' - }, - project: { - name: 'my-test-cf-project', - path: '/test/path', - folder: '/test/path/my-test-cf-project' - }, - ui5: { - version: '1.133.1' - }, - options: { - addStandaloneApprouter: false - } - }; const ui5YamlContent = `# yaml-language-server: $schema=https://sap.github.io/ui5-tooling/schema/ui5.yaml.json specVersion: "3.0" @@ -312,7 +314,7 @@ metadata: }; it('should write ui5.yaml for CF project to the specified folder', async () => { - await writeUI5YamlCf(projectPath, cfData, mockFs as unknown as Editor); + await writeCfUI5Yaml(projectPath, cfData, mockFs as unknown as Editor); expect(mockFs.read).toHaveBeenCalledWith(path.join(projectPath, 'ui5.yaml')); expect(writeFilesSpy).toHaveBeenCalledWith( @@ -336,11 +338,55 @@ metadata: }); try { - await writeUI5YamlCf(projectPath, cfData, mockFs as unknown as Editor); + await writeCfUI5Yaml(projectPath, cfData, mockFs as unknown as Editor); fail('Expected error to be thrown'); } catch (error) { expect(error.message).toBe(`Could not write ui5.yaml file. Reason: ${errMsg}`); } }); }); + + describe('writeCfUI5BuildYaml', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const projectPath = 'project'; + + const ui5BuildYamlContent = `# yaml-language-server: $schema=https://sap.github.io/ui5-tooling/schema/ui5-build.yaml.json +specVersion: "3.0" +metadata: + name: ${cfData.app.id} + type: application`; + + const writeFilesSpy = jest.fn(); + const mockFs = { + write: writeFilesSpy, + read: jest.fn().mockReturnValue(ui5BuildYamlContent) + }; + + it('should write ui5-build.yaml for CF project to the specified folder', async () => { + await writeCfUI5BuildYaml(projectPath, cfData, mockFs as unknown as Editor); + + expect(mockFs.read).toHaveBeenCalledWith(path.join(projectPath, 'ui5-build.yaml')); + expect(writeFilesSpy).toHaveBeenCalledWith( + path.join(projectPath, 'ui5-build.yaml'), + expect.stringContaining('propertiesFileSourceEncoding: UTF-8') + ); + }); + + it('should throw error when reading ui5-build.yaml fails', async () => { + const errMsg = 'File not found'; + mockFs.read.mockImplementation(() => { + throw new Error(errMsg); + }); + + try { + await writeCfUI5BuildYaml(projectPath, cfData, mockFs as unknown as Editor); + fail('Expected error to be thrown'); + } catch (error) { + expect(error.message).toBe(`Could not write ui5-build.yaml file. Reason: ${errMsg}`); + } + }); + }); }); From d714cf319fa80135eb35a245f0c62af97ce9a1db Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 21 Oct 2025 15:39:46 +0300 Subject: [PATCH 106/111] chore: update template package json versions --- .../adp-tooling/templates/cf/package.json | 10 ++-- .../unit/writer/__snapshots__/cf.test.ts.snap | 60 +++++++++---------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/packages/adp-tooling/templates/cf/package.json b/packages/adp-tooling/templates/cf/package.json index f6a30171068..43f3558d94b 100644 --- a/packages/adp-tooling/templates/cf/package.json +++ b/packages/adp-tooling/templates/cf/package.json @@ -22,12 +22,12 @@ ] }, "devDependencies": { - "@sap/ui5-builder-webide-extension": "1.0.x", - "@sapui5/ts-types": "^1.85.1", + "@sap/ui5-builder-webide-extension": "^1.1.9", + "@sapui5/ts-types": "^1.141.2", "@sap/ux-ui5-tooling": "1", "@ui5/cli": "^4.0.16", - "@ui5/task-adaptation": "^1.0.x", - "bestzip": "2.1.4", - "rimraf": "3.0.2" + "@ui5/task-adaptation": "^1.5.3", + "bestzip": "^2.2.1", + "rimraf": "^5.0.5" } } diff --git a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap index f6174f412f6..f8fe9226b03 100644 --- a/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap +++ b/packages/adp-tooling/test/unit/writer/__snapshots__/cf.test.ts.snap @@ -110,13 +110,13 @@ UIAdaptation*.html ] }, \\"devDependencies\\": { - \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", - \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@sap/ui5-builder-webide-extension\\": \\"^1.1.9\\", + \\"@sapui5/ts-types\\": \\"^1.141.2\\", \\"@sap/ux-ui5-tooling\\": \\"1\\", \\"@ui5/cli\\": \\"^4.0.16\\", - \\"@ui5/task-adaptation\\": \\"^1.0.x\\", - \\"bestzip\\": \\"2.1.4\\", - \\"rimraf\\": \\"3.0.2\\" + \\"@ui5/task-adaptation\\": \\"^1.5.3\\", + \\"bestzip\\": \\"^2.2.1\\", + \\"rimraf\\": \\"^5.0.5\\" } } ", @@ -357,13 +357,13 @@ UIAdaptation*.html ] }, \\"devDependencies\\": { - \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", - \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@sap/ui5-builder-webide-extension\\": \\"^1.1.9\\", + \\"@sapui5/ts-types\\": \\"^1.141.2\\", \\"@sap/ux-ui5-tooling\\": \\"1\\", \\"@ui5/cli\\": \\"^4.0.16\\", - \\"@ui5/task-adaptation\\": \\"^1.0.x\\", - \\"bestzip\\": \\"2.1.4\\", - \\"rimraf\\": \\"3.0.2\\" + \\"@ui5/task-adaptation\\": \\"^1.5.3\\", + \\"bestzip\\": \\"^2.2.1\\", + \\"rimraf\\": \\"^5.0.5\\" } } ", @@ -609,13 +609,13 @@ UIAdaptation*.html ] }, \\"devDependencies\\": { - \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", - \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@sap/ui5-builder-webide-extension\\": \\"^1.1.9\\", + \\"@sapui5/ts-types\\": \\"^1.141.2\\", \\"@sap/ux-ui5-tooling\\": \\"1\\", \\"@ui5/cli\\": \\"^4.0.16\\", - \\"@ui5/task-adaptation\\": \\"^1.0.x\\", - \\"bestzip\\": \\"2.1.4\\", - \\"rimraf\\": \\"3.0.2\\" + \\"@ui5/task-adaptation\\": \\"^1.5.3\\", + \\"bestzip\\": \\"^2.2.1\\", + \\"rimraf\\": \\"^5.0.5\\" } } ", @@ -830,13 +830,13 @@ UIAdaptation*.html ] }, \\"devDependencies\\": { - \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", - \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@sap/ui5-builder-webide-extension\\": \\"^1.1.9\\", + \\"@sapui5/ts-types\\": \\"^1.141.2\\", \\"@sap/ux-ui5-tooling\\": \\"1\\", \\"@ui5/cli\\": \\"^4.0.16\\", - \\"@ui5/task-adaptation\\": \\"^1.0.x\\", - \\"bestzip\\": \\"2.1.4\\", - \\"rimraf\\": \\"3.0.2\\" + \\"@ui5/task-adaptation\\": \\"^1.5.3\\", + \\"bestzip\\": \\"^2.2.1\\", + \\"rimraf\\": \\"^5.0.5\\" } } ", @@ -1113,13 +1113,13 @@ UIAdaptation*.html ] }, \\"devDependencies\\": { - \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", - \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@sap/ui5-builder-webide-extension\\": \\"^1.1.9\\", + \\"@sapui5/ts-types\\": \\"^1.141.2\\", \\"@sap/ux-ui5-tooling\\": \\"1\\", \\"@ui5/cli\\": \\"^4.0.16\\", - \\"@ui5/task-adaptation\\": \\"^1.0.x\\", - \\"bestzip\\": \\"2.1.4\\", - \\"rimraf\\": \\"3.0.2\\" + \\"@ui5/task-adaptation\\": \\"^1.5.3\\", + \\"bestzip\\": \\"^2.2.1\\", + \\"rimraf\\": \\"^5.0.5\\" } } ", @@ -1339,13 +1339,13 @@ UIAdaptation*.html ] }, \\"devDependencies\\": { - \\"@sap/ui5-builder-webide-extension\\": \\"1.0.x\\", - \\"@sapui5/ts-types\\": \\"^1.85.1\\", + \\"@sap/ui5-builder-webide-extension\\": \\"^1.1.9\\", + \\"@sapui5/ts-types\\": \\"^1.141.2\\", \\"@sap/ux-ui5-tooling\\": \\"1\\", \\"@ui5/cli\\": \\"^4.0.16\\", - \\"@ui5/task-adaptation\\": \\"^1.0.x\\", - \\"bestzip\\": \\"2.1.4\\", - \\"rimraf\\": \\"3.0.2\\" + \\"@ui5/task-adaptation\\": \\"^1.5.3\\", + \\"bestzip\\": \\"^2.2.1\\", + \\"rimraf\\": \\"^5.0.5\\" } } ", From 7e2736d1e35be34c7ba96339452e97b00495b200 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 21 Oct 2025 15:45:29 +0300 Subject: [PATCH 107/111] fix: conflict when overwriting existing files --- packages/generator-adp/src/app/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index e55b3af7552..a4cbd9fa1de 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -30,6 +30,7 @@ import { import { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import type { AbapServiceProvider } from '@sap-ux/axios-extension'; +import type { YeomanEnvironment } from '@sap-ux/fiori-generator-shared'; import { isInternalFeaturesSettingEnabled } from '@sap-ux/feature-toggle'; import type { CfConfig, CfServicesAnswers, AttributesAnswers, ConfigAnswers, UI5Version } from '@sap-ux/adp-tooling'; @@ -197,6 +198,11 @@ export default class extends Generator { const jsonInputString = getFirstArgAsString(args); this.jsonInput = parseJsonInput(jsonInputString, this.logger); + // Force the generator to overwrite existing files without additional prompting + if ((this.env as unknown as YeomanEnvironment).conflicter) { + (this.env as unknown as YeomanEnvironment).conflicter.force = this.options.force ?? true; + } + if (!this.jsonInput) { this.env.lookup({ packagePatterns: ['@sap/generator-fiori', '@bas-dev/generator-extensibility-sub'] From 9a48872d3a4f9052498095ff883caf95f5f088af Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 22 Oct 2025 15:24:32 +0300 Subject: [PATCH 108/111] feat: add feature toggle for cf flow --- packages/generator-adp/src/app/index.ts | 19 ++++++++++--------- packages/generator-adp/test/app.test.ts | 9 ++++++--- pnpm-lock.yaml | 6 +++++- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index a4cbd9fa1de..955e82e2cfc 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { isFeatureEnabled } from '@sap-ux/feature-toggle'; import { join } from 'node:path'; import Generator from 'yeoman-generator'; import { AppWizard, MessageType, Prompts as YeomanUiSteps, type IPrompt } from '@sap-devx/yeoman-ui-types'; @@ -166,14 +167,14 @@ export default class extends Generator { * CF services answers. */ private cfServicesAnswers: CfServicesAnswers; - /** - * Indicates if the extension is installed. - */ - private readonly isExtensionInstalled: boolean; /** * Indicates if CF is installed. */ private cfInstalled: boolean; + /** + * Indicates if the CF feature is enabled. + */ + private isCfFeatureEnabled: boolean; /** * Creates an instance of the generator. @@ -191,9 +192,9 @@ export default class extends Generator { this.options = opts; this.isMtaYamlFound = isMtaProject(process.cwd()) as boolean; - this.isExtensionInstalled = isInternalFeaturesSettingEnabled() - ? isExtensionInstalled(opts.vscode, 'SAP.adp-ve-bas-ext') - : false; + + this.isCfFeatureEnabled = isFeatureEnabled('sap.ux.appGenerator.testBetaFeatures.adpCfExperimental'); + this.logger.debug(`isCfFeatureEnabled: ${this.isCfFeatureEnabled}`); const jsonInputString = getFirstArgAsString(args); this.jsonInput = parseJsonInput(jsonInputString, this.logger); @@ -234,7 +235,7 @@ export default class extends Generator { const isInternalUsage = isInternalFeaturesSettingEnabled(); if (!this.jsonInput) { - const shouldShowTargetEnv = isAppStudio() && this.cfInstalled && this.isExtensionInstalled; + const shouldShowTargetEnv = isAppStudio() && this.cfInstalled && this.isCfFeatureEnabled; this.prompts.splice(0, 0, getWizardPages(shouldShowTargetEnv)); this.prompter = this._getOrCreatePrompter(); this.cfPrompter = new CFServicesPrompter(isInternalUsage, this.isCfLoggedIn, this.logger); @@ -452,7 +453,7 @@ export default class extends Generator { * Sets the target environment and updates related state accordingly. */ private async _determineTargetEnv(): Promise { - const hasRequiredExtensions = this.isExtensionInstalled && this.cfInstalled; + const hasRequiredExtensions = this.isCfFeatureEnabled && this.cfInstalled; if (isAppStudio() && hasRequiredExtensions) { await this._promptForTargetEnvironment(); diff --git a/packages/generator-adp/test/app.test.ts b/packages/generator-adp/test/app.test.ts index 88fbdd8a07e..692c55b5083 100644 --- a/packages/generator-adp/test/app.test.ts +++ b/packages/generator-adp/test/app.test.ts @@ -29,7 +29,7 @@ import { } from '@sap-ux/adp-tooling'; import { type AbapServiceProvider, AdaptationProjectType } from '@sap-ux/axios-extension'; import { isAppStudio } from '@sap-ux/btp-utils'; -import { isInternalFeaturesSettingEnabled } from '@sap-ux/feature-toggle'; +import { isInternalFeaturesSettingEnabled, isFeatureEnabled } from '@sap-ux/feature-toggle'; import { isCli, isExtensionInstalled, sendTelemetry } from '@sap-ux/fiori-generator-shared'; import type { ToolsLogger } from '@sap-ux/logger'; import * as Logger from '@sap-ux/logger'; @@ -53,7 +53,8 @@ import { jest.mock('@sap-ux/feature-toggle', () => ({ ...jest.requireActual('@sap-ux/feature-toggle'), - isInternalFeaturesSettingEnabled: jest.fn() + isInternalFeaturesSettingEnabled: jest.fn(), + isFeatureEnabled: jest.fn() })); jest.mock('../src/app/questions/helper/default-values.ts', () => ({ @@ -278,6 +279,7 @@ const isLoggedInCfMock = isLoggedInCf as jest.MockedFunction; +const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction; describe('Adaptation Project Generator Integration Test', () => { jest.setTimeout(60000); @@ -290,7 +292,8 @@ describe('Adaptation Project Generator Integration Test', () => { beforeEach(() => { fs.mkdirSync(testOutputDir, { recursive: true }); mockIsInternalFeaturesSettingEnabled.mockReturnValue(false); - isExtensionInstalledMock.mockReturnValueOnce(false).mockReturnValueOnce(true); + mockIsFeatureEnabled.mockReturnValue(false); + isExtensionInstalledMock.mockReturnValueOnce(true); loadAppsMock.mockResolvedValue(apps); jest.spyOn(ConfigPrompter.prototype, 'provider', 'get').mockReturnValue(dummyProvider); jest.spyOn(ConfigPrompter.prototype, 'ui5', 'get').mockReturnValue({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3067dcf03ee..e60354ee3c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2286,7 +2286,7 @@ importers: version: 0.22.0(apache-arrow@18.1.0) '@sap-ux/fiori-docs-embeddings': specifier: '*' - version: link:../fiori-docs-embeddings + version: 0.4.0 '@xenova/transformers': specifier: 2.17.2 version: 2.17.2 @@ -9201,6 +9201,10 @@ packages: - typescript dev: true + /@sap-ux/fiori-docs-embeddings@0.4.0: + resolution: {integrity: sha512-JVW0nqA6qRVAfCHF9zIoEe6FCe2ZvZvzTYWzu7aVztM9SC1EY52vvPdrjOFV/ThHXWqXM/XfY20pwOXJkTrd8A==} + dev: false + /@sap-ux/i18n@0.3.3: resolution: {integrity: sha512-/h5h0oPYKXuEohZ/5KduoY0oTPSHgq/FzXrTrNtkaF+pRoroLB2ctKTyG6AzM7EarVwUCeb/DmYqHXXmJrXBrQ==} engines: {node: '>=20.x'} From 48d116ea817788903c3026f02381ac465b99436d Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 22 Oct 2025 16:40:40 +0300 Subject: [PATCH 109/111] fix: sonar issues --- packages/generator-adp/src/app/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 955e82e2cfc..e34def5d0fd 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -1,5 +1,4 @@ import fs from 'node:fs'; -import { isFeatureEnabled } from '@sap-ux/feature-toggle'; import { join } from 'node:path'; import Generator from 'yeoman-generator'; import { AppWizard, MessageType, Prompts as YeomanUiSteps, type IPrompt } from '@sap-devx/yeoman-ui-types'; @@ -28,11 +27,12 @@ import { isExtensionInstalled, sendTelemetry } from '@sap-ux/fiori-generator-shared'; +import { isAppStudio } from '@sap-ux/btp-utils'; import { ToolsLogger } from '@sap-ux/logger'; import type { Manifest } from '@sap-ux/project-access'; import type { AbapServiceProvider } from '@sap-ux/axios-extension'; import type { YeomanEnvironment } from '@sap-ux/fiori-generator-shared'; -import { isInternalFeaturesSettingEnabled } from '@sap-ux/feature-toggle'; +import { isInternalFeaturesSettingEnabled, isFeatureEnabled } from '@sap-ux/feature-toggle'; import type { CfConfig, CfServicesAnswers, AttributesAnswers, ConfigAnswers, UI5Version } from '@sap-ux/adp-tooling'; import { EventName } from '../telemetryEvents'; @@ -65,7 +65,6 @@ import { type AttributePromptOptions, type JsonInput } from './types'; -import { isAppStudio } from '@sap-ux/btp-utils'; import { getProjectPathPrompt, getTargetEnvPrompt } from './questions/target-env'; const generatorTitle = 'Adaptation Project'; @@ -174,7 +173,7 @@ export default class extends Generator { /** * Indicates if the CF feature is enabled. */ - private isCfFeatureEnabled: boolean; + private readonly isCfFeatureEnabled: boolean; /** * Creates an instance of the generator. From 14b8e18b9e4205b63f3bc544735feaf1f45a95be Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 22 Oct 2025 17:12:58 +0300 Subject: [PATCH 110/111] test: adjust test --- packages/adp-tooling/test/unit/writer/project-utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adp-tooling/test/unit/writer/project-utils.test.ts b/packages/adp-tooling/test/unit/writer/project-utils.test.ts index 6c9f8e9fc34..214f9be8349 100644 --- a/packages/adp-tooling/test/unit/writer/project-utils.test.ts +++ b/packages/adp-tooling/test/unit/writer/project-utils.test.ts @@ -52,7 +52,7 @@ const cfData = { title: 'Original App Title' }, cf: { - url: 'https://cf.example.com', + url: '/cf.example.com', org: { Name: 'test-org', GUID: 'org-guid' }, space: { Name: 'test-space', GUID: 'space-guid' }, html5RepoRuntimeGuid: 'runtime-guid', From 5c9759b1d088ce6fe46a2c229a957b77eb6fb442 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 28 Oct 2025 08:39:47 +0200 Subject: [PATCH 111/111] refactor: replace url with constant --- packages/adp-tooling/src/writer/options.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/adp-tooling/src/writer/options.ts b/packages/adp-tooling/src/writer/options.ts index b677262a47a..b7c6738726b 100644 --- a/packages/adp-tooling/src/writer/options.ts +++ b/packages/adp-tooling/src/writer/options.ts @@ -18,6 +18,7 @@ import type { CloudCustomTaskConfigTarget, CfAdpWriterConfig } from '../types'; +import { UI5_CDN_URL } from '../base/constants'; const VSCODE_URL = 'https://REQUIRED_FOR_VSCODE.example'; @@ -371,7 +372,7 @@ export function enhanceUI5YamlWithCfCustomTask(ui5Config: UI5Config, config: CfA */ export function enhanceUI5YamlWithCfCustomMiddleware(ui5Config: UI5Config): void { const ui5ConfigOptions: Partial = { - url: 'https://ui5.sap.com' + url: UI5_CDN_URL }; ui5Config.addFioriToolsProxyMiddleware(