diff --git a/lib/build/ProjectBuilder.js b/lib/build/ProjectBuilder.js index 2fc76a9ba..8faba9cbb 100644 --- a/lib/build/ProjectBuilder.js +++ b/lib/build/ProjectBuilder.js @@ -162,7 +162,7 @@ class ProjectBuilder { }); // Count total number of projects to build based on input - const requestedProjects = this._graph.getAllProjects().map((p) => p.getName()).filter(function(projectName) { + const requestedProjects = this._graph.getProjectNames().filter(function(projectName) { return filterProject(projectName); }); @@ -264,18 +264,16 @@ class ProjectBuilder { } async _createRequiredBuildContexts(requestedProjects) { - const allProjects = this._graph.getAllProjects(); - const requiredProjects = new Set(allProjects.filter((project) => { - return requestedProjects.includes(project.getName()); + const requiredProjects = new Set(this._graph.getProjectNames().filter((projectName) => { + return requestedProjects.includes(projectName); })); const projectBuildContexts = new Map(); - for (const project of requiredProjects) { - const projectName = project.getName(); + for (const projectName of requiredProjects) { log.verbose(`Creating build context for project ${projectName}...`); const projectBuildContext = this._buildContext.createProjectContext({ - project, + project: this._graph.getProject(projectName), log }); @@ -299,8 +297,7 @@ class ProjectBuilder { return; } // Add dependency to list of projects to build - const depProject = this._graph.getProject(depName); - requiredProjects.add(depProject); + requiredProjects.add(depName); }); } } @@ -322,7 +319,7 @@ class ProjectBuilder { ); if (includedDependencies.length) { - if (includedDependencies.length === this._graph.getAllProjects().length - 1) { + if (includedDependencies.length === this._graph.getSize() - 1) { log.info(` Including all dependencies`); } else { log.info(` Requested dependencies:`); diff --git a/lib/build/TaskRunner.js b/lib/build/TaskRunner.js index aac5eeb8e..89409bf5d 100644 --- a/lib/build/TaskRunner.js +++ b/lib/build/TaskRunner.js @@ -450,7 +450,7 @@ class TaskRunner { // Add transitive dependencies to set of required dependencies const requiredDependencies = new Set(requiredDirectDependencies); for (const projectName of requiredDirectDependencies) { - this._graph.getAllDependencies(projectName).forEach((depName) => { + this._graph.getTransitiveDependencies(projectName).forEach((depName) => { requiredDependencies.add(depName); }); } diff --git a/lib/build/helpers/composeProjectList.js b/lib/build/helpers/composeProjectList.js index 77e6b1b8f..ae4db6dbb 100644 --- a/lib/build/helpers/composeProjectList.js +++ b/lib/build/helpers/composeProjectList.js @@ -13,14 +13,13 @@ async function getFlattenedDependencyTree(graph) { const dependencyMap = Object.create(null); const rootName = graph.getRoot().getName(); - await graph.traverseDepthFirst(({project, getDependencies}) => { + await graph.traverseDepthFirst(({project, dependencies}) => { if (project.getName() === rootName) { // Skip root project return; } const projectDeps = []; - getDependencies().forEach((dep) => { - const depName = dep.getName(); + dependencies.forEach((depName) => { projectDeps.push(depName); if (dependencyMap[depName]) { projectDeps.push(...dependencyMap[depName]); diff --git a/lib/graph/ProjectGraph.js b/lib/graph/ProjectGraph.js index a6bb22462..18ddaf546 100644 --- a/lib/graph/ProjectGraph.js +++ b/lib/graph/ProjectGraph.js @@ -20,11 +20,11 @@ class ProjectGraph { } this._rootProjectName = rootProjectName; - this._projects = Object.create(null); // maps project name to instance - this._adjList = Object.create(null); // maps project name to edges/dependencies - this._optAdjList = Object.create(null); // maps project name to optional dependencies + this._projects = new Map(); // maps project name to instance + this._adjList = new Map(); // maps project name to edges/dependencies + this._optAdjList = new Map(); // maps project name to optional dependencies - this._extensions = Object.create(null); // maps extension name to instance + this._extensions = new Map(); // maps extension name to instance this._sealed = false; this._hasUnresolvedOptionalDependencies = false; // Performance optimization flag @@ -38,7 +38,7 @@ class ProjectGraph { * @returns {@ui5/project/specifications/Project} Root project */ getRoot() { - const rootProject = this._projects[this._rootProjectName]; + const rootProject = this._projects.get(this._rootProjectName); if (!rootProject) { throw new Error(`Unable to find root project with name ${this._rootProjectName} in project graph`); } @@ -50,15 +50,11 @@ class ProjectGraph { * * @public * @param {@ui5/project/specifications/Project} project Project which should be added to the graph - * @param {boolean} [ignoreDuplicates=false] Whether an error should be thrown when a duplicate project is added */ - addProject(project, ignoreDuplicates) { + addProject(project) { this._checkSealed(); const projectName = project.getName(); - if (this._projects[projectName]) { - if (ignoreDuplicates) { - return; - } + if (this._projects.has(projectName)) { throw new Error( `Failed to add project ${projectName} to graph: A project with that name has already been added`); } @@ -69,9 +65,9 @@ class ProjectGraph { `Failed to add project ${projectName} to graph: Project name must not be integer-like`); } log.verbose(`Adding project: ${projectName}`); - this._projects[projectName] = project; - this._adjList[projectName] = []; - this._optAdjList[projectName] = []; + this._projects.set(projectName, project); + this._adjList.set(projectName, new Set()); + this._optAdjList.set(projectName, new Set()); } /** @@ -83,17 +79,37 @@ class ProjectGraph { * project instance or undefined if the project is unknown to the graph */ getProject(projectName) { - return this._projects[projectName]; + return this._projects.get(projectName); } /** * Get all projects in the graph * * @public - * @returns {@ui5/project/specifications/Project[]} + * @returns {Iterable.<@ui5/project/specifications/Project>} + */ + getProjects() { + return this._projects.values(); + } + + /** + * Get names of all projects in the graph + * + * @public + * @returns {string[]} Names of all projects + */ + getProjectNames() { + return Array.from(this._projects.keys()); + } + + /** + * Get the number of projects in the graph + * + * @public + * @returns {integer} Count of projects in the graph */ - getAllProjects() { - return Object.values(this._projects); + getSize() { + return this._projects.size; } /** @@ -105,7 +121,7 @@ class ProjectGraph { addExtension(extension) { this._checkSealed(); const extensionName = extension.getName(); - if (this._extensions[extensionName]) { + if (this._extensions.has(extensionName)) { throw new Error( `Failed to add extension ${extensionName} to graph: ` + `An extension with that name has already been added`); @@ -116,7 +132,7 @@ class ProjectGraph { throw new Error( `Failed to add extension ${extensionName} to graph: Extension name must not be integer-like`); } - this._extensions[extensionName] = extension; + this._extensions.set(extensionName, extension); } /** @@ -126,17 +142,27 @@ class ProjectGraph { * Extension instance or undefined if the extension is unknown to the graph */ getExtension(extensionName) { - return this._extensions[extensionName]; + return this._extensions.get(extensionName); } /** * Get all extensions in the graph * * @public - * @returns {@ui5/project/specifications/Extension[]} + * @returns {Iterable.<@ui5/project/specifications/Extension>} + */ + getExtensions() { + return this._extensions.values(); + } + + /** + * Get names of all extensions in the graph + * + * @public + * @returns {string[]} Names of all extensions */ - getAllExtensions() { - return Object.values(this._extensions); + getExtensionNames() { + return Array.from(this._extensions.keys()); } /** @@ -195,11 +221,11 @@ class ProjectGraph { * @param {string} toProjectName Name of project on which the other depends */ _declareDependency(map, fromProjectName, toProjectName) { - if (!this._projects[fromProjectName]) { + if (!this._projects.has(fromProjectName)) { throw new Error( `Unable to find depending project with name ${fromProjectName} in project graph`); } - if (!this._projects[toProjectName]) { + if (!this._projects.has(toProjectName)) { throw new Error( `Unable to find dependency project with name ${toProjectName} in project graph`); } @@ -207,10 +233,11 @@ class ProjectGraph { throw new Error( `A project can't depend on itself`); } - if (map[fromProjectName].includes(toProjectName)) { + const adjacencies = map.get(fromProjectName); + if (adjacencies.has(toProjectName)) { log.warn(`Dependency has already been declared: ${fromProjectName} depends on ${toProjectName}`); } else { - map[fromProjectName].push(toProjectName); + adjacencies.add(toProjectName); } } @@ -222,13 +249,13 @@ class ProjectGraph { * @returns {string[]} Names of all direct dependencies */ getDependencies(projectName) { - const adjacencies = this._adjList[projectName]; + const adjacencies = this._adjList.get(projectName); if (!adjacencies) { throw new Error( `Failed to get dependencies for project ${projectName}: ` + `Unable to find project in project graph`); } - return adjacencies; + return Array.from(adjacencies); } /** @@ -238,11 +265,11 @@ class ProjectGraph { * @param {string} projectName Name of the project to retrieve the dependencies of * @returns {string[]} Names of all direct and transitive dependencies */ - getAllDependencies(projectName) { + getTransitiveDependencies(projectName) { const dependencies = new Set(); const processDependency = (depName) => { - const adjacencies = this._adjList[depName]; + const adjacencies = this._adjList.get(depName); adjacencies.forEach((depName) => { if (!dependencies.has(depName)) { dependencies.add(depName); @@ -264,18 +291,18 @@ class ProjectGraph { * @returns {boolean} True if the dependency is currently optional */ isOptionalDependency(fromProjectName, toProjectName) { - const adjacencies = this._adjList[fromProjectName]; + const adjacencies = this._adjList.get(fromProjectName); if (!adjacencies) { throw new Error( `Failed to determine whether dependency from ${fromProjectName} to ${toProjectName} ` + `is optional: ` + `Unable to find project with name ${fromProjectName} in project graph`); } - if (adjacencies.includes(toProjectName)) { + if (adjacencies.has(toProjectName)) { return false; } - const optAdjacencies = this._optAdjList[fromProjectName]; - if (optAdjacencies.includes(toProjectName)) { + const optAdjacencies = this._optAdjList.get(fromProjectName); + if (optAdjacencies.has(toProjectName)) { return true; } return false; @@ -288,6 +315,7 @@ class ProjectGraph { * @public */ async resolveOptionalDependencies() { + this._checkSealed(); if (!this._hasUnresolvedOptionalDependencies) { log.verbose(`Skipping resolution of optional dependencies since none have been declared`); return; @@ -295,33 +323,32 @@ class ProjectGraph { log.verbose(`Resolving optional dependencies...`); // First collect all projects that are currently reachable from the root project (=all non-optional projects) - const resolvedProjects = new Set; + const resolvedProjects = new Set(); await this.traverseBreadthFirst(({project}) => { resolvedProjects.add(project.getName()); }); let unresolvedOptDeps = false; - for (const [projectName, optDependencies] of Object.entries(this._optAdjList)) { - for (let i = optDependencies.length - 1; i >= 0; i--) { - const targetProjectName = optDependencies[i]; - if (resolvedProjects.has(targetProjectName)) { + for (const [fromProjectName, optDependencies] of this._optAdjList) { + for (const toProjectName of optDependencies) { + if (resolvedProjects.has(toProjectName)) { // Target node is already reachable in the graph // => Resolve optional dependency - log.verbose(`Resolving optional dependency from ${projectName} to ${targetProjectName}...`); + log.verbose(`Resolving optional dependency from ${fromProjectName} to ${toProjectName}...`); - if (this._adjList[targetProjectName].includes(projectName)) { + if (this._adjList.get(toProjectName).has(fromProjectName)) { log.verbose( - ` Cyclic optional dependency detected: ${targetProjectName} already has a non-optional ` + - `dependency to ${projectName}`); + ` Cyclic optional dependency detected: ${toProjectName} already has a non-optional ` + + `dependency to ${fromProjectName}`); log.verbose( - ` Optional dependency from ${projectName} to ${targetProjectName} ` + + ` Optional dependency from ${fromProjectName} to ${toProjectName} ` + `will not be declared as it would introduce a cycle`); unresolvedOptDeps = true; } else { - this.declareDependency(projectName, targetProjectName); + this.declareDependency(fromProjectName, toProjectName); // This optional dependency has now been resolved // => Remove it from the list of optional dependencies - optDependencies.splice(i, 1); + optDependencies.delete(toProjectName); } } else { unresolvedOptDeps = true; @@ -340,26 +367,14 @@ class ProjectGraph { * @async * @callback @ui5/project/graph/ProjectGraph~traversalCallback * @param {object} parameters Parameters passed to the callback - * @param {@ui5/project/specifications/Project} parameters.project The project that is currently visited - * @param {@ui5/project/graph/ProjectGraph~getDependencies} parameters.getDependencies - * Function to access the dependencies of the project that is currently visited. - * @returns {Promise} Must return a promise on which the graph traversal will wait - */ - - /** - * Helper function available in the - * [traversalCallback]{@link @ui5/project/graph/ProjectGraph~traversalCallback} to access the - * dependencies of the corresponding project in the current graph. - *

