diff --git a/.changeset/tricky-pets-worry.md b/.changeset/tricky-pets-worry.md new file mode 100644 index 0000000000..03e059b8b5 --- /dev/null +++ b/.changeset/tricky-pets-worry.md @@ -0,0 +1,7 @@ +--- +"@sap-ux/adp-flp-config-sub-generator": patch +"@sap-ux/adp-tooling": patch +"@sap-ux/i18n": patch +--- + +Introduce ADP FLP config generator diff --git a/packages/adp-flp-config-sub-generator/.eslintignore b/packages/adp-flp-config-sub-generator/.eslintignore new file mode 100644 index 0000000000..09379ed8fa --- /dev/null +++ b/packages/adp-flp-config-sub-generator/.eslintignore @@ -0,0 +1,3 @@ +/test/test-output/ +/test/unit/expected-output/ +generators diff --git a/packages/adp-flp-config-sub-generator/.eslintrc.js b/packages/adp-flp-config-sub-generator/.eslintrc.js new file mode 100644 index 0000000000..b717f83ae9 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['../../.eslintrc'], + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname + } +}; diff --git a/packages/adp-flp-config-sub-generator/CHANGELOG.md b/packages/adp-flp-config-sub-generator/CHANGELOG.md new file mode 100644 index 0000000000..ba973e6468 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/CHANGELOG.md @@ -0,0 +1 @@ +# @sap-ux/adp-flp-config-sub-generator diff --git a/packages/adp-flp-config-sub-generator/LICENSE b/packages/adp-flp-config-sub-generator/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/packages/adp-flp-config-sub-generator/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/adp-flp-config-sub-generator/README.md b/packages/adp-flp-config-sub-generator/README.md new file mode 100644 index 0000000000..8597246d07 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/README.md @@ -0,0 +1,20 @@ +# @sap-ux/adp-flp-config-sub-generator + +## Features + +The SAP Adaptation Project FLP sub-generator enables users to create a FLP configuration for an Adaptation Project. + +## Installation + +The SAP Adaptation Project FLP Configuration sub-generator is included in the [@sap/generator-fiori](https://www.npmjs.com/package/@sap/generator-fiori) generator and cannot be used independently. Additionally, it requires the [SAP Fiori Tools Extension](https://marketplace.visualstudio.com/items?itemName=SAPSE.sap-ux-fiori-tools-extension-pack) pack from the VSCode marketplace. + + +## Launch the SAP Reference Library sub-generator + +Open the Command Palette in MS Visual Studio Code ( CMD/CTRL + Shift + P ) and execute the Fiori: Adaptation Project FLP configuration command. + + +## Keywords +SAP Fiori Elements +Yeoman +Generator diff --git a/packages/adp-flp-config-sub-generator/jest.config.js b/packages/adp-flp-config-sub-generator/jest.config.js new file mode 100644 index 0000000000..2f0a4db758 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/jest.config.js @@ -0,0 +1,6 @@ +const config = require('../../jest.base'); +config.snapshotFormat = { + escapeString: false, + printBasicPrototype: false +}; +module.exports = config; diff --git a/packages/adp-flp-config-sub-generator/package.json b/packages/adp-flp-config-sub-generator/package.json new file mode 100644 index 0000000000..85272a124a --- /dev/null +++ b/packages/adp-flp-config-sub-generator/package.json @@ -0,0 +1,65 @@ +{ + "name": "@sap-ux/adp-flp-config-sub-generator", + "description": "Generator for adding FLP configuration to an Adaptation Project", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/SAP/open-ux-tools.git", + "directory": "packages/adp-flp-config-sub-generator" + }, + "bugs": { + "url": "https://github.com/SAP/open-ux-tools/issues?q=is%3Aopen+is%3Aissue" + }, + "license": "Apache-2.0", + "main": "generators/app/index.js", + "scripts": { + "build": "tsc --build", + "clean": "rimraf --glob generators test/test-output coverage *.tsbuildinfo", + "watch": "tsc --watch", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "test": "jest --ci --forceExit --detectOpenHandles --colors --passWithNoTests", + "test-u": "jest --ci --forceExit --detectOpenHandles --colors -u", + "link": "pnpm link --global", + "unlink": "pnpm unlink --global" + }, + "files": [ + "LICENSE", + "generators", + "!generators/*.map", + "!generators/**/*.map" + ], + "dependencies": { + "@sap-devx/yeoman-ui-types": "1.14.4", + "@sap-ux/adp-tooling": "workspace:*", + "@sap-ux/axios-extension": "workspace:*", + "@sap-ux/btp-utils": "workspace:*", + "@sap-ux/feature-toggle": "workspace:*", + "@sap-ux/fiori-generator-shared": "workspace:*", + "@sap-ux/flp-config-inquirer": "workspace:*", + "@sap-ux/inquirer-common": "workspace:*", + "@sap-ux/logger": "workspace:*", + "@sap-ux/project-access": "workspace:*", + "@sap-ux/store": "workspace:*", + "@sap-ux/system-access": "workspace:*", + "i18next": "23.5.1", + "yeoman-generator": "5.10.0" + }, + "devDependencies": { + "@jest/types": "29.6.3", + "@types/fs-extra": "9.0.13", + "@types/inquirer": "8.2.6", + "@types/vscode": "1.73.1", + "@types/yeoman-environment": "2.10.11", + "@types/yeoman-generator": "5.2.11", + "@types/yeoman-test": "4.0.6", + "@vscode-logging/logger": "2.0.0", + "fs-extra": "10.0.0", + "rimraf": "5.0.5", + "typescript": "5.3.3", + "yeoman-test": "6.3.0" + }, + "engines": { + "node": ">=18.x" + } +} diff --git a/packages/adp-flp-config-sub-generator/src/app/index.ts b/packages/adp-flp-config-sub-generator/src/app/index.ts new file mode 100644 index 0000000000..e6e155a73e --- /dev/null +++ b/packages/adp-flp-config-sub-generator/src/app/index.ts @@ -0,0 +1,392 @@ +import type { Manifest } from '@sap-ux/project-access'; +import type { FlpConfigOptions } from './types'; +import type { Question } from 'inquirer'; +import Generator from 'yeoman-generator'; +import path, { join } from 'path'; +import { + type AxiosError, + type AxiosRequestConfig, + type ProviderConfiguration, + isAxiosError +} from '@sap-ux/axios-extension'; +import { + ManifestService, + getVariant, + getAdpConfig, + getInboundsFromManifest, + getRegistrationIdFromManifest, + isCFEnvironment, + generateInboundConfig, + type InternalInboundNavigation, + type AdpPreviewConfig +} from '@sap-ux/adp-tooling'; +import { ToolsLogger } from '@sap-ux/logger'; +import { EventName } from '../telemetryEvents'; +import { getPrompts, type FLPConfigAnswers } from '@sap-ux/flp-config-inquirer'; +import { AppWizard, Prompts, MessageType } from '@sap-devx/yeoman-ui-types'; +import { + TelemetryHelper, + sendTelemetry, + isCli, + type ILogWrapper, + type YeomanEnvironment +} from '@sap-ux/fiori-generator-shared'; +import { isInternalFeaturesSettingEnabled } from '@sap-ux/feature-toggle'; +import { FileName, getAppType } from '@sap-ux/project-access'; +import AdpFlpConfigLogger from '../utils/logger'; +import { t, initI18n } from '../utils/i18n'; +import { + ErrorHandler, + type CredentialsAnswers, + getCredentialsPrompts, + type ValidationLink +} from '@sap-ux/inquirer-common'; +import { + createAbapServiceProvider, + type AbapTarget, + type UrlAbapTarget, + getCredentialsFromStore +} from '@sap-ux/system-access'; +import { isAppStudio, listDestinations } from '@sap-ux/btp-utils'; + +/** + * Generator for adding a FLP configuration to an adaptation project. + * + * @extends Generator + */ +export default class extends Generator { + setPromptsCallback: (fn: object) => void; + private prompts: Prompts; + // Flag to determine if the generator was launched as a sub-generator or standalone + private readonly launchAsSubGen: boolean; + private readonly appWizard: AppWizard; + private readonly vscode: any; + private readonly toolsLogger: ToolsLogger; + private readonly projectRootPath: string = ''; + private manifest: Manifest; + private answers: FLPConfigAnswers; + private logger: ILogWrapper; + private authenticationRequired: boolean = false; + // Flag to determine if the generator was aborted + private abort: boolean = false; + private configuredSystem: string | undefined; + private ui5Yaml: AdpPreviewConfig; + private credentials: CredentialsAnswers; + + /** + * Creates an instance of the generator. + * + * @param {string | string[]} args - The arguments passed to the generator. + * @param {FlpConfigOptions} opts - The options for the generator. + */ + constructor(args: string | string[], opts: FlpConfigOptions) { + super(args, opts); + this.appWizard = opts.appWizard ?? AppWizard.create(opts); + this.launchAsSubGen = !!opts.launchAsSubGen; + this.manifest = opts.manifest; + this.toolsLogger = new ToolsLogger(); + this.projectRootPath = opts.data?.projectRootPath ?? this.destinationRoot(); + this.options = opts; + this.vscode = opts.vscode; + + this._setupPrompts(); + this._setupLogging(); + } + + async initializing(): Promise { + await initI18n(); + + // Check if the project is supported + if ((await getAppType(this.projectRootPath)) !== 'Fiori Adaptation' || isCFEnvironment(this.projectRootPath)) { + throw new Error(t('error.projectNotSupported')); + } + + // 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; + } + + this._setupFLPConfigPage(); + + this.ui5Yaml = await getAdpConfig(this.projectRootPath, join(this.projectRootPath, FileName.Ui5Yaml)); + this.configuredSystem = await this._findConfiguredSystem(this.ui5Yaml.target); + if (!this.configuredSystem) { + return; + } + + if (!this.manifest) { + try { + this.manifest = await this._getManifest(); + } catch (error) { + this.authenticationRequired = this._checkAuthRequired(error); + if (this.authenticationRequired) { + return; + } + this._handleFetchingError(error); + } + } + // Add telemetry to be sent once adp-flp-config is generated + await TelemetryHelper.initTelemetrySettings({ + consumerModule: { + name: '@sap/generator-fiori-deployment:adp-flp-config', + version: this.rootGeneratorVersion() + }, + internalFeature: isInternalFeaturesSettingEnabled(), + watchTelemetrySettingStore: false + }); + } + + public async prompting(): Promise { + if (this.authenticationRequired) { + await this._promptAuthentication(); + } + + if (this.abort) { + return; + } + const inbounds = getInboundsFromManifest(this.manifest); + const appId = getRegistrationIdFromManifest(this.manifest); + const prompts: Question[] = await getPrompts(inbounds, appId, { + overwrite: { hide: true }, + createAnotherInbound: { hide: true }, + emptyInboundsInfo: { hide: isCli() } + }); + this.answers = await this.prompt(prompts); + } + + async writing(): Promise { + if (this.abort) { + return; + } + try { + await generateInboundConfig(this.projectRootPath, this.answers as InternalInboundNavigation, this.fs); + } catch (error) { + this.logger.error(`Writing phase failed: ${error}`); + throw new Error(t('error.updatingApp')); + } + } + + end(): void { + if (this.abort) { + return; + } + if (!this.launchAsSubGen) { + this.appWizard?.showInformation(t('info.flpConfigAdded'), MessageType.notification); + } + const telemetryData = + TelemetryHelper.createTelemetryData({ + appType: 'adp-flp-config', + ...this.options.telemetryData + }) ?? {}; + if (telemetryData) { + sendTelemetry(EventName.ADP_FLP_CONFIG_ADDED, telemetryData, this.projectRootPath).catch((error) => { + this.logger.error(t('error.telemetry', { error })); + }); + } + } + + /** + * Retrieves the merged manifest for the project. + * + * @returns {Promise} The project manifest. + */ + private async _getManifest(): Promise { + const { target, ignoreCertErrors = false } = this.ui5Yaml; + const requestOptions: AxiosRequestConfig & Partial = { ignoreCertErrors }; + if (this.credentials) { + requestOptions['auth'] = { username: this.credentials.username, password: this.credentials.password }; + } + const provider = await createAbapServiceProvider(target, requestOptions, false, this.toolsLogger); + const variant = getVariant(this.projectRootPath); + const manifestService = await ManifestService.initMergedManifest( + provider, + this.projectRootPath, + variant, + this.toolsLogger + ); + return manifestService.getManifest(); + } + + /** + * Prompts the user for authentication credentials. + * + * @returns {void} + */ + private async _promptAuthentication(): Promise { + const prompts = await getCredentialsPrompts( + async (credentials: CredentialsAnswers): Promise => { + this.credentials = credentials; + try { + this.manifest = await this._getManifest(); + } catch (error) { + if (!isAxiosError(error)) { + this.logger.error(`Manifest fetching failed: ${error}`); + throw new Error(t('error.fetchingManifest')); + } + this.authenticationRequired = this._checkAuthRequired(error); + if (this.authenticationRequired) { + return t('error.authenticationFailed'); + } + return this._getErrorHandlerMessage(error) ?? false; + } + return true; + } + ); + this.prompts.splice(0, 0, [ + { + name: t('yuiNavSteps.flpCredentialsName'), + description: t('yuiNavSteps.flpCredentialsDesc', { system: this.configuredSystem }) + } + ]); + await this.prompt(prompts); + } + + /** + * Handles errors that occur during the fetching of the manifest. + * + * @param {Error | AxiosError} error - The error that occurred. + */ + private _handleFetchingError(error: AxiosError): void { + if (isAxiosError(error)) { + this.logger.error( + `Manifest fetching failed: ${error}. Status: ${error.response?.status}. URI: ${error.request?.path}` + ); + + const errorHelp = this._getErrorHandlerMessage(error); + if (errorHelp) { + this._abortExecution( + typeof errorHelp === 'string' + ? errorHelp + : `${errorHelp?.message} ([${errorHelp.link.text}](${errorHelp.link.url}))` + ); + } + return; + } + this.logger.error(`Manifest fetching failed: ${error}`); + throw new Error(t('error.fetchingManifest')); + } + + /** + * Adds navigations steps and callback function for the generator prompts. + */ + private _setupFLPConfigPage(): void { + // if launched as a sub-generator, the navigation steps will be set by the parent generator + if (!this.launchAsSubGen) { + this.prompts.splice(0, 0, [ + { + name: t('yuiNavSteps.flpConfigName'), + description: t('yuiNavSteps.flpConfigDesc', { projectName: path.basename(this.projectRootPath) }) + } + ]); + } + } + + /** + * Sets up the prompts for the generator. + */ + private _setupPrompts(): void { + // If launched as a sub-generator, the prompts will be set by the parent generator + if (!this.launchAsSubGen) { + this.prompts = new Prompts([]); + this.setPromptsCallback = (fn): void => { + if (this.prompts) { + this.prompts.setCallback(fn); + } + }; + } + } + + /** + * Finds the configured system based on the provided target in ui5.yaml configuration. + * + * @param {AbapTarget} target - The target ABAP system. + * @returns {Promise} The configured system. + */ + private async _findConfiguredSystem(target: AbapTarget): Promise { + let configuredSystem: string | undefined; + if (isAppStudio()) { + configuredSystem = target?.destination; + if (!configuredSystem) { + this._abortExecution(t('error.destinationNotFound')); + return; + } + + const destinations = await listDestinations(); + if (!(configuredSystem in destinations)) { + this._abortExecution(t('error.destinationNotFoundInStore', { destination: configuredSystem })); + return; + } + } else { + const url = target?.url; + if (!url) { + this._abortExecution(t('error.systemNotFound')); + return; + } + + configuredSystem = (await getCredentialsFromStore(target as UrlAbapTarget, this.toolsLogger))?.name; + if (!configuredSystem) { + this._abortExecution(t('error.systemNotFoundInStore', { systemUrl: url })); + return; + } + } + + return configuredSystem; + } + + /** + * Shows an error notification with the provided message and aborts the generator execution. + * + * @param {string} message - The error message to display. + */ + private _abortExecution(message: string): void { + if (isCli()) { + this.toolsLogger.error(message); + } else { + this.vscode.window.showErrorMessage(message); + } + this.abort = true; + } + + /** + * Retrieves the error handler message for the provided error. + * + * @param {Error | AxiosError} error - The error to handle. + * @returns {ValidationLink | string | undefined} The validation link or error message. + */ + private _getErrorHandlerMessage(error: Error | AxiosError): ValidationLink | string | undefined { + const errorHandler = new ErrorHandler(); + return errorHandler.getValidationErrorHelp(error); + } + + /** + * Checks if authentication is required based on the provided error. + * + * @param {Error | AxiosError} error - The error to check. + * @returns {boolean} True if authentication is required, false otherwise. + */ + private _checkAuthRequired(error: Error | AxiosError): boolean { + if (isAxiosError(error)) { + if (error.response?.status === 401) { + return true; + } + } + return false; + } + + /** + * Configures logging for the generator. + */ + private _setupLogging(): void { + AdpFlpConfigLogger.configureLogging( + this.options.logger, + this.rootGeneratorName(), + this.log, + this.options.vscode, + this.options.logLevel, + this.options.logWrapper + ); + this.logger = AdpFlpConfigLogger.logger; + } +} + +export type { FlpConfigOptions }; diff --git a/packages/adp-flp-config-sub-generator/src/app/types.ts b/packages/adp-flp-config-sub-generator/src/app/types.ts new file mode 100644 index 0000000000..5933afa8cd --- /dev/null +++ b/packages/adp-flp-config-sub-generator/src/app/types.ts @@ -0,0 +1,37 @@ +import type { AppWizard } from '@sap-devx/yeoman-ui-types'; +import type { Manifest } from '@sap-ux/project-access'; +import type Generator from 'yeoman-generator'; +import type { TelemetryData } from '@sap-ux/fiori-generator-shared'; + +export interface FlpConfigOptions extends Generator.GeneratorOptions { + /** + * VSCode instance + */ + vscode?: unknown; + /** + * Option to force the conflicter property of the yeoman environment (prevents additional prompt for overwriting files) + */ + force?: boolean; + /** + * AppWizard instance + */ + appWizard?: AppWizard; + /** + * Whether the generator is launched as a subgenerator + */ + launchAsSubGen?: boolean; + /** + * The manifest of the base application + */ + manifest: Manifest; + /** + * Telemetry data to be send after deployment configuration has been added + */ + telemetryData?: TelemetryData; + /** + * Additional data for the generator + */ + data?: { + projectRootPath: string; + }; +} diff --git a/packages/adp-flp-config-sub-generator/src/telemetryEvents/index.ts b/packages/adp-flp-config-sub-generator/src/telemetryEvents/index.ts new file mode 100644 index 0000000000..bda949558c --- /dev/null +++ b/packages/adp-flp-config-sub-generator/src/telemetryEvents/index.ts @@ -0,0 +1,6 @@ +/** + * Event names for telemetry for the adaptation project fiori launchpad configuration generator + */ +export enum EventName { + ADP_FLP_CONFIG_ADDED = 'ADP_FLP_CONFIG_ADDED' +} diff --git a/packages/adp-flp-config-sub-generator/src/translations/adp-flp-config-sub-generator.i18n.json b/packages/adp-flp-config-sub-generator/src/translations/adp-flp-config-sub-generator.i18n.json new file mode 100644 index 0000000000..9c3f7bce01 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/src/translations/adp-flp-config-sub-generator.i18n.json @@ -0,0 +1,24 @@ +{ + "yuiNavSteps": { + "flpCredentialsName": "FLP Configuration - Credentials", + "flpCredentialsDesc": "Enter credentials for your adaptation project's system ({{- system}})", + "sysConfirmDesc": "Validates connection to configured system", + "flpConfigName": "FLP Configuration", + "flpConfigDesc": "FLP Configuration for {{- projectName}}" + }, + "info": { + "flpConfigAdded": "FLP Configuration added successfully" + }, + "error": { + "projectNotSupported": "Unable to find a supported adaptation project", + "fetchingManifest": "Error fetching merged manifest for base application", + "destinationNotFound": "Missing destination configuration in ui5.yaml", + "destinationNotInSubaccount": "Destination not found in the subaccount: {{- destination}}", + "systemNotFound": "Missing system configuration in ui5.yaml", + "systemNotFoundInStore" : "System not found in the system store: {{- systemUrl}}", + "writingPhase": "Error in writing phase of the adaptation project FLP configuration", + "telemetry": "Error sending telemetry data: {{- error}}", + "updatingApp": "Error updating app with FLP configuration. Inspect the logs for full error.", + "authenticationFailed": "Authentication failed." + } +} \ No newline at end of file diff --git a/packages/adp-flp-config-sub-generator/src/utils/i18n.ts b/packages/adp-flp-config-sub-generator/src/utils/i18n.ts new file mode 100644 index 0000000000..d6881cfaf4 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/src/utils/i18n.ts @@ -0,0 +1,35 @@ +import type { TOptions } from 'i18next'; +import i18next from 'i18next'; +import translations from '../translations/adp-flp-config-sub-generator.i18n.json'; +import { addi18nResourceBundle as addInquirerCommonResourceBundle } from '@sap-ux/inquirer-common'; + +const adpFlpConfigI18nNamespace = 'adp-flp-config-sub-generator'; + +/** + * Initialize i18next with the translations for this module. + */ +export async function initI18n(): Promise { + await i18next.init({ lng: 'en', fallbackLng: 'en' }, () => + i18next.addResourceBundle('en', adpFlpConfigI18nNamespace, translations) + ); + + addInquirerCommonResourceBundle(); +} + +/** + * Helper function facading the call to i18next. Unless a namespace option is provided the local namespace will be used. + * + * @param key i18n key + * @param options additional options + * @returns {string} localized string stored for the given key + */ +export function t(key: string, options?: TOptions): string { + if (!options?.ns) { + options = Object.assign(options ?? {}, { ns: adpFlpConfigI18nNamespace }); + } + return i18next.t(key, options); +} + +initI18n().catch(() => { + // Needed for lint +}); diff --git a/packages/adp-flp-config-sub-generator/src/utils/logger.ts b/packages/adp-flp-config-sub-generator/src/utils/logger.ts new file mode 100644 index 0000000000..d2f075541b --- /dev/null +++ b/packages/adp-flp-config-sub-generator/src/utils/logger.ts @@ -0,0 +1,50 @@ +import { DefaultLogger, LogWrapper, type ILogWrapper } from '@sap-ux/fiori-generator-shared'; +import type { Logger } from 'yeoman-environment'; +import type { IVSCodeExtLogger, LogLevel } from '@vscode-logging/logger'; + +/** + * Static logger prevents passing of logger references through all functions, as this is a cross-cutting concern. + */ +export default class AdpFlpConfigLogger { + private static _logger: ILogWrapper = DefaultLogger; + + /** + * Get the logger. + * + * @returns the logger + */ + public static get logger(): ILogWrapper { + return AdpFlpConfigLogger._logger; + } + + /** + * Set the logger. + * + * @param value the logger to set + */ + public static set logger(value: ILogWrapper) { + AdpFlpConfigLogger._logger = value; + } + + /** + * Configures the vscode logger. + * + * @param vscLogger - the vscode logger + * @param loggerName - the logger name + * @param yoLogger - the yeoman logger + * @param vscode - the vscode instance + * @param logLevel - the log level + * @param logWrapper - log wrapper instance + */ + static configureLogging( + vscLogger: IVSCodeExtLogger, + loggerName: string, + yoLogger: Logger, + vscode?: unknown, + logLevel?: LogLevel, + logWrapper?: LogWrapper + ): void { + const logger = logWrapper ?? new LogWrapper(loggerName, yoLogger, logLevel, vscLogger, vscode); + AdpFlpConfigLogger.logger = logger; + } +} diff --git a/packages/adp-flp-config-sub-generator/test/__snapshots__/app.test.ts.snap b/packages/adp-flp-config-sub-generator/test/__snapshots__/app.test.ts.snap new file mode 100644 index 0000000000..873e610e92 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/test/__snapshots__/app.test.ts.snap @@ -0,0 +1,351 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FLPConfigGenerator Integration Tests should generate FLP configuration successfully - Application Studio 1`] = ` +"{ + "fileName": "manifest", + "layer": "CUSTOMER_BASE", + "fileType": "appdescr_variant", + "reference": "mockReference", + "id": "customer.app.variant1", + "namespace": "apps/mockReference/appVariants/customer.app.variant1/", + "version": "0.1.0", + "content": [ + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "i18n" + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "@i18n" + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "mockModel" + }, + "texts": { + "i18n": "mockI18n" + } + }, + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "mockModel" + }, + "texts": { + "i18n": "mockI18n" + } + }, + { + "changeType": "appdescr_ui5_setMinUI5Version", + "content": { + "minUI5Version": "1.120.25" + } + }, + { + "changeType": "appdescr_app_setTitle", + "content": {}, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_app_addNewInbound", + "content": { + "inbound": { + "customer.app.variant1.InboundID": { + "action": "testAction", + "semanticObject": "testSemanticObject", + "title": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.title}}", + "signature": { + "additionalParameters": "allowed", + "parameters": { + "param1": { + "required": true, + "defaultValue": { + "value": "test1", + "format": "plain" + } + }, + "param2": { + "required": true, + "defaultValue": { + "value": "test2", + "format": "plain" + } + }, + "sap-appvar-id": { + "required": true, + "filter": { + "value": "customer.app.variant1", + "format": "plain" + }, + "launcherValue": { + "value": "customer.app.variant1" + } + } + } + }, + "subTitle": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.subTitle}}" + } + } + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_app_removeAllInboundsExceptOne", + "content": { + "inboundId": "customer.app.variant1.InboundID" + }, + "texts": {} + }, + { + "changeType": "appdescr_app_addNewInbound", + "content": { + "inbound": { + "customer.app.variant1.InboundID": { + "action": "testAction", + "semanticObject": "testSemanticObject", + "title": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.title}}", + "signature": { + "additionalParameters": "allowed", + "parameters": { + "param1": { + "required": true, + "defaultValue": { + "value": "test1", + "format": "plain" + } + }, + "param2": { + "required": true, + "defaultValue": { + "value": "test2", + "format": "plain" + } + }, + "sap-appvar-id": { + "required": true, + "filter": { + "value": "customer.app.variant1", + "format": "plain" + }, + "launcherValue": { + "value": "customer.app.variant1" + } + } + } + }, + "subTitle": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.subTitle}}" + } + } + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_app_removeAllInboundsExceptOne", + "content": { + "inboundId": "customer.app.variant1.InboundID" + }, + "texts": {} + } + ] +} +" +`; + +exports[`FLPConfigGenerator Integration Tests should generate FLP configuration successfully - Application Studio 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.variant1_sap.app.title=App Variant Title" +`; + +exports[`FLPConfigGenerator Integration Tests should generate FLP configuration successfully - VS Code 1`] = ` +"{ + "fileName": "manifest", + "layer": "CUSTOMER_BASE", + "fileType": "appdescr_variant", + "reference": "mockReference", + "id": "customer.app.variant1", + "namespace": "apps/mockReference/appVariants/customer.app.variant1/", + "version": "0.1.0", + "content": [ + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "i18n" + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "@i18n" + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "mockModel" + }, + "texts": { + "i18n": "mockI18n" + } + }, + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "mockModel" + }, + "texts": { + "i18n": "mockI18n" + } + }, + { + "changeType": "appdescr_ui5_setMinUI5Version", + "content": { + "minUI5Version": "1.120.25" + } + }, + { + "changeType": "appdescr_app_setTitle", + "content": {}, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_app_addNewInbound", + "content": { + "inbound": { + "customer.app.variant1.InboundID": { + "action": "testAction", + "semanticObject": "testSemanticObject", + "title": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.title}}", + "signature": { + "additionalParameters": "allowed", + "parameters": { + "param1": { + "required": true, + "defaultValue": { + "value": "test1", + "format": "plain" + } + }, + "param2": { + "required": true, + "defaultValue": { + "value": "test2", + "format": "plain" + } + }, + "sap-appvar-id": { + "required": true, + "filter": { + "value": "customer.app.variant1", + "format": "plain" + }, + "launcherValue": { + "value": "customer.app.variant1" + } + } + } + }, + "subTitle": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.subTitle}}" + } + } + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_app_removeAllInboundsExceptOne", + "content": { + "inboundId": "customer.app.variant1.InboundID" + }, + "texts": {} + }, + { + "changeType": "appdescr_app_addNewInbound", + "content": { + "inbound": { + "customer.app.variant1.InboundID": { + "action": "testAction", + "semanticObject": "testSemanticObject", + "title": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.title}}", + "signature": { + "additionalParameters": "allowed", + "parameters": { + "param1": { + "required": true, + "defaultValue": { + "value": "test1", + "format": "plain" + } + }, + "param2": { + "required": true, + "defaultValue": { + "value": "test2", + "format": "plain" + } + }, + "sap-appvar-id": { + "required": true, + "filter": { + "value": "customer.app.variant1", + "format": "plain" + }, + "launcherValue": { + "value": "customer.app.variant1" + } + } + } + }, + "subTitle": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.subTitle}}" + } + } + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_app_removeAllInboundsExceptOne", + "content": { + "inboundId": "customer.app.variant1.InboundID" + }, + "texts": {} + } + ] +} +" +`; + +exports[`FLPConfigGenerator Integration Tests should generate FLP configuration successfully - VS Code 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.variant1_sap.app.title=App Variant Title" +`; diff --git a/packages/adp-flp-config-sub-generator/test/app.test.ts b/packages/adp-flp-config-sub-generator/test/app.test.ts new file mode 100644 index 0000000000..8fd466a4d2 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/test/app.test.ts @@ -0,0 +1,775 @@ +import type { BackendSystem } from '@sap-ux/store'; +import type { YUIQuestion, CredentialsAnswers } from '@sap-ux/inquirer-common'; +import type { FLPConfigAnswers } from '@sap-ux/flp-config-inquirer'; +import type { ToolsLogger } from '@sap-ux/logger'; +import { join } from 'path'; +import yeomanTest from 'yeoman-test'; +import fs from 'fs'; +import fsextra from 'fs-extra'; +import adpFlpConfigGenerator from '../src/app'; +import * as adpTooling from '@sap-ux/adp-tooling'; +import * as btpUtils from '@sap-ux/btp-utils'; +import * as Logger from '@sap-ux/logger'; +import * as fioriGenShared from '@sap-ux/fiori-generator-shared'; +import { rimraf } from 'rimraf'; +import { EventName } from '../src/telemetryEvents'; +import * as sysAccess from '@sap-ux/system-access'; +import { t, initI18n } from '../src/utils/i18n'; +import { MessageType } from '@sap-devx/yeoman-ui-types'; +import * as inquirerCommon from '@sap-ux/inquirer-common'; +import * as projectAccess from '@sap-ux/project-access'; + +const originalCwd = process.cwd(); + +jest.mock('@sap-ux/system-access'); +jest.mock('@sap-ux/btp-utils'); +jest.mock('@sap-ux/adp-tooling', () => ({ + ...jest.requireActual('@sap-ux/adp-tooling'), + isCFEnvironment: jest.fn(), + getAdpConfig: jest.fn(), + generateInboundConfig: jest.fn() +})); +jest.mock('@sap-ux/inquirer-common', () => ({ + ...jest.requireActual('@sap-ux/inquirer-common'), + getCredentialsPrompts: jest.fn(), + ErrorHandler: jest.fn().mockImplementation( + () => + ({ + getValidationErrorHelp: () => 'Network Error' + } as unknown as inquirerCommon.ErrorHandler) + ) +})); +jest.mock('@sap-ux/fiori-generator-shared', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + ...(jest.requireActual('@sap-ux/fiori-generator-shared') as {}), + sendTelemetry: jest.fn().mockReturnValue(new Promise(() => {})), + TelemetryHelper: { + initTelemetrySettings: jest.fn(), + createTelemetryData: jest.fn().mockReturnValue({ + OperatingSystem: 'testOS', + Platform: 'testPlatform' + }) + }, + isExtensionInstalled: jest.fn().mockReturnValue(true), + getHostEnvironment: jest.fn(), + isCli: jest.fn().mockReturnValue(false) +})); + +const toolsLoggerErrorSpy = jest.fn(); +const loggerMock: ToolsLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: toolsLoggerErrorSpy +} as Partial as ToolsLogger; +jest.spyOn(Logger, 'ToolsLogger').mockImplementation(() => loggerMock); + +describe('FLPConfigGenerator Integration Tests', () => { + jest.setTimeout(60000); + + jest.spyOn(adpTooling, 'isCFEnvironment').mockReturnValue(false); + const generatorPath = join(__dirname, '../../src/app/index.ts'); + const testOutputDir = join(__dirname, 'test-output'); + const credentialsPrompts = { + prompts: [ + { + username: 'systemUsername' + }, + { + password: 'systemPassword' + } + ] + }; + const destinationList = { + testDestination: { + Name: 'https://testUrl', + Host: '000', + Type: 'Type', + Authentication: 'Basic', + ProxyType: 'Internet', + Description: 'Description' + } + }; + let answers: + | FLPConfigAnswers + | { + username: string; + password: string; + }; + + jest.spyOn(adpTooling.ManifestService, 'initMergedManifest').mockResolvedValue({ + getManifest: jest.fn().mockReturnValue({}) + } as unknown as adpTooling.ManifestService); + const showInformationSpy = jest.fn(); + const mockAppWizard = { + setHeaderTitle: jest.fn(), + showInformation: showInformationSpy + }; + jest.spyOn(inquirerCommon, 'getCredentialsPrompts').mockResolvedValue( + credentialsPrompts as unknown as YUIQuestion[] + ); + const vsCodeMessageSpy = jest.fn(); + const vscode = { + window: { + showErrorMessage: vsCodeMessageSpy + } + }; + jest.spyOn(projectAccess, 'getAppType').mockResolvedValue('Fiori Adaptation'); + + beforeEach(async () => { + answers = { + username: 'testUsername', + password: 'testPassword', + semanticObject: 'testSemanticObject', + emptyInboundsInfo: 'testEmptyInboundsInfo', + action: 'testAction', + title: 'testTitle', + subTitle: 'testSubTitle', + additionalParameters: 'param1=test1¶m2=test2' + }; + }); + + afterEach(() => { + showInformationSpy.mockReset(); + }); + + beforeAll(async () => { + await initI18n(); + fs.mkdirSync(testOutputDir, { recursive: true }); + }); + + afterAll(() => { + process.chdir(originalCwd); // Generation changes the cwd, this breaks sonar report so we restore later + rimraf.sync(testOutputDir); + }); + + it('should generate FLP configuration successfully - Application Studio', async () => { + const testPath = join(testOutputDir, 'test_project1'); + fs.mkdirSync(testPath, { recursive: true }); + fsextra.copySync(join(__dirname, 'fixtures/app.variant1'), join(testPath, 'app.variant1')); + const testProjectPath = join(testPath, 'app.variant1'); + + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + jest.spyOn(btpUtils, 'listDestinations').mockResolvedValue(destinationList); + const sendTelemetrySpy = jest.spyOn(fioriGenShared, 'sendTelemetry'); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchAsSubGen: false + }) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + const variant = fs.readFileSync(join(testProjectPath, 'webapp', 'manifest.appdescr_variant'), { + encoding: 'utf8' + }); + const i18n = fs.readFileSync(join(testProjectPath, 'webapp', 'i18n', 'i18n.properties'), { + encoding: 'utf8' + }); + expect(variant).toMatchSnapshot(); + expect(i18n).toMatchSnapshot(); + expect(sendTelemetrySpy).toBeCalledWith( + EventName.ADP_FLP_CONFIG_ADDED, + expect.objectContaining({ + OperatingSystem: 'testOS', + Platform: 'testPlatform' + }), + testProjectPath + ); + expect(showInformationSpy).toHaveBeenCalledWith(t('info.flpConfigAdded'), MessageType.notification); + }); + + it('should generate FLP configuration successfully - VS Code', async () => { + const testPath = join(testOutputDir, 'test_project2'); + fs.mkdirSync(testPath, { recursive: true }); + fsextra.copySync(join(__dirname, 'fixtures/app.variant1'), join(testPath, 'app.variant1')); + const testProjectPath = join(testPath, 'app.variant1'); + jest.spyOn(sysAccess, 'getCredentialsFromStore').mockResolvedValue({ + name: 'testSystem' + } as unknown as BackendSystem); + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + url: 'https://testUrl', + client: '000' + } + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(false); + const sendTelemetrySpy = jest.spyOn(fioriGenShared, 'sendTelemetry').mockRejectedValueOnce(new Error('Error')); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchAsSubGen: true + }) + .withPrompts(answers); + + await expect(runContext.run()).resolves.not.toThrow(); + const variant = fs.readFileSync(join(testProjectPath, 'webapp', 'manifest.appdescr_variant'), { + encoding: 'utf8' + }); + const i18n = fs.readFileSync(join(testProjectPath, 'webapp', 'i18n', 'i18n.properties'), { + encoding: 'utf8' + }); + expect(variant).toMatchSnapshot(); + expect(i18n).toMatchSnapshot(); + expect(sendTelemetrySpy).toBeCalledWith( + EventName.ADP_FLP_CONFIG_ADDED, + expect.objectContaining({ + OperatingSystem: 'testOS', + Platform: 'testPlatform' + }), + testProjectPath + ); + expect(showInformationSpy).not.toBeCalled(); + }); + + it('Should throw an error if writing phase fails', async () => { + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + jest.spyOn(adpTooling, 'generateInboundConfig').mockRejectedValueOnce(new Error('Error')); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await expect(runContext.run()).rejects.toThrow(t('error.updatingApp')); + }); + + it('Should result in an error message if the project is a CF project', async () => { + jest.spyOn(adpTooling, 'isCFEnvironment').mockReturnValueOnce(true); + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + await initI18n(); + await expect(runContext.run()).rejects.toThrow(t('error.projectNotSupported')); + }); + + it('Should throw an error when no destination is configured in Application Studio', async () => { + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: {} as unknown as sysAccess.AbapTarget + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + jest.spyOn(btpUtils, 'listDestinations').mockResolvedValue({}); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await runContext.run(); + expect(vsCodeMessageSpy).toBeCalledWith(t('error.destinationNotFound')); + }); + + it('Should throw an error when no url is configured for target in VS Code', async () => { + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: {} as unknown as sysAccess.AbapTarget + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(false); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await runContext.run(); + expect(vsCodeMessageSpy).toBeCalledWith(t('error.systemNotFound')); + }); + + it('Should throw an error when system is not found in the store in VS Code', async () => { + const systemUrl = 'https://testUrl'; + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + url: systemUrl + } + }); + jest.spyOn(sysAccess, 'getCredentialsFromStore').mockResolvedValueOnce(undefined); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(false); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + await runContext.run(); + expect(vsCodeMessageSpy).toBeCalledWith(t('error.systemNotFound', { systemUrl })); + }); + + it('Should throw an error when fetching manifest fails', async () => { + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + url: 'https://testUrl' + } + }); + jest.spyOn(adpTooling.ManifestService, 'initMergedManifest').mockRejectedValueOnce(new Error('Error')); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(false); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await expect(runContext.run()).rejects.toThrow(t('error.fetchingManifest')); + }); + + it('Should require authentication if manifest fetching returns 401 and fail after authentication', async () => { + jest.spyOn(btpUtils, 'listDestinations').mockResolvedValue(destinationList); + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + const initMergedManifestSpy = jest + .spyOn(adpTooling.ManifestService, 'initMergedManifest') + .mockRejectedValueOnce({ + isAxiosError: true, + response: { + status: 401 + } + }) + .mockRejectedValueOnce(new Error('Error')); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + jest.spyOn(inquirerCommon, 'getCredentialsPrompts').mockImplementationOnce( + async ( + callback?: inquirerCommon.AdditionalValidation + ): Promise[]> => { + await callback?.({ username: 'testUsername', password: 'testPassword' }); + return Promise.resolve([ + { + username: 'testUsername' + } as unknown as inquirerCommon.InputQuestion, + { + password: 'testPassword' + } as unknown as inquirerCommon.PasswordQuestion + ]); + } + ); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + initMergedManifestSpy.mockClear(); + await expect(runContext.run()).rejects.toThrow(t('error.fetchingManifest')); + expect(initMergedManifestSpy).toBeCalledTimes(2); + }); + + it('Should require authentication again if credentials are wrong', async () => { + jest.spyOn(btpUtils, 'listDestinations').mockResolvedValue(destinationList); + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + jest.spyOn(adpTooling.ManifestService, 'initMergedManifest') + .mockRejectedValueOnce({ + isAxiosError: true, + response: { + status: 401 + } + }) + .mockRejectedValueOnce({ + isAxiosError: true, + response: { + status: 401 + } + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + let callbackResult: string = ''; + jest.spyOn(inquirerCommon, 'getCredentialsPrompts').mockImplementationOnce( + async ( + callback?: inquirerCommon.AdditionalValidation + ): Promise[]> => { + callbackResult = (await callback?.({ username: 'testUsername', password: 'testPassword' })) as string; + return Promise.resolve([ + { + username: 'testUsername' + } as unknown as inquirerCommon.InputQuestion, + { + password: 'testPassword' + } as unknown as inquirerCommon.PasswordQuestion + ]); + } + ); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await runContext.run(); + await initI18n(); + expect(callbackResult).toEqual(t('error.authenticationFailed')); + }); + + it('Should show error message after authetication when manifest request fails with connection error', async () => { + jest.spyOn(btpUtils, 'listDestinations').mockResolvedValue(destinationList); + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + jest.spyOn(adpTooling.ManifestService, 'initMergedManifest') + .mockRejectedValueOnce({ + isAxiosError: true, + response: { + status: 401 + } + }) + .mockRejectedValueOnce({ + isAxiosError: true, + message: 'Network Error' + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + let callbackResult: string = ''; + jest.spyOn(inquirerCommon, 'getCredentialsPrompts').mockImplementationOnce( + async ( + callback?: inquirerCommon.AdditionalValidation + ): Promise[]> => { + callbackResult = (await callback?.({ username: 'testUsername', password: 'testPassword' })) as string; + return Promise.resolve([ + { + username: 'testUsername' + } as unknown as inquirerCommon.InputQuestion, + { + password: 'testPassword' + } as unknown as inquirerCommon.PasswordQuestion + ]); + } + ); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await runContext.run(); + expect(callbackResult).toEqual('Network Error'); + }); + + it('Should pass authentication successfully', async () => { + jest.spyOn(btpUtils, 'listDestinations').mockResolvedValue(destinationList); + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + jest.spyOn(adpTooling.ManifestService, 'initMergedManifest').mockRejectedValueOnce({ + isAxiosError: true, + response: { + status: 401 + } + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + let callbackResult: string = ''; + jest.spyOn(inquirerCommon, 'getCredentialsPrompts').mockImplementationOnce( + async ( + callback?: inquirerCommon.AdditionalValidation + ): Promise[]> => { + callbackResult = (await callback?.({ username: 'testUsername', password: 'testPassword' })) as string; + return Promise.resolve([ + { + username: 'testUsername' + } as unknown as inquirerCommon.InputQuestion, + { + password: 'testPassword' + } as unknown as inquirerCommon.PasswordQuestion + ]); + } + ); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await runContext.run(); + expect(callbackResult).toEqual(true); + }); + + it('Should fail manifest fetching with network error', async () => { + jest.spyOn(btpUtils, 'listDestinations').mockResolvedValue(destinationList); + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + jest.spyOn(adpTooling.ManifestService, 'initMergedManifest').mockRejectedValueOnce({ + isAxiosError: true, + message: 'Network Error' + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + let callbackResult: string = ''; + jest.spyOn(inquirerCommon, 'getCredentialsPrompts').mockImplementationOnce( + async ( + callback?: inquirerCommon.AdditionalValidation + ): Promise[]> => { + callbackResult = (await callback?.({ username: 'testUsername', password: 'testPassword' })) as string; + return Promise.resolve([ + { + username: 'testUsername' + } as unknown as inquirerCommon.InputQuestion, + { + password: 'testPassword' + } as unknown as inquirerCommon.PasswordQuestion + ]); + } + ); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await runContext.run(); + expect(vsCodeMessageSpy).toBeCalledWith('Network Error'); + }); + + it('Should fail if destination is not found in BTP subaccount', async () => { + jest.spyOn(btpUtils, 'listDestinations').mockResolvedValue({}); + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await runContext.run(); + expect(vsCodeMessageSpy).toBeCalledWith( + t('error.destinationNotFoundInStore', { destination: 'testDestination' }) + ); + }); + + it('Should fail if destination is not found in BTP subaccount and log error in CLI', async () => { + jest.spyOn(fioriGenShared, 'isCli').mockReturnValue(true); + jest.spyOn(btpUtils, 'listDestinations').mockResolvedValue({}); + jest.spyOn(adpTooling, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + } + }); + jest.spyOn(btpUtils, 'isAppStudio').mockReturnValue(true); + const testProjectPath = join(__dirname, 'fixtures/app.variant1'); + + const runContext = yeomanTest + .create( + adpFlpConfigGenerator, + { + resolved: generatorPath + }, + { + cwd: testProjectPath + } + ) + .withOptions({ + vscode, + appWizard: mockAppWizard, + launchFlpConfigAsSubGenerator: false + }) + .withPrompts(answers); + + await runContext.run(); + expect(toolsLoggerErrorSpy).toBeCalledWith( + t('error.destinationNotFoundInStore', { destination: 'testDestination' }) + ); + }); +}); diff --git a/packages/adp-flp-config-sub-generator/test/fixtures/app.variant1/webapp/i18n/i18n.properties b/packages/adp-flp-config-sub-generator/test/fixtures/app.variant1/webapp/i18n/i18n.properties new file mode 100644 index 0000000000..29b745952a --- /dev/null +++ b/packages/adp-flp-config-sub-generator/test/fixtures/app.variant1/webapp/i18n/i18n.properties @@ -0,0 +1,4 @@ +#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.variant1_sap.app.title=App Variant Title \ No newline at end of file diff --git a/packages/adp-flp-config-sub-generator/test/fixtures/app.variant1/webapp/manifest.appdescr_variant b/packages/adp-flp-config-sub-generator/test/fixtures/app.variant1/webapp/manifest.appdescr_variant new file mode 100644 index 0000000000..517e5b6ce1 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/test/fixtures/app.variant1/webapp/manifest.appdescr_variant @@ -0,0 +1,164 @@ +{ + "fileName": "manifest", + "layer": "CUSTOMER_BASE", + "fileType": "appdescr_variant", + "reference": "mockReference", + "id": "customer.app.variant1", + "namespace": "apps/mockReference/appVariants/customer.app.variant1/", + "version": "0.1.0", + "content": [ + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "i18n" + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "@i18n" + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "mockModel" + }, + "texts": { + "i18n": "mockI18n" + } + }, + { + "changeType": "appdescr_ui5_addNewModelEnhanceWith", + "content": { + "modelId": "mockModel" + }, + "texts": { + "i18n": "mockI18n" + } + }, + { + "changeType": "appdescr_ui5_setMinUI5Version", + "content": { + "minUI5Version": "1.120.25" + } + }, + { + "changeType": "appdescr_app_setTitle", + "content": {}, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_app_addNewInbound", + "content": { + "inbound": { + "customer.app.variant1.InboundID": { + "action": "testAction", + "semanticObject": "testSemanticObject", + "title": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.title}}", + "signature": { + "additionalParameters": "allowed", + "parameters": { + "param1": { + "required": true, + "defaultValue": { + "value": "test1", + "format": "plain" + } + }, + "param2": { + "required": true, + "defaultValue": { + "value": "test2", + "format": "plain" + } + }, + "sap-appvar-id": { + "required": true, + "filter": { + "value": "customer.app.variant1", + "format": "plain" + }, + "launcherValue": { + "value": "customer.app.variant1" + } + } + } + }, + "subTitle": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.subTitle}}" + } + } + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_app_removeAllInboundsExceptOne", + "content": { + "inboundId": "customer.app.variant1.InboundID" + }, + "texts": {} + }, + { + "changeType": "appdescr_app_addNewInbound", + "content": { + "inbound": { + "customer.app.variant1.InboundID": { + "action": "testAction", + "semanticObject": "testSemanticObject", + "title": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.title}}", + "signature": { + "additionalParameters": "allowed", + "parameters": { + "param1": { + "required": true, + "defaultValue": { + "value": "test1", + "format": "plain" + } + }, + "param2": { + "required": true, + "defaultValue": { + "value": "test2", + "format": "plain" + } + }, + "sap-appvar-id": { + "required": true, + "filter": { + "value": "customer.app.variant1", + "format": "plain" + }, + "launcherValue": { + "value": "customer.app.variant1" + } + } + } + }, + "subTitle": "{{customer.app.variant1_sap.app.crossNavigation.inbounds.customer.app.variant1.InboundID.subTitle}}" + } + } + }, + "texts": { + "i18n": "i18n/i18n.properties" + } + }, + { + "changeType": "appdescr_app_removeAllInboundsExceptOne", + "content": { + "inboundId": "customer.app.variant1.InboundID" + }, + "texts": {} + } + ] +} diff --git a/packages/adp-flp-config-sub-generator/tsconfig.eslint.json b/packages/adp-flp-config-sub-generator/tsconfig.eslint.json new file mode 100644 index 0000000000..d5f1aa3474 --- /dev/null +++ b/packages/adp-flp-config-sub-generator/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "test", ".eslintrc.js"] +} diff --git a/packages/adp-flp-config-sub-generator/tsconfig.json b/packages/adp-flp-config-sub-generator/tsconfig.json new file mode 100644 index 0000000000..f6bd55853a --- /dev/null +++ b/packages/adp-flp-config-sub-generator/tsconfig.json @@ -0,0 +1,46 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src", + "src/**/*.json" + ], + "compilerOptions": { + "rootDir": "src", + "outDir": "generators" + }, + "references": [ + { + "path": "../adp-tooling" + }, + { + "path": "../axios-extension" + }, + { + "path": "../btp-utils" + }, + { + "path": "../feature-toggle" + }, + { + "path": "../fiori-generator-shared" + }, + { + "path": "../flp-config-inquirer" + }, + { + "path": "../inquirer-common" + }, + { + "path": "../logger" + }, + { + "path": "../project-access" + }, + { + "path": "../store" + }, + { + "path": "../system-access" + } + ] +} diff --git a/packages/adp-tooling/src/base/helper.ts b/packages/adp-tooling/src/base/helper.ts index eb93b5634c..74bfaddffa 100644 --- a/packages/adp-tooling/src/base/helper.ts +++ b/packages/adp-tooling/src/base/helper.ts @@ -10,9 +10,13 @@ import type { DescriptorVariant, AdpPreviewConfig } from '../types'; * Get the app descriptor variant. * * @param {string} basePath - The path to the adaptation project. + * @param {Editor} fs - The mem-fs editor instance. * @returns {DescriptorVariant} The app descriptor variant. */ -export function getVariant(basePath: string): DescriptorVariant { +export function getVariant(basePath: string, fs?: Editor): DescriptorVariant { + if (fs) { + return fs.readJSON(join(basePath, 'webapp', 'manifest.appdescr_variant')) as unknown as DescriptorVariant; + } return JSON.parse(readFileSync(join(basePath, 'webapp', 'manifest.appdescr_variant'), 'utf-8')); } diff --git a/packages/adp-tooling/src/writer/inbound-navigation.ts b/packages/adp-tooling/src/writer/inbound-navigation.ts index e33ad2de6d..620b295a0f 100644 --- a/packages/adp-tooling/src/writer/inbound-navigation.ts +++ b/packages/adp-tooling/src/writer/inbound-navigation.ts @@ -25,7 +25,7 @@ export async function generateInboundConfig( fs = create(createStorage()); } - const variant = getVariant(basePath); + const variant = getVariant(basePath, fs); if (!config?.inboundId) { config.addInboundId = true; diff --git a/packages/adp-tooling/test/unit/base/helper.test.ts b/packages/adp-tooling/test/unit/base/helper.test.ts index 1cec392005..029cef5ac5 100644 --- a/packages/adp-tooling/test/unit/base/helper.test.ts +++ b/packages/adp-tooling/test/unit/base/helper.test.ts @@ -45,6 +45,17 @@ describe('helper', () => { expect(getVariant(basePath)).toStrictEqual(JSON.parse(mockVariant)); }); + + test('should return variant using fs editor', () => { + const fs = { + readJSON: jest.fn().mockReturnValue(JSON.parse(mockVariant)) + } as unknown as Editor; + + const result = getVariant(basePath, fs); + + expect(fs.readJSON).toHaveBeenCalledWith(join(basePath, 'webapp', 'manifest.appdescr_variant')); + expect(result).toStrictEqual(JSON.parse(mockVariant)); + }); }); describe('updateVariant', () => { diff --git a/packages/adp-tooling/test/unit/writer/inbound-navigation.test.ts b/packages/adp-tooling/test/unit/writer/inbound-navigation.test.ts index f5967eb630..67faf22cc9 100644 --- a/packages/adp-tooling/test/unit/writer/inbound-navigation.test.ts +++ b/packages/adp-tooling/test/unit/writer/inbound-navigation.test.ts @@ -48,7 +48,7 @@ describe('FLP Configuration Functions', () => { await generateInboundConfig(basePath, config, fs); - expect(getVariantMock).toHaveBeenCalledWith(basePath); + expect(getVariantMock).toHaveBeenCalledWith(basePath, expect.any(Object)); expect(fs.writeJSON).toHaveBeenCalledWith(join(basePath, 'webapp', 'manifest.appdescr_variant'), variant); expect(createPropertiesI18nEntriesMock).toHaveBeenCalledWith( join(basePath, 'webapp', 'i18n', 'i18n.properties'), @@ -64,7 +64,7 @@ describe('FLP Configuration Functions', () => { const fs = await generateInboundConfig(basePath, config); expect(fs).toBeDefined(); - expect(getVariantMock).toHaveBeenCalledWith(basePath); + expect(getVariantMock).toHaveBeenCalledWith(basePath, expect.any(Object)); expect(createPropertiesI18nEntriesMock).toHaveBeenCalledWith( join(basePath, 'webapp', 'i18n', 'i18n.properties'), expect.any(Array), diff --git a/packages/i18n/src/write/properties/create.ts b/packages/i18n/src/write/properties/create.ts index 1f4d54691c..00dfed0ccd 100644 --- a/packages/i18n/src/write/properties/create.ts +++ b/packages/i18n/src/write/properties/create.ts @@ -21,7 +21,7 @@ export async function createPropertiesI18nEntries( root?: string, fs?: Editor ): Promise { - if (!(await doesExist(i18nFilePath))) { + if ((!fs && !(await doesExist(i18nFilePath))) || (fs && !fs.exists(i18nFilePath))) { let content = '# Resource bundle \n'; if (root) { content = `# This is the resource bundle for ${basename(root)}\n`; diff --git a/packages/i18n/test/unit/write/properties/create.test.ts b/packages/i18n/test/unit/write/properties/create.test.ts index b3d3eaf6e7..3997166543 100644 --- a/packages/i18n/test/unit/write/properties/create.test.ts +++ b/packages/i18n/test/unit/write/properties/create.test.ts @@ -3,6 +3,7 @@ import * as utilsWrite from '../../../../src/write/utils'; import * as utils from '../../../../src/utils'; import { create as createStorage } from 'mem-fs'; import { create } from 'mem-fs-editor'; +import { basename } from 'path'; describe('create', () => { describe('createPropertiesI18nEntries', () => { @@ -64,7 +65,7 @@ describe('create', () => { const result = await createPropertiesI18nEntries('i18n.properties', newEntries, undefined, memFs); expect(result).toEqual(true); - expect(doesExistSpy).toHaveBeenCalledTimes(1); + expect(doesExistSpy).toHaveBeenCalledTimes(0); expect(writeFileSpy).toHaveBeenNthCalledWith(1, 'i18n.properties', '# Resource bundle \n', memFs); expect(writeToExistingI18nPropertiesFileSpy).toHaveBeenNthCalledWith( 1, @@ -89,6 +90,50 @@ describe('create', () => { undefined ); }); + test('create a new i18n file if it does not exist in both real and virtual file systems', async () => { + const i18nFilePath = 'path/to/i18n.properties'; + const root = 'path/to/root'; + const doesExistSpy = jest.spyOn(utils, 'doesExist').mockResolvedValue(false); + const memFs = create(createStorage()); + memFs.exists = jest.fn().mockReturnValue(false); + const writeFileSpy = jest.spyOn(utils, 'writeFile').mockResolvedValue(); + const writeToExistingI18nPropertiesFileSpy = jest + .spyOn(utilsWrite, 'writeToExistingI18nPropertiesFile') + .mockResolvedValue(true); + + await createPropertiesI18nEntries(i18nFilePath, newEntries, root, memFs); + + expect(doesExistSpy).not.toHaveBeenCalled(); + expect(memFs.exists).toHaveBeenCalledWith(i18nFilePath); + expect(writeFileSpy).toHaveBeenCalledWith( + i18nFilePath, + `# This is the resource bundle for ${basename(root)}\n`, + memFs + ); + expect(writeToExistingI18nPropertiesFileSpy).toHaveBeenCalledWith(i18nFilePath, newEntries, memFs); + }); + test('create a new i18n file if it exists in the real file system, but does not exist in the passed virtual file system', async () => { + const i18nFilePath = 'path/to/i18n.properties'; + const root = 'path/to/root'; + const doesExistSpy = jest.spyOn(utils, 'doesExist').mockResolvedValue(true); + const memFs = create(createStorage()); + memFs.exists = jest.fn().mockReturnValue(false); + const writeFileSpy = jest.spyOn(utils, 'writeFile').mockResolvedValue(); + const writeToExistingI18nPropertiesFileSpy = jest + .spyOn(utilsWrite, 'writeToExistingI18nPropertiesFile') + .mockResolvedValue(true); + + await createPropertiesI18nEntries(i18nFilePath, newEntries, root, memFs); + + expect(doesExistSpy).not.toHaveBeenCalled(); + expect(memFs.exists).toHaveBeenCalledWith(i18nFilePath); + expect(writeFileSpy).toHaveBeenCalledWith( + i18nFilePath, + `# This is the resource bundle for ${basename(root)}\n`, + memFs + ); + expect(writeToExistingI18nPropertiesFileSpy).toHaveBeenCalledWith(i18nFilePath, newEntries, memFs); + }); test('exception / error case', async () => { jest.spyOn(utilsWrite, 'writeToExistingI18nPropertiesFile').mockImplementation(() => { throw new Error('should-throw-error'); diff --git a/packages/inquirer-common/src/index.ts b/packages/inquirer-common/src/index.ts index e6379b8ab6..a8c0a65d17 100644 --- a/packages/inquirer-common/src/index.ts +++ b/packages/inquirer-common/src/index.ts @@ -4,4 +4,5 @@ export * from './prompts/helpers'; export * from './error-handler/error-handler'; export * from './prompts/cf-helper'; export * from './telemetry/telemetry'; +export * from './prompts/credentials'; export { addi18nResourceBundle } from './i18n'; diff --git a/packages/inquirer-common/src/prompts/credentials.ts b/packages/inquirer-common/src/prompts/credentials.ts new file mode 100644 index 0000000000..77ec4864f8 --- /dev/null +++ b/packages/inquirer-common/src/prompts/credentials.ts @@ -0,0 +1,57 @@ +import { t } from '../i18n'; +import type { YUIQuestion, InputQuestion, PasswordQuestion, ValidationLink } from '../types'; + +export type CredentialsAnswers = { username: string; password: string }; +export type AdditionalValidation = (credentials: CredentialsAnswers) => Promise; + +/** + * Prompts the user for credentials. + * + * @param {Function} additionalValidation - Optional callback function called in the validation phase of password prompt. Callback function should return a boolean or a message. + * @returns {Array} An array of prompts. + */ +export async function getCredentialsPrompts( + additionalValidation?: AdditionalValidation +): Promise[]> { + return [ + { + type: 'input', + name: 'username', + message: t('prompts.username.message'), + guiOptions: { + mandatory: true + }, + store: false, + validate: (value: string): string | boolean => { + return value ? true : t('errors.cannotBeEmpty', { field: t('prompts.username.message') }); + } + } as InputQuestion, + { + type: 'password', + guiType: 'login', + name: 'password', + message: t('prompts.password.message'), + mask: '*', + guiOptions: { + mandatory: true + }, + store: false, + validate: async ( + value: string, + answers: CredentialsAnswers + ): Promise => { + if (!value) { + return t('errors.cannotBeEmpty', { field: t('prompts.password.message') }); + } + + if (!answers.username) { + return t('errors.cannotBeEmpty', { field: t('prompts.username.message') }); + } + if (additionalValidation) { + return await additionalValidation({ username: answers.username, password: value }); + } + return true; + } + } as PasswordQuestion + ]; +} diff --git a/packages/inquirer-common/src/translations/inquirer-common.i18n.json b/packages/inquirer-common/src/translations/inquirer-common.i18n.json index ce66886d13..3ba04b4222 100644 --- a/packages/inquirer-common/src/translations/inquirer-common.i18n.json +++ b/packages/inquirer-common/src/translations/inquirer-common.i18n.json @@ -1,4 +1,12 @@ { + "prompts": { + "username": { + "message": "Username" + }, + "password": { + "message": "Password" + } + }, "ui5VersionLabels": { "maintained": "Maintained", "outOfMaintenance": "Out of maintenance", @@ -37,7 +45,8 @@ "systemConnectionValidationFailed": "A connection to the selected system could not be established.", "internalServerError": "Internal server error{{-errorMsg, addMsgWithColonFormatter}}", "badGateway": "Bad gateway{{- errorMsg, addMsgWithColonFormatter}}", - "badRequest": "Bad request{{- errorMsg, addMsgWithColonFormatter}}" + "badRequest": "Bad request{{- errorMsg, addMsgWithColonFormatter}}", + "cannotBeEmpty": "{{- field}} cannot be empty." }, "guidedAnswers": { "validationErrorHelpText": "Need help with this error?" diff --git a/packages/inquirer-common/test/unit/prompts/credentials.test.ts b/packages/inquirer-common/test/unit/prompts/credentials.test.ts new file mode 100644 index 0000000000..c72782f9ce --- /dev/null +++ b/packages/inquirer-common/test/unit/prompts/credentials.test.ts @@ -0,0 +1,100 @@ +import { + getCredentialsPrompts, + type CredentialsAnswers, + type AdditionalValidation +} from '../../../src/prompts/credentials'; +import { t, addi18nResourceBundle } from '../../../src/i18n'; + +import type { InputQuestion, PasswordQuestion } from '../../../src/types'; + +describe('getCredentialsPrompts', () => { + beforeAll(() => { + addi18nResourceBundle(); + }); + + it('should return an array of prompts', async () => { + const prompts = await getCredentialsPrompts(); + expect(prompts).toBeInstanceOf(Array); + expect(prompts).toEqual([ + { + type: 'input', + name: 'username', + message: 'Username', + guiOptions: { + mandatory: true + }, + store: false, + validate: expect.any(Function) + } as InputQuestion, + { + type: 'password', + guiType: 'login', + name: 'password', + message: 'Password', + mask: '*', + guiOptions: { + mandatory: true + }, + store: false, + validate: expect.any(Function) + } as PasswordQuestion + ]); + }); + + it('should validate username correctly', async () => { + const prompts = await getCredentialsPrompts(); + const validate = (prompts[0] as InputQuestion).validate; + + expect(typeof validate).toBe('function'); + expect(validate?.('')).toBe(t('errors.cannotBeEmpty', { field: t('prompts.username.message') })); + expect(validate?.('user')).toBe(true); + }); + + it('should validate password correctly', async () => { + const prompts = await getCredentialsPrompts(); + const validate = (prompts[1] as PasswordQuestion).validate; + + const answers: CredentialsAnswers = { + username: undefined + } as unknown as CredentialsAnswers; + + expect(await validate?.('', answers)).toBe(t('errors.cannotBeEmpty', { field: t('prompts.password.message') })); + expect(await validate?.('password', answers)).toBe( + t('errors.cannotBeEmpty', { field: t('prompts.username.message') }) + ); + answers.username = 'user'; + expect(await validate?.('pass', answers)).toBe(true); + }); + + it('should call additionalValidation if provided', async () => { + const additionalValidation: AdditionalValidation = jest.fn().mockResolvedValue(true); + const prompts = await getCredentialsPrompts(additionalValidation); + const validate = (prompts[1] as PasswordQuestion).validate; + + const answers: CredentialsAnswers = { username: 'user', password: 'pass' }; + + await validate?.('pass', answers); + expect(additionalValidation).toHaveBeenCalledWith({ username: 'user', password: 'pass' }); + }); + + it('should return validation message from additionalValidation if provided', async () => { + const additionalValidation: AdditionalValidation = jest.fn().mockResolvedValue('Additional validation failed'); + const prompts = await getCredentialsPrompts(additionalValidation); + const validate = (prompts[1] as PasswordQuestion).validate; + + const answers: CredentialsAnswers = { username: 'user', password: 'pass' }; + + const result = await validate?.('pass', answers); + expect(result).toBe('Additional validation failed'); + }); + + it('should handle missing username in password validation', async () => { + const prompts = await getCredentialsPrompts(); + const validate = (prompts[1] as PasswordQuestion).validate; + + const answers: CredentialsAnswers = { username: '', password: 'pass' }; + + const result = await validate?.('pass', answers); + expect(result).toBe(t('errors.cannotBeEmpty', { field: t('prompts.username.message') })); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7163a5d09..28c3f00cfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,6 +478,88 @@ importers: specifier: 10.0.0 version: 10.0.0 + packages/adp-flp-config-sub-generator: + dependencies: + '@sap-devx/yeoman-ui-types': + specifier: 1.14.4 + version: 1.14.4 + '@sap-ux/adp-tooling': + specifier: workspace:* + version: link:../adp-tooling + '@sap-ux/axios-extension': + specifier: workspace:* + version: link:../axios-extension + '@sap-ux/btp-utils': + specifier: workspace:* + version: link:../btp-utils + '@sap-ux/feature-toggle': + specifier: workspace:* + version: link:../feature-toggle + '@sap-ux/fiori-generator-shared': + specifier: workspace:* + version: link:../fiori-generator-shared + '@sap-ux/flp-config-inquirer': + specifier: workspace:* + version: link:../flp-config-inquirer + '@sap-ux/inquirer-common': + specifier: workspace:* + version: link:../inquirer-common + '@sap-ux/logger': + specifier: workspace:* + version: link:../logger + '@sap-ux/project-access': + specifier: workspace:* + version: link:../project-access + '@sap-ux/store': + specifier: workspace:* + version: link:../store + '@sap-ux/system-access': + specifier: workspace:* + version: link:../system-access + i18next: + specifier: 23.5.1 + version: 23.5.1 + yeoman-generator: + specifier: 5.10.0 + version: 5.10.0(mem-fs@2.1.0)(yeoman-environment@3.19.3) + devDependencies: + '@jest/types': + specifier: 29.6.3 + version: 29.6.3 + '@types/fs-extra': + specifier: 9.0.13 + version: 9.0.13 + '@types/inquirer': + specifier: 8.2.6 + version: 8.2.6 + '@types/vscode': + specifier: 1.73.1 + version: 1.73.1 + '@types/yeoman-environment': + specifier: 2.10.11 + version: 2.10.11 + '@types/yeoman-generator': + specifier: 5.2.11 + version: 5.2.11 + '@types/yeoman-test': + specifier: 4.0.6 + version: 4.0.6 + '@vscode-logging/logger': + specifier: 2.0.0 + version: 2.0.0 + fs-extra: + specifier: 10.0.0 + version: 10.0.0 + rimraf: + specifier: 5.0.5 + version: 5.0.5 + typescript: + specifier: 5.3.3 + version: 5.3.3 + yeoman-test: + specifier: 6.3.0 + version: 6.3.0(mem-fs@2.1.0)(yeoman-environment@3.19.3)(yeoman-generator@5.10.0) + packages/adp-tooling: dependencies: '@sap-ux/axios-extension': diff --git a/sonar-project.properties b/sonar-project.properties index b48530a655..f0244942ae 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -12,6 +12,7 @@ sonar.javascript.lcov.reportPaths=packages/abap-deploy-config-inquirer/coverage/ packages/adp-tooling/coverage/lcov.info, \ packages/annotation-generator/coverage/lcov.info, \ packages/app-config-writer/coverage/lcov.info, \ + packages/adp-flp-config-sub-generator/coverage/lcov.info, \ packages/axios-extension/coverage/lcov.info, \ packages/backend-proxy-middleware/coverage/lcov.info, \ packages/btp-utils/coverage/lcov.info, \ @@ -89,6 +90,7 @@ sonar.testExecutionReportPaths=packages/abap-deploy-config-inquirer/coverage/son packages/adp-tooling/coverage/sonar-report.xml, \ packages/annotation-generator/coverage/sonar-report.xml, \ packages/app-config-writer/coverage/sonar-report.xml, \ + packages/adp-flp-config-sub-generator/coverage/sonar-report.xml, \ packages/axios-extension/coverage/sonar-report.xml, \ packages/backend-proxy-middleware/coverage/sonar-report.xml, \ packages/btp-utils/coverage/sonar-report.xml, \ diff --git a/tsconfig.json b/tsconfig.json index 2709cab9d1..0540199f7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,9 @@ { "path": "packages/abap-deploy-config-writer" }, + { + "path": "packages/adp-flp-config-sub-generator" + }, { "path": "packages/adp-tooling" },