diff --git a/packages/documentation/docs/pages/Configuration.md b/packages/documentation/docs/pages/Configuration.md index 8db3f6db502..8b7d25bf0d6 100644 --- a/packages/documentation/docs/pages/Configuration.md +++ b/packages/documentation/docs/pages/Configuration.md @@ -3,7 +3,7 @@ A projects UI5 CLI configuration is typically located in a [YAML](https://yaml.org/) file named `ui5.yaml`, located in the root directory. ::: info Info -This document describes the configuration of UI5 CLI-based projects and extensions. It represents **[Specification Version 3.0](#specification-versions)**. +This document describes the configuration of UI5 CLI-based projects and extensions. It represents **[Specification Version 5.0](#specification-versions)**. ::: diff --git a/packages/documentation/docs/pages/Project.md b/packages/documentation/docs/pages/Project.md index 1a5f4610402..a3f5108842c 100644 --- a/packages/documentation/docs/pages/Project.md +++ b/packages/documentation/docs/pages/Project.md @@ -11,8 +11,33 @@ Types define how a project can be configured and how it is built. A type orchest Also see [UI5 Project: Configuration](./Configuration.md#general-configuration) +### component +*Available since [Specification Version 5.0](./Configuration.md#specification-version-50)* + +> **Note:** The UI5 CLI project type `component` differs from the `sap.app/type` runtime property with the same name defined in the `manifest.json` file. In most cases, a CLI project of type `component` is still a runtime `application`. For more information, see the [manifest documentation](https://ui5.sap.com/#/topic/be0cf40f61184b358b5faedaec98b2da.html#loiobe0cf40f61184b358b5faedaec98b2da/section_sap_app). + +Projects of the `component` type cover a range of use cases beyond typical standalone UI5 applications: + +- **Application components:** These are typical UI5 applications designed to run in container-like applications such as the SAP Fiori launchpad (FLP). They generally inherit from `sap.ui.core.UIComponent` (or a subclass) and define the `manifest.json` property `sap.app/type: application`. +- **Reusable UI components:** These provide UI elements or features that you can embed in different contexts. They typically inherit from `sap.ui.core.UIComponent` and define the `manifest.json` property `sap.app/type: component`. +- **Faceless components:** These provide functionality without a user interface. They are defined with `manifest.json` property `sap.app/type: component` and inherit from `sap.ui.core.Component` (not `UIComponent`). + +For more information, see [Components](https://ui5.sap.com/#/topic/958ead51e2e94ab8bcdc90fb7e9d53d0). + +To allow multiple component projects to coexist in the same environment, each project is served under its own namespace, for example `/resources/my/bookstore/admin`. In contrast, `application`-type projects act as root projects and are served at `/`, without a namespace. + +By default, component projects use the same directory structure as library projects: they include `src` and `test` directories in the root. Both directories can have either a flat or a namespace structure. If you use a flat structure, the project namespace derives from the `sap.app/id` property in the `manifest.json`. + +A component project must contain both, a `Component.js` and a `manifest.json` file. + +Unlike `application`-type projects, component projects typically don't have dedicated `index.html` files in their regular resources (`src/`). However, you can still run them standalone. You can do this by using a dedicated HTML file located in their test resources or by declaring a development dependency to an application-type project that can serve the component, such as the FLP sandbox. + +Component projects support all [output styles](#build-output-style) that library projects currently support. This allows a deployment where you can omit the namespace from the final directory structure using the output style: `flat`. + +For more details, see also [RFC 0018 Component Type](https://github.com/UI5/cli/blob/rfc-component-type/rfcs/0018-component-type.md#rfc-0018-component-type). + ### application -Projects of type `application` are typically the main or root project. In a projects dependency tree, there should only be one project of type `application`. If multiple are found, those further away from the root are ignored. +Projects of the `application` type typically serve as the main or root project. In a project's dependency tree, there shouldn't be more than one project of this type. If the system detects additional application projects, it ignores those that are further away from the root. The source directory of an application (typically named `webapp`) is mapped to the virtual root path `/`. @@ -21,12 +46,12 @@ An applications source directory may or may not contain a `Component.js` file. I ### library UI5 libraries are often referred to as reuse-, custom- or [control libraries](https://github.com/SAP/openui5/blob/-/docs/controllibraries.md). They are a key component in sharing code across multiple projects in UI5. -A project of type `library` must have a source directory (typically named `src`). It may also feature a "test" directory. These directories are mapped to the virtual directories `/resources` for the sources and `/test-resources` for the test resources. +A project of the `library` type must have a source directory (typically named `src`). It may also feature a "test" directory. These directories are mapped to the virtual directories `/resources` for the sources and `/test-resources` for the test resources. These directories should contain a directory structure representing the namespace of the library (e.g. `src/my/first/library`) to prevent name clashes between the resources of different libraries. ### theme-library -*Available since [Specification Version](./Configuration.md#specification-versions) 1.1* +*Available since [Specification Version 1.1](./Configuration.md#specification-version-11)* UI5 theme libraries provide theming resources for the controls of one or multiple libraries. @@ -44,28 +69,27 @@ The _Output Style_ offers you control over your project's build output folder. N In the table below you can find the available combinations of project type & output style. -| Project Type / Requested Output Style | Resulting Style | -|---|---| -| **application** | | -| `Default` | Root project is written `Flat`-style. ^1^ | -| `Flat` | Same as `Default`. | -| `Namespace` | Root project is written `Namespace`-style (resources are prefixed with the project's namespace). ^1^ | -| **library** | | -| `Default` | Root project is written `Namespace`-style. ^1^ | -| `Flat` | Root project is written `Flat`-style (without its namespace, logging warnings for resources outside of it). ^1^ | -| `Namespace` | Same as `Default`. | -| **theme-library** | | -| `Default` | Root project is written in the style of the sources (multiple namespaces). ^1^ | -| `Flat` | **Unsupported** ^2^ | -| `Namespace` | **Unsupported** ^2^ | -| **module** | | -| `Default` | Root project is written with the [configured paths](https://ui5.github.io/cli/v5/pages/Configuration/#available-path-mappings). ^1^ | -| `Flat` | **Unsupported** ^3^ | -| `Namespace` | **Unsupported** ^3^ | - -^1^ The Output Style is only applied to the root project's output folder structure. Any dependencies included in the build would retain their `Default` output style. -^2^ Theme libraries in most cases have more than one namespace. -^3^ Modules have explicit path mappings configured and no namespace concept. +| Project Type | Requested Output Style | Resulting Style | +| :--- | :--- | :--- | +| **component** | `Default` | Root project is written `Namespace`-style.¹ | +| | `Flat` | Root project is written `Flat`-style (without its namespace, logging warnings for resources outside of it).¹ | +| | `Namespace` | Same as `Default`. | +| **application** | `Default` | Root project is written `Flat`-style.¹ | +| | `Flat` | Same as `Default`. | +| | `Namespace` | Root project is written `Namespace`-style (resources are prefixed with the project's namespace).¹ | +| **library** | `Default` | Root project is written `Namespace`-style.¹ | +| | `Flat` | Root project is written `Flat`-style (without its namespace, logging warnings for resources outside of it).¹ | +| | `Namespace` | Same as `Default`. | +| **theme-library** | `Default` | Root project is written in the style of the sources (multiple namespaces).¹ | +| | `Flat` | **Unsupported** ² | +| | `Namespace` | **Unsupported** ² | +| **module** | `Default` | Root project is written with the [configured paths](https://ui5.github.io/cli/v5/pages/Configuration/#available-path-mappings).¹ | +| | `Flat` | **Unsupported** ³ | +| | `Namespace` | **Unsupported** ³ | + +¹ The output style is only applied to the root project's output folder structure. Any dependencies included in the build would retain their `Default` output style. +² Theme libraries in most cases have more than one namespace. +³ Modules have explicit path mappings configured and no namespace concept.
diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 70e7bf32ae4..0f5677170a3 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -53,6 +53,9 @@ class TaskRunner { case "library": buildDefinition = "./definitions/library.js"; break; + case "component": + buildDefinition = "./definitions/component.js"; + break; case "module": buildDefinition = "./definitions/module.js"; break; diff --git a/packages/project/lib/build/definitions/component.js b/packages/project/lib/build/definitions/component.js new file mode 100644 index 00000000000..48684b6df03 --- /dev/null +++ b/packages/project/lib/build/definitions/component.js @@ -0,0 +1,128 @@ +import {enhancePatternWithExcludes} from "./_utils.js"; +import {enhanceBundlesWithDefaults} from "../../validation/validator.js"; + +/** + * Get tasks and their configuration for a given component project + * + * @private + * @param {object} parameters + * @param {object} parameters.project + * @param {object} parameters.taskUtil + * @param {Function} parameters.getTask + */ +export default function({project, taskUtil, getTask}) { + const tasks = new Map(); + tasks.set("escapeNonAsciiCharacters", { + options: { + encoding: project.getPropertiesFileSourceEncoding(), + pattern: "/**/*.properties" + } + }); + + tasks.set("replaceCopyright", { + options: { + copyright: project.getCopyright(), + pattern: "/**/*.{js,json}" + } + }); + + tasks.set("replaceVersion", { + options: { + version: project.getVersion(), + pattern: "/**/*.{js,json}" + } + }); + + // Support rules should not be minified to have readable code in the Support Assistant + const minificationPattern = ["/**/*.js", "!**/*.support.js"]; + const minificationExcludes = project.getMinificationExcludes(); + if (minificationExcludes.length) { + enhancePatternWithExcludes(minificationPattern, minificationExcludes, "/resources/"); + } + + tasks.set("minify", { + options: { + pattern: minificationPattern + } + }); + + tasks.set("enhanceManifest", {}); + + tasks.set("generateFlexChangesBundle", {}); + + const bundles = project.getBundles(); + const existingBundleDefinitionNames = + bundles.map(({bundleDefinition}) => bundleDefinition.name).filter(Boolean); + + const componentPreloadPaths = project.getComponentPreloadPaths(); + const componentPreloadNamespaces = project.getComponentPreloadNamespaces(); + const componentPreloadExcludes = project.getComponentPreloadExcludes(); + if (componentPreloadPaths.length || componentPreloadNamespaces.length) { + tasks.set("generateComponentPreload", { + options: { + paths: componentPreloadPaths, + namespaces: componentPreloadNamespaces, + excludes: componentPreloadExcludes, + skipBundles: existingBundleDefinitionNames + } + }); + } else { + // Default component preload + tasks.set("generateComponentPreload", { + options: { + namespaces: [project.getNamespace()], + excludes: componentPreloadExcludes, + skipBundles: existingBundleDefinitionNames + } + }); + } + + if (bundles.length) { + tasks.set("generateBundle", { + requiresDependencies: true, + taskFunction: async ({workspace, dependencies, taskUtil, options}) => { + const generateBundleTask = await getTask("generateBundle"); + // Async resolve default values for bundle definitions and options + const bundlesDefaults = await enhanceBundlesWithDefaults(bundles, taskUtil.getProject()); + + return bundlesDefaults.reduce(async function(sequence, bundle) { + return sequence.then(function() { + return generateBundleTask.task({ + workspace, + dependencies, + taskUtil, + options: { + projectName: options.projectName, + bundleDefinition: bundle.bundleDefinition, + bundleOptions: bundle.bundleOptions + } + }); + }); + }, Promise.resolve()); + } + }); + } else { + // No bundles defined. Just set task so that it can be referenced by custom tasks + tasks.set("generateBundle", { + taskFunction: null + }); + } + + tasks.set("generateVersionInfo", { + requiresDependencies: true, + options: { + rootProject: project, + pattern: "/resources/**/.library" + } + }); + + tasks.set("generateCachebusterInfo", { + options: { + signatureType: project.getCachebusterSignatureType(), + } + }); + + tasks.set("generateResourcesJson", {requiresDependencies: true}); + + return tasks; +} diff --git a/packages/project/lib/specifications/Specification.js b/packages/project/lib/specifications/Specification.js index 71553e73af7..02bdc58036e 100644 --- a/packages/project/lib/specifications/Specification.js +++ b/packages/project/lib/specifications/Specification.js @@ -39,6 +39,9 @@ class Specification { case "application": { return createAndInitializeSpec("types/Application.js", parameters); } + case "component": { + return createAndInitializeSpec("types/Component.js", parameters); + } case "library": { return createAndInitializeSpec("types/Library.js", parameters); } diff --git a/packages/project/lib/specifications/types/Component.js b/packages/project/lib/specifications/types/Component.js new file mode 100644 index 00000000000..fbf77f94ec8 --- /dev/null +++ b/packages/project/lib/specifications/types/Component.js @@ -0,0 +1,303 @@ +import fsPath from "node:path"; +import posixPath from "node:path/posix"; +import ComponentProject from "../ComponentProject.js"; +import {createReader} from "@ui5/fs/resourceFactory"; + +/** + * Component + * + * @public + * @class + * @alias @ui5/project/specifications/types/Component + * @extends @ui5/project/specifications/ComponentProject + * @hideconstructor + */ +class Component extends ComponentProject { + constructor(parameters) { + super(parameters); + + this._pManifests = Object.create(null); + + this._srcPath = "src"; + this._testPath = "test"; + this._testPathExists = false; + + this._propertiesFilesSourceEncoding = "UTF-8"; + } + + /* === Attributes === */ + + /** + * Get the cachebuster signature type configuration of the project + * + * @returns {string} time or hash + */ + getCachebusterSignatureType() { + return this._config.builder && this._config.builder.cachebuster && + this._config.builder.cachebuster.signatureType || "time"; + } + + /** + * Get the path of the project's source directory. This might not be POSIX-style on some platforms. + * + * @public + * @returns {string} Absolute path to the source directory of the project + */ + getSourcePath() { + return fsPath.join(this.getRootPath(), this._srcPath); + } + + getSourcePaths() { + const paths = [this.getSourcePath()]; + if (this._testPathExists) { + paths.push(fsPath.join(this.getRootPath(), this._testPath)); + } + return paths; + } + + getVirtualPath(sourceFilePath) { + const sourcePath = this.getSourcePath(); + if (sourceFilePath.startsWith(sourcePath)) { + const relSourceFilePath = fsPath.relative(sourcePath, sourceFilePath); + let virBasePath = "/resources/"; + if (!this._isSourceNamespaced) { + virBasePath += `${this._namespace}/`; + } + return posixPath.join(virBasePath, relSourceFilePath); + } + + const testPath = fsPath.join(this.getRootPath(), this._testPath); + if (sourceFilePath.startsWith(testPath)) { + const relSourceFilePath = fsPath.relative(testPath, sourceFilePath); + let virBasePath = "/test-resources/"; + if (!this._isSourceNamespaced) { + virBasePath += `${this._namespace}/`; + } + return posixPath.join(virBasePath, relSourceFilePath); + } + + throw new Error( + `Unable to convert source path ${sourceFilePath} to virtual path for project ${this.getName()}`); + } + + /* === Resource Access === */ + /** + * Get a resource reader for the sources of the project (excluding any test resources) + * + * @param {string[]} excludes List of glob patterns to exclude + * @returns {@ui5/fs/ReaderCollection} Reader collection + */ + _getSourceReader(excludes) { + return createReader({ + fsBasePath: this.getSourcePath(), + virBasePath: `/resources/${this._namespace}/`, + name: `Source reader for component project ${this.getName()}`, + project: this, + excludes + }); + } + + /** + * Get a resource reader for the test-resources of the project + * + * @param {string[]} excludes List of glob patterns to exclude + * @returns {@ui5/fs/ReaderCollection} Reader collection + */ + _getTestReader(excludes) { + if (!this._testPathExists) { + return null; + } + const testReader = createReader({ + fsBasePath: fsPath.join(this.getRootPath(), this._testPath), + virBasePath: `/test-resources/${this._namespace}/`, + name: `Runtime test-resources reader for component project ${this.getName()}`, + project: this, + excludes + }); + return testReader; + } + + /** + * Get a resource reader for the sources of the project (excluding any test resources) + * without a virtual base path + * + * @returns {@ui5/fs/ReaderCollection} Reader collection + */ + _getRawSourceReader() { + return createReader({ + fsBasePath: this.getSourcePath(), + virBasePath: "/", + name: `Raw source reader for component project ${this.getName()}`, + project: this + }); + } + + /* === Internals === */ + /** + * @private + * @param {object} config Configuration object + */ + async _configureAndValidatePaths(config) { + await super._configureAndValidatePaths(config); + + if (config.resources && config.resources.configuration && config.resources.configuration.paths) { + if (config.resources.configuration.paths.src) { + this._srcPath = config.resources.configuration.paths.src; + } + if (config.resources.configuration.paths.test) { + this._testPath = config.resources.configuration.paths.test; + } + } + if (!(await this._dirExists("/" + this._srcPath))) { + throw new Error( + `Unable to find source directory '${this._srcPath}' in component project ${this.getName()}`); + } + this._testPathExists = await this._dirExists("/" + this._testPath); + + this._log.verbose(`Path mapping for component project ${this.getName()}:`); + this._log.verbose(` Physical root path: ${this.getRootPath()}`); + this._log.verbose(` Mapped to:`); + this._log.verbose(` /resources/ => ${this._srcPath}`); + this._log.verbose( + ` /test-resources/ => ${this._testPath}${this._testPathExists ? "" : " [does not exist]"}`); + } + + /** + * @private + * @param {object} config Configuration object + * @param {object} buildDescription Cache metadata object + */ + async _parseConfiguration(config, buildDescription) { + await super._parseConfiguration(config, buildDescription); + + if (buildDescription) { + this._namespace = buildDescription.namespace; + return; + } + this._namespace = await this._getNamespace(); + await this._ensureComponent(); + } + + /** + * Determine component namespace either based on a project`s + * manifest.json or manifest.appdescr_variant (fallback if present) + * + * @returns {string} Namespace of the project + * @throws {Error} if namespace can not be determined + */ + async _getNamespace() { + try { + return await this._getNamespaceFromManifestJson(); + } catch (manifestJsonError) { + if (manifestJsonError.code !== "ENOENT") { + throw manifestJsonError; + } + // No manifest.json present + // => attempt fallback to manifest.appdescr_variant (typical for App Variants) + try { + return await this._getNamespaceFromManifestAppDescVariant(); + } catch (appDescVarError) { + if (appDescVarError.code === "ENOENT") { + // Fallback not possible: No manifest.appdescr_variant present + // => Throw error indicating missing manifest.json + // (do not mention manifest.appdescr_variant since it is only + // relevant for the rather "uncommon" App Variants) + throw new Error( + `Could not find required manifest.json for project ` + + `${this.getName()}: ${manifestJsonError.message}`); + } + throw appDescVarError; + } + } + } + + /** + * Determine application namespace by checking manifest.json. + * Any maven placeholders are resolved from the projects pom.xml + * + * @returns {string} Namespace of the project + * @throws {Error} if namespace can not be determined + */ + async _getNamespaceFromManifestJson() { + const manifest = await this._getManifest("/manifest.json"); + let appId; + // check for a proper sap.app/id in manifest.json to determine namespace + if (manifest["sap.app"] && manifest["sap.app"].id) { + appId = manifest["sap.app"].id; + } else { + throw new Error( + `No sap.app/id configuration found in manifest.json of project ${this.getName()}`); + } + + if (this._hasMavenPlaceholder(appId)) { + try { + appId = await this._resolveMavenPlaceholder(appId); + } catch (err) { + throw new Error( + `Failed to resolve namespace of project ${this.getName()}: ${err.message}`); + } + } + const namespace = appId.replace(/\./g, "/"); + this._log.verbose( + `Namespace of project ${this.getName()} is ${namespace} (from manifest.json)`); + return namespace; + } + + /** + * Determine application namespace by checking manifest.appdescr_variant. + * + * @returns {string} Namespace of the project + * @throws {Error} if namespace can not be determined + */ + async _getNamespaceFromManifestAppDescVariant() { + const manifest = await this._getManifest("/manifest.appdescr_variant"); + let appId; + // check for the id property in manifest.appdescr_variant to determine namespace + if (manifest && manifest.id) { + appId = manifest.id; + } else { + throw new Error( + `No "id" property found in manifest.appdescr_variant of project ${this.getName()}`); + } + + const namespace = appId.replace(/\./g, "/"); + this._log.verbose( + `Namespace of project ${this.getName()} is ${namespace} (from manifest.appdescr_variant)`); + return namespace; + } + + /** + * Reads and parses a JSON file with the provided name from the projects source directory + * + * @param {string} filePath Name of the JSON file to read. Typically "manifest.json" or "manifest.appdescr_variant" + * @returns {Promise} resolves with an object containing the content requested manifest file + */ + async _getManifest(filePath) { + if (this._pManifests[filePath]) { + return this._pManifests[filePath]; + } + return this._pManifests[filePath] = this._getRawSourceReader().byPath(filePath) + .then(async (resource) => { + if (!resource) { + throw new Error( + `Could not find resource ${filePath} in project ${this.getName()}`); + } + return JSON.parse(await resource.getString()); + }).catch((err) => { + throw new Error( + `Failed to read ${filePath} for project ` + + `${this.getName()}: ${err.message}`); + }); + } + + async _ensureComponent() { + // Ensure that a Component.js exists + const componentResource = await this._getRawSourceReader().byPath("/Component.js"); + if (!componentResource) { + throw new Error( + `Unable to find required file Component.js in component project ${this.getName()}`); + } + } +} + +export default Component; diff --git a/packages/project/lib/validation/schema/specVersion/kind/project.json b/packages/project/lib/validation/schema/specVersion/kind/project.json index 278dd340d6a..55761a15600 100644 --- a/packages/project/lib/validation/schema/specVersion/kind/project.json +++ b/packages/project/lib/validation/schema/specVersion/kind/project.json @@ -4,6 +4,26 @@ "type": "object", "required": ["specVersion", "type"], + "allOf": [ + { + "if": { + "required": ["type"], + "properties": { + "type": { + "const": "component" + } + } + }, + "then": { + "properties": { + "specVersion": { + "enum": ["5.0"], + "errorMessage": "The 'component' type is only supported with specVersion '5.0' and higher." + } + } + } + } + ], "properties": { "specVersion": { "enum": ["5.0", "4.0", "3.2", "3.1", "3.0", "2.6", "2.5", "2.4", "2.3", "2.2", "2.1", "2.0"] }, "kind": { @@ -14,6 +34,7 @@ "enum": [ "application", "library", + "component", "theme-library", "module" ] @@ -61,6 +82,16 @@ }, "then": { "$ref": "project/module.json" + }, + "else": { + "if": { + "properties": { + "type": {"const": "component"} + } + }, + "then": { + "$ref": "project/component.json" + } } } } diff --git a/packages/project/lib/validation/schema/specVersion/kind/project/component.json b/packages/project/lib/validation/schema/specVersion/kind/project/component.json new file mode 100644 index 00000000000..15e32a8f58e --- /dev/null +++ b/packages/project/lib/validation/schema/specVersion/kind/project/component.json @@ -0,0 +1,110 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "http://ui5.sap/schema/specVersion/kind/project/component.json", + + "type": "object", + "required": ["specVersion", "type", "metadata"], + "if": { + "properties": { + "specVersion": { "enum": ["5.0"] } + } + }, + "then": { + "additionalProperties": false, + "properties": { + "specVersion": { "enum": ["5.0"] }, + "kind": { + "enum": ["project", null] + }, + "type": { + "enum": ["component"] + }, + "metadata": { + "$ref": "../project.json#/definitions/metadata-3.0" + }, + "framework": { + "$ref": "../project.json#/definitions/framework" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "builder": { + "$ref": "#/definitions/builder-specVersion-4.0" + }, + "server": { + "$ref": "../project.json#/definitions/server" + }, + "customConfiguration": { + "type": "object", + "additionalProperties": true + } + } + }, + "else": { + }, + + "definitions": { + + "resources": { + "type": "object", + "additionalProperties": false, + "properties": { + "configuration": { + "type": "object", + "additionalProperties": false, + "properties": { + "propertiesFileSourceEncoding": { + "$ref": "../project.json#/definitions/resources-configuration-propertiesFileSourceEncoding" + }, + "paths": { + "type": "object", + "additionalProperties": false, + "properties": { + "src": { + "type": "string" + }, + "test": { + "type": "string" + } + } + } + } + } + } + }, + + "builder-specVersion-4.0": { + "type": "object", + "additionalProperties": false, + "properties": { + "resources": { + "$ref": "../project.json#/definitions/builder-resources" + }, + "cachebuster": { + "type": "object", + "additionalProperties": false, + "properties": { + "signatureType": { + "enum": ["time", "hash"] + } + } + }, + "bundles": { + "$ref": "../project.json#/definitions/builder-bundles-4.0" + }, + "componentPreload": { + "$ref": "../project.json#/definitions/builder-componentPreload-specVersion-2.3" + }, + "customTasks": { + "$ref": "../project.json#/definitions/customTasks" + }, + "minification": { + "$ref": "../project.json#/definitions/builder-minification" + }, + "settings": { + "$ref": "../project.json#/definitions/builder-settings" + } + } + } + } +} diff --git a/packages/project/test/fixtures/component.a/middleware.a.js b/packages/project/test/fixtures/component.a/middleware.a.js new file mode 100644 index 00000000000..ea41b01de46 --- /dev/null +++ b/packages/project/test/fixtures/component.a/middleware.a.js @@ -0,0 +1 @@ +module.exports = function () {}; diff --git a/packages/project/test/fixtures/component.a/package.json b/packages/project/test/fixtures/component.a/package.json new file mode 100644 index 00000000000..cd7457d2ba8 --- /dev/null +++ b/packages/project/test/fixtures/component.a/package.json @@ -0,0 +1,13 @@ +{ + "name": "component.a", + "version": "1.0.0", + "description": "Simple SAPUI5 based component", + "main": "index.html", + "dependencies": { + "library.d": "file:../library.d", + "collection": "file:../collection" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/project/test/fixtures/component.a/src/Component.js b/packages/project/test/fixtures/component.a/src/Component.js new file mode 100644 index 00000000000..6fd2c3ac2ba --- /dev/null +++ b/packages/project/test/fixtures/component.a/src/Component.js @@ -0,0 +1,8 @@ +sap.ui.define(["sap/ui/core/UIComponent"], function(UIComponent){ + "use strict"; + return UIComponent.extend('application.a.Component', { + metadata: { + manifest: "json" + } + }); +}); diff --git a/packages/project/test/fixtures/component.a/src/index.html b/packages/project/test/fixtures/component.a/src/index.html new file mode 100644 index 00000000000..77b0207cc80 --- /dev/null +++ b/packages/project/test/fixtures/component.a/src/index.html @@ -0,0 +1,9 @@ + + + + Application A + + + + + \ No newline at end of file diff --git a/packages/project/test/fixtures/component.a/src/manifest.json b/packages/project/test/fixtures/component.a/src/manifest.json new file mode 100644 index 00000000000..781945df9dc --- /dev/null +++ b/packages/project/test/fixtures/component.a/src/manifest.json @@ -0,0 +1,13 @@ +{ + "_version": "1.1.0", + "sap.app": { + "_version": "1.1.0", + "id": "id1", + "type": "application", + "applicationVersion": { + "version": "1.2.2" + }, + "embeds": ["embedded"], + "title": "{{title}}" + } +} \ No newline at end of file diff --git a/packages/project/test/fixtures/component.a/src/test.js b/packages/project/test/fixtures/component.a/src/test.js new file mode 100644 index 00000000000..a3df410c341 --- /dev/null +++ b/packages/project/test/fixtures/component.a/src/test.js @@ -0,0 +1,5 @@ +function test(paramA) { + var variableA = paramA; + console.log(variableA); +} +test(); diff --git a/packages/project/test/fixtures/component.a/task.a.js b/packages/project/test/fixtures/component.a/task.a.js new file mode 100644 index 00000000000..ea41b01de46 --- /dev/null +++ b/packages/project/test/fixtures/component.a/task.a.js @@ -0,0 +1 @@ +module.exports = function () {}; diff --git a/packages/project/test/fixtures/component.a/ui5-test-configPath.yaml b/packages/project/test/fixtures/component.a/ui5-test-configPath.yaml new file mode 100644 index 00000000000..68fa0177e47 --- /dev/null +++ b/packages/project/test/fixtures/component.a/ui5-test-configPath.yaml @@ -0,0 +1,7 @@ +--- +specVersion: "5.0" +type: component +metadata: + name: component.a +customConfiguration: + configPathTest: true \ No newline at end of file diff --git a/packages/project/test/fixtures/component.a/ui5-test-corrupt.yaml b/packages/project/test/fixtures/component.a/ui5-test-corrupt.yaml new file mode 100644 index 00000000000..ecce9d7e78b --- /dev/null +++ b/packages/project/test/fixtures/component.a/ui5-test-corrupt.yaml @@ -0,0 +1 @@ +|-\nfoo\nbar diff --git a/packages/project/test/fixtures/component.a/ui5-test-empty.yaml b/packages/project/test/fixtures/component.a/ui5-test-empty.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/project/test/fixtures/component.a/ui5-test-error.yaml b/packages/project/test/fixtures/component.a/ui5-test-error.yaml new file mode 100644 index 00000000000..2aa22544a42 --- /dev/null +++ b/packages/project/test/fixtures/component.a/ui5-test-error.yaml @@ -0,0 +1,7 @@ +--- +specVersion: "5.0" +type: component +metadata: + name: component.a +xyz: + foo: true \ No newline at end of file diff --git a/packages/project/test/fixtures/component.a/ui5.yaml b/packages/project/test/fixtures/component.a/ui5.yaml new file mode 100644 index 00000000000..fc96c5c4dcd --- /dev/null +++ b/packages/project/test/fixtures/component.a/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "5.0" +type: component +metadata: + name: component.a diff --git a/packages/project/test/fixtures/component.h/pom.xml b/packages/project/test/fixtures/component.h/pom.xml new file mode 100644 index 00000000000..7ee5daf7afc --- /dev/null +++ b/packages/project/test/fixtures/component.h/pom.xml @@ -0,0 +1,41 @@ + + + + + + + 4.0.0 + + + + + com.sap.test + component.h + 1.0.0 + war + + + + + component.h + Simple SAPUI5 based component + + + + + + + component.h + + + + + diff --git a/packages/project/test/fixtures/component.h/src-no-component/manifest.json b/packages/project/test/fixtures/component.h/src-no-component/manifest.json new file mode 100644 index 00000000000..7d63e359cdf --- /dev/null +++ b/packages/project/test/fixtures/component.h/src-no-component/manifest.json @@ -0,0 +1,13 @@ +{ + "_version": "1.1.0", + "sap.app": { + "_version": "1.1.0", + "id": "${componentName}", + "type": "application", + "applicationVersion": { + "version": "1.2.2" + }, + "embeds": ["embedded"], + "title": "{{title}}" + } +} diff --git a/packages/project/test/fixtures/component.h/src-project.artifactId/Component.js b/packages/project/test/fixtures/component.h/src-project.artifactId/Component.js new file mode 100644 index 00000000000..cb9bd406864 --- /dev/null +++ b/packages/project/test/fixtures/component.h/src-project.artifactId/Component.js @@ -0,0 +1,8 @@ +sap.ui.define(["sap/ui/core/UIComponent"], function(UIComponent){ + "use strict"; + return UIComponent.extend('application.h.Component', { + metadata: { + manifest: "json" + } + }); +}); diff --git a/packages/project/test/fixtures/component.h/src-project.artifactId/manifest.json b/packages/project/test/fixtures/component.h/src-project.artifactId/manifest.json new file mode 100644 index 00000000000..7de6072ce82 --- /dev/null +++ b/packages/project/test/fixtures/component.h/src-project.artifactId/manifest.json @@ -0,0 +1,13 @@ +{ + "_version": "1.1.0", + "sap.app": { + "_version": "1.1.0", + "id": "${project.artifactId}", + "type": "application", + "applicationVersion": { + "version": "1.2.2" + }, + "embeds": ["embedded"], + "title": "{{title}}" + } +} diff --git a/packages/project/test/fixtures/component.h/src-properties.appId/Component.js b/packages/project/test/fixtures/component.h/src-properties.appId/Component.js new file mode 100644 index 00000000000..cb9bd406864 --- /dev/null +++ b/packages/project/test/fixtures/component.h/src-properties.appId/Component.js @@ -0,0 +1,8 @@ +sap.ui.define(["sap/ui/core/UIComponent"], function(UIComponent){ + "use strict"; + return UIComponent.extend('application.h.Component', { + metadata: { + manifest: "json" + } + }); +}); diff --git a/packages/project/test/fixtures/component.h/src-properties.appId/manifest.json b/packages/project/test/fixtures/component.h/src-properties.appId/manifest.json new file mode 100644 index 00000000000..e1515df7025 --- /dev/null +++ b/packages/project/test/fixtures/component.h/src-properties.appId/manifest.json @@ -0,0 +1,13 @@ +{ + "_version": "1.1.0", + "sap.app": { + "_version": "1.1.0", + "id": "${appId}", + "type": "application", + "applicationVersion": { + "version": "1.2.2" + }, + "embeds": ["embedded"], + "title": "{{title}}" + } +} diff --git a/packages/project/test/fixtures/component.h/src-properties.componentName/Component.js b/packages/project/test/fixtures/component.h/src-properties.componentName/Component.js new file mode 100644 index 00000000000..cb9bd406864 --- /dev/null +++ b/packages/project/test/fixtures/component.h/src-properties.componentName/Component.js @@ -0,0 +1,8 @@ +sap.ui.define(["sap/ui/core/UIComponent"], function(UIComponent){ + "use strict"; + return UIComponent.extend('application.h.Component', { + metadata: { + manifest: "json" + } + }); +}); diff --git a/packages/project/test/fixtures/component.h/src-properties.componentName/manifest.json b/packages/project/test/fixtures/component.h/src-properties.componentName/manifest.json new file mode 100644 index 00000000000..7d63e359cdf --- /dev/null +++ b/packages/project/test/fixtures/component.h/src-properties.componentName/manifest.json @@ -0,0 +1,13 @@ +{ + "_version": "1.1.0", + "sap.app": { + "_version": "1.1.0", + "id": "${componentName}", + "type": "application", + "applicationVersion": { + "version": "1.2.2" + }, + "embeds": ["embedded"], + "title": "{{title}}" + } +} diff --git a/packages/project/test/fixtures/component.h/webapp/Component.js b/packages/project/test/fixtures/component.h/webapp/Component.js new file mode 100644 index 00000000000..cb9bd406864 --- /dev/null +++ b/packages/project/test/fixtures/component.h/webapp/Component.js @@ -0,0 +1,8 @@ +sap.ui.define(["sap/ui/core/UIComponent"], function(UIComponent){ + "use strict"; + return UIComponent.extend('application.h.Component', { + metadata: { + manifest: "json" + } + }); +}); diff --git a/packages/project/test/fixtures/component.h/webapp/manifest.json b/packages/project/test/fixtures/component.h/webapp/manifest.json new file mode 100644 index 00000000000..32b7e4a8458 --- /dev/null +++ b/packages/project/test/fixtures/component.h/webapp/manifest.json @@ -0,0 +1,13 @@ +{ + "_version": "1.1.0", + "sap.app": { + "_version": "1.1.0", + "id": "application.h", + "type": "application", + "applicationVersion": { + "version": "1.2.2" + }, + "embeds": ["embedded"], + "title": "{{title}}" + } +} diff --git a/packages/project/test/fixtures/component.h/webapp/sectionsA/section1.js b/packages/project/test/fixtures/component.h/webapp/sectionsA/section1.js new file mode 100644 index 00000000000..ac4a8129651 --- /dev/null +++ b/packages/project/test/fixtures/component.h/webapp/sectionsA/section1.js @@ -0,0 +1,3 @@ +sap.ui.define(["sap/m/Button"], function(Button) { + console.log("Section 1 included"); +}); diff --git a/packages/project/test/fixtures/component.h/webapp/sectionsA/section2.js b/packages/project/test/fixtures/component.h/webapp/sectionsA/section2.js new file mode 100644 index 00000000000..e009c828602 --- /dev/null +++ b/packages/project/test/fixtures/component.h/webapp/sectionsA/section2.js @@ -0,0 +1,3 @@ +sap.ui.define(["sap/m/Button"], function(Button) { + console.log("Section 2 included"); +}); diff --git a/packages/project/test/fixtures/component.h/webapp/sectionsA/section3.js b/packages/project/test/fixtures/component.h/webapp/sectionsA/section3.js new file mode 100644 index 00000000000..5fd9349d49b --- /dev/null +++ b/packages/project/test/fixtures/component.h/webapp/sectionsA/section3.js @@ -0,0 +1,3 @@ +sap.ui.define(["sap/m/Button"], function(Button) { + console.log("Section 3 included"); +}); diff --git a/packages/project/test/fixtures/component.h/webapp/sectionsB/section1.js b/packages/project/test/fixtures/component.h/webapp/sectionsB/section1.js new file mode 100644 index 00000000000..ac4a8129651 --- /dev/null +++ b/packages/project/test/fixtures/component.h/webapp/sectionsB/section1.js @@ -0,0 +1,3 @@ +sap.ui.define(["sap/m/Button"], function(Button) { + console.log("Section 1 included"); +}); diff --git a/packages/project/test/fixtures/component.h/webapp/sectionsB/section2.js b/packages/project/test/fixtures/component.h/webapp/sectionsB/section2.js new file mode 100644 index 00000000000..e009c828602 --- /dev/null +++ b/packages/project/test/fixtures/component.h/webapp/sectionsB/section2.js @@ -0,0 +1,3 @@ +sap.ui.define(["sap/m/Button"], function(Button) { + console.log("Section 2 included"); +}); diff --git a/packages/project/test/fixtures/component.h/webapp/sectionsB/section3.js b/packages/project/test/fixtures/component.h/webapp/sectionsB/section3.js new file mode 100644 index 00000000000..5fd9349d49b --- /dev/null +++ b/packages/project/test/fixtures/component.h/webapp/sectionsB/section3.js @@ -0,0 +1,3 @@ +sap.ui.define(["sap/m/Button"], function(Button) { + console.log("Section 3 included"); +}); diff --git a/packages/project/test/lib/build/definitions/component.js b/packages/project/test/lib/build/definitions/component.js new file mode 100644 index 00000000000..abb281a86ed --- /dev/null +++ b/packages/project/test/lib/build/definitions/component.js @@ -0,0 +1,400 @@ +import test from "ava"; +import sinon from "sinon"; +import component from "../../../../lib/build/definitions/component.js"; + +function emptyarray() { + return []; +} + +function getMockProject() { + return { + getName: () => "project.b", + getNamespace: () => "project/b", + getType: () => "component", + getPropertiesFileSourceEncoding: () => "UTF-412", + getCopyright: () => "copyright", + getVersion: () => "version", + getSpecVersion: () => { + return { + toString: () => "5.0" + }; + }, + getMinificationExcludes: emptyarray, + getComponentPreloadPaths: emptyarray, + getComponentPreloadNamespaces: emptyarray, + getComponentPreloadExcludes: emptyarray, + getBundles: emptyarray, + getCachebusterSignatureType: () => "PONY", + getCustomTasks: emptyarray, + }; +} + +test.beforeEach((t) => { + t.context.project = getMockProject(); + t.context.taskUtil = { + getProject: sinon.stub().returns(t.context.project), + isRootProject: sinon.stub().returns(true), + getBuildOption: sinon.stub(), + getInterface: sinon.stub() + }; + + t.context.getTask = sinon.stub(); +}); + +test("Standard build", (t) => { + const {project, taskUtil, getTask} = t.context; + const tasks = component({ + project, taskUtil, getTask + }); + + t.deepEqual(Object.fromEntries(tasks), { + escapeNonAsciiCharacters: { + options: { + encoding: "UTF-412", pattern: "/**/*.properties" + } + }, + replaceCopyright: { + options: { + copyright: "copyright", pattern: "/**/*.{js,json}" + } + }, + replaceVersion: { + options: { + version: "version", pattern: "/**/*.{js,json}" + } + }, + minify: { + options: { + pattern: [ + "/**/*.js", + "!**/*.support.js", + ] + } + }, + enhanceManifest: {}, + generateFlexChangesBundle: {}, + generateComponentPreload: { + options: { + namespaces: ["project/b"], + excludes: [], + skipBundles: [] + } + }, + generateBundle: { + taskFunction: null + }, + generateVersionInfo: { + requiresDependencies: true, + options: { + rootProject: project, + pattern: "/resources/**/.library" + } + }, + generateCachebusterInfo: { + options: { + signatureType: "PONY" + } + }, + generateResourcesJson: { + requiresDependencies: true + } + }, "Correct task definitions"); + + t.is(taskUtil.getBuildOption.callCount, 0, "taskUtil#getBuildOption has not been called"); +}); + +test("Custom bundles", async (t) => { + const {project, taskUtil, getTask} = t.context; + project.getBundles = () => [{ + bundleDefinition: { + name: "project/b/sectionsA/customBundle.js", + defaultFileTypes: [".js"], + sections: [{ + mode: "preload", + filters: [ + "project/b/sectionsA/", + "!project/b/sectionsA/section2**", + ] + }] + }, + bundleOptions: { + optimize: true, + addTryCatchRestartWrapper: false, + decorateBootstrapModule: true, + numberOfParts: 1, + } + }, { + bundleDefinition: { + name: "project/b/sectionsB/customBundle.js", + defaultFileTypes: [".js"], + sections: [{ + mode: "preload", + filters: [ + "project/b/sectionsB/", + "!project/b/sectionsB/section2**", + ] + }] + }, + bundleOptions: { + optimize: false, + addTryCatchRestartWrapper: false, + decorateBootstrapModule: true, + numberOfParts: 1, + } + }]; + + const generateBundleTaskStub = sinon.stub(); + getTask.returns({ + task: generateBundleTaskStub + }); + + const tasks = component({ + project, taskUtil, getTask + }); + const generateBundleTaskDefinition = tasks.get("generateBundle"); + + t.deepEqual(Object.fromEntries(tasks), { + escapeNonAsciiCharacters: { + options: { + encoding: "UTF-412", pattern: "/**/*.properties" + } + }, + replaceCopyright: { + options: { + copyright: "copyright", pattern: "/**/*.{js,json}" + } + }, + replaceVersion: { + options: { + version: "version", pattern: "/**/*.{js,json}" + } + }, + minify: { + options: { + pattern: [ + "/**/*.js", + "!**/*.support.js", + ] + } + }, + enhanceManifest: {}, + generateFlexChangesBundle: {}, + generateComponentPreload: { + options: { + namespaces: ["project/b"], + excludes: [], + skipBundles: [ + "project/b/sectionsA/customBundle.js", + "project/b/sectionsB/customBundle.js" + ] + } + }, + generateBundle: { + requiresDependencies: true, + taskFunction: generateBundleTaskDefinition.taskFunction + }, + generateVersionInfo: { + requiresDependencies: true, + options: { + rootProject: project, + pattern: "/resources/**/.library" + } + }, + generateCachebusterInfo: { + options: { + signatureType: "PONY" + } + }, + generateResourcesJson: { + requiresDependencies: true + } + }, "Correct task definitions"); + + + await generateBundleTaskDefinition.taskFunction({ + workspace: "workspace", + dependencies: "dependencies", + taskUtil, + options: { + projectName: "projectName" + } + }); + + t.is(generateBundleTaskStub.callCount, 2, "generateBundle task got called twice"); + t.deepEqual(generateBundleTaskStub.getCall(0).args[0], { + workspace: "workspace", + dependencies: "dependencies", + taskUtil, + options: { + projectName: "projectName", + bundleDefinition: { + name: "project/b/sectionsA/customBundle.js", + defaultFileTypes: [".js"], + sections: [{ + mode: "preload", + filters: [ + "project/b/sectionsA/", + "!project/b/sectionsA/section2**", + ], + declareRawModules: false, + renderer: false, + resolve: false, + resolveConditional: false, + sort: true, + }] + }, + bundleOptions: { + optimize: true, + addTryCatchRestartWrapper: false, + decorateBootstrapModule: true, + numberOfParts: 1, + sourceMap: true, + } + } + }, "generateBundle task got called with correct arguments"); + t.deepEqual(generateBundleTaskStub.getCall(1).args[0], { + workspace: "workspace", + dependencies: "dependencies", + taskUtil, + options: { + projectName: "projectName", + bundleDefinition: { + name: "project/b/sectionsB/customBundle.js", + defaultFileTypes: [".js"], + sections: [{ + mode: "preload", + filters: [ + "project/b/sectionsB/", + "!project/b/sectionsB/section2**", + ], + declareRawModules: false, + renderer: false, + resolve: false, + resolveConditional: false, + sort: true, + }] + }, + bundleOptions: { + optimize: false, + addTryCatchRestartWrapper: false, + decorateBootstrapModule: true, + numberOfParts: 1, + sourceMap: true, + } + } + }, "generateBundle task got called with correct arguments"); +}); + +test("Minification excludes", (t) => { + const {project, taskUtil, getTask} = t.context; + project.getMinificationExcludes = () => ["**.html"]; + + const tasks = component({ + project, taskUtil, getTask + }); + + const taskDefinition = tasks.get("minify"); + t.deepEqual(taskDefinition, { + options: { + pattern: [ + "/**/*.js", + "!**/*.support.js", + "!/resources/**.html", + ] + } + }, "Correct minify task definition"); +}); + +test("generateComponentPreload with custom paths, excludes and custom bundle", (t) => { + const {project, taskUtil, getTask} = t.context; + project.getBundles = () => [{ + bundleDefinition: { + name: "project/b/sectionsA/customBundle.js", + defaultFileTypes: [".js"], + sections: [{ + mode: "preload", + filters: [ + "project/b/sectionsA/", + "!project/b/sectionsA/section2**", + ] + }] + }, + bundleOptions: { + optimize: true, + addTryCatchRestartWrapper: false, + decorateBootstrapModule: true, + numberOfParts: 1, + } + }]; + + project.getComponentPreloadPaths = () => [ + "project/b/**/Component.js", + "project/b/**/SubComponent.js" + ]; + project.getComponentPreloadExcludes = () => ["project/b/dir/**"]; + + const tasks = component({ + project, taskUtil, getTask + }); + + const taskDefinition = tasks.get("generateComponentPreload"); + t.deepEqual(taskDefinition, { + options: { + paths: [ + "project/b/**/Component.js", + "project/b/**/SubComponent.js" + ], + namespaces: [], + excludes: ["project/b/dir/**"], + skipBundles: [ + "project/b/sectionsA/customBundle.js" + ] + } + }, "Correct generateComponentPreload task definition"); +}); + +test("generateComponentPreload with custom namespaces and excludes", (t) => { + const {project, taskUtil, getTask} = t.context; + project.getComponentPreloadNamespaces = () => [ + "project/b/componentA", + "project/b/componentB" + ]; + project.getComponentPreloadExcludes = () => ["project/b/componentA/dir/**"]; + + const tasks = component({ + project, taskUtil, getTask + }); + + const taskDefinition = tasks.get("generateComponentPreload"); + t.deepEqual(taskDefinition, { + options: { + paths: [], + namespaces: [ + "project/b/componentA", + "project/b/componentB" + ], + excludes: ["project/b/componentA/dir/**"], + skipBundles: [] + } + }, "Correct generateComponentPreload task definition"); +}); + +test("generateComponentPreload with excludes", (t) => { + const {project, taskUtil, getTask} = t.context; + project.getComponentPreloadExcludes = () => ["project/b/componentA/dir/**"]; + + const tasks = component({ + project, taskUtil, getTask + }); + + const taskDefinition = tasks.get("generateComponentPreload"); + t.deepEqual(taskDefinition, { + options: { + namespaces: [ + "project/b", + ], + excludes: ["project/b/componentA/dir/**"], + skipBundles: [] + } + }, "Correct generateComponentPreload task definition"); +}); diff --git a/packages/project/test/lib/specifications/types/Component.js b/packages/project/test/lib/specifications/types/Component.js new file mode 100644 index 00000000000..fec0ae0c744 --- /dev/null +++ b/packages/project/test/lib/specifications/types/Component.js @@ -0,0 +1,688 @@ +import test from "ava"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {createResource} from "@ui5/fs/resourceFactory"; +import sinonGlobal from "sinon"; +import Specification from "../../../../lib/specifications/Specification.js"; +import Component from "../../../../lib/specifications/types/Component.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const componentAPath = path.join(__dirname, "..", "..", "..", "fixtures", "component.a"); +const componentHPath = path.join(__dirname, "..", "..", "..", "fixtures", "component.h"); + +test.beforeEach((t) => { + t.context.sinon = sinonGlobal.createSandbox(); + t.context.projectInput = { + id: "component.a.id", + version: "1.0.0", + modulePath: componentAPath, + configuration: { + specVersion: "5.0", + kind: "project", + type: "component", + metadata: {name: "component.a"} + } + }; + + t.context.componentHInput = { + id: "component.h.id", + version: "1.0.0", + modulePath: componentHPath, + configuration: { + specVersion: "5.0", + kind: "project", + type: "component", + metadata: {name: "component.h"}, + resources: { + configuration: { + paths: { + src: "webapp" + } + } + } + } + }; +}); + +test.afterEach.always((t) => { + t.context.sinon.restore(); +}); + +test("Correct class", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + t.true(project instanceof Component, `Is an instance of the Component class`); +}); + +test("getNamespace", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + t.is(project.getNamespace(), "id1", + "Returned correct namespace"); +}); + +test("getSourcePath", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + t.is(project.getSourcePath(), path.join(componentAPath, "src"), + "Returned correct source path"); +}); + +test("getCachebusterSignatureType: Default", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + t.is(project.getCachebusterSignatureType(), "time", + "Returned correct default cachebuster signature type configuration"); +}); + +test("getCachebusterSignatureType: Configuration", async (t) => { + const {projectInput} = t.context; + projectInput.configuration.builder = { + cachebuster: { + signatureType: "hash" + } + }; + const project = await Specification.create(projectInput); + t.is(project.getCachebusterSignatureType(), "hash", + "Returned correct default cachebuster signature type configuration"); +}); + +test("Access project resources via reader: buildtime style", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + const reader = project.getReader(); + const resource = await reader.byPath("/resources/id1/manifest.json"); + t.truthy(resource, "Found the requested resource"); + t.is(resource.getPath(), "/resources/id1/manifest.json", "Resource has correct path"); +}); + +test("Access project resources via reader: flat style", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + const reader = project.getReader({style: "flat"}); + const resource = await reader.byPath("/manifest.json"); + t.truthy(resource, "Found the requested resource"); + t.is(resource.getPath(), "/manifest.json", "Resource has correct path"); +}); + +test("Access project resources via reader: runtime style", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + const reader = project.getReader({style: "runtime"}); + const resource = await reader.byPath("/resources/id1/manifest.json"); + t.truthy(resource, "Found the requested resource"); + t.is(resource.getPath(), "/resources/id1/manifest.json", "Resource has correct path"); +}); + +test("Access project resources via reader w/ builder excludes", async (t) => { + const {projectInput} = t.context; + const baselineProject = await Specification.create(projectInput); + + projectInput.configuration.builder = { + resources: { + excludes: ["**/manifest.json"] + } + }; + const excludesProject = await Specification.create(projectInput); + + // We now have two projects: One with excludes and one without + // Always compare the results of both to make sure a file is really excluded because of the + // configuration and not because of a typo or because of it's absence in the fixture + + t.is((await baselineProject.getReader({}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for default style"); + t.is((await excludesProject.getReader({}).byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for default style"); + + t.is((await baselineProject.getReader({style: "buildtime"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for buildtime style"); + t.is((await excludesProject.getReader({style: "buildtime"}).byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for buildtime style"); + + t.is((await baselineProject.getReader({style: "dist"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for dist style"); + t.is((await excludesProject.getReader({style: "dist"}).byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for dist style"); + + t.is((await baselineProject.getReader({style: "flat"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for flat style"); + t.is((await excludesProject.getReader({style: "flat"}).byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for flat style"); + + t.is((await baselineProject.getReader({style: "runtime"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for runtime style"); + t.is((await excludesProject.getReader({style: "runtime"}).byGlob("**/manifest.json")).length, 1, + "Found excluded resource for runtime style"); +}); + +test("Access project resources via workspace w/ builder excludes", async (t) => { + const {projectInput} = t.context; + const baselineProject = await Specification.create(projectInput); + + projectInput.configuration.builder = { + resources: { + excludes: ["**/manifest.json"] + } + }; + const excludesProject = await Specification.create(projectInput); + + // We now have two projects: One with excludes and one without + // Always compare the results of both to make sure a file is really excluded because of the + // configuration and not because of a typo or because of it's absence in the fixture + + t.is((await baselineProject.getWorkspace().byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for default style"); + t.is((await excludesProject.getWorkspace().byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for default style"); +}); + +test("Access project resources w/ absolute builder excludes", async (t) => { + const {projectInput} = t.context; + const baselineProject = await Specification.create(projectInput); + + projectInput.configuration.builder = { + resources: { + excludes: ["/resources/id1/manifest.json"] + } + }; + const excludesProject = await Specification.create(projectInput); + + // We now have two projects: One with excludes and one without + // Always compare the results of both to make sure a file is really excluded because of the + // configuration and not because of a typo or because of it's absence in the fixture + + t.is((await baselineProject.getReader({}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for default style"); + t.is((await excludesProject.getReader({}).byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for default style"); + + t.is((await baselineProject.getReader({style: "buildtime"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for buildtime style"); + t.is((await excludesProject.getReader({style: "buildtime"}).byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for buildtime style"); + + t.is((await baselineProject.getReader({style: "dist"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for dist style"); + t.is((await excludesProject.getReader({style: "dist"}).byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for dist style"); + + t.is((await baselineProject.getReader({style: "flat"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for flat style"); + t.is((await excludesProject.getReader({style: "flat"}).byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for flat style"); + + // Excludes are not applied for "runtime" style + t.is((await baselineProject.getReader({style: "runtime"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for runtime style"); + t.is((await excludesProject.getReader({style: "runtime"}).byGlob("**/manifest.json")).length, 1, + "Found excluded resource for runtime style"); + + t.is((await baselineProject.getWorkspace().byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for default style"); + t.is((await excludesProject.getWorkspace().byGlob("**/manifest.json")).length, 0, + "Did not find excluded resource for default style"); +}); + +test("Access project resources w/ relative builder excludes", async (t) => { + const {projectInput} = t.context; + const baselineProject = await Specification.create(projectInput); + + projectInput.configuration.builder = { + resources: { + excludes: ["manifest.json"] // Has no effect since component excludes must be absolute or use wildcards + } + }; + const excludesProject = await Specification.create(projectInput); + + // We now have two projects: One with excludes and one without + // Always compare the results of both to make sure a file is really excluded because of the + // configuration and not because of a typo or because of it's absence in the fixture + + t.is((await baselineProject.getReader({}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for default style"); + t.is((await excludesProject.getReader({}).byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for default style"); + + t.is((await baselineProject.getReader({style: "buildtime"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for buildtime style"); + t.is((await excludesProject.getReader({style: "buildtime"}).byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for buildtime style"); + + t.is((await baselineProject.getReader({style: "dist"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for dist style"); + t.is((await excludesProject.getReader({style: "dist"}).byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for dist style"); + + t.is((await baselineProject.getReader({style: "flat"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for flat style"); + t.is((await excludesProject.getReader({style: "flat"}).byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for flat style"); + + // Excludes are not applied for "runtime" style + t.is((await baselineProject.getReader({style: "runtime"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for runtime style"); + t.is((await excludesProject.getReader({style: "runtime"}).byGlob("**/manifest.json")).length, 1, + "Found excluded resource for runtime style"); + + t.is((await baselineProject.getWorkspace().byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for default style"); + t.is((await excludesProject.getWorkspace().byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for default style"); +}); + +test("Access project resources w/ incorrect builder excludes", async (t) => { + const {projectInput} = t.context; + const baselineProject = await Specification.create(projectInput); + + projectInput.configuration.builder = { + resources: { + excludes: ["/manifest.json"] + } + }; + const excludesProject = await Specification.create(projectInput); + + // We now have two projects: One with excludes and one without + // Always compare the results of both to make sure a file is really excluded because of the + // configuration and not because of a typo or because of it's absence in the fixture + + t.is((await baselineProject.getReader({}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for default style"); + t.is((await excludesProject.getReader({}).byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for default style"); + + t.is((await baselineProject.getReader({style: "buildtime"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for buildtime style"); + t.is((await excludesProject.getReader({style: "buildtime"}).byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for buildtime style"); + + t.is((await baselineProject.getReader({style: "dist"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for dist style"); + t.is((await excludesProject.getReader({style: "dist"}).byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for dist style"); + + t.is((await baselineProject.getReader({style: "flat"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for flat style"); + t.is((await excludesProject.getReader({style: "flat"}).byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for flat style"); + + // Excludes are not applied for "runtime" style + t.is((await baselineProject.getReader({style: "runtime"}).byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for runtime style"); + t.is((await excludesProject.getReader({style: "runtime"}).byGlob("**/manifest.json")).length, 1, + "Found excluded resource for runtime style"); + + t.is((await baselineProject.getWorkspace().byGlob("**/manifest.json")).length, 1, + "Found resource in baseline project for default style"); + t.is((await excludesProject.getWorkspace().byGlob("**/manifest.json")).length, 1, + "Did not find excluded resource for default style"); +}); + +test("Modify project resources via workspace and access via flat and runtime readers", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + const workspace = project.getWorkspace(); + const workspaceResource = await workspace.byPath("/resources/id1/index.html"); + t.truthy(workspaceResource, "Found resource in workspace"); + + const newContent = (await workspaceResource.getString()).replace("Component A", "Some Name"); + workspaceResource.setString(newContent); + await workspace.write(workspaceResource); + + const flatReader = project.getReader({style: "flat"}); + const flatReaderResource = await flatReader.byPath("/index.html"); + t.truthy(flatReaderResource, "Found the requested resource byPath"); + t.is(flatReaderResource.getPath(), "/index.html", "Resource (byPath) has correct path"); + t.is(await flatReaderResource.getString(), newContent, "Found resource (byPath) has expected (changed) content"); + + const flatGlobResult = await flatReader.byGlob("**/index.html"); + t.is(flatGlobResult.length, 1, "Found the requested resource byGlob"); + t.is(flatGlobResult[0].getPath(), "/index.html", "Resource (byGlob) has correct path"); + t.is(await flatGlobResult[0].getString(), newContent, "Found resource (byGlob) has expected (changed) content"); + + const runtimeReader = project.getReader({style: "runtime"}); + const runtimeReaderResource = await runtimeReader.byPath("/resources/id1/index.html"); + t.truthy(runtimeReaderResource, "Found the requested resource byPath"); + t.is(runtimeReaderResource.getPath(), "/resources/id1/index.html", "Resource (byPath) has correct path"); + t.is(await runtimeReaderResource.getString(), newContent, "Found resource (byPath) has expected (changed) content"); + + const runtimeGlobResult = await runtimeReader.byGlob("**/index.html"); + t.is(runtimeGlobResult.length, 1, "Found the requested resource byGlob"); + t.is(runtimeGlobResult[0].getPath(), "/resources/id1/index.html", "Resource (byGlob) has correct path"); + t.is(await runtimeGlobResult[0].getString(), newContent, "Found resource (byGlob) has expected (changed) content"); +}); + + +test("Read and write resources outside of app namespace", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + const workspace = project.getWorkspace(); + + await workspace.write(createResource({ + path: "/resources/my-custom-bundle.js" + })); + + const buildtimeReader = project.getReader({style: "buildtime"}); + const buildtimeReaderResource = await buildtimeReader.byPath("/resources/my-custom-bundle.js"); + t.truthy(buildtimeReaderResource, "Found the requested resource byPath (buildtime)"); + t.is(buildtimeReaderResource.getPath(), "/resources/my-custom-bundle.js", + "Resource (byPath) has correct path (buildtime)"); + + const buildtimeGlobResult = await buildtimeReader.byGlob("**/my-custom-bundle.js"); + t.is(buildtimeGlobResult.length, 1, "Found the requested resource byGlob (buildtime)"); + t.is(buildtimeGlobResult[0].getPath(), "/resources/my-custom-bundle.js", + "Resource (byGlob) has correct path (buildtime)"); + + const flatReader = project.getReader({style: "flat"}); + const flatReaderResource = await flatReader.byPath("/resources/my-custom-bundle.js"); + t.falsy(flatReaderResource, "Resource outside of app namespace can't be read using flat reader"); + + const flatGlobResult = await flatReader.byGlob("**/my-custom-bundle.js"); + t.is(flatGlobResult.length, 0, "Resource outside of app namespace can't be found using flat reader"); + + const runtimeReader = project.getReader({style: "runtime"}); + const runtimeReaderResource = await runtimeReader.byPath("/resources/my-custom-bundle.js"); + t.truthy(runtimeReaderResource, "Found the requested resource byPath (runtime)"); + t.is(runtimeReaderResource.getPath(), "/resources/my-custom-bundle.js", + "Resource (byPath) has correct path (runtime)"); + + const runtimeGlobResult = await runtimeReader.byGlob("**/my-custom-bundle.js"); + t.is(runtimeGlobResult.length, 1, "Found the requested resource byGlob (runtime)"); + t.is(runtimeGlobResult[0].getPath(), "/resources/my-custom-bundle.js", + "Resource (byGlob) has correct path (runtime)"); +}); + +test("_configureAndValidatePaths: Default paths", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + + t.is(project._srcPath, "src", "Correct default path"); +}); + +test("_configureAndValidatePaths: Custom src directory", async (t) => { + const componentHPath = path.join(__dirname, "..", "..", "..", "fixtures", "component.h"); + const projectInput = { + id: "component.h.id", + version: "1.0.0", + modulePath: componentHPath, + configuration: { + specVersion: "5.0", + kind: "project", + type: "component", + metadata: {name: "component.h"}, + resources: { + configuration: { + paths: { + src: "src-properties.componentName" + } + } + } + } + }; + + const project = await Specification.create(projectInput); + + t.is(project._srcPath, "src-properties.componentName", "Correct path for src"); +}); + +test("_configureAndValidatePaths: src directory does not exist", async (t) => { + const {projectInput} = t.context; + projectInput.configuration.resources = { + configuration: { + paths: { + src: "does/not/exist" + } + } + }; + const err = await t.throwsAsync(Specification.create(projectInput)); + + t.is(err.message, "Unable to find source directory 'does/not/exist' in component project component.a"); +}); + +test("_getNamespaceFromManifestJson: No 'sap.app' configuration found", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + sinon.stub(project, "_getManifest").resolves({}); + + const error = await t.throwsAsync(project._getNamespaceFromManifestJson()); + t.is(error.message, "No sap.app/id configuration found in manifest.json of project component.a", + "Rejected with correct error message"); +}); + +test("_getNamespaceFromManifestJson: No component id in 'sap.app' configuration found", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + sinon.stub(project, "_getManifest").resolves({"sap.app": {}}); + + const error = await t.throwsAsync(project._getNamespaceFromManifestJson()); + t.is(error.message, "No sap.app/id configuration found in manifest.json of project component.a"); +}); + +test("_getNamespaceFromManifestJson: set namespace to id", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + sinon.stub(project, "_getManifest").resolves({"sap.app": {id: "my.id"}}); + + const namespace = await project._getNamespaceFromManifestJson(); + t.is(namespace, "my/id", "Returned correct namespace"); +}); + +test("_getNamespaceFromManifestAppDescVariant: No 'id' property found", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + sinon.stub(project, "_getManifest").resolves({}); + + const error = await t.throwsAsync(project._getNamespaceFromManifestAppDescVariant()); + t.is(error.message, `No "id" property found in manifest.appdescr_variant of project component.a`, + "Rejected with correct error message"); +}); + +test("_getNamespaceFromManifestAppDescVariant: set namespace to id", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + sinon.stub(project, "_getManifest").resolves({id: "my.id"}); + + const namespace = await project._getNamespaceFromManifestAppDescVariant(); + t.is(namespace, "my/id", "Returned correct namespace"); +}); + +test("_getNamespace: Correct fallback to manifest.appdescr_variant if manifest.json is missing", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + const _getManifestStub = sinon.stub(project, "_getManifest") + .onFirstCall().rejects({code: "ENOENT"}) + .onSecondCall().resolves({id: "my.id"}); + + const namespace = await project._getNamespace(); + t.is(namespace, "my/id", "Returned correct namespace"); + t.is(_getManifestStub.callCount, 2, "_getManifest called exactly twice"); + t.is(_getManifestStub.getCall(0).args[0], "/manifest.json", "_getManifest called for manifest.json first"); + t.is(_getManifestStub.getCall(1).args[0], "/manifest.appdescr_variant", + "_getManifest called for manifest.appdescr_variant in fallback"); +}); + +test("_getNamespace: Correct error message if fallback to manifest.appdescr_variant failed", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + const _getManifestStub = sinon.stub(project, "_getManifest") + .onFirstCall().rejects({code: "ENOENT"}) + .onSecondCall().rejects(new Error("EPON: Pony Error")); + + const error = await t.throwsAsync(project._getNamespace()); + t.is(error.message, "EPON: Pony Error", + "Rejected with correct error message"); + t.is(_getManifestStub.callCount, 2, "_getManifest called exactly twice"); + t.is(_getManifestStub.getCall(0).args[0], "/manifest.json", "_getManifest called for manifest.json first"); + t.is(_getManifestStub.getCall(1).args[0], "/manifest.appdescr_variant", + "_getManifest called for manifest.appdescr_variant in fallback"); +}); + +test("_getNamespace: Correct error message if fallback to manifest.appdescr_variant is not possible", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + const _getManifestStub = sinon.stub(project, "_getManifest") + .onFirstCall().rejects({message: "No such stable or directory: manifest.json", code: "ENOENT"}) + .onSecondCall().rejects({code: "ENOENT"}); // both files are missing + + const error = await t.throwsAsync(project._getNamespace()); + t.deepEqual(error.message, + "Could not find required manifest.json for project component.a: " + + "No such stable or directory: manifest.json", + "Rejected with correct error message"); + + t.is(_getManifestStub.callCount, 2, "_getManifest called exactly twice"); + t.is(_getManifestStub.getCall(0).args[0], "/manifest.json", "_getManifest called for manifest.json first"); + t.is(_getManifestStub.getCall(1).args[0], "/manifest.appdescr_variant", + "_getManifest called for manifest.appdescr_variant in fallback"); +}); + +test("_getNamespace: No fallback if manifest.json is present but failed to parse", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + const _getManifestStub = sinon.stub(project, "_getManifest") + .onFirstCall().rejects(new Error("EPON: Pony Error")); + + const error = await t.throwsAsync(project._getNamespace()); + t.is(error.message, "EPON: Pony Error", + "Rejected with correct error message"); + + t.is(_getManifestStub.callCount, 1, "_getManifest called exactly once"); + t.is(_getManifestStub.getCall(0).args[0], "/manifest.json", "_getManifest called for manifest.json only"); +}); + +test("_getManifest: reads correctly", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + + const content = await project._getManifest("/manifest.json"); + t.is(content._version, "1.1.0", "manifest.json content has been read"); +}); + +test("_getManifest: invalid JSON", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + + const byPathStub = sinon.stub().resolves({ + getString: async () => "no json" + }); + + project._getRawSourceReader = () => { + return { + byPath: byPathStub + }; + }; + + const error = await t.throwsAsync(project._getManifest("/some-manifest.json")); + t.regex(error.message, /^Failed to read \/some-manifest\.json for project component\.a: /, + "Rejected with correct error message"); + t.is(byPathStub.callCount, 1, "byPath got called once"); + t.is(byPathStub.getCall(0).args[0], "/some-manifest.json", "byPath got called with the correct argument"); +}); + +test.serial("_getManifest: File does not exist", async (t) => { + const {projectInput} = t.context; + const project = await Specification.create(projectInput); + + const error = await t.throwsAsync(project._getManifest("/does-not-exist.json")); + t.deepEqual(error.message, + "Failed to read /does-not-exist.json for project component.a: " + + "Could not find resource /does-not-exist.json in project component.a", + "Rejected with correct error message"); +}); + +test.serial("_getManifest: result is cached", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + + const byPathStub = sinon.stub().resolves({ + getString: async () => `{"pony": "no unicorn"}` + }); + + project._getRawSourceReader = () => { + return { + byPath: byPathStub + }; + }; + + const content = await project._getManifest("/some-manifest.json"); + t.deepEqual(content, {pony: "no unicorn"}, "Correct result on first call"); + + const content2 = await project._getManifest("/some-other-manifest.json"); + t.deepEqual(content2, {pony: "no unicorn"}, "Correct result on second call"); + + t.is(byPathStub.callCount, 2, "byPath got called exactly twice (and then cached)"); +}); + +test.serial("_getManifest: Caches successes and failures", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + + const getStringStub = sinon.stub() + .onFirstCall().rejects(new Error("EPON: Pony Error")) + .onSecondCall().resolves(`{"pony": "no unicorn"}`); + const byPathStub = sinon.stub().resolves({ + getString: getStringStub + }); + + project._getRawSourceReader = () => { + return { + byPath: byPathStub + }; + }; + + const error = await t.throwsAsync(project._getManifest("/some-manifest.json")); + t.deepEqual(error.message, + "Failed to read /some-manifest.json for project component.a: " + + "EPON: Pony Error", + "Rejected with correct error message"); + + const content = await project._getManifest("/some-other.manifest.json"); + t.deepEqual(content, {pony: "no unicorn"}, "Correct result on second call"); + + const error2 = await t.throwsAsync(project._getManifest("/some-manifest.json")); + t.deepEqual(error2.message, + "Failed to read /some-manifest.json for project component.a: " + + "EPON: Pony Error", + "From cache: Rejected with correct error message"); + + const content2 = await project._getManifest("/some-other.manifest.json"); + t.deepEqual(content2, {pony: "no unicorn"}, "From cache: Correct result on first call"); + + t.is(byPathStub.callCount, 2, + "byPath got called exactly twice (and then cached)"); +}); + +test("namespace: detect namespace from pom.xml via ${project.artifactId}", async (t) => { + const {componentHInput} = t.context; + componentHInput.configuration.resources.configuration.paths.src = "src-project.artifactId"; + const project = await Specification.create(componentHInput); + + t.is(project.getNamespace(), "component/h", + "namespace was successfully set since getJson provides the correct object structure"); +}); + +test("namespace: detect namespace from pom.xml via ${componentName} from properties", async (t) => { + const {componentHInput} = t.context; + componentHInput.configuration.resources.configuration.paths.src = "src-properties.componentName"; + const project = await Specification.create(componentHInput); + + t.is(project.getNamespace(), "component/h", + "namespace was successfully set since getJson provides the correct object structure"); +}); + +test("namespace: detect namespace from pom.xml via ${appId} from properties", async (t) => { + const {componentHInput} = t.context; + componentHInput.configuration.resources.configuration.paths.src = "src-properties.appId"; + + const error = await t.throwsAsync(Specification.create(componentHInput)); + t.deepEqual(error.message, "Failed to resolve namespace of project component.h: \"${appId}\"" + + " couldn't be resolved from maven property \"appId\" of pom.xml of project component.h"); +}); + +test("Throw for missing Component.js", async (t) => { + const {componentHInput} = t.context; + componentHInput.configuration.resources.configuration.paths.src = "src-no-component"; + + const error = await t.throwsAsync(Specification.create(componentHInput)); + t.is(error.message, + "Unable to find required file Component.js in component project component.h"); +}); diff --git a/packages/project/test/lib/validation/schema/__helper__/builder-bundleOptions.js b/packages/project/test/lib/validation/schema/__helper__/builder-bundleOptions.js index 144590c1740..967da600b4d 100644 --- a/packages/project/test/lib/validation/schema/__helper__/builder-bundleOptions.js +++ b/packages/project/test/lib/validation/schema/__helper__/builder-bundleOptions.js @@ -5,15 +5,20 @@ import SpecificationVersion from "../../../../../lib/specifications/Specificatio */ export default { /** - * Executes the tests for different kind of projects, e.g. "application", "library" + * Executes the tests for different kind of projects, e.g. "application", "library", "component" * * @param {Function} test ava test * @param {Function} assertValidation assertion function - * @param {string} type one of "application", "library" + * @param {string} type one of "application", "library", "component" */ defineTests: function(test, assertValidation, type) { // Version specific tests SpecificationVersion.getVersionsForRange(">=4.0").forEach(function(specVersion) { + if (type === "component" && SpecificationVersion.lt(specVersion, "5.0")) { + // Component type only became available with specVersion 5.0 + return; + } + test(`${type} (specVersion ${specVersion}): builder/bundles/bundleOptions`, async (t) => { await assertValidation(t, { "specVersion": specVersion, @@ -134,6 +139,9 @@ export default { }); }); + if (type === "component") { // Component type only became available with specVersion 5.0 + return; + } SpecificationVersion.getVersionsForRange("3.0 - 3.2").forEach(function(specVersion) { test(`${type} (specVersion ${specVersion}): builder/bundles/bundleOptions`, async (t) => { await assertValidation(t, { diff --git a/packages/project/test/lib/validation/schema/__helper__/customConfiguration.js b/packages/project/test/lib/validation/schema/__helper__/customConfiguration.js index 34db358c983..66ca7633f92 100644 --- a/packages/project/test/lib/validation/schema/__helper__/customConfiguration.js +++ b/packages/project/test/lib/validation/schema/__helper__/customConfiguration.js @@ -11,33 +11,40 @@ export default { * @param {Function} test ava test * @param {Function} assertValidation assertion function * @param {string} type one of "project-shim", "server-middleware" "task", - * "application", "library", "theme-library" and "module" + * "application", "library", "component", theme-library" and "module" * @param {object} additionalConfiguration additional configuration content */ defineTests: function(test, assertValidation, type, additionalConfiguration) { additionalConfiguration = additionalConfiguration || {}; // version specific tests for customConfiguration - test(`${type}: Invalid customConfiguration (specVersion 2.0)`, async (t) => { - await assertValidation(t, Object.assign({ - "specVersion": "2.0", - "type": type, - "metadata": { - "name": "my-" + type - }, - "customConfiguration": {} - }, additionalConfiguration), [ - { - dataPath: "", - keyword: "additionalProperties", - message: "should NOT have additional properties", - params: { - additionalProperty: "customConfiguration", + + if (type !== "component") { // Component type only became available with specVersion 5.0 + test(`${type}: Invalid customConfiguration (specVersion 2.0)`, async (t) => { + await assertValidation(t, Object.assign({ + "specVersion": "2.0", + "type": type, + "metadata": { + "name": "my-" + type + }, + "customConfiguration": {} + }, additionalConfiguration), [ + { + dataPath: "", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "customConfiguration", + } } - } - ]); - }); + ]); + }); + } SpecificationVersion.getVersionsForRange(">=2.1").forEach((specVersion) => { + if (type === "component" && SpecificationVersion.lt(specVersion, "5.0")) { + // Component type only became available with specVersion 5.0 + return; + } test(`${type}: Valid customConfiguration (specVersion ${specVersion})`, async (t) => { await assertValidation(t, Object.assign( { "specVersion": specVersion, diff --git a/packages/project/test/lib/validation/schema/__helper__/framework.js b/packages/project/test/lib/validation/schema/__helper__/framework.js index 841ce8fc790..cc122546b5c 100644 --- a/packages/project/test/lib/validation/schema/__helper__/framework.js +++ b/packages/project/test/lib/validation/schema/__helper__/framework.js @@ -6,14 +6,18 @@ import SpecificationVersion from "../../../../../lib/specifications/Specificatio export default { /** * Executes the tests for different types of kind project, - * e.g. "application", library" and "theme-library" + * e.g. "application", library", "component" and "theme-library" * * @param {Function} test ava test * @param {Function} assertValidation assertion function - * @param {string} type one of "application", library" and "theme-library" + * @param {string} type one of "application", library", "component" and "theme-library" */ defineTests: function(test, assertValidation, type) { SpecificationVersion.getVersionsForRange(">=2.0").forEach((specVersion) => { + if (type === "component" && SpecificationVersion.lt(specVersion, "5.0")) { + // Component type only became available with specVersion 5.0 + return; + } test(`${type} (specVersion ${specVersion}): framework configuration: OpenUI5`, async (t) => { const config = { "specVersion": specVersion, diff --git a/packages/project/test/lib/validation/schema/__helper__/project.js b/packages/project/test/lib/validation/schema/__helper__/project.js index cbac64534f7..c2a4cae1d5b 100644 --- a/packages/project/test/lib/validation/schema/__helper__/project.js +++ b/packages/project/test/lib/validation/schema/__helper__/project.js @@ -9,15 +9,15 @@ import bundleOptions from "./builder-bundleOptions.js"; export default { /** * Executes the tests for different types of kind project, - * e.g. "application", "library", "theme-library" and "module" + * e.g. "application", "library", "component", "theme-library" and "module" * * @param {Function} test ava test * @param {Function} assertValidation assertion function - * @param {string} type one of "application", "library", "theme-library" and "module" + * @param {string} type one of "application", "library", "component", "theme-library" and "module" */ defineTests: function(test, assertValidation, type) { // framework tests - if (["application", "library", "theme-library"].includes(type)) { + if (["application", "library", "component", "theme-library"].includes(type)) { framework.defineTests(test, assertValidation, type); } @@ -25,12 +25,16 @@ export default { customConfiguration.defineTests(test, assertValidation, type); // builder.bundleOptions tests - if (["application", "library"].includes(type)) { + if (["application", "library", "component"].includes(type)) { bundleOptions.defineTests(test, assertValidation, type); } // version specific tests SpecificationVersion.getVersionsForRange(">=2.0").forEach((specVersion) => { + if (type === "component" && SpecificationVersion.lt(specVersion, "5.0")) { + // Component type only became available with specVersion 5.0 + return; + } // tests for all kinds and version 2.0 and above test(`${type} (specVersion ${specVersion}): No metadata`, async (t) => { await assertValidation(t, { @@ -261,28 +265,34 @@ export default { }); }); - ["2.6", "2.5", "2.4", "2.3", "2.2", "2.1", "2.0"].forEach((specVersion) => { - test(`${type} (specVersion ${specVersion}): Invalid metadata.name`, async (t) => { - await assertValidation(t, { - "specVersion": specVersion, - "type": type, - "metadata": { - "name": {} - } - }, [ - { - dataPath: "/metadata/name", - keyword: "type", - message: "should be string", - params: { - type: "string" + if (type !== "component") { + ["2.6", "2.5", "2.4", "2.3", "2.2", "2.1", "2.0"].forEach((specVersion) => { + test(`${type} (specVersion ${specVersion}): Invalid metadata.name`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "type": type, + "metadata": { + "name": {} } - } - ]); + }, [ + { + dataPath: "/metadata/name", + keyword: "type", + message: "should be string", + params: { + type: "string" + } + } + ]); + }); }); - }); + } SpecificationVersion.getVersionsForRange(">=3.0").forEach((specVersion) => { + if (type === "component" && SpecificationVersion.lt(specVersion, "5.0")) { + // Component type only became available with specVersion 5.0 + return; + } test(`${type} (specVersion ${specVersion}): Invalid metadata.name`, async (t) => { await assertValidation(t, { "specVersion": specVersion, diff --git a/packages/project/test/lib/validation/schema/specVersion/kind/project.js b/packages/project/test/lib/validation/schema/specVersion/kind/project.js index ba9d09ca579..40319e20666 100644 --- a/packages/project/test/lib/validation/schema/specVersion/kind/project.js +++ b/packages/project/test/lib/validation/schema/specVersion/kind/project.js @@ -81,6 +81,53 @@ test("Type library (no kind)", async (t) => { }); }); +test("Type component", async (t) => { + await assertValidation(t, { + "specVersion": "5.0", + "kind": "project", + "type": "component", + "metadata": { + "name": "my-component" + } + }); +}); + +test("Type component (no kind)", async (t) => { + await assertValidation(t, { + "specVersion": "5.0", + "type": "component", + "metadata": { + "name": "my-component" + } + }); +}); + +test("Type component, legacy specVersion", async (t) => { + await assertValidation(t, { + "specVersion": "4.0", + "kind": "project", + "type": "component", + "metadata": { + "name": "my-component" + } + }, [{ + dataPath: "/specVersion", + keyword: "errorMessage", + message: "The 'component' type is only supported with specVersion '5.0' and higher.", + params: { + errors: [{ + dataPath: "/specVersion", + keyword: "enum", + message: "should be equal to one of the allowed values", + params: { + allowedValues: ["5.0"], + }, + schemaPath: "#/allOf/0/then/properties/specVersion/enum", + }], + } + }]); +}); + test("Type theme-library", async (t) => { await assertValidation(t, { "specVersion": "2.0", @@ -172,6 +219,7 @@ test("Invalid type", async (t) => { allowedValues: [ "application", "library", + "component", "theme-library", "module", ], diff --git a/packages/project/test/lib/validation/schema/specVersion/kind/project/component.js b/packages/project/test/lib/validation/schema/specVersion/kind/project/component.js new file mode 100644 index 00000000000..441e78d0d55 --- /dev/null +++ b/packages/project/test/lib/validation/schema/specVersion/kind/project/component.js @@ -0,0 +1,963 @@ +import test from "ava"; +import Ajv from "ajv"; +import ajvErrors from "ajv-errors"; +import SpecificationVersion from "../../../../../../../lib/specifications/SpecificationVersion.js"; +import AjvCoverage from "../../../../../../utils/AjvCoverage.js"; +import {_Validator as Validator} from "../../../../../../../lib/validation/validator.js"; +import ValidationError from "../../../../../../../lib/validation/ValidationError.js"; +import project from "../../../__helper__/project.js"; + +async function assertValidation(t, config, expectedErrors = undefined) { + const validation = t.context.validator.validate({config, project: {id: "my-project"}}); + if (expectedErrors) { + const validationError = await t.throwsAsync(validation, { + instanceOf: ValidationError, + name: "ValidationError" + }); + validationError.errors.forEach((error) => { + delete error.schemaPath; + if (error.params && Array.isArray(error.params.errors)) { + error.params.errors.forEach(($) => { + delete $.schemaPath; + }); + } + }); + t.deepEqual(validationError.errors, expectedErrors); + } else { + await t.notThrowsAsync(validation); + } +} + +test.before((t) => { + t.context.validator = new Validator({Ajv, ajvErrors, schemaName: "ui5"}); + t.context.ajvCoverage = new AjvCoverage(t.context.validator.ajv, { + includes: ["schema/specVersion/kind/project/component.json"] + }); +}); + +test.after.always((t) => { + t.context.ajvCoverage.createReport("html", {dir: "coverage/ajv-project-component"}); + const thresholds = { + statements: 80, + branches: 75, + functions: 100, + lines: 80 + }; + t.context.ajvCoverage.verify(thresholds); +}); + +SpecificationVersion.getVersionsForRange(">=5.0").forEach(function(specVersion) { + test(`Valid configuration (specVersion ${specVersion})`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "kind": "project", + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "okay" + }, + "resources": { + "configuration": { + "propertiesFileSourceEncoding": "UTF-8", + "paths": { + "src": "/my/path" + } + } + }, + "builder": { + "resources": { + "excludes": [ + "/resources/some/project/name/test_results/**", + "/test-resources/**", + "!/test-resources/some/project/name/demo-app/**" + ] + }, + "bundles": [ + { + "bundleDefinition": { + "name": "sap-ui-custom.js", + "defaultFileTypes": [ + ".js" + ], + "sections": [ + { + "name": "my-raw-section", + "mode": "raw", + "filters": [ + "ui5loader-autoconfig.js" + ], + "resolve": true, + "resolveConditional": true, + "renderer": true, + "sort": true + }, + { + "mode": "provided", + "filters": [ + "ui5loader-autoconfig.js" + ], + "resolve": false, + "resolveConditional": false, + "renderer": false, + "sort": false, + "declareRawModules": true + } + ] + }, + "bundleOptions": { + "optimize": true, + "decorateBootstrapModule": true, + "addTryCatchRestartWrapper": true + } + }, + { + "bundleDefinition": { + "name": "app.js", + "defaultFileTypes": [ + ".js" + ], + "sections": [ + { + "name": "some-app-preload", + "mode": "preload", + "filters": [ + "some/app/Component.js" + ], + "resolve": true, + "sort": true, + "declareRawModules": false + }, + { + "mode": "require", + "filters": [ + "ui5loader-autoconfig.js" + ], + "resolve": true + } + ] + }, + "bundleOptions": { + "optimize": true, + "numberOfParts": 3 + } + } + ], + "componentPreload": { + "paths": [ + "some/glob/**/pattern/Component.js", + "some/other/glob/**/pattern/Component.js" + ], + "namespaces": [ + "some/namespace", + "some/other/namespace" + ] + }, + "cachebuster": { + "signatureType": "hash" + }, + "customTasks": [ + { + "name": "custom-task-1", + "beforeTask": "replaceCopyright", + "configuration": { + "some-key": "some value" + } + }, + { + "name": "custom-task-2", + "afterTask": "custom-task-1", + "configuration": { + "color": "blue" + } + }, + { + "name": "custom-task-2", + "beforeTask": "not-valid", + "configuration": false + } + ] + }, + "server": { + "settings": { + "httpPort": 1337, + "httpsPort": 1443 + }, + "customMiddleware": [ + { + "name": "myCustomMiddleware", + "mountPath": "/myapp", + "afterMiddleware": "compression", + "configuration": { + "debug": true + } + }, + { + "name": "myCustomMiddleware-2", + "beforeMiddleware": "myCustomMiddleware", + "configuration": { + "debug": true + } + } + ] + } + }); + }); + + test(`Invalid resources configuration (specVersion ${specVersion})`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "com.sap.ui5.test" + }, + "resources": { + "configuration": { + "propertiesFileSourceEncoding": "FOO", + "paths": { + "app": "src", + "src": { + "path": "invalid" + } + }, + "notAllowed": true + }, + "notAllowed": true + } + }, [ + { + dataPath: "/resources", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "notAllowed", + } + }, + { + dataPath: "/resources/configuration", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "notAllowed", + } + }, + { + dataPath: "/resources/configuration/propertiesFileSourceEncoding", + keyword: "enum", + message: "should be equal to one of the allowed values", + params: { + allowedValues: [ + "UTF-8", + "ISO-8859-1" + ], + } + }, + { + dataPath: "/resources/configuration/paths", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "app", + } + }, + { + dataPath: "/resources/configuration/paths/src", + keyword: "type", + message: "should be string", + params: { + type: "string" + } + } + ]); + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "com.sap.ui5.test" + }, + "resources": { + "configuration": { + "paths": "src" + } + } + }, [ + { + dataPath: "/resources/configuration/paths", + keyword: "type", + message: "should be object", + params: { + type: "object" + } + } + ]); + }); + + test(`Invalid builder configuration (specVersion ${specVersion})`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + // jsdoc is not supported for type component + "jsdoc": { + "excludes": [ + "some/project/name/thirdparty/**" + ] + }, + "bundles": [ + { + "bundleDefinition": { + "name": "sap-ui-custom.js", + "defaultFileTypes": [ + ".js" + ], + "sections": [ + { + "name": true, + "mode": "raw", + "filters": [ + "ui5loader-autoconfig.js" + ], + "resolve": true, + "sort": true, + "declareModules": true + } + ] + }, + "bundleOptions": { + "optimize": true + } + }, + { + "bundleDefinition": { + "defaultFileTypes": [ + ".js", true + ], + "sections": [ + { + "filters": [ + "some/app/Component.js" + ], + "resolve": true, + "sort": true, + "declareRawModules": [] + }, + { + "mode": "provide", + "filters": "*", + "resolve": true + } + ] + }, + "bundleOptions": { + "optimize": "true", + "numberOfParts": "3", + "notAllowed": true + } + } + ], + "componentPreload": { + "path": "some/invalid/path", + "paths": "some/invalid/glob/**/pattern/Component.js", + "namespaces": "some/invalid/namespace", + }, + "libraryPreload": {} // Only supported for type library + } + }, [ + { + dataPath: "/builder", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "jsdoc" + } + }, + { + dataPath: "/builder", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "libraryPreload" + } + }, + { + dataPath: "/builder/bundles/0/bundleDefinition/sections/0", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "declareModules", + } + }, + { + dataPath: "/builder/bundles/0/bundleDefinition/sections/0/name", + keyword: "type", + message: "should be string", + params: { + type: "string", + } + }, + { + dataPath: "/builder/bundles/1/bundleDefinition", + keyword: "required", + message: "should have required property 'name'", + params: { + missingProperty: "name", + } + }, + { + dataPath: "/builder/bundles/1/bundleDefinition/defaultFileTypes/1", + keyword: "type", + message: "should be string", + params: { + type: "string", + } + }, + { + dataPath: "/builder/bundles/1/bundleDefinition/sections/0", + keyword: "required", + message: "should have required property 'mode'", + params: { + missingProperty: "mode", + } + }, + { + dataPath: "/builder/bundles/1/bundleDefinition/sections/0/declareRawModules", + keyword: "type", + message: "should be boolean", + params: { + type: "boolean", + } + }, + { + dataPath: "/builder/bundles/1/bundleDefinition/sections/1/mode", + keyword: "enum", + message: "should be equal to one of the allowed values", + params: { + allowedValues: [ + "raw", + "preload", + "require", + "provided", + "bundleInfo", + "depCache", + ] + } + }, + { + dataPath: "/builder/bundles/1/bundleDefinition/sections/1/filters", + keyword: "type", + message: "should be array", + params: { + type: "array", + } + }, + { + dataPath: "/builder/bundles/1/bundleOptions", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "notAllowed", + } + }, + { + dataPath: "/builder/bundles/1/bundleOptions/optimize", + keyword: "type", + message: "should be boolean", + params: { + type: "boolean", + } + }, + { + dataPath: "/builder/bundles/1/bundleOptions/numberOfParts", + keyword: "type", + message: "should be number", + params: { + type: "number", + } + }, + { + dataPath: "/builder/componentPreload", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "path", + } + }, + { + dataPath: "/builder/componentPreload/paths", + keyword: "type", + message: "should be array", + params: { + type: "array", + } + }, + { + dataPath: "/builder/componentPreload/namespaces", + keyword: "type", + message: "should be array", + params: { + type: "array", + } + } + ]); + }); + test(`component (specVersion ${specVersion}): builder/componentPreload/excludes`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "kind": "project", + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "componentPreload": { + "excludes": [ + "some/excluded/files/**", + "some/other/excluded/files/**" + ] + } + } + }); + }); + test(`Invalid builder/componentPreload/excludes configuration (specVersion ${specVersion})`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "componentPreload": { + "excludes": "some/excluded/files/**" + } + } + }, [ + { + dataPath: "/builder/componentPreload/excludes", + keyword: "type", + message: "should be array", + params: { + type: "array", + }, + }, + ]); + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "componentPreload": { + "excludes": [ + true, + 1, + {} + ], + "notAllowed": true + } + } + }, [ + { + dataPath: "/builder/componentPreload", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "notAllowed", + }, + }, + { + dataPath: "/builder/componentPreload/excludes/0", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/componentPreload/excludes/1", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/componentPreload/excludes/2", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + ]); + }); + test(`component (specVersion ${specVersion}): builder/bundles/bundleDefinition/sections/mode: bundleInfo`, + async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "kind": "project", + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "bundles": [{ + "bundleDefinition": { + "name": "my-bundle.js", + "sections": [{ + "name": "my-bundle-info", + "mode": "bundleInfo", + "filters": [] + }] + } + }] + } + }); + }); + test(`component (specVersion ${specVersion}): builder/settings/includeDependency*`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "kind": "project", + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "settings": { + "includeDependency": [ + "sap.a", + "sap.b" + ], + "includeDependencyRegExp": [ + ".ui.[a-z]+", + "^sap.[mf]$" + ], + "includeDependencyTree": [ + "sap.c", + "sap.d" + ] + } + } + }); + }); + test(`Invalid builder/settings/includeDependency* configuration (specVersion ${specVersion})`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "settings": { + "includeDependency": "a", + "includeDependencyRegExp": "b", + "includeDependencyTree": "c" + } + } + }, [ + { + dataPath: "/builder/settings/includeDependency", + keyword: "type", + message: "should be array", + params: { + type: "array", + }, + }, + { + dataPath: "/builder/settings/includeDependencyRegExp", + keyword: "type", + message: "should be array", + params: { + type: "array", + }, + }, + { + dataPath: "/builder/settings/includeDependencyTree", + keyword: "type", + message: "should be array", + params: { + type: "array", + }, + }, + ]); + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "settings": { + "includeDependency": [ + true, + 1, + {} + ], + "includeDependencyRegExp": [ + true, + 1, + {} + ], + "includeDependencyTree": [ + true, + 1, + {} + ], + "notAllowed": true + } + } + }, [ + { + dataPath: "/builder/settings", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "notAllowed", + }, + }, + { + dataPath: "/builder/settings/includeDependency/0", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/settings/includeDependency/1", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/settings/includeDependency/2", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/settings/includeDependencyRegExp/0", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/settings/includeDependencyRegExp/1", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/settings/includeDependencyRegExp/2", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/settings/includeDependencyTree/0", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/settings/includeDependencyTree/1", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/settings/includeDependencyTree/2", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + ]); + }); + test(`component (specVersion ${specVersion}): builder/minification/excludes`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "kind": "project", + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "minification": { + "excludes": [ + "some/excluded/files/**", + "some/other/excluded/files/**" + ] + } + } + }); + }); + test(`Invalid builder/minification/excludes configuration (specVersion ${specVersion})`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "minification": { + "excludes": "some/excluded/files/**" + } + } + }, [ + { + dataPath: "/builder/minification/excludes", + keyword: "type", + message: "should be array", + params: { + type: "array", + }, + }, + ]); + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "com.sap.ui5.test", + "copyright": "yes" + }, + "builder": { + "minification": { + "excludes": [ + true, + 1, + {} + ], + "notAllowed": true + } + } + }, [ + { + dataPath: "/builder/minification", + keyword: "additionalProperties", + message: "should NOT have additional properties", + params: { + additionalProperty: "notAllowed", + }, + }, + { + dataPath: "/builder/minification/excludes/0", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/minification/excludes/1", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + { + dataPath: "/builder/minification/excludes/2", + keyword: "type", + message: "should be string", + params: { + type: "string", + }, + }, + ]); + }); + test(`Invalid project name (specVersion ${specVersion})`, async (t) => { + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "illegal/name" + } + }, [{ + dataPath: "/metadata/name", + keyword: "errorMessage", + message: `Not a valid project name. It must consist of lowercase alphanumeric characters, dash, underscore, and period only. Additionally, it may contain an npm-style package scope. For details, see: https://ui5.github.io/cli/stable/pages/Configuration/#name`, + params: { + errors: [{ + dataPath: "/metadata/name", + keyword: "pattern", + message: `should match pattern "^(?:@[0-9a-z-_.]+\\/)?[a-z][0-9a-z-_.]*$"`, + params: { + pattern: "^(?:@[0-9a-z-_.]+\\/)?[a-z][0-9a-z-_.]*$", + }, + }] + }, + }]); + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "a" + } + }, [{ + dataPath: "/metadata/name", + keyword: "errorMessage", + message: `Not a valid project name. It must consist of lowercase alphanumeric characters, dash, underscore, and period only. Additionally, it may contain an npm-style package scope. For details, see: https://ui5.github.io/cli/stable/pages/Configuration/#name`, + params: { + errors: [{ + dataPath: "/metadata/name", + keyword: "minLength", + message: "should NOT be shorter than 3 characters", + params: { + limit: 3, + }, + }] + }, + }]); + await assertValidation(t, { + "specVersion": specVersion, + "type": "component", + "metadata": { + "name": "a".repeat(81) + } + }, [{ + dataPath: "/metadata/name", + keyword: "errorMessage", + message: `Not a valid project name. It must consist of lowercase alphanumeric characters, dash, underscore, and period only. Additionally, it may contain an npm-style package scope. For details, see: https://ui5.github.io/cli/stable/pages/Configuration/#name`, + params: { + errors: [{ + dataPath: "/metadata/name", + keyword: "maxLength", + message: "should NOT be longer than 80 characters", + params: { + limit: 80, + }, + }] + }, + }]); + }); +}); + +project.defineTests(test, assertValidation, "component"); diff --git a/packages/project/test/lib/validation/schema/ui5.js b/packages/project/test/lib/validation/schema/ui5.js index be965ca7daf..adf7cee71a9 100644 --- a/packages/project/test/lib/validation/schema/ui5.js +++ b/packages/project/test/lib/validation/schema/ui5.js @@ -150,6 +150,7 @@ test("Invalid type", async (t) => { allowedValues: [ "application", "library", + "component", "theme-library", "module" ]