- * Note that transitive dependencies can't be accessed this way. Projects should rather add a direct - * dependency to projects they need access to. - * - * @public - * @function @ui5/project/graph/ProjectGraph~getDependencies - * @returns {Array.<@ui5/project/specifications/Project>} Direct dependencies of the visited project + * @param {@ui5/project/specifications/Project} parameters.project + * Project that is currently visited + * @param {string[]} parameters.dependencies + * Array containing the names of all direct dependencies of the project + * @returns {Promise|undefined} If a promise is returned, + * graph traversal will wait and only continue once the promise has resolved. */ - // TODO: Use generator functions instead? /** * Visit every project in the graph that can be reached by the given entry project exactly once. @@ -409,9 +424,7 @@ class ProjectGraph { await callback({ project: this.getProject(projectName), - getDependencies: () => { - return dependencies.map(($) => this.getProject($)); - } + dependencies }); })(); })); @@ -437,7 +450,7 @@ class ProjectGraph { if (!this.getProject(startName)) { throw new Error(`Failed to start graph traversal: Could not find project ${startName} in project graph`); } - return this._traverseDepthFirst(startName, {}, [], callback); + return this._traverseDepthFirst(startName, Object.create(null), [], callback); } async _traverseDepthFirst(projectName, visited, predecessors, callback) { @@ -455,9 +468,7 @@ class ProjectGraph { await callback({ project: this.getProject(projectName), - getDependencies: () => { - return dependencies.map(($) => this.getProject($)); - } + dependencies }); })(); } @@ -480,8 +491,12 @@ class ProjectGraph { projectGraph.seal(); } mergeMap(this._projects, projectGraph._projects); - mergeMap(this._adjList, projectGraph._adjList); mergeMap(this._extensions, projectGraph._extensions); + mergeMap(this._adjList, projectGraph._adjList); + mergeMap(this._optAdjList, projectGraph._optAdjList); + + this._hasUnresolvedOptionalDependencies = + this._hasUnresolvedOptionalDependencies || projectGraph._hasUnresolvedOptionalDependencies; } catch (err) { throw new Error( `Failed to join project graph with root project ${projectGraph._rootProjectName} into ` + @@ -608,11 +623,16 @@ class ProjectGraph { } function mergeMap(target, source) { - for (const [key, value] of Object.entries(source)) { - if (target[key]) { + for (const [key, value] of source) { + if (target.has(key)) { throw new Error(`Failed to merge map: Key '${key}' already present in target set`); } - target[key] = value; + if (value instanceof Set) { + // Shallow-clone any Sets + target.set(key, new Set(value)); + } else { + target.set(key, value); + } } } diff --git a/test/lib/build/ProjectBuilder.js b/test/lib/build/ProjectBuilder.js index d7b8f332b..05f249550 100644 --- a/test/lib/build/ProjectBuilder.js +++ b/test/lib/build/ProjectBuilder.js @@ -31,11 +31,12 @@ test.beforeEach(async (t) => { }; }, isSealed: sinon.stub().returns(true), - getAllProjects: sinon.stub().returns([ - getMockProject("library", "a"), - getMockProject("library", "b"), - getMockProject("library", "c"), + getProjectNames: sinon.stub().returns([ + "project.a", + "project.b", + "project.c", ]), + getSize: sinon.stub().returns(3), getDependencies: sinon.stub().returns([]).withArgs("project.a").returns(["project.b"]), traverseBreadthFirst: async (start, callback) => { if (callback) { @@ -65,7 +66,9 @@ test.beforeEach(async (t) => { project: getMockProject("library", "c") }); }, - getProject: sinon.stub().returns(getMockProject("project", "b")) + getProject: sinon.stub().callsFake((projectName) => { + return getMockProject(...projectName.split(".")); + }) }; t.context.ProjectBuilder = await esmock("../../../lib/build/ProjectBuilder.js"); diff --git a/test/lib/build/TaskRunner.js b/test/lib/build/TaskRunner.js index 04c89da1e..f0683227f 100644 --- a/test/lib/build/TaskRunner.js +++ b/test/lib/build/TaskRunner.js @@ -102,7 +102,7 @@ test.beforeEach(async (t) => { }, getExtension: sinon.stub().returns(t.context.customTask), traverseBreadthFirst: sinon.stub(), - getAllDependencies: sinon.stub().returns(["dep.a", "dep.b", "dep.c"]) + getTransitiveDependencies: sinon.stub().returns(["dep.a", "dep.b", "dep.c"]) }; t.context.logger = { diff --git a/test/lib/graph/ProjectGraph.js b/test/lib/graph/ProjectGraph.js index db79e47ba..fdbbe1f41 100644 --- a/test/lib/graph/ProjectGraph.js +++ b/test/lib/graph/ProjectGraph.js @@ -161,23 +161,6 @@ test("addProject: Add duplicate", async (t) => { t.is(res, project1, "Should return correct project"); }); -test("addProject: Add duplicate with ignoreDuplicates", async (t) => { - const {ProjectGraph} = t.context; - const graph = new ProjectGraph({ - rootProjectName: "my root project" - }); - const project1 = await createProject("application.a"); - graph.addProject(project1); - - const project2 = await createProject("application.a"); - t.notThrows(() => { - graph.addProject(project2, true); - }, "Should not throw when adding duplicates"); - - const res = graph.getProject("application.a"); - t.is(res, project1, "Should return correct project"); -}); - test("addProject: Add project with integer-like name", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ @@ -202,7 +185,7 @@ test("getProject: Project is not in graph", (t) => { t.is(res, undefined, "Should return undefined"); }); -test("getAllProjects", async (t) => { +test("getProjects", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ rootProjectName: "my root project" @@ -213,10 +196,45 @@ test("getAllProjects", async (t) => { const project2 = await createProject("application.b"); graph.addProject(project2); - const res = graph.getAllProjects(); - t.deepEqual(res, [ + const res = graph.getProjects(); + t.deepEqual(Array.from(res), [ project1, project2 - ], "Should return all projects in a flat array"); + ], "Should return an iterable for all projects"); +}); + +test("getProjectNames", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "my root project" + }); + const project1 = await createProject("application.a"); + graph.addProject(project1); + + const project2 = await createProject("application.b"); + graph.addProject(project2); + + const res = graph.getProjectNames(); + t.deepEqual(res, [ + "application.a", "application.b" + ], "Should return all project names in a flat array"); +}); + +test("getSize", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "my root project" + }); + const project1 = await createProject("application.a"); + graph.addProject(project1); + + const project2 = await createProject("application.b"); + graph.addProject(project2); + + // Extensions should not influence graph size + const extension1 = await createExtension("extension.a"); + graph.addExtension(extension1); + + t.is(graph.getSize(), 2, "Should return correct project count"); }); test("add-/getExtension", async (t) => { @@ -274,7 +292,7 @@ test("getExtension: Project is not in graph", (t) => { t.is(res, undefined, "Should return undefined"); }); -test("getAllExtensions", async (t) => { +test("getExtensions", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ rootProjectName: "my root project" @@ -284,10 +302,10 @@ test("getAllExtensions", async (t) => { const extension2 = await createExtension("extension.b"); graph.addExtension(extension2); - const res = graph.getAllExtensions(); - t.deepEqual(res, [ + const res = graph.getExtensions(); + t.deepEqual(Array.from(res), [ extension1, extension2 - ], "Should return all extensions in a flat array"); + ], "Should return an iterable for all extensions"); }); test("declareDependency / getDependencies", async (t) => { @@ -321,7 +339,7 @@ test("declareDependency / getDependencies", async (t) => { "Should declare dependency as non-optional"); }); -test("getAllDependencies", async (t) => { +test("getTransitiveDependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ rootProjectName: "my root project" @@ -338,7 +356,7 @@ test("getAllDependencies", async (t) => { graph.declareDependency("library.a", "library.d"); graph.declareDependency("library.d", "library.e"); - t.deepEqual(graph.getAllDependencies("library.a"), [ + t.deepEqual(graph.getTransitiveDependencies("library.a"), [ "library.b", "library.c", "library.d", @@ -671,7 +689,7 @@ test("resolveOptionalDependencies: Resolves transitive optional dependencies", a ]); }); -test("traverseBreadthFirst", async (t) => { +test("traverseBreadthFirst: Async", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ rootProjectName: "library.a" @@ -681,10 +699,47 @@ test("traverseBreadthFirst", async (t) => { graph.declareDependency("library.a", "library.b"); - await traverseBreadthFirst(t, graph, [ + const callbackStub = t.context.sinon.stub().resolves().onFirstCall().callsFake(() => { + return new Promise((resolve, reject) => { + setTimeout(() => { + t.is(callbackStub.callCount, 1, "Callback still called only once while waiting for promise"); + resolve(); + }, 100); + }); + }); + await graph.traverseBreadthFirst(callbackStub); + + t.is(callbackStub.callCount, 2, "Correct number of projects have been visited"); + + const callbackCalls = callbackStub.getCalls().map((call) => call.args[0].project.getName()); + + t.deepEqual(callbackCalls, [ "library.a", - "library.b" - ]); + "library.b", + ], "Traversed graph in correct order, starting with library.a"); +}); + +test("traverseBreadthFirst: Sync", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + + graph.declareDependency("library.a", "library.b"); + + const callbackStub = t.context.sinon.stub().returns(); + await graph.traverseBreadthFirst(callbackStub); + + t.is(callbackStub.callCount, 2, "Correct number of projects have been visited"); + + const callbackCalls = callbackStub.getCalls().map((call) => call.args[0].project.getName()); + + t.deepEqual(callbackCalls, [ + "library.a", + "library.b", + ], "Traversed graph in correct order, starting with library.a"); }); test("traverseBreadthFirst: No project visited twice", async (t) => { @@ -782,7 +837,7 @@ test("traverseBreadthFirst: Custom start node", async (t) => { ], "Traversed graph in correct order, starting with library.b"); }); -test("traverseBreadthFirst: getDependencies callback", async (t) => { +test("traverseBreadthFirst: dependencies parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ rootProjectName: "library.a" @@ -802,9 +857,7 @@ test("traverseBreadthFirst: getDependencies callback", async (t) => { const callbackCalls = callbackStub.getCalls().map((call) => call.args[0].project.getName()); const dependencies = callbackStub.getCalls().map((call) => { - return call.args[0].getDependencies().map((dep) => { - return dep.getName(); - }); + return call.args[0].dependencies; }); t.deepEqual(callbackCalls, [ @@ -861,7 +914,7 @@ test("traverseBreadthFirst: Dependency declaration order is followed", async (t) ]); }); -test("traverseDepthFirst", async (t) => { +test("traverseDepthFirst: Async", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ rootProjectName: "library.a" @@ -871,10 +924,47 @@ test("traverseDepthFirst", async (t) => { graph.declareDependency("library.a", "library.b"); - await traverseDepthFirst(t, graph, [ + const callbackStub = t.context.sinon.stub().resolves().onFirstCall().callsFake(() => { + return new Promise((resolve, reject) => { + setTimeout(() => { + t.is(callbackStub.callCount, 1, "Callback still called only once while waiting for promise"); + resolve(); + }, 100); + }); + }); + await graph.traverseDepthFirst(callbackStub); + + t.is(callbackStub.callCount, 2, "Correct number of projects have been visited"); + + const callbackCalls = callbackStub.getCalls().map((call) => call.args[0].project.getName()); + + t.deepEqual(callbackCalls, [ "library.b", - "library.a" - ]); + "library.a", + ], "Traversed graph in correct order, starting with library.b"); +}); + +test("traverseDepthFirst: Sync", async (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "library.a" + }); + graph.addProject(await createProject("library.a")); + graph.addProject(await createProject("library.b")); + + graph.declareDependency("library.a", "library.b"); + + const callbackStub = t.context.sinon.stub().returns(); + await graph.traverseDepthFirst(callbackStub); + + t.is(callbackStub.callCount, 2, "Correct number of projects have been visited"); + + const callbackCalls = callbackStub.getCalls().map((call) => call.args[0].project.getName()); + + t.deepEqual(callbackCalls, [ + "library.b", + "library.a", + ], "Traversed graph in correct order, starting with library.b"); }); test("traverseDepthFirst: No project visited twice", async (t) => { @@ -971,7 +1061,7 @@ test("traverseDepthFirst: Custom start node", async (t) => { ], "Traversed graph in correct order, starting with library.b"); }); -test("traverseDepthFirst: getDependencies callback", async (t) => { +test("traverseDepthFirst: dependencies parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ rootProjectName: "library.a" @@ -991,9 +1081,7 @@ test("traverseDepthFirst: getDependencies callback", async (t) => { const callbackCalls = callbackStub.getCalls().map((call) => call.args[0].project.getName()); const dependencies = callbackStub.getCalls().map((call) => { - return call.args[0].getDependencies().map((dep) => { - return dep.getName(); - }); + return call.args[0].dependencies; }); t.deepEqual(callbackCalls, [ @@ -1074,22 +1162,31 @@ test("join", async (t) => { graph2.addProject(await createProject("theme.b")); graph2.addProject(await createProject("theme.c")); graph2.addProject(await createProject("theme.d")); + graph2.addProject(await createProject("theme.e")); graph2.declareDependency("theme.a", "theme.d"); graph2.declareDependency("theme.a", "theme.c"); graph2.declareDependency("theme.b", "theme.a"); // This causes theme.b to not appear + graph2.declareOptionalDependency("theme.a", "theme.e"); const extensionB = await createExtension("extension.b"); graph2.addExtension(extensionB); - graph1.join(graph2); + + t.true(graph1._hasUnresolvedOptionalDependencies, + "Graph has unresolved optional dependencies taken over from graph2"); + graph1.declareDependency("library.d", "theme.a"); + graph1.declareDependency("library.d", "theme.e"); + + graph1.resolveOptionalDependencies(); await traverseDepthFirst(t, graph1, [ "library.b", "library.c", "theme.d", "theme.c", + "theme.e", "theme.a", "library.d", "library.a", @@ -1097,6 +1194,33 @@ test("join", async (t) => { t.is(graph1.getExtension("extension.a"), extensionA, "Should return correct extension"); t.is(graph1.getExtension("extension.b"), extensionB, "Should return correct joined extension"); + + // graph2 remained unmodified + await traverseDepthFirst(t, graph2, [ + "theme.d", + "theme.c", + "theme.a", + ]); +}); + +test("join: Preserves hasUnresolvedOptionalDependencies flag", async (t) => { + const {ProjectGraph} = t.context; + const graph1 = new ProjectGraph({ + rootProjectName: "library.a" + }); + const graph2 = new ProjectGraph({ + rootProjectName: "theme.a" + }); + graph1.addProject(await createProject("library.a")); + graph1.addProject(await createProject("library.b")); + graph1.declareOptionalDependency("library.a", "library.b"); + + graph1.join(graph2); + + t.true(graph1._hasUnresolvedOptionalDependencies, + "graph1 still has unresolved optional dependencies"); + t.false(graph2._hasUnresolvedOptionalDependencies, + "graph2 still does not have unresolved optional dependencies"); }); test("join: Seals incoming graph", (t) => { @@ -1219,6 +1343,9 @@ test("Seal/isSealed", async (t) => { }, { message: expectedSealMsg }); + await t.throwsAsync(graph.resolveOptionalDependencies(), { + message: expectedSealMsg + }); const graph2 = new ProjectGraph({ diff --git a/test/lib/graph/helpers/ui5Framework.js b/test/lib/graph/helpers/ui5Framework.js index bc608d0cd..b176ec157 100644 --- a/test/lib/graph/helpers/ui5Framework.js +++ b/test/lib/graph/helpers/ui5Framework.js @@ -231,7 +231,7 @@ test.serial("generateDependencyTree should skip framework project without versio const projectGraph = await projectGraphBuilder(provider); await ui5Framework.enrichProjectGraph(projectGraph); - t.is(projectGraph.getAllProjects().length, 1, "Project graph should remain unchanged"); + t.is(projectGraph.getSize(), 1, "Project graph should remain unchanged"); }); test.serial("generateDependencyTree should skip framework project with version and framework config", async (t) => { @@ -263,7 +263,7 @@ test.serial("generateDependencyTree should skip framework project with version a const projectGraph = await projectGraphBuilder(provider); await ui5Framework.enrichProjectGraph(projectGraph); - t.is(projectGraph.getAllProjects().length, 1, "Project graph should remain unchanged"); + t.is(projectGraph.getSize(), 1, "Project graph should remain unchanged"); }); test.serial("generateDependencyTree should throw for framework project with dependency missing in graph", async (t) => { @@ -316,7 +316,7 @@ test.serial("generateDependencyTree should ignore root project without framework const projectGraph = await projectGraphBuilder(provider); await ui5Framework.enrichProjectGraph(projectGraph); - t.is(projectGraph.getAllProjects().length, 1, "Project graph should remain unchanged"); + t.is(projectGraph.getSize(), 1, "Project graph should remain unchanged"); }); test.serial("utils.shouldIncludeDependency", (t) => {