diff --git a/lib/graph/helpers/ui5Framework.js b/lib/graph/helpers/ui5Framework.js index 9fe94d1bb..6df9258b0 100644 --- a/lib/graph/helpers/ui5Framework.js +++ b/lib/graph/helpers/ui5Framework.js @@ -208,6 +208,60 @@ const utils = { ); } }, + /** + * This logic needs to stay in sync with the dependency definitions for the + * sapui5/distribution-metadata package. + * + * @param {@ui5/project/specifications/Project} project + */ + async getFrameworkLibraryDependencies(project) { + let dependencies = []; + let optionalDependencies = []; + + if (project.getId().startsWith("@sapui5/")) { + project.getFrameworkDependencies().forEach((dependency) => { + if (dependency.optional) { + // Add optional dependency to optionalDependencies + optionalDependencies.push(dependency.name); + } else if (!dependency.development) { + // Add non-development dependency to dependencies + dependencies.push(dependency.name); + } + }); + } else if (project.getId().startsWith("@openui5/")) { + const packageResource = await project.getRootReader().byPath("/package.json"); + const packageInfo = JSON.parse(await packageResource.getString()); + + dependencies = Object.keys( + packageInfo.dependencies || {} + ).map(($) => $.replace("@openui5/", "")); // @sapui5 dependencies must not be defined in package.json + optionalDependencies = Object.keys( + packageInfo.devDependencies || {} + ).map(($) => $.replace("@openui5/", "")); // @sapui5 dependencies must not be defined in package.json + } + + return {dependencies, optionalDependencies}; + }, + async getWorkspaceFrameworkLibraryMetadata({workspace, projectGraph}) { + const libraryMetadata = Object.create(null); + const ui5Modules = await workspace.getModules(); + for (const ui5Module of ui5Modules) { + const {project} = await ui5Module.getSpecifications(); + // Only framework projects that are not already part of the projectGraph should be handled. + // Otherwise they would be available twice which is checked + // after installing via checkForDuplicateFrameworkProjects + if (project?.isFrameworkProject?.() && !projectGraph.getProject(project.getName())) { + const metadata = libraryMetadata[project.getName()] = Object.create(null); + metadata.id = project.getId(); + metadata.path = project.getRootPath(); + metadata.version = project.getVersion(); + const {dependencies, optionalDependencies} = await utils.getFrameworkLibraryDependencies(project); + metadata.dependencies = dependencies; + metadata.optionalDependencies = optionalDependencies; + } + } + return libraryMetadata; + }, ProjectProcessor }; @@ -232,26 +286,31 @@ export default { * Promise resolving with the given graph instance to allow method chaining */ enrichProjectGraph: async function(projectGraph, options = {}) { + const {workspace} = options; const rootProject = projectGraph.getRoot(); const frameworkName = rootProject.getFrameworkName(); const frameworkVersion = rootProject.getFrameworkVersion(); - // It is allowed omit the framework version in ui5.yaml and only provide one via the override + // It is allowed to omit the framework version in ui5.yaml and only provide one via the override // This is a common use case for framework libraries, which generally should not define a // framework version in their respective ui5.yaml let version = options.versionOverride || frameworkVersion; if (rootProject.isFrameworkProject() && !version) { // If the root project is a framework project and no framework version is defined, - // all framework dependencies must already be part of the graph - rootProject.getFrameworkDependencies().forEach((dep) => { - if (utils.shouldIncludeDependency(dep) && !projectGraph.getProject(dep.name)) { - throw new Error( - `Missing framework dependency ${dep.name} for framework project ${rootProject.getName()}`); - } + // all framework dependencies must either be already part of the graph or part of the workspace. + // A mixed setup of framework deps within the graph AND from the workspace is currently not supported. + + const someDependencyMissing = rootProject.getFrameworkDependencies().some((dep) => { + return utils.shouldIncludeDependency(dep) && !projectGraph.getProject(dep.name); }); - // All framework dependencies are already present in the graph - return projectGraph; + + // If all dependencies are available there is nothing else to do here. + // In case of a workspace setup, the resolver will be created below without a version and + // will succeed in case no library needs to be actually installed. + if (!someDependencyMissing) { + return projectGraph; + } } @@ -267,12 +326,6 @@ export default { ); } - if (!version) { - throw new Error( - `No framework version defined for root project ${rootProject.getName()}` - ); - } - let Resolver; if (frameworkName === "OpenUI5") { Resolver = (await import("../../ui5Framework/Openui5Resolver.js")).default; @@ -296,9 +349,20 @@ export default { return projectGraph; } - log.info(`Using ${frameworkName} version: ${version}`); + if (version) { + log.info(`Using ${frameworkName} version: ${version}`); + } + + let providedLibraryMetadata; + if (workspace) { + providedLibraryMetadata = await utils.getWorkspaceFrameworkLibraryMetadata({ + workspace, projectGraph + }); + } - const resolver = new Resolver({cwd: rootProject.getRootPath(), version}); + // Note: version might be undefined here and the Resolver will throw an error when calling + // #install and it can't be resolved via the provided library metadata + const resolver = new Resolver({cwd: rootProject.getRootPath(), version, providedLibraryMetadata}); let startTime; if (log.isLevelEnabled("verbose")) { @@ -322,7 +386,7 @@ export default { const projectProcessor = new utils.ProjectProcessor({ libraryMetadata, graph: frameworkGraph, - workspace: options.workspace + workspace }); await Promise.all(referencedLibraries.map(async (libName) => { diff --git a/lib/ui5Framework/AbstractResolver.js b/lib/ui5Framework/AbstractResolver.js index e6e9537be..09b281e35 100644 --- a/lib/ui5Framework/AbstractResolver.js +++ b/lib/ui5Framework/AbstractResolver.js @@ -27,22 +27,25 @@ const VERSION_RANGE_REGEXP = /^(0|[1-9]\d*)\.(0|[1-9]\d*)$/; * @hideconstructor */ class AbstractResolver { + /* eslint-disable max-len */ /** * @param {*} options options - * @param {string} options.version Framework version to use + * @param {string} [options.version] Framework version to use. When omitted, all libraries need to be available + * via providedLibraryMetadata parameter. Otherwise an error is thrown. * @param {string} [options.cwd=process.cwd()] Working directory to resolve configurations like .npmrc * @param {string} [options.ui5HomeDir="~/.ui5"] UI5 home directory location. This will be used to store packages, * metadata and configuration used by the resolvers. Relative to `process.cwd()` + * @param {object.} [options.providedLibraryMetadata] + * Resolver skips installing listed libraries and uses the dependency information to resolve their dependencies. + * version can be omitted in case all libraries can be resolved via the providedLibraryMetadata. + * Otherwise an error is thrown. */ - constructor({cwd, version, ui5HomeDir}) { + /* eslint-enable max-len */ + constructor({cwd, version, ui5HomeDir, providedLibraryMetadata}) { if (new.target === AbstractResolver) { throw new TypeError("Class 'AbstractResolver' is abstract"); } - if (!version) { - throw new Error(`AbstractResolver: Missing parameter "version"`); - } - // In some CI environments, the homedir might be set explicitly to a relative // path (e.g. "./"), but tooling requires an absolute path this._ui5HomeDir = path.resolve( @@ -50,6 +53,7 @@ class AbstractResolver { ); this._cwd = cwd ? path.resolve(cwd) : process.cwd(); this._version = version; + this._providedLibraryMetadata = providedLibraryMetadata; } async _processLibrary(libraryName, libraryMetadata, errors) { @@ -62,7 +66,23 @@ class AbstractResolver { log.verbose("Processing " + libraryName); - const promises = await this.handleLibrary(libraryName); + let promises; + const providedLibraryMetadata = this._providedLibraryMetadata?.[libraryName]; + if (providedLibraryMetadata) { + log.verbose(`Skipping install for ${libraryName} (provided)`); + promises = { + // Use existing metadata if library is provided from outside (e.g. workspace) + metadata: Promise.resolve(providedLibraryMetadata), + // Provided libraries are already "installed" + install: Promise.resolve({ + pkgPath: providedLibraryMetadata.path + }) + }; + } else if (!this._version) { + throw new Error(`Unable to install library ${libraryName}. No framework version provided.`); + } else { + promises = await this.handleLibrary(libraryName); + } const [metadata, {pkgPath}] = await Promise.all([ promises.metadata.then((metadata) => @@ -143,7 +163,6 @@ class AbstractResolver { * Object containing all installed libraries with library name as key */ - /* eslint-enable max-len */ /** * Installs the provided libraries and their dependencies * diff --git a/test/lib/graph/helpers/ui5Framework.integration.js b/test/lib/graph/helpers/ui5Framework.integration.js index 132c58461..7cff9788a 100644 --- a/test/lib/graph/helpers/ui5Framework.integration.js +++ b/test/lib/graph/helpers/ui5Framework.integration.js @@ -135,7 +135,8 @@ test.afterEach.always((t) => { function defineTest(testName, { frameworkName, - verbose = false + verbose = false, + librariesInWorkspace = null }) { const npmScope = frameworkName === "SAPUI5" ? "@sapui5" : "@openui5"; @@ -410,7 +411,66 @@ function defineTest(testName, { const provider = new DependencyTreeProvider({dependencyTree}); const projectGraph = await projectGraphBuilder(provider); - await ui5Framework.enrichProjectGraph(projectGraph); + if (librariesInWorkspace) { + const projectNameMap = new Map(); + const moduleIdMap = new Map(); + librariesInWorkspace.forEach((libName) => { + const libraryDistMetadata = distributionMetadata.libraries[libName]; + const module = { + getSpecifications: sinon.stub().resolves({ + project: { + getName: sinon.stub().returns(libName), + getVersion: sinon.stub().returns("1.76.0-SNAPSHOT"), + getRootPath: sinon.stub().returns(path.join(fakeBaseDir, "workspace", libName)), + isFrameworkProject: sinon.stub().returns(true), + getId: sinon.stub().returns(libraryDistMetadata.npmPackageName), + getRootReader: sinon.stub().returns({ + byPath: sinon.stub().resolves({ + getString: sinon.stub().resolves(JSON.stringify({dependencies: {}})) + }) + }), + getFrameworkDependencies: sinon.stub().callsFake(() => { + const deps = []; + libraryDistMetadata.dependencies.forEach((dep) => { + deps.push({name: dep}); + }); + libraryDistMetadata.optionalDependencies.forEach((optDep) => { + deps.push({name: optDep, optional: true}); + }); + return deps; + }), + isDeprecated: sinon.stub().returns(false), + isSapInternal: sinon.stub().returns(false), + getAllowSapInternal: sinon.stub().returns(false), + } + }), + getVersion: sinon.stub().returns("1.76.0-SNAPSHOT"), + getPath: sinon.stub().returns(path.join(fakeBaseDir, "workspace", libName)), + }; + projectNameMap.set(libName, module); + moduleIdMap.set(libraryDistMetadata.npmPackageName, module); + }); + + const getModuleByProjectName = sinon.stub().callsFake( + async (projectName) => projectNameMap.get(projectName) + ); + const getModules = sinon.stub().callsFake( + async () => { + const sortedMap = new Map([...moduleIdMap].sort((a, b) => String(a[0]).localeCompare(b[0]))); + return Array.from(sortedMap.values()); + } + ); + + const workspace = { + getName: sinon.stub().returns("test"), + getModules, + getModuleByProjectName + }; + + await ui5Framework.enrichProjectGraph(projectGraph, {workspace}); + } else { + await ui5Framework.enrichProjectGraph(projectGraph); + } const callbackStub = sinon.stub().resolves(); await projectGraph.traverseDepthFirst(callbackStub); @@ -465,6 +525,15 @@ defineTest("ui5Framework helper should enhance project graph with UI5 framework verbose: true }); +defineTest("ui5Framework helper should not cause install of libraries within workspace", { + frameworkName: "SAPUI5", + librariesInWorkspace: ["sap.ui.lib1", "sap.ui.lib2", "sap.ui.lib8"] +}); +defineTest("ui5Framework helper should not cause install of libraries within workspace", { + frameworkName: "OpenUI5", + librariesInWorkspace: ["sap.ui.lib1", "sap.ui.lib2", "sap.ui.lib8"] +}); + function defineErrorTest(testName, { frameworkName, failExtract = false, @@ -723,8 +792,8 @@ test.serial("ui5Framework translator should not try to install anything when no t.is(pacote.manifest.callCount, 0, "No manifest should be requested"); }); -test.serial("ui5Framework translator should throw an error when framework version is not defined", async (t) => { - const {ui5Framework, projectGraphBuilder} = t.context; +test.serial("ui5Framework helper shouldn't throw when framework version and libraries are not provided", async (t) => { + const {ui5Framework, projectGraphBuilder, logStub} = t.context; const dependencyTree = { id: "test-id", @@ -745,13 +814,28 @@ test.serial("ui5Framework translator should throw an error when framework versio const provider = new DependencyTreeProvider({dependencyTree}); const projectGraph = await projectGraphBuilder(provider); - await t.throwsAsync(async () => { - await ui5Framework.enrichProjectGraph(projectGraph); - }, {message: `No framework version defined for root project test-project`}, "Correct error message"); + await ui5Framework.enrichProjectGraph(projectGraph); + + t.is(logStub.verbose.callCount, 5); + t.deepEqual(logStub.verbose.getCall(0).args, [ + "Configuration for module test-id has been supplied directly" + ]); + t.deepEqual(logStub.verbose.getCall(1).args, [ + "Module test-id contains project test-project" + ]); + t.deepEqual(logStub.verbose.getCall(2).args, [ + "Root project test-project qualified as application project for project graph" + ]); + t.deepEqual(logStub.verbose.getCall(3).args, [ + "Project test-project has no framework dependencies" + ]); + t.deepEqual(logStub.verbose.getCall(4).args, [ + "No SAPUI5 libraries referenced in project test-project or in any of its dependencies" + ]); }); test.serial( - "SAPUI5: ui5Framework translator should throw error when using a library that is not part of the dist metadata", + "SAPUI5: ui5Framework helper should throw error when using a library that is not part of the dist metadata", async (t) => { const {sinon, ui5Framework, Installer, projectGraphBuilder} = t.context; diff --git a/test/lib/graph/helpers/ui5Framework.js b/test/lib/graph/helpers/ui5Framework.js index 52426261d..863646fe4 100644 --- a/test/lib/graph/helpers/ui5Framework.js +++ b/test/lib/graph/helpers/ui5Framework.js @@ -100,7 +100,8 @@ test.serial("enrichProjectGraph", async (t) => { t.is(t.context.Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(t.context.Sapui5ResolverStub.getCall(0).args, [{ cwd: dependencyTree.path, - version: dependencyTree.configuration.framework.version + version: dependencyTree.configuration.framework.version, + providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); t.is(t.context.Sapui5ResolverInstallStub.callCount, 1, "Sapui5Resolver#install should be called once"); @@ -138,6 +139,34 @@ test.serial("enrichProjectGraph", async (t) => { ], "Traversed graph in correct order"); }); +test.serial("enrichProjectGraph: without framework configuration", async (t) => { + const {ui5Framework, log} = t.context; + const dependencyTree = { + id: "application.a", + version: "1.2.3", + path: applicationAPath, + configuration: { + specVersion: "2.0", + type: "application", + metadata: { + name: "application.a" + } + } + }; + + t.is(log.verbose.callCount, 0); + + const provider = new DependencyTreeProvider({dependencyTree}); + const projectGraph = await projectGraphBuilder(provider); + + await ui5Framework.enrichProjectGraph(projectGraph); + t.is(projectGraph.getSize(), 1, "Project graph should remain unchanged"); + t.is(log.verbose.callCount, 1); + t.deepEqual(log.verbose.getCall(0).args, [ + "Root project application.a has no framework configuration. Nothing to do here" + ]); +}); + test.serial("enrichProjectGraph: With versionOverride", async (t) => { const { sinon, ui5Framework, utils, @@ -186,12 +215,13 @@ test.serial("enrichProjectGraph: With versionOverride", async (t) => { t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ cwd: dependencyTree.path, - version: "1.99.9" + version: "1.99.9", + providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); -test.serial("enrichProjectGraph should throw error when no framework version is provided", async (t) => { - const {ui5Framework} = t.context; +test.serial("enrichProjectGraph shouldn't throw when no framework version and no libraries are provided", async (t) => { + const {ui5Framework, log} = t.context; const dependencyTree = { id: "test-id", version: "1.2.3", @@ -211,9 +241,15 @@ test.serial("enrichProjectGraph should throw error when no framework version is const provider = new DependencyTreeProvider({dependencyTree}); const projectGraph = await projectGraphBuilder(provider); - await t.throwsAsync(async () => { - await ui5Framework.enrichProjectGraph(projectGraph); - }, {message: "No framework version defined for root project application.a"}); + await ui5Framework.enrichProjectGraph(projectGraph); + + t.is(log.verbose.callCount, 2); + t.deepEqual(log.verbose.getCall(0).args, [ + "Project application.a has no framework dependencies" + ]); + t.deepEqual(log.verbose.getCall(1).args, [ + "No SAPUI5 libraries referenced in project application.a or in any of its dependencies" + ]); }); test.serial("enrichProjectGraph should skip framework project without version", async (t) => { @@ -314,7 +350,8 @@ test.serial("enrichProjectGraph should resolve framework project with version an t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ cwd: dependencyTree.path, - version: "1.2.3" + version: "1.2.3", + providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -390,15 +427,56 @@ test.serial("enrichProjectGraph should resolve framework project " + t.is(projectGraph.getSize(), 2, "Project graph should remain unchanged"); t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); - t.is(getFrameworkLibrariesFromGraphStub.callCount, 1, "getFrameworkLibrariesFromGrap should be called once"); + t.is(getFrameworkLibrariesFromGraphStub.callCount, 1, "getFrameworkLibrariesFromGraph should be called once"); t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ cwd: dependencyTree.path, - version: "1.99.9" + version: "1.99.9", + providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); -test.serial("enrichProjectGraph should throw for framework project with dependency missing in graph", async (t) => { +test.serial("enrichProjectGraph should skip framework project when all dependencies are in graph", async (t) => { const {ui5Framework} = t.context; + const dependencyTree = { + id: "@sapui5/project", + version: "1.2.3", + path: applicationAPath, + configuration: { + specVersion: "2.0", + type: "application", + metadata: { + name: "application.a" + }, + framework: { + name: "SAPUI5", + libraries: [ + {name: "lib1"} + ] + } + }, + dependencies: [{ + id: "@openui5/lib1", + version: "1.2.3", + path: libraryEPath, + configuration: { + specVersion: "2.0", + type: "library", + metadata: { + name: "lib1" + } + } + }] + }; + + const provider = new DependencyTreeProvider({dependencyTree}); + const projectGraph = await projectGraphBuilder(provider); + + await ui5Framework.enrichProjectGraph(projectGraph); + t.is(projectGraph.getSize(), 2, "Project graph should remain unchanged"); +}); + +test.serial("enrichProjectGraph should throw for framework project with dependency missing in graph", async (t) => { + const {ui5Framework, Sapui5ResolverInstallStub} = t.context; const dependencyTree = { id: "@sapui5/project", version: "1.2.3", @@ -420,12 +498,15 @@ test.serial("enrichProjectGraph should throw for framework project with dependen } }; + const installError = new Error("Resolution of framework libraries failed with errors: TEST ERROR"); + + Sapui5ResolverInstallStub.rejects(installError); + const provider = new DependencyTreeProvider({dependencyTree}); const projectGraph = await projectGraphBuilder(provider); const err = await t.throwsAsync(ui5Framework.enrichProjectGraph(projectGraph)); - t.is(err.message, `Missing framework dependency lib1 for framework project application.a`, - "Threw with expected error message"); + t.is(err.message, installError.message); }); test.serial("enrichProjectGraph should throw for incorrect framework name", async (t) => { @@ -561,6 +642,120 @@ test.serial("enrichProjectGraph should throw error when projectGraph contains a }); }); +test.serial("enrichProjectGraph should use framework library metadata from workspace", async (t) => { + const {ui5Framework, utils, Sapui5ResolverStub, Sapui5ResolverInstallStub, sinon} = t.context; + const dependencyTree = { + id: "@sapui5/project", + version: "1.2.3", + path: applicationAPath, + configuration: { + specVersion: "2.0", + type: "application", + metadata: { + name: "application.a" + }, + framework: { + name: "SAPUI5", + version: "1.111.1", + libraries: [ + {name: "lib1"}, + {name: "lib2"} + ] + } + } + }; + + const workspace = { + getName: sinon.stub().resolves("default") + }; + + const workspaceFrameworkLibraryMetadata = {}; + const libraryMetadata = {}; + + sinon.stub(utils, "getWorkspaceFrameworkLibraryMetadata").resolves(workspaceFrameworkLibraryMetadata); + Sapui5ResolverInstallStub.resolves({libraryMetadata}); + + const addProjectToGraphStub = sinon.stub(); + sinon.stub(utils, "ProjectProcessor") + .callsFake(() => { + return { + addProjectToGraph: addProjectToGraphStub + }; + }); + + sinon.stub(utils, "declareFrameworkDependenciesInGraph").resolves(); + + const provider = new DependencyTreeProvider({dependencyTree}); + const projectGraph = await projectGraphBuilder(provider, {workspace}); + + await ui5Framework.enrichProjectGraph(projectGraph, {workspace}); + + t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); + t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ + cwd: dependencyTree.path, + version: "1.111.1", + providedLibraryMetadata: workspaceFrameworkLibraryMetadata + }], "Sapui5Resolver#constructor should be called with expected args"); + t.is(Sapui5ResolverStub.getCall(0).args[0].providedLibraryMetadata, workspaceFrameworkLibraryMetadata); +}); + +test.serial("enrichProjectGraph should allow omitting framework version in case " + + "all framework libraries come from the workspace", async (t) => { + const {ui5Framework, utils, Sapui5ResolverStub, Sapui5ResolverInstallStub, sinon} = t.context; + const dependencyTree = { + id: "@sapui5/project", + version: "1.2.3", + path: applicationAPath, + configuration: { + specVersion: "2.0", + type: "application", + metadata: { + name: "application.a" + }, + framework: { + name: "SAPUI5", + libraries: [ + {name: "lib1"}, + {name: "lib2"} + ] + } + } + }; + + const workspace = { + getName: sinon.stub().resolves("default") + }; + + const workspaceFrameworkLibraryMetadata = {}; + const libraryMetadata = {}; + + sinon.stub(utils, "getWorkspaceFrameworkLibraryMetadata").resolves(workspaceFrameworkLibraryMetadata); + Sapui5ResolverInstallStub.resolves({libraryMetadata}); + + const addProjectToGraphStub = sinon.stub(); + sinon.stub(utils, "ProjectProcessor") + .callsFake(() => { + return { + addProjectToGraph: addProjectToGraphStub + }; + }); + + sinon.stub(utils, "declareFrameworkDependenciesInGraph").resolves(); + + const provider = new DependencyTreeProvider({dependencyTree}); + const projectGraph = await projectGraphBuilder(provider, {workspace}); + + await ui5Framework.enrichProjectGraph(projectGraph, {workspace}); + + t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); + t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ + cwd: dependencyTree.path, + version: undefined, + providedLibraryMetadata: workspaceFrameworkLibraryMetadata + }], "Sapui5Resolver#constructor should be called with expected args"); + t.is(Sapui5ResolverStub.getCall(0).args[0].providedLibraryMetadata, workspaceFrameworkLibraryMetadata); +}); + test.serial("utils.shouldIncludeDependency", (t) => { const {utils} = t.context; // root project dependency should always be included @@ -1102,6 +1297,240 @@ test("utils.checkForDuplicateFrameworkProjects: Two duplicates", (t) => { }); }); +test("utils.getFrameworkLibraryDependencies: OpenUI5 library", async (t) => { + const {utils, sinon} = t.context; + + const project = { + getId: sinon.stub().returns("@openui5/sap.ui.lib1"), + getRootReader: sinon.stub().returns({ + byPath: sinon.stub().withArgs("/package.json").resolves({ + getString: sinon.stub().resolves(JSON.stringify({ + dependencies: { + "@openui5/sap.ui.lib2": "*" + }, + devDependencies: { + "@openui5/themelib_fancy": "*" + } + })) + }) + }), + }; + + const result = await utils.getFrameworkLibraryDependencies(project); + t.deepEqual(result, { + dependencies: ["sap.ui.lib2"], + optionalDependencies: ["themelib_fancy"] + }); +}); + +test("utils.getFrameworkLibraryDependencies: SAPUI5 library", async (t) => { + const {utils, sinon} = t.context; + + const project = { + getId: sinon.stub().returns("@sapui5/sap.ui.lib1"), + getFrameworkDependencies: sinon.stub().returns([ + { + name: "sap.ui.lib2" + }, + { + name: "themelib_fancy", + optional: true + }, + { + name: "sap.ui.lib3", + development: true + } + ]) + }; + + const result = await utils.getFrameworkLibraryDependencies(project); + t.deepEqual(result, { + dependencies: ["sap.ui.lib2"], + optionalDependencies: ["themelib_fancy"] + }); +}); + +test("utils.getFrameworkLibraryDependencies: OpenUI5 library - no dependencies", async (t) => { + const {utils, sinon} = t.context; + + const project = { + getId: sinon.stub().returns("@openui5/sap.ui.lib1"), + getRootReader: sinon.stub().returns({ + byPath: sinon.stub().withArgs("/package.json").resolves({ + getString: sinon.stub().resolves(JSON.stringify({})) + }) + }), + }; + + const result = await utils.getFrameworkLibraryDependencies(project); + t.deepEqual(result, { + dependencies: [], + optionalDependencies: [] + }); +}); + +test("utils.getFrameworkLibraryDependencies: No framework library", async (t) => { + const {utils, sinon} = t.context; + + const project = { + getId: sinon.stub().returns("foo") + }; + + const result = await utils.getFrameworkLibraryDependencies(project); + t.deepEqual(result, { + dependencies: [], + optionalDependencies: [] + }); +}); + +test("utils.getWorkspaceFrameworkLibraryMetadata: No workspace modules", async (t) => { + const {utils, sinon} = t.context; + + const workspace = { + getModules: sinon.stub().resolves([]) + }; + + const libraryMetadata = await utils.getWorkspaceFrameworkLibraryMetadata({workspace, projectGraph: {}}); + + t.deepEqual(libraryMetadata, {}); +}); + +test("utils.getWorkspaceFrameworkLibraryMetadata: With workspace modules", async (t) => { + const {utils, sinon} = t.context; + + const workspace = { + getModules: sinon.stub().resolves([ + { + // Extensions don't have projects, should be ignored + getSpecifications: sinon.stub().resolves({ + project: null + }) + }, + { + getSpecifications: sinon.stub().resolves({ + project: { + // some types don't have a "isFrameworkProject" method + } + }) + }, + { + getSpecifications: sinon.stub().resolves({ + project: { + isFrameworkProject: sinon.stub().returns(false) + } + }) + }, + { + getSpecifications: sinon.stub().resolves({ + project: { + isFrameworkProject: sinon.stub().returns(true), + getName: sinon.stub().returns("sap.ui.lib1"), + getId: sinon.stub().returns("@openui5/sap.ui.lib1"), + getRootPath: sinon.stub().returns("/rootPath"), + getRootReader: sinon.stub().returns({ + byPath: sinon.stub().withArgs("/package.json").resolves({ + getString: sinon.stub().resolves(JSON.stringify({ + dependencies: { + "@openui5/sap.ui.lib2": "*" + }, + devDependencies: { + "@openui5/themelib_fancy": "*" + } + })) + }) + }), + getVersion: sinon.stub().returns("1.0.0"), + } + }) + }, + { + getSpecifications: sinon.stub().resolves({ + project: { + isFrameworkProject: sinon.stub().returns(true), + getName: sinon.stub().returns("sap.ui.lib3"), + getId: sinon.stub().returns("@sapui5/sap.ui.lib3"), + getRootPath: sinon.stub().returns("/rootPath"), + getVersion: sinon.stub().returns("1.0.0"), + getFrameworkDependencies: sinon.stub().returns([ + { + name: "sap.ui.lib4" + }, + { + name: "sap.ui.lib5", + optional: true + }, + { + name: "sap.ui.lib6", + development: true + } + ]) + } + }) + } + ]) + }; + + const getProject = sinon.stub(); + getProject.withArgs("sap.ui.lib1").returns(undefined); + const projectGraph = { + getProject + }; + + const libraryMetadata = await utils.getWorkspaceFrameworkLibraryMetadata({workspace, projectGraph}); + + t.deepEqual(libraryMetadata, { + "sap.ui.lib1": { + dependencies: [ + "sap.ui.lib2" + ], + id: "@openui5/sap.ui.lib1", + optionalDependencies: [ + "themelib_fancy" + ], + path: "/rootPath", + version: "1.0.0" + }, + "sap.ui.lib3": { + dependencies: [ + "sap.ui.lib4" + ], + id: "@sapui5/sap.ui.lib3", + optionalDependencies: [ + "sap.ui.lib5" + ], + path: "/rootPath", + version: "1.0.0" + }, + }); +}); + +test("utils.getWorkspaceFrameworkLibraryMetadata: With workspace module within projectGraph", async (t) => { + const {utils, sinon} = t.context; + + const workspace = { + getModules: sinon.stub().resolves([ + { + getSpecifications: sinon.stub().resolves({ + project: { + isFrameworkProject: sinon.stub().returns(true), + getName: sinon.stub().returns("sap.ui.lib1") + } + }) + } + ]) + }; + + const getProject = sinon.stub(); + getProject.withArgs("sap.ui.lib1").returns({}); + const projectGraph = { + getProject + }; + + const libraryMetadata = await utils.getWorkspaceFrameworkLibraryMetadata({workspace, projectGraph}); + + t.deepEqual(libraryMetadata, {}); +}); + test.serial("ProjectProcessor: Add project to graph", async (t) => { const {sinon} = t.context; const {ProjectProcessor} = t.context.utils; diff --git a/test/lib/ui5framework/AbstractResolver.js b/test/lib/ui5framework/AbstractResolver.js index 7b1da9df3..a89d48ed0 100644 --- a/test/lib/ui5framework/AbstractResolver.js +++ b/test/lib/ui5framework/AbstractResolver.js @@ -38,19 +38,24 @@ test("AbstractResolver: abstract constructor should throw", async (t) => { test("AbstractResolver: constructor", (t) => { const {MyResolver, AbstractResolver} = t.context; + const providedLibraryMetadata = {"test": "data"}; const resolver = new MyResolver({ cwd: "/test-project/", - version: "1.75.0" + version: "1.75.0", + providedLibraryMetadata }); t.true(resolver instanceof MyResolver, "Constructor returns instance of sub-class"); t.true(resolver instanceof AbstractResolver, "Constructor returns instance of abstract class"); + t.is(resolver._version, "1.75.0"); + t.is(resolver._providedLibraryMetadata, providedLibraryMetadata); }); -test("AbstractResolver: constructor requires 'version'", (t) => { +test("AbstractResolver: constructor without version", (t) => { const {MyResolver} = t.context; - t.throws(() => { - new MyResolver({}); - }, {message: `AbstractResolver: Missing parameter "version"`}); + const resolver = new MyResolver({ + cwd: "/test-project/" + }); + t.is(resolver._version, undefined); }); test("AbstractResolver: Set absolute 'cwd'", (t) => { @@ -204,9 +209,160 @@ test("AbstractResolver: install", async (t) => { install: Promise.resolve({pkgPath: "/foo/sap.ui.lib4"}) }); - await resolver.install(["sap.ui.lib1", "sap.ui.lib2", "sap.ui.lib4"]); + const result = await resolver.install(["sap.ui.lib1", "sap.ui.lib2", "sap.ui.lib4"]); t.is(handleLibraryStub.callCount, 4, "Each library should be handled once"); + t.deepEqual(result, { + libraryMetadata: { + "sap.ui.lib1": { + dependencies: [], + npmPackageName: "@openui5/sap.ui.lib1", + optionalDependencies: [], + path: "/foo/sap.ui.lib1", + version: "1.75.0", + }, + "sap.ui.lib2": { + dependencies: [ + "sap.ui.lib3", + ], + npmPackageName: "@openui5/sap.ui.lib2", + optionalDependencies: [], + path: "/foo/sap.ui.lib2", + version: "1.75.0", + }, + "sap.ui.lib3": { + dependencies: [], + npmPackageName: "@openui5/sap.ui.lib3", + optionalDependencies: [ + "sap.ui.lib4", + ], + path: "/foo/sap.ui.lib3", + version: "1.75.0", + }, + "sap.ui.lib4": { + dependencies: [ + "sap.ui.lib1", + ], + npmPackageName: "@openui5/sap.ui.lib4", + optionalDependencies: [], + path: "/foo/sap.ui.lib4", + version: "1.75.0", + }, + } + }); +}); + +test("AbstractResolver: install (with providedLibraryMetadata)", async (t) => { + const {MyResolver} = t.context; + const resolver = new MyResolver({ + cwd: "/test-project/", + version: "1.75.0", + providedLibraryMetadata: { + "sap.ui.lib1": { + "npmPackageName": "@openui5/sap.ui.lib1", + "version": "1.75.0-workspace", + "dependencies": [ + "sap.ui.lib3" + ], + "optionalDependencies": [], + "path": "/workspace/sap.ui.lib1" + }, + "sap.ui.lib4": { + "npmPackageName": "@openui5/sap.ui.lib4", + "version": "1.75.0-workspace", + "dependencies": [ + "sap.ui.lib5" + ], + "optionalDependencies": [], + "path": "/workspace/sap.ui.lib4" + }, + "sap.ui.lib5": { + "npmPackageName": "@openui5/sap.ui.lib5", + "version": "1.75.0-workspace", + "dependencies": [], + "optionalDependencies": [], + "path": "/workspace/sap.ui.lib5" + }, + } + }); + + const metadata = { + libraries: { + "sap.ui.lib2": { + "npmPackageName": "@openui5/sap.ui.lib2", + "version": "1.75.0", + "dependencies": [], + "optionalDependencies": [] + }, + "sap.ui.lib3": { + "npmPackageName": "@openui5/sap.ui.lib3", + "version": "1.75.0", + "dependencies": [ + "sap.ui.lib4" + ], + "optionalDependencies": [] + }, + } + }; + + const handleLibraryStub = sinon.stub(resolver, "handleLibrary"); + handleLibraryStub + .callsFake(async (libraryName) => { + throw new Error(`Unknown handleLibrary call: ${libraryName}`); + }) + .withArgs("sap.ui.lib2").resolves({ + metadata: Promise.resolve(metadata.libraries["sap.ui.lib2"]), + install: Promise.resolve({pkgPath: "/foo/sap.ui.lib2"}) + }) + .withArgs("sap.ui.lib3").resolves({ + metadata: Promise.resolve(metadata.libraries["sap.ui.lib3"]), + install: Promise.resolve({pkgPath: "/foo/sap.ui.lib3"}) + }); + + const result = await resolver.install(["sap.ui.lib1", "sap.ui.lib2"]); + + t.is(handleLibraryStub.callCount, 2, "Each library not part of providedLibraryMetadata should be handled once"); + t.deepEqual(result, { + libraryMetadata: { + "sap.ui.lib1": { + dependencies: ["sap.ui.lib3"], + npmPackageName: "@openui5/sap.ui.lib1", + optionalDependencies: [], + path: "/workspace/sap.ui.lib1", + version: "1.75.0-workspace", + }, + "sap.ui.lib2": { + dependencies: [], + npmPackageName: "@openui5/sap.ui.lib2", + optionalDependencies: [], + path: "/foo/sap.ui.lib2", + version: "1.75.0", + }, + "sap.ui.lib3": { + dependencies: ["sap.ui.lib4",], + npmPackageName: "@openui5/sap.ui.lib3", + optionalDependencies: [], + path: "/foo/sap.ui.lib3", + version: "1.75.0", + }, + "sap.ui.lib4": { + dependencies: [ + "sap.ui.lib5", + ], + npmPackageName: "@openui5/sap.ui.lib4", + optionalDependencies: [], + path: "/workspace/sap.ui.lib4", + version: "1.75.0-workspace", + }, + "sap.ui.lib5": { + dependencies: [], + npmPackageName: "@openui5/sap.ui.lib5", + optionalDependencies: [], + path: "/workspace/sap.ui.lib5", + version: "1.75.0-workspace", + }, + } + }); }); test("AbstractResolver: install error handling (rejection of metadata/install)", async (t) => { @@ -335,6 +491,49 @@ Failed to resolve library sap.ui.lib2: Error within handleLibrary: sap.ui.lib2`} t.is(handleLibraryStub.callCount, 2, "Each library should be handled once"); }); +test("AbstractResolver: install error handling " + +"(no version, no providedLibraryMetadata)", async (t) => { + const {MyResolver} = t.context; + const resolver = new MyResolver({ + cwd: "/test-project/", + }); + + const handleLibraryStub = sinon.stub(resolver, "handleLibrary"); + + await t.throwsAsync(resolver.install(["sap.ui.lib1", "sap.ui.lib2"]), { + message: `Resolution of framework libraries failed with errors: +Failed to resolve library sap.ui.lib1: Unable to install library sap.ui.lib1. No framework version provided. +Failed to resolve library sap.ui.lib2: Unable to install library sap.ui.lib2. No framework version provided.` + }); + + t.is(handleLibraryStub.callCount, 0, "Handle library should not be called when no version is available"); +}); + +test("AbstractResolver: install error handling " + +"(no version, one lib not part of providedLibraryMetadata)", async (t) => { + const {MyResolver} = t.context; + const resolver = new MyResolver({ + cwd: "/test-project/", + providedLibraryMetadata: { + "sap.ui.lib1": { + "npmPackageName": "@openui5/sap.ui.lib1", + "version": "1.75.0-SNAPSHOT", + "dependencies": [], + "optionalDependencies": [] + } + } + }); + + const handleLibraryStub = sinon.stub(resolver, "handleLibrary"); + + await t.throwsAsync(resolver.install(["sap.ui.lib1", "sap.ui.lib2"]), { + message: `Resolution of framework libraries failed with errors: +Failed to resolve library sap.ui.lib2: Unable to install library sap.ui.lib2. No framework version provided.` + }); + + t.is(handleLibraryStub.callCount, 0, "Handle library should not be called when no version is available"); +}); + test("AbstractResolver: static fetchAllVersions should throw an Error when not implemented", async (t) => { const {AbstractResolver} = t.context; await t.throwsAsync(async () => {