diff --git a/lib/graph/Module.js b/lib/graph/Module.js index f098d7bcd..f495a3c28 100644 --- a/lib/graph/Module.js +++ b/lib/graph/Module.js @@ -100,7 +100,7 @@ class Module { * * @private * @typedef {object} @ui5/project/graph/Module~SpecificationsResult - * @property {@ui5/project/specifications/Project|undefined} Project found in the module (if one is found) + * @property {@ui5/project/specifications/Project|null} Project found in the module (if one is found) * @property {@ui5/project/specifications/Extension[]} Array of extensions found in the module * */ @@ -187,7 +187,7 @@ class Module { } return { - project: projects[0], + project: projects[0] || null, extensions }; } @@ -338,6 +338,8 @@ class Module { } // Validate found configurations with schema + // Validation is done again in the Specification class. But here we can reference the YAML file + // which adds helpful information like the line number const validationResults = await Promise.all( configs.map(async (config, documentIndex) => { // Catch validation errors to ensure proper order of rejections within Promise.all diff --git a/lib/graph/ProjectGraph.js b/lib/graph/ProjectGraph.js index 8ea683f4f..00d8973e3 100644 --- a/lib/graph/ProjectGraph.js +++ b/lib/graph/ProjectGraph.js @@ -2,7 +2,9 @@ import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); /** - * A rooted, directed graph representing a UI5 project, its dependencies and available extensions + * A rooted, directed graph representing a UI5 project, its dependencies and available extensions. + *

+ * While it allows defining cyclic dependencies, both traversal functions will throw an error if they encounter cycles. * * @public * @class @@ -20,9 +22,9 @@ class ProjectGraph { } this._rootProjectName = rootProjectName; - 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._projects = new Map(); // maps project name to instance (= nodes) + this._adjList = new Map(); // maps project name to dependencies (= edges) + this._optAdjList = new Map(); // maps project name to optional dependencies (= edges) this._extensions = new Map(); // maps extension name to instance @@ -175,10 +177,6 @@ class ProjectGraph { declareDependency(fromProjectName, toProjectName) { this._checkSealed(); try { - // if (this._optAdjList[fromProjectName] && this._optAdjList[fromProjectName][toProjectName]) { - // // TODO: Do we even care? - // throw new Error(`Dependency has already been declared as optional`); - // } log.verbose(`Declaring dependency: ${fromProjectName} depends on ${toProjectName}`); this._declareDependency(this._adjList, fromProjectName, toProjectName); } catch (err) { @@ -199,10 +197,6 @@ class ProjectGraph { declareOptionalDependency(fromProjectName, toProjectName) { this._checkSealed(); try { - // if (this._adjList[fromProjectName] && this._adjList[fromProjectName][toProjectName]) { - // // TODO: Do we even care? - // throw new Error(`Dependency has already been declared as non-optional`); - // } log.verbose(`Declaring optional dependency: ${fromProjectName} depends on ${toProjectName}`); this._declareDependency(this._optAdjList, fromProjectName, toProjectName); this._hasUnresolvedOptionalDependencies = true; @@ -267,6 +261,11 @@ class ProjectGraph { */ getTransitiveDependencies(projectName) { const dependencies = new Set(); + if (!this._projects.has(projectName)) { + throw new Error( + `Failed to get transitive dependencies for project ${projectName}: ` + + `Unable to find project in project graph`); + } const processDependency = (depName) => { const adjacencies = this._adjList.get(depName); @@ -375,7 +374,6 @@ class ProjectGraph { * 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. * The entry project defaults to the root project. @@ -398,28 +396,28 @@ class ProjectGraph { const queue = [{ projectNames: [startName], - predecessors: [] + ancestors: [] }]; const visited = Object.create(null); while (queue.length) { - const {projectNames, predecessors} = queue.shift(); // Get and remove first entry from queue + const {projectNames, ancestors} = queue.shift(); // Get and remove first entry from queue await Promise.all(projectNames.map(async (projectName) => { - this._checkCycle(predecessors, projectName); + this._checkCycle(ancestors, projectName); if (visited[projectName]) { return visited[projectName]; } return visited[projectName] = (async () => { - const newPredecessors = [...predecessors, projectName]; + const newAncestors = [...ancestors, projectName]; const dependencies = this.getDependencies(projectName); queue.push({ projectNames: dependencies, - predecessors: newPredecessors + ancestors: newAncestors }); await callback({ @@ -453,17 +451,17 @@ class ProjectGraph { return this._traverseDepthFirst(startName, Object.create(null), [], callback); } - async _traverseDepthFirst(projectName, visited, predecessors, callback) { - this._checkCycle(predecessors, projectName); + async _traverseDepthFirst(projectName, visited, ancestors, callback) { + this._checkCycle(ancestors, projectName); if (visited[projectName]) { return visited[projectName]; } return visited[projectName] = (async () => { - const newPredecessors = [...predecessors, projectName]; + const newAncestors = [...ancestors, projectName]; const dependencies = this.getDependencies(projectName); await Promise.all(dependencies.map((depName) => { - return this._traverseDepthFirst(depName, visited, newPredecessors, callback); + return this._traverseDepthFirst(depName, visited, newAncestors, callback); })); await callback({ @@ -609,13 +607,13 @@ class ProjectGraph { } } - _checkCycle(predecessors, projectName) { - if (predecessors.includes(projectName)) { - // We start to run in circles. That's neither expected nor something we can deal with - - // Mark first and last occurrence in chain with an asterisk - predecessors[predecessors.indexOf(projectName)] = `${projectName}*`; - throw new Error(`Detected cyclic dependency chain: ${predecessors.join(" -> ")} -> ${projectName}*`); + _checkCycle(ancestors, projectName) { + if (ancestors.includes(projectName)) { + // "Back-edge" detected. Neither BFS nor DFS searches should continue + // Mark first and last occurrence in chain with an asterisk and throw an error detailing the + // problematic dependency chain + ancestors[ancestors.indexOf(projectName)] = `*${projectName}*`; + throw new Error(`Detected cyclic dependency chain: ${ancestors.join(" -> ")} -> *${projectName}*`); } } diff --git a/lib/graph/Workspace.js b/lib/graph/Workspace.js new file mode 100644 index 000000000..f5b5a7756 --- /dev/null +++ b/lib/graph/Workspace.js @@ -0,0 +1,257 @@ +import fs from "graceful-fs"; +import {globby, isDynamicPattern} from "globby"; +import path from "node:path"; +import {promisify} from "node:util"; +import {getLogger} from "@ui5/logger"; +import Module from "./Module.js"; +import {validateWorkspace} from "../validation/validator.js"; + +const readFile = promisify(fs.readFile); +const log = getLogger("graph:Workspace"); + + +/** + * Dependency graph node representing a module + * + * @public + * @typedef {object} @ui5/project/graph/Workspace~Configuration + * @property {string} node.specVersion + * @property {object} node.metadata Version of the project + * @property {string} node.metadata.name Name of the workspace configuration + * @property {object} node.dependencyManagement + * @property {@ui5/project/graph/Workspace~DependencyManagementResolutions[]} node.dependencyManagement.resolutions + */ + +/** + * A resolution entry for the dependency management section of the configuration + * + * @public + * @typedef {object} @ui5/project/graph/Workspace~DependencyManagementResolution + * @property {string} path Relative path to use for the workspace resolution process + */ + +/** + * Workspace representation + * + * @public + * @class + * @alias @ui5/project/graph/Workspace + */ +class Workspace { + #visitedNodePaths = new Set(); + #configValidated = false; + #configuration; + #cwd; + + /** + * @public + * @param {object} options + * @param {string} options.cwd Path to use for resolving all paths of the workspace configuration from. + * This should contain platform-specific path separators (i.e. must not be POSIX on non-POSIX systems) + * @param {@ui5/project/graph/Workspace~Configuration} options.configuration + * Workspace configuration + */ + constructor({cwd, configuration}) { + if (!cwd) { + throw new Error(`Could not create Workspace: Missing or empty parameter 'cwd'`); + } + if (!configuration) { + throw new Error(`Could not create Workspace: Missing or empty parameter 'configuration'`); + } + + this.#cwd = cwd; + this.#configuration = configuration; + } + + /** + * Get the name of this workspace + * + * @public + * @returns {string} Name of this workspace configuration + */ + getName() { + return this.#configuration.metadata.name; + } + + /** + * For a given project name (e.g. the value of the metadata.name property in a ui5.yaml), + * returns a [Module]{@ui5/project/graph/Module} instance or undefined depending on whether the project + * has been found in the configured dependency-management resolution paths of this workspace + * + * @public + * @param {string} projectName Name of the project + * @returns {Promise<@ui5/project/graph/Module|undefined>} + * Module instance of undefined if none is found + */ + async getModuleByProjectName(projectName) { + const {projectNameMap} = await this._getResolvedModules(); + return projectNameMap.get(projectName); + } + + /** + * For a given node id (e.g. the value of the name property in a package.json), + * returns a [Module]{@eId Node ID of} instance or undefined depending on whether the module + * has been found in the configured dependency-management resolution paths of this workspace + * and contains at least one project or extension + * + * @public + * @param {string} nodeId Node ID of the module + * @returns {Promise<@ui5/project/graph/Module|undefined>} + * Module instance of undefined if none is found + */ + async getModuleByNodeId(nodeId) { + const {moduleIdMap} = await this._getResolvedModules(); + return moduleIdMap.get(nodeId); + } + + _getResolvedModules() { + if (this._pResolvedModules) { + return this._pResolvedModules; + } + + return this._pResolvedModules = this._resolveModules(); + } + + async _resolveModules() { + await this._validateConfig(); + const resolutions = this.#configuration.dependencyManagement?.resolutions; + if (!resolutions?.length) { + return { + projectNameMap: new Map(), + moduleIdMap: new Map() + }; + } + + let resolvedModules = await Promise.all(resolutions.map(async (resolutionConfig) => { + if (!resolutionConfig.path) { + throw new Error( + `Missing property 'path' in dependency resolution configuration of workspace ${this.getName()}`); + } + return await this._getModulesFromPath( + this.#cwd, resolutionConfig.path); + })); + + // Flatten array since package-workspaces might have resolved to multiple modules for a single resolution + resolvedModules = Array.prototype.concat.apply([], resolvedModules); + + const projectNameMap = new Map(); + const moduleIdMap = new Map(); + await Promise.all(resolvedModules.map(async (module) => { + const {project, extensions} = await module.getSpecifications(); + if (project || extensions.length) { + moduleIdMap.set(module.getId(), module); + } else { + log.warn( + `Failed to create a project or extensions from module ${module.getId()} at ${module.getPath()}`); + } + if (project) { + projectNameMap.set(project.getName(), module); + log.verbose(`Module ${module.getId()} contains project ${project.getName()}`); + } + if (extensions.length) { + const extensionNames = extensions.map((e) => e.getName()).join(", "); + log.verbose(`Module ${module.getId()} contains extensions: ${extensionNames}`); + } + })); + return { + projectNameMap, + moduleIdMap + }; + } + + async _getModulesFromPath(cwd, relPath) { + const nodePath = path.join(cwd, relPath); + if (this.#visitedNodePaths.has(nodePath)) { + log.verbose(`Module located at ${nodePath} has already been visited`); + return []; + } + this.#visitedNodePaths.add(nodePath); + let pkg; + try { + pkg = await this._readPackageJson(nodePath); + if (!pkg?.name || !pkg?.version) { + throw new Error( + `package.json must contain fields 'name' and 'version'`); + } + } catch (err) { + throw new Error( + `Failed to resolve workspace dependency resolution path ${relPath} to ${nodePath}: ${err.message}`); + } + + // If the package.json defines an npm "workspaces", or an equivalent "ui5.workspaces" configuration, + // resolve the workspace and only use the resulting modules. The root package is ignored. + const packageWorkspaceConfig = pkg.ui5?.workspaces || pkg.workspaces; + if (packageWorkspaceConfig?.length) { + log.verbose(`Module ${pkg.name} provides a package.json workspaces configuration. ` + + `Ignoring the module and resolving workspaces instead...`); + const staticPatterns = []; + // Split provided patterns into dynamic and static patterns + // This is necessary, since fast-glob currently behaves different from + // "glob" (used by @npmcli/map-workspaces) in that it does not match the + // base directory in case it is equal to the pattern (https://github.com/mrmlnc/fast-glob/issues/47) + // For example a pattern "package-a" would not match a directory called + // "package-a" in the root directory of the project. + // We therefore detect the static pattern and resolve it directly + const dynamicPatterns = packageWorkspaceConfig.filter((pattern) => { + if (isDynamicPattern(pattern)) { + return true; + } else { + staticPatterns.push(pattern); + return false; + } + }); + + let searchPaths = []; + if (dynamicPatterns.length) { + searchPaths = await globby(dynamicPatterns, { + cwd: nodePath, + followSymbolicLinks: false, + onlyDirectories: true, + }); + } + searchPaths.push(...staticPatterns); + + const resolvedModules = new Map(); + await Promise.all(searchPaths.map(async (pkgPath) => { + const modules = await this._getModulesFromPath(nodePath, pkgPath); + modules.forEach((module) => { + const id = module.getId(); + if (!resolvedModules.get(id)) { + resolvedModules.set(id, module); + } + }); + })); + return Array.from(resolvedModules.values()); + } else { + return [new Module({ + id: pkg.name, + version: pkg.version, + modulePath: nodePath + })]; + } + } + + /** + * Reads the package.json file and returns its content + * + * @private + * @param {string} modulePath Path to the module containing the package.json + * @returns {object} Package json content + */ + async _readPackageJson(modulePath) { + const content = await readFile(path.join(modulePath, "package.json"), "utf8"); + return JSON.parse(content); + } + + async _validateConfig() { + if (this.#configValidated) { + return; + } + await validateWorkspace({ + config: this.#configuration + }); + this.#configValidated = true; + } +} + +export default Workspace; diff --git a/lib/graph/graph.js b/lib/graph/graph.js index 4fc60c481..f0e10c613 100644 --- a/lib/graph/graph.js +++ b/lib/graph/graph.js @@ -1,6 +1,7 @@ import path from "node:path"; import projectGraphBuilder from "./projectGraphBuilder.js"; import ui5Framework from "./helpers/ui5Framework.js"; +import createWorkspace from "./helpers/createWorkspace.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("generateProjectGraph"); @@ -46,27 +47,47 @@ function resolveProjectPaths(cwd, project) { * @param {string} [options.versionOverride] Framework version to use instead of the one defined in the root project * @param {string} [options.resolveFrameworkDependencies=true] * Whether framework dependencies should be added to the graph + * @param {string} [options.workspaceName] + * Name of the workspace configuration that should be used if any is provided + * @param {string} [options.workspaceConfigPath=ui5-workspace.yaml] + * Workspace configuration file to use if no object has been provided + * @param {@ui5/project/graph/Workspace~Configuration} [options.workspaceConfiguration] + * Workspace configuration object to use instead of reading from a configuration file. + * Parameter workspaceName can either be omitted or has to match with the given configuration name * @returns {Promise<@ui5/project/graph/ProjectGraph>} Promise resolving to a Project Graph instance */ export async function graphFromPackageDependencies({ cwd, rootConfiguration, rootConfigPath, - versionOverride, resolveFrameworkDependencies = true + versionOverride, resolveFrameworkDependencies = true, + workspaceName, workspaceConfiguration, workspaceConfigPath = "ui5-workspace.yaml" }) { log.verbose(`Creating project graph using npm provider...`); const { default: NpmProvider } = await import("./providers/NodePackageDependencies.js"); + cwd = cwd ? path.resolve(cwd) : process.cwd(); + + let workspace; + if (workspaceName || workspaceConfiguration) { + workspace = await createWorkspace({ + cwd, + name: workspaceName, + configObject: workspaceConfiguration, + configPath: workspaceConfigPath + }); + } + const provider = new NpmProvider({ - cwd: cwd ? path.resolve(cwd) : process.cwd(), + cwd, rootConfiguration, rootConfigPath }); - const projectGraph = await projectGraphBuilder(provider); + const projectGraph = await projectGraphBuilder(provider, workspace); if (resolveFrameworkDependencies) { - await ui5Framework.enrichProjectGraph(projectGraph, {versionOverride}); + await ui5Framework.enrichProjectGraph(projectGraph, {versionOverride, workspace}); } return projectGraph; diff --git a/lib/graph/helpers/createWorkspace.js b/lib/graph/helpers/createWorkspace.js new file mode 100644 index 000000000..2283c698e --- /dev/null +++ b/lib/graph/helpers/createWorkspace.js @@ -0,0 +1,146 @@ +import path from "node:path"; +import Workspace from "../Workspace.js"; +import {validateWorkspace} from "../../validation/validator.js"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("generateProjectGraph"); + +const DEFAULT_WORKSPACE_CONFIG_PATH = "ui5-workspace.yaml"; +const DEFAULT_WORKSPACE_NAME = "default"; + +export default async function createWorkspace({ + cwd, name, configObject, configPath +}) { + if (!cwd || (!configObject && !configPath)) { + throw new Error(`createWorkspace: Missing parameter 'cwd', 'configObject' or 'configPath'`); + } + if (configObject) { + if (!configObject?.metadata?.name) { + throw new Error(`Invalid workspace configuration: Missing or empty property 'metadata.name'`); + } + if (name && configObject.metadata.name !== name) { + throw new Error( + `The provided workspace name '${name}' does not match ` + + `the provided workspace configuration '${configObject.metadata.name}'`); + } else { + log.verbose(`Using provided workspace configuration ${configObject.metadata.name}...`); + return new Workspace({ + cwd, + configuration: configObject + }); + } + } else { + if (!name) { + throw new Error(`createWorkspace: Parameter 'configPath' implies parameter 'name', but it's empty`); + } + let filePath = configPath; + if (!path.isAbsolute(filePath)) { + filePath = path.join(cwd, configPath); + } + try { + const workspaceConfigs = + await readWorkspaceConfigFile(filePath, ); + const configuration = workspaceConfigs.find((config) => { + return config.metadata.name === name; + }); + + if (configuration) { + log.verbose(`Using workspace configuration "${name}" from ${configPath}...`); + return new Workspace({ + cwd: path.dirname(filePath), + configuration + }); + } else if (name === DEFAULT_WORKSPACE_NAME) { + // Requested workspace not found + // Do not throw if the requested name is the default + return null; + } else { + throw new Error(`Could not find a workspace named '${name}' in ${configPath}`); + } + } catch (err) { + if (name === DEFAULT_WORKSPACE_NAME && configPath === DEFAULT_WORKSPACE_CONFIG_PATH && + err.cause?.code === "ENOENT") { + // Do not throw if the default workspace in the default file was requested but not found + log.verbose(`No workspace configuration file provided at ${filePath}`); + return null; + } else { + throw err; + } + } + } +} + +async function readWorkspaceConfigFile(filePath, throwIfMissing) { + const { + default: fs + } = await import("graceful-fs"); + const {promisify} = await import("node:util"); + const readFile = promisify(fs.readFile); + const jsyaml = await import("js-yaml"); + + let fileContent; + try { + fileContent = await readFile(filePath, {encoding: "utf8"}); + } catch (err) { + throw new Error( + `Failed to load workspace configuration from path ${filePath}: ${err.message}`, { + cause: err + }); + } + let configs; + try { + configs = jsyaml.loadAll(fileContent, undefined, { + filename: filePath, + }); + } catch (err) { + if (err.name === "YAMLException") { + throw new Error(`Failed to parse workspace configuration at ` + + `${filePath}\nError: ${err.message}`); + } else { + throw new Error( + `Failed to parse workspace configuration at ${filePath}: ${err.message}`); + } + } + + if (!configs || !configs.length) { + // No configs found => exit here + log.verbose(`Found empty workspace configuration file at ${filePath}`); + return configs; + } + + // Validate found configurations with schema + // Validation is done again in the Workspace class. But here we can reference the YAML file + // which adds helpful information like the line number + const validationResults = await Promise.all( + configs.map(async (config, documentIndex) => { + // Catch validation errors to ensure proper order of rejections within Promise.all + try { + await validateWorkspace({ + config, + yaml: { + path: filePath, + source: fileContent, + documentIndex + } + }); + } catch (error) { + return error; + } + }) + ); + + const validationErrors = validationResults.filter(($) => $); + + if (validationErrors.length > 0) { + // Throw any validation errors + // For now just throw the error of the first invalid document + throw validationErrors[0]; + } + + return configs; +} + +// Export function for testing only +/* istanbul ignore else */ +if (process.env.NODE_ENV === "test") { + createWorkspace._readWorkspaceConfigFile = readWorkspaceConfigFile; +} diff --git a/lib/graph/helpers/ui5Framework.js b/lib/graph/helpers/ui5Framework.js index a50af78f0..01f510364 100644 --- a/lib/graph/helpers/ui5Framework.js +++ b/lib/graph/helpers/ui5Framework.js @@ -4,33 +4,38 @@ import {getLogger} from "@ui5/logger"; const log = getLogger("graph:helpers:ui5Framework"); class ProjectProcessor { - constructor({libraryMetadata}) { + constructor({libraryMetadata, graph, workspace}) { this._libraryMetadata = libraryMetadata; + this._graph = graph; + this._workspace = workspace; this._projectGraphPromises = Object.create(null); } - async addProjectToGraph(libName, projectGraph) { + async addProjectToGraph(libName, ancestors) { + if (ancestors) { + this._checkCycle(ancestors, libName); + } if (this._projectGraphPromises[libName]) { return this._projectGraphPromises[libName]; } - return this._projectGraphPromises[libName] = this._addProjectToGraph(libName, projectGraph); + return this._projectGraphPromises[libName] = this._addProjectToGraph(libName, ancestors); } - async _addProjectToGraph(libName, projectGraph) { + async _addProjectToGraph(libName, ancestors = []) { log.verbose(`Creating project for library ${libName}...`); - if (!this._libraryMetadata[libName]) { throw new Error(`Failed to find library ${libName} in dist packages metadata.json`); } const depMetadata = this._libraryMetadata[libName]; + const graph = this._graph; - if (projectGraph.getProject(depMetadata.id)) { + if (graph.getProject(libName)) { // Already added return; } const dependencies = await Promise.all(depMetadata.dependencies.map(async (depName) => { - await this.addProjectToGraph(depName, projectGraph); + await this.addProjectToGraph(depName, [...ancestors, libName]); return depName; })); @@ -38,7 +43,7 @@ class ProjectProcessor { const resolvedOptionals = await Promise.all(depMetadata.optionalDependencies.map(async (depName) => { if (this._libraryMetadata[depName]) { log.verbose(`Resolving optional dependency ${depName} for project ${libName}...`); - await this.addProjectToGraph(depName, projectGraph); + await this.addProjectToGraph(depName, [...ancestors, libName]); return depName; } })); @@ -46,16 +51,63 @@ class ProjectProcessor { dependencies.push(...resolvedOptionals.filter(($)=>$)); } - const ui5Module = new Module({ - id: depMetadata.id, - version: depMetadata.version, - modulePath: depMetadata.path - }); + let projectIsFromWorkspace = false; + let ui5Module; + if (this._workspace) { + ui5Module = await this._workspace.getModuleByProjectName(libName); + if (ui5Module) { + log.info(`Resolved project ${libName} via ${this._workspace.getName()} workspace ` + + `to version ${ui5Module.getVersion()}`); + log.verbose(` Resolved module ${libName} to path ${ui5Module.getPath()}`); + log.verbose(` Requested version was: ${depMetadata.version}`); + projectIsFromWorkspace = true; + } + } + + if (!ui5Module) { + ui5Module = new Module({ + id: depMetadata.id, + version: depMetadata.version, + modulePath: depMetadata.path + }); + } const {project} = await ui5Module.getSpecifications(); - projectGraph.addProject(project); + graph.addProject(project); dependencies.forEach((dependency) => { - projectGraph.declareDependency(libName, dependency); + graph.declareDependency(libName, dependency); }); + if (projectIsFromWorkspace) { + // Add any dependencies that are only declared in the workspace resolved project + // Do not remove superfluous dependencies (might be added later though) + await Promise.all(project.getFrameworkDependencies().map(async ({name, optional, development}) => { + // Only proceed with dependencies which are not "optional" or "development", + // and not already listed in the dependencies of the original node + if (optional || development || dependencies.includes(name)) { + return; + } + + if (!this._libraryMetadata[name]) { + throw new Error( + `Unable to find dependency ${name}, required by project ${project.getName()} ` + + `(resolved via ${this._workspace.getName()} workspace) in current set of libraries. ` + + `Try adding it temporarily to the root project's dependencies`); + } + + await this.addProjectToGraph(name, [...ancestors, libName]); + graph.declareDependency(libName, name); + })); + } + } + _checkCycle(ancestors, projectName) { + if (ancestors.includes(projectName)) { + // "Back-edge" detected. This would cause a deadlock + // Mark first and last occurrence in chain with an asterisk and throw an error detailing the + // problematic dependency chain + ancestors[ancestors.indexOf(projectName)] = `*${projectName}*`; + throw new Error( + `ui5Framework:ProjectPreprocessor: Detected cyclic dependency chain: ` + + `${ancestors.join(" -> ")} -> *${projectName}*`); + } } } @@ -158,6 +210,8 @@ export default { * @param {object} [options] * @param {string} [options.versionOverride] Framework version to use instead of the root projects framework * version + * @param {@ui5/project/graph/Workspace} [options.workspace] + * Optional workspace instance to use for overriding node resolutions * @returns {Promise<@ui5/project/graph/ProjectGraph>} * Promise resolving with the given graph instance to allow method chaining */ @@ -245,15 +299,18 @@ export default { `resolved in ${prettyHrtime(timeDiff)}`); } - const projectProcessor = new utils.ProjectProcessor({ - libraryMetadata - }); - const frameworkGraph = new ProjectGraph({ rootProjectName: `fake-root-of-${rootProject.getName()}-framework-dependency-graph` }); + + const projectProcessor = new utils.ProjectProcessor({ + libraryMetadata, + graph: frameworkGraph, + workspace: options.workspace + }); + await Promise.all(referencedLibraries.map(async (libName) => { - await projectProcessor.addProjectToGraph(libName, frameworkGraph); + await projectProcessor.addProjectToGraph(libName); })); log.verbose("Joining framework graph into project graph..."); diff --git a/lib/graph/projectGraphBuilder.js b/lib/graph/projectGraphBuilder.js index 713e2fa43..0277ca1d1 100644 --- a/lib/graph/projectGraphBuilder.js +++ b/lib/graph/projectGraphBuilder.js @@ -83,7 +83,8 @@ function validateNode(node) { * @public * @function * @name @ui5/project/graph/ProjectGraphBuilder~NodeProvider#getDependencies - * @param {Node} The root node of the dependency graph + * @param {Node} node The root node of the dependency graph + * @param {@ui5/project/graph/Workspace} [workspace] workspace instance to use for overriding node resolution * @returns {Node[]} Array of nodes which are direct dependencies of the given node */ @@ -95,9 +96,12 @@ function validateNode(node) { * @function default * @static * @param {@ui5/project/graph/ProjectGraphBuilder~NodeProvider} nodeProvider + * Node provider instance to use for building the graph + * @param {@ui5/project/graph/Workspace} [workspace] + * Optional workspace instance to use for overriding project resolutions * @returns {@ui5/project/graph/ProjectGraph} A new project graph instance */ -async function projectGraphBuilder(nodeProvider) { +async function projectGraphBuilder(nodeProvider, workspace) { const shimCollection = new ShimCollection(); const moduleCollection = Object.create(null); @@ -141,7 +145,7 @@ async function projectGraphBuilder(nodeProvider) { const queue = []; - const rootDependencies = await nodeProvider.getDependencies(rootNode); + const rootDependencies = await nodeProvider.getDependencies(rootNode, workspace); if (rootDependencies && rootDependencies.length) { queue.push({ @@ -174,7 +178,6 @@ async function projectGraphBuilder(nodeProvider) { } const {project, extensions} = await ui5Module.getSpecifications(); - return { node, project, diff --git a/lib/graph/providers/NodePackageDependencies.js b/lib/graph/providers/NodePackageDependencies.js index ad57d0804..ecf79c5ef 100644 --- a/lib/graph/providers/NodePackageDependencies.js +++ b/lib/graph/providers/NodePackageDependencies.js @@ -34,8 +34,6 @@ class NodePackageDependencies { this._cwd = cwd; this._rootConfiguration = rootConfiguration; this._rootConfigPath = rootConfigPath; - - // this._nodes = {}; } async getRootNode() { @@ -49,9 +47,6 @@ class NodePackageDependencies { `Failed to locate package.json for directory ${path.resolve(this._cwd)}`); } const modulePath = path.dirname(rootPkg.path); - // this._nodes[rootPkg.packageJson.name] = { - // dependencies: Object.keys(rootPkg.packageJson.dependencies) - // }; return { id: rootPkg.packageJson.name, version: rootPkg.packageJson.version, @@ -62,19 +57,32 @@ class NodePackageDependencies { }; } - async getDependencies(node) { + async getDependencies(node, workspace) { log.verbose(`Resolving dependencies of ${node.id}...`); if (!node._dependencies) { return []; } return Promise.all(node._dependencies.map(async ({name, optional}) => { - const modulePath = await this._resolveModulePath(node.path, name); + const modulePath = await this._resolveModulePath(node.path, name, workspace); return this._getNode(modulePath, optional); })); } - async _resolveModulePath(baseDir, moduleName) { + async _resolveModulePath(baseDir, moduleName, workspace) { log.verbose(`Resolving module path for '${moduleName}'...`); + + if (workspace) { + // Check whether node can be resolved via the provided Workspace instance + // If yes, replace the node from NodeProvider with the one from Workspace + const workspaceNode = await workspace.getModuleByNodeId(moduleName); + if (workspaceNode) { + log.info(`Resolved module ${moduleName} via ${workspace.getName()} workspace ` + + `to version ${workspaceNode.getVersion()}`); + log.verbose(` Resolved module ${moduleName} to path ${workspaceNode.getPath()}`); + return workspaceNode.getPath(); + } + } + try { let packageJsonPath = await resolveModulePath(moduleName + "/package.json", { basedir: baseDir, diff --git a/package-lock.json b/package-lock.json index fb51aec6b..406a0fab9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "ajv-errors": "^1.0.1", "chalk": "^5.2.0", "escape-string-regexp": "^5.0.0", + "globby": "^13.1.2", "graceful-fs": "^4.2.10", "js-yaml": "^4.1.0", "libnpmconfig": "^1.2.1", diff --git a/package.json b/package.json index d1b28f06c..86b3b54ba 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "ajv-errors": "^1.0.1", "chalk": "^5.2.0", "escape-string-regexp": "^5.0.0", + "globby": "^13.1.2", "graceful-fs": "^4.2.10", "js-yaml": "^4.1.0", "libnpmconfig": "^1.2.1", diff --git a/test/fixtures/application.a/ui5-test-corrupt.yaml b/test/fixtures/application.a/ui5-test-corrupt.yaml new file mode 100644 index 000000000..ecce9d7e7 --- /dev/null +++ b/test/fixtures/application.a/ui5-test-corrupt.yaml @@ -0,0 +1 @@ +|-\nfoo\nbar diff --git a/test/fixtures/application.a/ui5-test-empty.yaml b/test/fixtures/application.a/ui5-test-empty.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/collection.b/library.a/package.json b/test/fixtures/collection.b/library.a/package.json new file mode 100644 index 000000000..aec498f72 --- /dev/null +++ b/test/fixtures/collection.b/library.a/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.a", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/test/fixtures/collection.b/library.a/src/library/a/.library b/test/fixtures/collection.b/library.a/src/library/a/.library new file mode 100644 index 000000000..ef0ea1065 --- /dev/null +++ b/test/fixtures/collection.b/library.a/src/library/a/.library @@ -0,0 +1,17 @@ + + + + library.a + SAP SE + Some fancy copyright ${currentYear} + ${version} + + Library A + + + + library.d + + + + diff --git a/test/fixtures/collection.b/library.a/src/library/a/themes/base/library.source.less b/test/fixtures/collection.b/library.a/src/library/a/themes/base/library.source.less new file mode 100644 index 000000000..ff0f1d5e3 --- /dev/null +++ b/test/fixtures/collection.b/library.a/src/library/a/themes/base/library.source.less @@ -0,0 +1,6 @@ +@libraryAColor1: lightgoldenrodyellow; + +.library-a-foo { + color: @libraryAColor1; + padding: 1px 2px 3px 4px; +} diff --git a/test/fixtures/collection.b/library.a/test/library/a/Test.html b/test/fixtures/collection.b/library.a/test/library/a/Test.html new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/collection.b/library.a/ui5.yaml b/test/fixtures/collection.b/library.a/ui5.yaml new file mode 100644 index 000000000..8d4784313 --- /dev/null +++ b/test/fixtures/collection.b/library.a/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.a diff --git a/test/fixtures/collection.b/library.b/package.json b/test/fixtures/collection.b/library.b/package.json new file mode 100644 index 000000000..2a0243b16 --- /dev/null +++ b/test/fixtures/collection.b/library.b/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.b", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/test/fixtures/collection.b/library.b/src/library/b/.library b/test/fixtures/collection.b/library.b/src/library/b/.library new file mode 100644 index 000000000..7128151f3 --- /dev/null +++ b/test/fixtures/collection.b/library.b/src/library/b/.library @@ -0,0 +1,17 @@ + + + + library.b + SAP SE + Some fancy copyright ${currentYear} + ${version} + + Library B + + + + library.d + + + + diff --git a/test/fixtures/collection.b/library.b/test/library/b/Test.html b/test/fixtures/collection.b/library.b/test/library/b/Test.html new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/collection.b/library.b/ui5.yaml b/test/fixtures/collection.b/library.b/ui5.yaml new file mode 100644 index 000000000..b2fe5be59 --- /dev/null +++ b/test/fixtures/collection.b/library.b/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.b diff --git a/test/fixtures/collection.b/library.c/package.json b/test/fixtures/collection.b/library.c/package.json new file mode 100644 index 000000000..64ac75d6f --- /dev/null +++ b/test/fixtures/collection.b/library.c/package.json @@ -0,0 +1,9 @@ +{ + "name": "library.c", + "version": "1.0.0", + "description": "Simple SAPUI5 based library", + "dependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/test/fixtures/collection.b/library.c/src/library/c/.library b/test/fixtures/collection.b/library.c/src/library/c/.library new file mode 100644 index 000000000..4180ce2af --- /dev/null +++ b/test/fixtures/collection.b/library.c/src/library/c/.library @@ -0,0 +1,17 @@ + + + + library.c + SAP SE + ${copyright} + ${version} + + Library C + + + + library.d + + + + diff --git a/test/fixtures/collection.b/library.c/test/LibraryC/Test.html b/test/fixtures/collection.b/library.c/test/LibraryC/Test.html new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/collection.b/library.c/ui5.yaml b/test/fixtures/collection.b/library.c/ui5.yaml new file mode 100644 index 000000000..7c5e38a7f --- /dev/null +++ b/test/fixtures/collection.b/library.c/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.c diff --git a/test/fixtures/collection.b/package.json b/test/fixtures/collection.b/package.json new file mode 100644 index 000000000..25e37da04 --- /dev/null +++ b/test/fixtures/collection.b/package.json @@ -0,0 +1,17 @@ +{ + "name": "collection", + "version": "1.0.0", + "description": "Simple Collection used for Workspace testing", + "dependencies": { + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "ui5": { + "workspaces": [ + "library.{a,b}", + "*c", + "sub-*" + ] + } +} diff --git a/test/fixtures/collection.b/sub-collection/package.json b/test/fixtures/collection.b/sub-collection/package.json new file mode 100644 index 000000000..046177300 --- /dev/null +++ b/test/fixtures/collection.b/sub-collection/package.json @@ -0,0 +1,10 @@ +{ + "name": "sub-collection", + "version": "1.0.0", + "description": "Sub-Collection package", + "ui5": { + "workspaces": [ + "../../library.d" + ] + } +} diff --git a/test/fixtures/collection.b/test.js b/test/fixtures/collection.b/test.js new file mode 100644 index 000000000..d063db1e7 --- /dev/null +++ b/test/fixtures/collection.b/test.js @@ -0,0 +1,4 @@ +import {globby} from 'globby'; + +const paths = await globby(["library.a"]); +console.log("paths") diff --git a/test/fixtures/collection/package.json b/test/fixtures/collection/package.json index 81b948438..24849dbe4 100644 --- a/test/fixtures/collection/package.json +++ b/test/fixtures/collection/package.json @@ -8,11 +8,9 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, - "collection": { - "modules": { - "library.a": "./library.a", - "library.b": "./library.b", - "library.c": "./library.c" - } - } + "workspaces": [ + "library.a", + "library.b", + "library.c" + ] } diff --git a/test/fixtures/collection/test.js b/test/fixtures/collection/test.js new file mode 100644 index 000000000..d063db1e7 --- /dev/null +++ b/test/fixtures/collection/test.js @@ -0,0 +1,4 @@ +import {globby} from 'globby'; + +const paths = await globby(["library.a"]); +console.log("paths") diff --git a/test/fixtures/extension.a/package.json b/test/fixtures/extension.a/package.json index c1f690b16..c3f37004f 100644 --- a/test/fixtures/extension.a/package.json +++ b/test/fixtures/extension.a/package.json @@ -1,3 +1,4 @@ { - "name": "extension.a" + "name": "extension.a", + "version": "1.0.0" } diff --git a/test/fixtures/extension.a/ui5.yaml b/test/fixtures/extension.a/ui5.yaml new file mode 100644 index 000000000..1ae473d86 --- /dev/null +++ b/test/fixtures/extension.a/ui5.yaml @@ -0,0 +1,8 @@ +--- +specVersion: "2.0" +kind: extension +type: task +metadata: + name: extension.a-ui5-yaml +task: + path: lib/extensionModule.js diff --git a/test/fixtures/library.d-adtl-deps/main/src/library/d/.library b/test/fixtures/library.d-adtl-deps/main/src/library/d/.library new file mode 100644 index 000000000..53c2d14c9 --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/main/src/library/d/.library @@ -0,0 +1,11 @@ + + + + library.d + SAP SE + Some fancy copyright + ${version} + + Library D + + diff --git a/test/fixtures/library.d-adtl-deps/main/src/library/d/some.js b/test/fixtures/library.d-adtl-deps/main/src/library/d/some.js new file mode 100644 index 000000000..719155d1e --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/main/src/library/d/some.js @@ -0,0 +1,4 @@ +/*! + * ${copyright} + */ +console.log('HelloWorld'); diff --git a/test/fixtures/library.d-adtl-deps/main/test/library/d/Test.html b/test/fixtures/library.d-adtl-deps/main/test/library/d/Test.html new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/library.d-adtl-deps/node_modules/library.f/package.json b/test/fixtures/library.d-adtl-deps/node_modules/library.f/package.json new file mode 100644 index 000000000..d8f009d42 --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/node_modules/library.f/package.json @@ -0,0 +1,11 @@ +{ + "name": "library.f", + "version": "1.0.0", + "description": "Simple SAPUI5 based library - test for circular dependencies", + "dependencies": { + "library.g": "file:../library.g" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/test/fixtures/library.d-adtl-deps/node_modules/library.f/src/library/f/.library b/test/fixtures/library.d-adtl-deps/node_modules/library.f/src/library/f/.library new file mode 100644 index 000000000..c45172d48 --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/node_modules/library.f/src/library/f/.library @@ -0,0 +1,11 @@ + + + + library.f + SAP SE + ${copyright} + ${version} + + Library F + + diff --git a/test/fixtures/library.d-adtl-deps/node_modules/library.f/src/library/f/some.js b/test/fixtures/library.d-adtl-deps/node_modules/library.f/src/library/f/some.js new file mode 100644 index 000000000..81e734360 --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/node_modules/library.f/src/library/f/some.js @@ -0,0 +1,4 @@ +/*! + * ${copyright} + */ +console.log('HelloWorld'); \ No newline at end of file diff --git a/test/fixtures/library.d-adtl-deps/node_modules/library.f/ui5.yaml b/test/fixtures/library.d-adtl-deps/node_modules/library.f/ui5.yaml new file mode 100644 index 000000000..52c17922b --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/node_modules/library.f/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.f diff --git a/test/fixtures/library.d-adtl-deps/node_modules/library.g/package.json b/test/fixtures/library.d-adtl-deps/node_modules/library.g/package.json new file mode 100644 index 000000000..fff39011d --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/node_modules/library.g/package.json @@ -0,0 +1,11 @@ +{ + "name": "library.g", + "version": "1.0.0", + "description": "Simple SAPUI5 based library - test for circular dependencies", + "devDependencies": { + "library.f": "file:../library.f" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/test/fixtures/library.d-adtl-deps/node_modules/library.g/src/library/g/.library b/test/fixtures/library.d-adtl-deps/node_modules/library.g/src/library/g/.library new file mode 100644 index 000000000..4d884278e --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/node_modules/library.g/src/library/g/.library @@ -0,0 +1,11 @@ + + + + library.g + SAP SE + ${copyright} + ${version} + + Library G + + diff --git a/test/fixtures/library.d-adtl-deps/node_modules/library.g/src/library/g/some.js b/test/fixtures/library.d-adtl-deps/node_modules/library.g/src/library/g/some.js new file mode 100644 index 000000000..81e734360 --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/node_modules/library.g/src/library/g/some.js @@ -0,0 +1,4 @@ +/*! + * ${copyright} + */ +console.log('HelloWorld'); \ No newline at end of file diff --git a/test/fixtures/library.d-adtl-deps/node_modules/library.g/ui5.yaml b/test/fixtures/library.d-adtl-deps/node_modules/library.g/ui5.yaml new file mode 100644 index 000000000..a20d2d499 --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/node_modules/library.g/ui5.yaml @@ -0,0 +1,5 @@ +--- +specVersion: "2.3" +type: library +metadata: + name: library.g diff --git a/test/fixtures/library.d-adtl-deps/package.json b/test/fixtures/library.d-adtl-deps/package.json new file mode 100644 index 000000000..10cd27bde --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/package.json @@ -0,0 +1,11 @@ +{ + "name": "library.d", + "version": "2.0.0", + "description": "Version of library.d that has additional dependencies defined. Used for testing UI5 Workspace resolutions", + "dependencies": { + "library.f": "file:../library.f" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/test/fixtures/library.d-adtl-deps/ui5.yaml b/test/fixtures/library.d-adtl-deps/ui5.yaml new file mode 100644 index 000000000..9c8d48e11 --- /dev/null +++ b/test/fixtures/library.d-adtl-deps/ui5.yaml @@ -0,0 +1,9 @@ +specVersion: "2.3" +type: library +metadata: + name: library.d +resources: + configuration: + paths: + src: main/src + test: main/test diff --git a/test/fixtures/library.e/ui5-workspace.yaml b/test/fixtures/library.e/ui5-workspace.yaml new file mode 100644 index 000000000..7e8fd36b8 --- /dev/null +++ b/test/fixtures/library.e/ui5-workspace.yaml @@ -0,0 +1,13 @@ +specVersion: workspace/1.0 +metadata: + name: config-a +dependencyManagement: + resolutions: + - path: ../library.d +--- +specVersion: workspace/1.0 +metadata: + name: config-b +dependencyManagement: + resolutions: + - path: ../library.x diff --git a/test/fixtures/library.h/corrupt-ui5-workspace.yaml b/test/fixtures/library.h/corrupt-ui5-workspace.yaml new file mode 100644 index 000000000..ecce9d7e7 --- /dev/null +++ b/test/fixtures/library.h/corrupt-ui5-workspace.yaml @@ -0,0 +1 @@ +|-\nfoo\nbar diff --git a/test/fixtures/library.h/custom-ui5-workspace.yaml b/test/fixtures/library.h/custom-ui5-workspace.yaml new file mode 100644 index 000000000..177dea5f6 --- /dev/null +++ b/test/fixtures/library.h/custom-ui5-workspace.yaml @@ -0,0 +1,15 @@ +specVersion: workspace/1.0 +metadata: + name: library-d +dependencyManagement: + resolutions: + - path: ../library.d +--- +specVersion: workspace/1.0 +metadata: + name: all-libraries +dependencyManagement: + resolutions: + - path: ../library.d + - path: ../library.e + - path: ../library.f diff --git a/test/fixtures/library.h/empty-ui5-workspace.yaml b/test/fixtures/library.h/empty-ui5-workspace.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/library.h/invalid-ui5-workspace.yaml b/test/fixtures/library.h/invalid-ui5-workspace.yaml new file mode 100644 index 000000000..ca4ad00f4 --- /dev/null +++ b/test/fixtures/library.h/invalid-ui5-workspace.yaml @@ -0,0 +1,6 @@ +specVersion: wörkspace/1.0 +metadata: + name: default +dependencyManagement: + resolutions: + - path: ../library.d diff --git a/test/fixtures/library.h/ui5-workspace.yaml b/test/fixtures/library.h/ui5-workspace.yaml new file mode 100644 index 000000000..ac0ea1c35 --- /dev/null +++ b/test/fixtures/library.h/ui5-workspace.yaml @@ -0,0 +1,15 @@ +specVersion: workspace/1.0 +metadata: + name: default +dependencyManagement: + resolutions: + - path: ../library.d +--- +specVersion: workspace/1.0 +metadata: + name: all-libraries +dependencyManagement: + resolutions: + - path: ../library.d + - path: ../library.e + - path: ../library.f diff --git a/test/lib/graph/Module.js b/test/lib/graph/Module.js index b7c7ec8ed..b5ca1afe1 100644 --- a/test/lib/graph/Module.js +++ b/test/lib/graph/Module.js @@ -6,14 +6,15 @@ import Module from "../../../lib/graph/Module.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const applicationAPath = path.join(__dirname, "..", "..", "fixtures", "application.a"); +const fixturesPath = path.join(__dirname, "..", "..", "fixtures"); +const applicationAPath = path.join(fixturesPath, "application.a"); const buildDescriptionApplicationAPath = - path.join(__dirname, "..", "..", "fixtures", "build-manifest", "application.a"); + path.join(fixturesPath, "build-manifest", "application.a"); const buildDescriptionLibraryAPath = - path.join(__dirname, "..", "..", "fixtures", "build-manifest", "library.e"); -const applicationHPath = path.join(__dirname, "..", "..", "fixtures", "application.h"); -const collectionPath = path.join(__dirname, "..", "..", "fixtures", "collection"); -const themeLibraryEPath = path.join(__dirname, "..", "..", "fixtures", "theme.library.e"); + path.join(fixturesPath, "build-manifest", "library.e"); +const applicationHPath = path.join(fixturesPath, "application.h"); +const collectionPath = path.join(fixturesPath, "collection"); +const themeLibraryEPath = path.join(fixturesPath, "theme.library.e"); const basicModuleInput = { id: "application.a.id", @@ -379,6 +380,49 @@ test("Invalid configuration in file", async (t) => { t.truthy(err.yaml, "Error object contains yaml information"); }); +test("Corrupt configuration in file", async (t) => { + const ui5Module = new Module({ + id: "application.a.id", + version: "1.0.0", + modulePath: applicationAPath, + configPath: "ui5-test-corrupt.yaml" + }); + const err = await t.throwsAsync(ui5Module.getSpecifications()); + + t.regex(err.message, + new RegExp("^Failed to parse configuration for project application.a.id at 'ui5-test-corrupt.yaml'.*"), + "Threw with parsing error"); +}); + +test("Empty configuration in file", async (t) => { + const ui5Module = new Module({ + id: "application.a.id", + version: "1.0.0", + modulePath: applicationAPath, + configPath: "ui5-test-empty.yaml" + }); + const res = await ui5Module.getSpecifications(); + + t.deepEqual(res, { + project: null, + extensions: [] + }, "Returned no project or extensions"); +}); + +test("No configuration", async (t) => { + const ui5Module = new Module({ + id: "application.a.id", + version: "1.0.0", + modulePath: fixturesPath, // does not contain a ui5.yaml + }); + const res = await ui5Module.getSpecifications(); + + t.deepEqual(res, { + project: null, + extensions: [] + }, "Returned no project or extensions"); +}); + test("Incorrect config path", async (t) => { const ui5Module = new Module({ id: "application.a.id", diff --git a/test/lib/graph/ProjectGraph.js b/test/lib/graph/ProjectGraph.js index fdbbe1f41..ea0a3e198 100644 --- a/test/lib/graph/ProjectGraph.js +++ b/test/lib/graph/ProjectGraph.js @@ -364,6 +364,20 @@ test("getTransitiveDependencies", async (t) => { ], "Should store and return correct transitive dependencies for library.a"); }); +test("getTransitiveDependencies: Unknown project", (t) => { + const {ProjectGraph} = t.context; + const graph = new ProjectGraph({ + rootProjectName: "my root project" + }); + + const error = t.throws(() => { + graph.getTransitiveDependencies("library.x"); + }); + t.is(error.message, + "Failed to get transitive dependencies for project library.x: Unable to find project in project graph", + "Should throw with expected error message"); +}); + test("declareDependency: Unknown source", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ @@ -485,7 +499,6 @@ test("declareDependency: Already declared as optional, now non-optional", async "Should declare dependency as non-optional"); }); - test("getDependencies: Project without dependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ @@ -775,7 +788,7 @@ test("traverseBreadthFirst: Detect cycle", async (t) => { const error = await t.throwsAsync(graph.traverseBreadthFirst(() => {})); t.is(error.message, - "Detected cyclic dependency chain: library.a* -> library.b -> library.a*", + "Detected cyclic dependency chain: *library.a* -> library.b -> *library.a*", "Should throw with expected error message"); }); @@ -1000,7 +1013,7 @@ test("traverseDepthFirst: Detect cycle", async (t) => { const error = await t.throwsAsync(graph.traverseDepthFirst(() => {})); t.is(error.message, - "Detected cyclic dependency chain: library.a* -> library.b -> library.a*", + "Detected cyclic dependency chain: *library.a* -> library.b -> *library.a*", "Should throw with expected error message"); }); @@ -1020,7 +1033,7 @@ test("traverseDepthFirst: Cycle which does not occur in BFS", async (t) => { const error = await t.throwsAsync(graph.traverseDepthFirst(() => {})); t.is(error.message, - "Detected cyclic dependency chain: library.a -> library.b* -> library.c -> library.b*", + "Detected cyclic dependency chain: library.a -> *library.b* -> library.c -> *library.b*", "Should throw with expected error message"); }); diff --git a/test/lib/graph/Workspace.js b/test/lib/graph/Workspace.js new file mode 100644 index 000000000..af79ca3d4 --- /dev/null +++ b/test/lib/graph/Workspace.js @@ -0,0 +1,473 @@ +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import test from "ava"; +import sinonGlobal from "sinon"; +import esmock from "esmock"; +import Module from "../../../lib/graph/Module.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const libraryD = path.join(__dirname, "..", "..", "fixtures", "library.d"); +const libraryE = path.join(__dirname, "..", "..", "fixtures", "library.e"); +const collectionLibraryA = path.join(__dirname, "..", "..", "fixtures", "collection", "library.a"); +const collectionLibraryB = path.join(__dirname, "..", "..", "fixtures", "collection", "library.b"); +const collectionLibraryC = path.join(__dirname, "..", "..", "fixtures", "collection", "library.c"); +const collectionBLibraryA = path.join(__dirname, "..", "..", "fixtures", "collection.b", "library.a"); +const collectionBLibraryB = path.join(__dirname, "..", "..", "fixtures", "collection.b", "library.b"); +const collectionBLibraryC = path.join(__dirname, "..", "..", "fixtures", "collection.b", "library.c"); + +function createWorkspaceConfig({dependencyManagement}) { + return { + specVersion: "workspace/1.0", + metadata: { + name: "workspace-name" + }, + dependencyManagement + }; +} + +test.beforeEach(async (t) => { + const sinon = t.context.sinon = sinonGlobal.createSandbox(); + + t.context.log = { + warn: sinon.stub(), + verbose: sinon.stub(), + error: sinon.stub(), + info: sinon.stub(), + isLevelEnabled: () => true + }; + + t.context.Workspace = await esmock("../../../lib/graph/Workspace.js", { + "@ui5/logger": { + getLogger: sinon.stub().withArgs("graph:Workspace").returns(t.context.log) + } + }); +}); + +test.afterEach.always((t) => { + t.context.sinon.restore(); + esmock.purge(t.context.ProjectGraph); +}); + +test("Basic resolution", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + path: "../../fixtures/library.d" + }, { + path: "../../fixtures/library.e" + }] + } + }) + }); + + t.is(workspace.getName(), "workspace-name"); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + t.deepEqual(Array.from(projectNameMap.keys()).sort(), ["library.d", "library.e"], "Correct project name keys"); + + const libE = projectNameMap.get("library.e"); + t.true(libE instanceof Module, "library.e value is instance of Module"); + t.is(libE.getVersion(), "1.0.0", "Correct version for library.e"); + t.is(libE.getPath(), libraryE, "Correct path for library.e"); + + const libD = projectNameMap.get("library.d"); + t.true(libD instanceof Module, "library.d value is instance of Module"); + t.is(libD.getVersion(), "1.0.0", "Correct version for library.d"); + t.is(libD.getPath(), libraryD, "Correct path for library.d"); + + t.is(await workspace.getModuleByProjectName("library.d"), libD, + "getModuleByProjectName returns correct module for library.d"); + t.is(await workspace.getModuleByNodeId("library.d"), libD, + "getModuleByNodeId returns correct module for library.d"); + + t.deepEqual(Array.from(moduleIdMap.keys()).sort(), ["library.d", "library.e"], "Correct module ID keys"); + moduleIdMap.forEach((value, key) => { + t.is(value, projectNameMap.get(key), `Same instance of module ${key} in both maps`); + }); +}); + +test("Basic resolution: package.json is missing name field", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + path: "../../fixtures/library.d" + }, { + path: "../../fixtures/library.e" + }] + } + }) + }); + + t.context.sinon.stub(workspace, "_readPackageJson") + .resolves({ + version: "1.0.0", + }); + + const err = await t.throwsAsync(workspace._getResolvedModules()); + t.is(err.message, + `Failed to resolve workspace dependency resolution path ` + + `../../fixtures/library.d to ${libraryD}: package.json must contain fields 'name' and 'version'`, + "Threw with expected error message"); +}); + +test("Basic resolution: package.json is missing version field", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + path: "../../fixtures/library.d" + }, { + path: "../../fixtures/library.e" + }] + } + }) + }); + + t.context.sinon.stub(workspace, "_readPackageJson") + .resolves({ + name: "Package", + }); + + const err = await t.throwsAsync(workspace._getResolvedModules()); + t.is(err.message, + `Failed to resolve workspace dependency resolution path ` + + `../../fixtures/library.d to ${libraryD}: package.json must contain fields 'name' and 'version'`, + "Threw with expected error message"); +}); + +test("Package workspace resolution: Static patterns", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + path: "../../fixtures/collection" + }] + } + }) + }); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + t.deepEqual(Array.from(projectNameMap.keys()).sort(), ["library.a", "library.b", "library.c"], + "Correct project name keys"); + + const libA = projectNameMap.get("library.a"); + t.true(libA instanceof Module, "library.a value is instance of Module"); + t.is(libA.getVersion(), "1.0.0", "Correct version for library.a"); + t.is(libA.getPath(), collectionLibraryA, "Correct path for library.a"); + + const libB = projectNameMap.get("library.b"); + t.true(libB instanceof Module, "library.b value is instance of Module"); + t.is(libB.getVersion(), "1.0.0", "Correct version for library.b"); + t.is(libB.getPath(), collectionLibraryB, "Correct path for library.b"); + + const libC = projectNameMap.get("library.c"); + t.true(libC instanceof Module, "library.c value is instance of Module"); + t.is(libC.getVersion(), "1.0.0", "Correct version for library.c"); + t.is(libC.getPath(), collectionLibraryC, "Correct path for library.c"); + + t.deepEqual(Array.from(moduleIdMap.keys()).sort(), ["library.a", "library.b", "library.c"], + "Correct module ID keys"); + moduleIdMap.forEach((value, key) => { + t.is(value, projectNameMap.get(key), `Same instance of module ${key} in both maps`); + }); +}); + +test("Package workspace resolution: Dynamic patterns", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + path: "../../fixtures/collection.b" + }] + } + }) + }); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + t.deepEqual(Array.from(projectNameMap.keys()).sort(), ["library.a", "library.b", "library.c", "library.d"], + "Correct project name keys"); + + const libA = projectNameMap.get("library.a"); + t.true(libA instanceof Module, "library.a value is instance of Module"); + t.is(libA.getVersion(), "1.0.0", "Correct version for library.a"); + t.is(libA.getPath(), collectionBLibraryA, "Correct path for library.a"); + + const libB = projectNameMap.get("library.b"); + t.true(libB instanceof Module, "library.b value is instance of Module"); + t.is(libB.getVersion(), "1.0.0", "Correct version for library.b"); + t.is(libB.getPath(), collectionBLibraryB, "Correct path for library.b"); + + const libC = projectNameMap.get("library.c"); + t.true(libC instanceof Module, "library.c value is instance of Module"); + t.is(libC.getVersion(), "1.0.0", "Correct version for library.c"); + t.is(libC.getPath(), collectionBLibraryC, "Correct path for library.c"); + + const libD = projectNameMap.get("library.d"); + t.true(libD instanceof Module, "library.d value is instance of Module"); + t.is(libD.getVersion(), "1.0.0", "Correct version for library.d"); + t.is(libD.getPath(), libraryD, "Correct path for library.d"); + + t.deepEqual(Array.from(moduleIdMap.keys()).sort(), ["library.a", "library.b", "library.c", "library.d"], + "Correct module ID keys"); + moduleIdMap.forEach((value, key) => { + t.is(value, projectNameMap.get(key), `Same instance of module ${key} in both maps`); + }); +}); + +test("Package workspace resolution: Nested workspaces", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + path: "../../fixtures/library.xyz" + }] + } + }) + }); + + const readPackageJsonStub = t.context.sinon.stub(workspace, "_readPackageJson") + .rejects(new Error("Test does not provide for more package mocks")) + .onCall(0).resolves({ + name: "First Package", + version: "1.0.0", + ui5: { + workspaces: [ + "workspace-a", + "workspace-b" + ] + } + }).onCall(1).resolves({ + name: "Second Package", + version: "1.0.0", + workspaces: [ + "workspace-c", + "workspace-d" + ] + }).onCall(2).resolves({ + name: "Third Package", + version: "1.0.0" + }).onCall(3).resolves({ + name: "Fourth Package", + version: "1.0.0", + }).onCall(4).resolves({ + name: "Fifth Package", + version: "1.0.0", + }); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + // All workspaces. Should not resolve to any module + t.is(readPackageJsonStub.callCount, 5, "readPackageJson got called five times"); + t.is(projectNameMap.size, 0, "Project name to module map is empty"); + t.is(moduleIdMap.size, 0, "Module ID to module map is empty"); +}); + +test("Package workspace resolution: Recursive workspaces", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + path: "../../fixtures/library.xyz" + }] + } + }) + }); + + const basePath = path.join(__dirname, "../../fixtures/library.xyz"); + const workspaceAPath = path.join(basePath, "workspace-a"); + + const readPackageJsonStub = t.context.sinon.stub(workspace, "_readPackageJson"); + readPackageJsonStub.withArgs(basePath).resolves({ + name: "Base Package", + version: "1.0.0", + workspaces: [ + "workspace-a" + ] + }); + readPackageJsonStub.withArgs(workspaceAPath).resolves({ + name: "Workspace A Package", + version: "1.0.0", + workspaces: [ + ".." + ] + }); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + // All workspaces. Should not resolve to any module + // Recursive workspace definition should not lead to another readPackageJson call + t.is(readPackageJsonStub.callCount, 2, "readPackageJson got called two times"); + t.is(projectNameMap.size, 0, "Project name to module map is empty"); + t.is(moduleIdMap.size, 0, "Module ID to module map is empty"); +}); + +test("No resolutions configuration", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: {} + }) + }); + + t.is(workspace.getName(), "workspace-name"); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + t.is(projectNameMap.size, 0, "Project name to module map is empty"); + t.is(moduleIdMap.size, 0, "Module ID to module map is empty"); + + t.falsy(await workspace.getModuleByProjectName("library.e"), + "getModuleByProjectName yields no result for library.e"); + t.falsy(await workspace.getModuleByNodeId("library.e"), + "getModuleByNodeId yields no result for library.e"); +}); + +test("Empty dependencyManagement configuration", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: {} + }) + }); + + t.is(workspace.getName(), "workspace-name"); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + t.is(projectNameMap.size, 0, "Project name to module map is empty"); + t.is(moduleIdMap.size, 0, "Module ID to module map is empty"); +}); + +test("Empty resolutions configuration", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [] + } + }) + }); + + t.is(workspace.getName(), "workspace-name"); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + t.is(projectNameMap.size, 0, "Project name to module map is empty"); + t.is(moduleIdMap.size, 0, "Module ID to module map is empty"); +}); + +test("Missing path in resolution", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{}] + } + }) + }); + + await t.throwsAsync(workspace._getResolvedModules(), { + message: "Missing property 'path' in dependency resolution configuration of workspace workspace-name" + }, "Threw with expected error message"); +}); + +test("Invalid specVersion", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: { + specVersion: "project/1.0", + metadata: { + name: "workspace-name" + } + } + }); + + const err = await t.throwsAsync(workspace._getResolvedModules()); + t.true( + err.message.includes(`Unsupported "specVersion"`), + "Threw with expected error message"); +}); + +test("Invalid resolutions configuration", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + path: "../../fixtures/does-not-exist" + }] + } + }) + }); + + const absPath = path.join(__dirname, "../../fixtures/does-not-exist"); + + const err = await t.throwsAsync(workspace._getResolvedModules()); + t.true( + err.message.startsWith(`Failed to resolve workspace dependency resolution path ` + + `../../fixtures/does-not-exist to ${absPath}: ENOENT:`), + "Threw with expected error message"); +}); + +test("Resolves extension only", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + path: "../../fixtures/extension.a" + }] + } + }) + }); + + t.is(workspace.getName(), "workspace-name"); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + t.is(projectNameMap.size, 0, "Project name to module map is empty"); + t.is(moduleIdMap.size, 1, "Added one entry to Module ID to module map"); + t.deepEqual(Array.from(moduleIdMap.keys()), ["extension.a"], + "Expected entry in Module ID to module map"); +}); + +test("Resolution does not lead to a project", async (t) => { + const workspace = new t.context.Workspace({ + cwd: __dirname, + configuration: createWorkspaceConfig({ + dependencyManagement: { + resolutions: [{ + // Using a directory with a package.json but no ui5.yaml + path: "../../fixtures/init-library" + }] + } + }) + }); + + t.is(workspace.getName(), "workspace-name"); + + const {projectNameMap, moduleIdMap} = await workspace._getResolvedModules(); + t.is(projectNameMap.size, 0, "Project name to module map is empty"); + t.is(moduleIdMap.size, 0, "Module ID to module map is empty"); +}); + +test("Missing parameters", (t) => { + t.throws(() => { + new t.context.Workspace({ + configuration: {metadata: {name: "config-a"}} + }); + }, { + message: "Could not create Workspace: Missing or empty parameter 'cwd'" + }, "Threw with expected error message"); + + t.throws(() => { + new t.context.Workspace({ + cwd: "cwd" + }); + }, { + message: "Could not create Workspace: Missing or empty parameter 'configuration'" + }, "Threw with expected error message"); +}); diff --git a/test/lib/graph/graph.integration.js b/test/lib/graph/graph.integration.js new file mode 100644 index 000000000..4f27781e9 --- /dev/null +++ b/test/lib/graph/graph.integration.js @@ -0,0 +1,281 @@ +import test from "ava"; +import {fileURLToPath} from "node:url"; +import path from "node:path"; +import sinonGlobal from "sinon"; +import esmock from "esmock"; +import Workspace from "../../../lib/graph/Workspace.js"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const fixturesPath = path.join(__dirname, "..", "..", "fixtures"); +const libraryHPath = path.join(fixturesPath, "library.h"); + +test.beforeEach(async (t) => { + const sinon = t.context.sinon = sinonGlobal.createSandbox(); + + t.context.npmProviderConstructorStub = sinon.stub(); + class MockNpmProvider { + constructor(params) { + t.context.npmProviderConstructorStub(params); + } + } + + t.context.MockNpmProvider = MockNpmProvider; + + t.context.projectGraphBuilderStub = sinon.stub().resolves("graph"); + t.context.enrichProjectGraphStub = sinon.stub(); + t.context.graph = await esmock.p("../../../lib/graph/graph.js", { + "../../../lib/graph/providers/NodePackageDependencies.js": t.context.MockNpmProvider, + "../../../lib/graph/projectGraphBuilder.js": t.context.projectGraphBuilderStub, + "../../../lib/graph/helpers/ui5Framework.js": { + enrichProjectGraph: t.context.enrichProjectGraphStub + } + }); +}); + +test.afterEach.always((t) => { + t.context.sinon.restore(); + esmock.purge(t.context.graph); +}); + +test.serial("graphFromPackageDependencies with workspace object", async (t) => { + const { + npmProviderConstructorStub, + projectGraphBuilderStub, enrichProjectGraphStub, MockNpmProvider + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + const res = await graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceConfiguration: { + specVersion: "workspace/1.0", + metadata: { + name: "default" + }, + dependencyManagement: { + resolutions: [{ + path: "resolution/path" + }] + } + } + }); + + t.is(res, "graph"); + + t.is(npmProviderConstructorStub.callCount, 1, "NPM provider constructor got called once"); + t.deepEqual(npmProviderConstructorStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + }, "Created NodePackageDependencies provider instance with correct parameters"); + + t.is(projectGraphBuilderStub.callCount, 1, "projectGraphBuilder got called once"); + t.true(projectGraphBuilderStub.getCall(0).args[0] instanceof MockNpmProvider, + "projectGraphBuilder got called with correct provider instance"); + t.true(projectGraphBuilderStub.getCall(0).args[1] instanceof Workspace, + "projectGraphBuilder got called with correct workspace instance"); + + t.is(enrichProjectGraphStub.callCount, 1, "enrichProjectGraph got called once"); + t.is(enrichProjectGraphStub.getCall(0).args[0], "graph", + "enrichProjectGraph got called with graph"); + t.is(enrichProjectGraphStub.getCall(0).args[1].versionOverride, "versionOverride", + "enrichProjectGraph got called with correct versionOverride parameter"); + t.true(enrichProjectGraphStub.getCall(0).args[1].workspace instanceof Workspace, + "enrichProjectGraph got called with correct workspace parameter"); +}); + +test.serial("graphFromPackageDependencies with workspace object and workspace name", async (t) => { + const { + npmProviderConstructorStub, + projectGraphBuilderStub, enrichProjectGraphStub, MockNpmProvider + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + const res = await graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceName: "dolphin", + workspaceConfiguration: { + specVersion: "workspace/1.0", + metadata: { + name: "dolphin" + }, + dependencyManagement: { + resolutions: [{ + path: "resolution/path" + }] + } + } + }); + + t.is(res, "graph"); + + t.is(npmProviderConstructorStub.callCount, 1, "NPM provider constructor got called once"); + t.deepEqual(npmProviderConstructorStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + }, "Created NodePackageDependencies provider instance with correct parameters"); + + t.is(projectGraphBuilderStub.callCount, 1, "projectGraphBuilder got called once"); + t.true(projectGraphBuilderStub.getCall(0).args[0] instanceof MockNpmProvider, + "projectGraphBuilder got called with correct provider instance"); + t.true(projectGraphBuilderStub.getCall(0).args[1] instanceof Workspace, + "projectGraphBuilder got called with correct workspace instance"); + + t.is(enrichProjectGraphStub.callCount, 1, "enrichProjectGraph got called once"); + t.is(enrichProjectGraphStub.getCall(0).args[0], "graph", + "enrichProjectGraph got called with graph"); + t.is(enrichProjectGraphStub.getCall(0).args[1].versionOverride, "versionOverride", + "enrichProjectGraph got called with correct versionOverride parameter"); + t.true(enrichProjectGraphStub.getCall(0).args[1].workspace instanceof Workspace, + "enrichProjectGraph got called with correct workspace parameter"); +}); + +test.serial("graphFromPackageDependencies with workspace object not matching workspaceName", async (t) => { + const {graphFromPackageDependencies} = t.context.graph; + + await t.throwsAsync(graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceName: "other", + workspaceConfiguration: { + specVersion: "workspace/1.0", + metadata: { + name: "default" + }, + dependencyManagement: { + resolutions: [{ + path: "resolution/path" + }] + } + } + }), { + message: "The provided workspace name 'other' does not match the provided workspace configuration 'default'" + }, "Threw with expected error message"); +}); + +test.serial("graphFromPackageDependencies with workspace file", async (t) => { + const { + npmProviderConstructorStub, + projectGraphBuilderStub, enrichProjectGraphStub, MockNpmProvider + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + const res = await graphFromPackageDependencies({ + cwd: libraryHPath, + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceName: "default", + }); + + t.is(res, "graph"); + + t.is(npmProviderConstructorStub.callCount, 1, "NPM provider constructor got called once"); + t.deepEqual(npmProviderConstructorStub.getCall(0).args[0], { + cwd: libraryHPath, + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath" + }, "Created NodePackageDependencies provider instance with correct parameters"); + + t.is(projectGraphBuilderStub.callCount, 1, "projectGraphBuilder got called once"); + t.true(projectGraphBuilderStub.getCall(0).args[0] instanceof MockNpmProvider, + "projectGraphBuilder got called with correct provider instance"); + t.true(projectGraphBuilderStub.getCall(0).args[1] instanceof Workspace, + "projectGraphBuilder got called with correct workspace instance"); + + t.is(enrichProjectGraphStub.callCount, 1, "enrichProjectGraph got called once"); + t.is(enrichProjectGraphStub.getCall(0).args[0], "graph", + "enrichProjectGraph got called with graph"); + t.is(enrichProjectGraphStub.getCall(0).args[1].versionOverride, "versionOverride", + "enrichProjectGraph got called with correct versionOverride parameter"); + t.true(enrichProjectGraphStub.getCall(0).args[1].workspace instanceof Workspace, + "enrichProjectGraph got called with correct workspace parameter"); +}); + +test.serial("graphFromPackageDependencies with workspace file at custom path", async (t) => { + const { + npmProviderConstructorStub, + projectGraphBuilderStub, enrichProjectGraphStub, MockNpmProvider + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + const res = await graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceName: "default", + workspaceConfigPath: path.join(libraryHPath, "ui5-workspace.yaml") + }); + + t.is(res, "graph"); + + t.is(npmProviderConstructorStub.callCount, 1, "NPM provider constructor got called once"); + t.deepEqual(npmProviderConstructorStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath" + }, "Created NodePackageDependencies provider instance with correct parameters"); + + t.is(projectGraphBuilderStub.callCount, 1, "projectGraphBuilder got called once"); + t.true(projectGraphBuilderStub.getCall(0).args[0] instanceof MockNpmProvider, + "projectGraphBuilder got called with correct provider instance"); + t.true(projectGraphBuilderStub.getCall(0).args[1] instanceof Workspace, + "projectGraphBuilder got called with correct workspace instance"); + + t.is(enrichProjectGraphStub.callCount, 1, "enrichProjectGraph got called once"); + t.is(enrichProjectGraphStub.getCall(0).args[0], "graph", + "enrichProjectGraph got called with graph"); + t.is(enrichProjectGraphStub.getCall(0).args[1].versionOverride, "versionOverride", + "enrichProjectGraph got called with correct versionOverride parameter"); + t.true(enrichProjectGraphStub.getCall(0).args[1].workspace instanceof Workspace, + "enrichProjectGraph got called with correct workspace parameter"); +}); + +test.serial("graphFromPackageDependencies with inactive workspace file at custom path", async (t) => { + const { + npmProviderConstructorStub, + projectGraphBuilderStub, enrichProjectGraphStub, MockNpmProvider + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + const res = await graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceName: "default", + workspaceConfigPath: path.join(libraryHPath, "custom-ui5-workspace.yaml") + }); + + t.is(res, "graph"); + + t.is(npmProviderConstructorStub.callCount, 1, "NPM provider constructor got called once"); + t.deepEqual(npmProviderConstructorStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath" + }, "Created NodePackageDependencies provider instance with correct parameters"); + + t.is(projectGraphBuilderStub.callCount, 1, "projectGraphBuilder got called once"); + t.true(projectGraphBuilderStub.getCall(0).args[0] instanceof MockNpmProvider, + "projectGraphBuilder got called with correct provider instance"); + t.is(projectGraphBuilderStub.getCall(0).args[1], null, + "projectGraphBuilder got called with no workspace instance"); + + t.is(enrichProjectGraphStub.callCount, 1, "enrichProjectGraph got called once"); + t.is(enrichProjectGraphStub.getCall(0).args[0], "graph", + "enrichProjectGraph got called with graph"); + t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { + versionOverride: "versionOverride", + workspace: null + }, "enrichProjectGraph got called with correct options"); +}); diff --git a/test/lib/graph/graph.js b/test/lib/graph/graph.js index 76a419f7a..e5bf55fad 100644 --- a/test/lib/graph/graph.js +++ b/test/lib/graph/graph.js @@ -3,19 +3,22 @@ import {fileURLToPath} from "node:url"; import path from "node:path"; import sinonGlobal from "sinon"; import esmock from "esmock"; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesPath = path.join(__dirname, "..", "..", "fixtures"); test.beforeEach(async (t) => { const sinon = t.context.sinon = sinonGlobal.createSandbox(); t.context.npmProviderConstructorStub = sinon.stub(); - class DummyNpmProvider { + class MockNpmProvider { constructor(params) { t.context.npmProviderConstructorStub(params); } } + t.context.createWorkspaceStub = sinon.stub().returns("workspace"); - t.context.DummyNpmProvider = DummyNpmProvider; + t.context.MockNpmProvider = MockNpmProvider; t.context.dependencyTreeProviderStub = sinon.stub(); class DummyDependencyTreeProvider { @@ -28,8 +31,9 @@ test.beforeEach(async (t) => { t.context.projectGraphBuilderStub = sinon.stub().resolves("graph"); t.context.enrichProjectGraphStub = sinon.stub(); t.context.graph = await esmock.p("../../../lib/graph/graph.js", { - "../../../lib/graph/providers/NodePackageDependencies.js": t.context.DummyNpmProvider, + "../../../lib/graph/providers/NodePackageDependencies.js": t.context.MockNpmProvider, "../../../lib/graph/providers/DependencyTree.js": t.context.DummyDependencyTreeProvider, + "../../../lib/graph/helpers/createWorkspace.js": t.context.createWorkspaceStub, "../../../lib/graph/projectGraphBuilder.js": t.context.projectGraphBuilderStub, "../../../lib/graph/helpers/ui5Framework.js": { enrichProjectGraph: t.context.enrichProjectGraphStub @@ -44,8 +48,8 @@ test.afterEach.always((t) => { test.serial("graphFromPackageDependencies", async (t) => { const { - npmProviderConstructorStub, - projectGraphBuilderStub, enrichProjectGraphStub, DummyNpmProvider + createWorkspaceStub, npmProviderConstructorStub, + projectGraphBuilderStub, enrichProjectGraphStub, MockNpmProvider } = t.context; const {graphFromPackageDependencies} = t.context.graph; @@ -58,6 +62,54 @@ test.serial("graphFromPackageDependencies", async (t) => { t.is(res, "graph"); + t.is(createWorkspaceStub.callCount, 0, "createWorkspace did not get called"); + t.is(npmProviderConstructorStub.callCount, 1, "NPM provider constructor got called once"); + t.deepEqual(npmProviderConstructorStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath" + }, "Created NodePackageDependencies provider instance with correct parameters"); + + t.is(projectGraphBuilderStub.callCount, 1, "projectGraphBuilder got called once"); + t.true(projectGraphBuilderStub.getCall(0).args[0] instanceof MockNpmProvider, + "projectGraphBuilder got called with correct provider instance"); + t.is(projectGraphBuilderStub.getCall(0).args[1], undefined, + "projectGraphBuilder got called with an empty workspace"); + + t.is(enrichProjectGraphStub.callCount, 1, "enrichProjectGraph got called once"); + t.is(enrichProjectGraphStub.getCall(0).args[0], "graph", + "enrichProjectGraph got called with graph"); + t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { + versionOverride: "versionOverride", + workspace: undefined + }, "enrichProjectGraph got called with correct options"); +}); + +test.serial("graphFromPackageDependencies with workspace name", async (t) => { + const { + createWorkspaceStub, npmProviderConstructorStub, + projectGraphBuilderStub, enrichProjectGraphStub, MockNpmProvider + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + const res = await graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceName: "dolphin", + }); + + t.is(res, "graph"); + + t.is(createWorkspaceStub.callCount, 1, "createWorkspace got called once"); + t.deepEqual(createWorkspaceStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + name: "dolphin", + configPath: "ui5-workspace.yaml", + configObject: undefined, + }, "createWorkspace called with correct parameters"); + t.is(npmProviderConstructorStub.callCount, 1, "NPM provider constructor got called once"); t.deepEqual(npmProviderConstructorStub.getCall(0).args[0], { cwd: path.join(__dirname, "..", "..", "..", "cwd"), @@ -66,14 +118,144 @@ test.serial("graphFromPackageDependencies", async (t) => { }, "Created NodePackageDependencies provider instance with correct parameters"); t.is(projectGraphBuilderStub.callCount, 1, "projectGraphBuilder got called once"); - t.true(projectGraphBuilderStub.getCall(0).args[0] instanceof DummyNpmProvider, + t.true(projectGraphBuilderStub.getCall(0).args[0] instanceof MockNpmProvider, "projectGraphBuilder got called with correct provider instance"); + t.is(projectGraphBuilderStub.getCall(0).args[1], "workspace", + "projectGraphBuilder got called with correct workspace instance"); t.is(enrichProjectGraphStub.callCount, 1, "enrichProjectGraph got called once"); t.is(enrichProjectGraphStub.getCall(0).args[0], "graph", "enrichProjectGraph got called with graph"); t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { - versionOverride: "versionOverride" + versionOverride: "versionOverride", + workspace: "workspace" + }, "enrichProjectGraph got called with correct options"); +}); + +test.serial("graphFromPackageDependencies with workspace object", async (t) => { + const { + createWorkspaceStub + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + const res = await graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceConfiguration: "workspaceConfiguration" + }); + + t.is(res, "graph"); + + t.is(createWorkspaceStub.callCount, 1, "createWorkspace got called once"); + t.deepEqual(createWorkspaceStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + configPath: "ui5-workspace.yaml", + name: undefined, + configObject: "workspaceConfiguration" + }, "createWorkspace called with correct parameters"); +}); + +test.serial("graphFromPackageDependencies with workspace object and workspace name", async (t) => { + const { + createWorkspaceStub + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + const res = await graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceName: "dolphin", + workspaceConfiguration: "workspaceConfiguration" + }); + + t.is(res, "graph"); + + t.is(createWorkspaceStub.callCount, 1, "createWorkspace got called once"); + t.deepEqual(createWorkspaceStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + name: "dolphin", + configPath: "ui5-workspace.yaml", + configObject: "workspaceConfiguration" + }, "createWorkspace called with correct parameters"); +}); + +test.serial("graphFromPackageDependencies with workspace path and workspace name", async (t) => { + const { + createWorkspaceStub + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + const res = await graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceName: "dolphin", + workspaceConfigPath: "workspaceConfigurationPath" + }); + + t.is(res, "graph"); + + t.is(createWorkspaceStub.callCount, 1, "createWorkspace got called once"); + t.deepEqual(createWorkspaceStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + name: "dolphin", + configPath: "workspaceConfigurationPath", + configObject: undefined + }, "createWorkspace called with correct parameters"); +}); + +test.serial("graphFromPackageDependencies with empty workspace", async (t) => { + const { + createWorkspaceStub, npmProviderConstructorStub, + projectGraphBuilderStub, enrichProjectGraphStub, MockNpmProvider + } = t.context; + const {graphFromPackageDependencies} = t.context.graph; + + // Simulate no workspace config found + createWorkspaceStub.resolves(null); + + const res = await graphFromPackageDependencies({ + cwd: "cwd", + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + versionOverride: "versionOverride", + workspaceName: "dolphin", + }); + + t.is(res, "graph"); + + t.is(createWorkspaceStub.callCount, 1, "createWorkspace got called once"); + t.deepEqual(createWorkspaceStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + name: "dolphin", + configPath: "ui5-workspace.yaml", + configObject: undefined, + }, "createWorkspace called with correct parameters"); + + t.is(npmProviderConstructorStub.callCount, 1, "NPM provider constructor got called once"); + t.deepEqual(npmProviderConstructorStub.getCall(0).args[0], { + cwd: path.join(__dirname, "..", "..", "..", "cwd"), + rootConfiguration: "rootConfiguration", + rootConfigPath: "rootConfigPath", + }, "Created NodePackageDependencies provider instance with correct parameters"); + + t.is(projectGraphBuilderStub.callCount, 1, "projectGraphBuilder got called once"); + t.true(projectGraphBuilderStub.getCall(0).args[0] instanceof MockNpmProvider, + "projectGraphBuilder got called with correct provider instance"); + t.is(projectGraphBuilderStub.getCall(0).args[1], null, + "projectGraphBuilder got called with correct workspace instance"); + + t.is(enrichProjectGraphStub.callCount, 1, "enrichProjectGraph got called once"); + t.is(enrichProjectGraphStub.getCall(0).args[0], "graph", + "enrichProjectGraph got called with graph"); + t.deepEqual(enrichProjectGraphStub.getCall(0).args[1], { + versionOverride: "versionOverride", + workspace: null }, "enrichProjectGraph got called with correct options"); }); @@ -208,3 +390,21 @@ test.serial("usingObject: Do not resolve framework dependencies", async (t) => { t.is(res, "graph"); t.is(enrichProjectGraphStub.callCount, 0, "enrichProjectGraph did not get called"); }); + +test.serial("utils: readDependencyConfigFile", async (t) => { + const {graphFromStaticFile} = t.context.graph; + const res = await graphFromStaticFile._utils.readDependencyConfigFile( + path.join(fixturesPath, "application.h"), "projectDependencies.yaml"); + + t.deepEqual(res, { + id: "static-application.a", + path: path.join(fixturesPath, "application.a"), + version: "0.0.1", + dependencies: [{ + id: "static-library.e", + path: path.join(fixturesPath, "library.e"), + version: "0.0.1", + }], + }, "Returned correct file content"); +}); + diff --git a/test/lib/graph/graphFromObject.js b/test/lib/graph/graphFromObject.js index 7efa41072..0e4c90011 100644 --- a/test/lib/graph/graphFromObject.js +++ b/test/lib/graph/graphFromObject.js @@ -81,8 +81,8 @@ test("Application Cycle A: Traverse project graph breadth first with cycles", as t.is(callbackStub.callCount, 4, "Four projects have been visited"); t.is(error.message, - "Detected cyclic dependency chain: application.cycle.a* -> component.cycle.a " + - "-> application.cycle.a*", + "Detected cyclic dependency chain: *application.cycle.a* -> component.cycle.a " + + "-> *application.cycle.a*", "Threw with expected error message"); const callbackCalls = callbackStub.getCalls().map((call) => call.args[0].project.getName()); @@ -143,8 +143,8 @@ test("Application Cycle A: Traverse project graph depth first with cycles", asyn t.is(callbackStub.callCount, 0, "Zero projects have been visited"); t.is(error.message, - "Detected cyclic dependency chain: application.cycle.a* -> component.cycle.a " + - "-> application.cycle.a*", + "Detected cyclic dependency chain: *application.cycle.a* -> component.cycle.a " + + "-> *application.cycle.a*", "Threw with expected error message"); }); @@ -157,8 +157,8 @@ test("Application Cycle B: Traverse project graph depth first with cycles", asyn t.is(callbackStub.callCount, 0, "Zero projects have been visited"); t.is(error.message, - "Detected cyclic dependency chain: application.cycle.b -> module.d* " + - "-> module.e -> module.d*", + "Detected cyclic dependency chain: application.cycle.b -> *module.d* " + + "-> module.e -> *module.d*", "Threw with expected error message"); }); diff --git a/test/lib/graph/helpers/createWorkspace.js b/test/lib/graph/helpers/createWorkspace.js new file mode 100644 index 000000000..db8a43005 --- /dev/null +++ b/test/lib/graph/helpers/createWorkspace.js @@ -0,0 +1,296 @@ +import test from "ava"; +import {fileURLToPath} from "node:url"; +import path from "node:path"; +import sinonGlobal from "sinon"; +import esmock from "esmock"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesPath = path.join(__dirname, "..", "..", "..", "fixtures"); +const libraryHPath = path.join(fixturesPath, "library.h"); + +test.beforeEach(async (t) => { + const sinon = t.context.sinon = sinonGlobal.createSandbox(); + + t.context.workspaceConstructorStub = sinon.stub(); + class MockWorkspace { + constructor(params) { + t.context.workspaceConstructorStub(params); + } + } + t.context.MockWorkspace = MockWorkspace; + + t.context.createWorkspace = await esmock.p("../../../../lib/graph/helpers/createWorkspace", { + "../../../../lib/graph/Workspace.js": t.context.MockWorkspace + }); +}); + +test.afterEach.always((t) => { + t.context.sinon.restore(); +}); + +test("createWorkspace: Missing parameter 'configObject' or 'configPath'", async (t) => { + const {createWorkspace} = t.context; + + const err = await t.throwsAsync(createWorkspace({ + cwd: "cwd", + })); + t.is(err.message, "createWorkspace: Missing parameter 'cwd', 'configObject' or 'configPath'", + "Threw with expected error message"); +}); + +test("createWorkspace: Missing parameter 'cwd'", async (t) => { + const {createWorkspace} = t.context; + + const err = await t.throwsAsync(createWorkspace({ + configPath: path.join(libraryHPath, "invalid-ui5-workspace.yaml") + })); + t.is(err.message, "createWorkspace: Missing parameter 'cwd', 'configObject' or 'configPath'", + "Threw with expected error message"); +}); + +test("createWorkspace: Missing parameter 'name' if 'configPath' is set", async (t) => { + const {createWorkspace} = t.context; + + const err = await t.throwsAsync(createWorkspace({ + cwd: "cwd", + configPath: path.join(libraryHPath, "invalid-ui5-workspace.yaml") + })); + t.is(err.message, "createWorkspace: Parameter 'configPath' implies parameter 'name', but it's empty", + "Threw with expected error message"); +}); + +test("createWorkspace: Using object", async (t) => { + const { + workspaceConstructorStub, + MockWorkspace, + createWorkspace + } = t.context; + + const res = await createWorkspace({ + cwd: "cwd", + configObject: { + specVersion: "workspace/1.0", + metadata: { + name: "default" + }, + dependencyManagement: { + resolutions: [{ + path: "resolution/path" + }] + } + } + }); + + t.true(res instanceof MockWorkspace, "Returned instance of Workspace"); + + t.is(workspaceConstructorStub.callCount, 1, "Workspace constructor got called once"); + t.deepEqual(workspaceConstructorStub.getCall(0).args[0], { + cwd: "cwd", + configuration: { + specVersion: "workspace/1.0", + metadata: { + name: "default" + }, + dependencyManagement: { + resolutions: [{ + path: "resolution/path" + }] + } + } + }, "Created Workspace instance with correct parameters"); +}); + +test("createWorkspace: Using invalid object", async (t) => { + const {createWorkspace} = t.context; + + const err = await t.throwsAsync(createWorkspace({ + cwd: "cwd", + configObject: { + dependencyManagement: { + resolutions: [{ + path: "resolution/path" + }] + } + } + })); + t.is(err.message, "Invalid workspace configuration: Missing or empty property 'metadata.name'", + "Threw with validation error"); +}); + +test("createWorkspace: Using file", async (t) => { + const {createWorkspace, MockWorkspace, workspaceConstructorStub} = t.context; + + const res = await createWorkspace({ + cwd: "cwd", + name: "default", + configPath: path.join(libraryHPath, "ui5-workspace.yaml") + }); + + t.true(res instanceof MockWorkspace, "Returned instance of Workspace"); + + t.is(workspaceConstructorStub.callCount, 1, "Workspace constructor got called once"); + t.deepEqual(workspaceConstructorStub.getCall(0).args[0], { + cwd: libraryHPath, + configuration: { + specVersion: "workspace/1.0", + metadata: { + name: "default" + }, + dependencyManagement: { + resolutions: [{ + path: "../library.d" + }] + } + } + }, "Created Workspace instance with correct parameters"); +}); + +test("createWorkspace: Using invalid file", async (t) => { + const {createWorkspace} = t.context; + + const err = await t.throwsAsync(createWorkspace({ + cwd: "cwd", + name: "default", + configPath: path.join(libraryHPath, "invalid-ui5-workspace.yaml") + })); + + t.true(err.message.includes("Invalid workspace configuration"), "Threw with validation error"); +}); + +test("createWorkspace: Using missing file", async (t) => { + const {createWorkspace, workspaceConstructorStub} = t.context; + + const res = await createWorkspace({ + cwd: path.join(fixturesPath, "library.d"), + name: "default", + configPath: "ui5-workspace.yaml" + }); + + t.is(res, null, "Returned no workspace"); + + t.is(workspaceConstructorStub.callCount, 0, "Workspace constructor did not get called"); +}); + +test("createWorkspace: Using missing file and non-default name", async (t) => { + const {createWorkspace, workspaceConstructorStub} = t.context; + + const err = await t.throwsAsync(createWorkspace({ + cwd: path.join(fixturesPath, "library.d"), + name: "special", + configPath: "ui5-workspace.yaml" + })); + + const filePath = path.join(fixturesPath, "library.d", "ui5-workspace.yaml"); + t.true(err.message.startsWith( + `Failed to load workspace configuration from path ${filePath}: `), "Threw with expected error message"); + + t.is(workspaceConstructorStub.callCount, 0, "Workspace constructor did not get called"); +}); + +test("createWorkspace: Using non-default file and non-default name", async (t) => { + const {createWorkspace, workspaceConstructorStub, MockWorkspace} = t.context; + + const res = await createWorkspace({ + cwd: path.join(fixturesPath, "library.h"), + name: "library-d", + configPath: "custom-ui5-workspace.yaml" + }); + + t.true(res instanceof MockWorkspace, "Returned instance of Workspace"); + + t.is(workspaceConstructorStub.callCount, 1, "Workspace constructor got called once"); + t.deepEqual(workspaceConstructorStub.getCall(0).args[0], { + cwd: libraryHPath, + configuration: { + specVersion: "workspace/1.0", + metadata: { + name: "library-d" + }, + dependencyManagement: { + resolutions: [{ + path: "../library.d" + }] + } + } + }, "Created Workspace instance with correct parameters"); +}); + +test("createWorkspace: Using non-default file and non-default name which is not in file", async (t) => { + const {createWorkspace, workspaceConstructorStub} = t.context; + + const err = await t.throwsAsync(createWorkspace({ + cwd: path.join(fixturesPath, "library.h"), + name: "special", + configPath: "custom-ui5-workspace.yaml" + })); + + t.is(err.message, `Could not find a workspace named 'special' in custom-ui5-workspace.yaml`, + "Threw with expected error message"); + + t.is(workspaceConstructorStub.callCount, 0, "Workspace constructor did not get called"); +}); + +test("readWorkspaceConfigFile", async (t) => { + const {createWorkspace} = t.context; + const res = await createWorkspace._readWorkspaceConfigFile( + path.join(libraryHPath, "ui5-workspace.yaml"), false); + t.deepEqual(res, + [{ + specVersion: "workspace/1.0", + metadata: { + name: "default", + }, + dependencyManagement: { + resolutions: [{ + path: "../library.d", + }] + }, + }, { + specVersion: "workspace/1.0", + metadata: { + name: "all-libraries", + }, + dependencyManagement: { + resolutions: [{ + path: "../library.d", + }, { + path: "../library.e", + }, { + path: "../library.f", + }], + }, + }], "Read workspace configuration file correctly"); +}); + +test("readWorkspaceConfigFile: Throws for missing file", async (t) => { + const {createWorkspace} = t.context; + const filePath = path.join(fixturesPath, "library.d", "other-ui5-workspace.yaml"); + const err = + await t.throwsAsync(createWorkspace._readWorkspaceConfigFile(filePath)); + t.true(err.message.startsWith( + `Failed to load workspace configuration from path ${filePath}: `), "Threw with expected error message"); +}); + +test("readWorkspaceConfigFile: Validation errors", async (t) => { + const {createWorkspace} = t.context; + const filePath = path.join(libraryHPath, "invalid-ui5-workspace.yaml"); + const err = + await t.throwsAsync(createWorkspace._readWorkspaceConfigFile(filePath, true)); + t.true(err.message.includes("Invalid workspace configuration"), "Threw with validation error"); +}); + +test("readWorkspaceConfigFile: Not a YAML", async (t) => { + const {createWorkspace} = t.context; + const filePath = path.join(libraryHPath, "corrupt-ui5-workspace.yaml"); + const err = + await t.throwsAsync(createWorkspace._readWorkspaceConfigFile(filePath, true)); + t.true(err.message.includes(`Failed to parse workspace configuration at ${filePath}`), + "Threw with parsing error"); +}); + +test("readWorkspaceConfigFile: Empty file", async (t) => { + const {createWorkspace} = t.context; + const filePath = path.join(libraryHPath, "empty-ui5-workspace.yaml"); + const res = await createWorkspace._readWorkspaceConfigFile(filePath, true); + t.deepEqual(res, [], "No workspace configuration returned"); +}); diff --git a/test/lib/graph/helpers/ui5Framework.js b/test/lib/graph/helpers/ui5Framework.js index 23c71028c..f40a6aae4 100644 --- a/test/lib/graph/helpers/ui5Framework.js +++ b/test/lib/graph/helpers/ui5Framework.js @@ -9,7 +9,9 @@ import projectGraphBuilder from "../../../../lib/graph/projectGraphBuilder.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const applicationAPath = path.join(__dirname, "..", "..", "..", "fixtures", "application.a"); +const libraryDPath = path.join(__dirname, "..", "..", "..", "fixtures", "library.d"); const libraryEPath = path.join(__dirname, "..", "..", "..", "fixtures", "library.e"); +const libraryFPath = path.join(__dirname, "..", "..", "..", "fixtures", "library.f"); test.beforeEach(async (t) => { const sinon = t.context.sinon = sinonGlobal.createSandbox(); @@ -48,7 +50,7 @@ test.afterEach.always((t) => { esmock.purge(t.context.ui5Framework); }); -test.serial("ui5Framework translator should throw an error when framework version is not defined", async (t) => { +test.serial("enrichProjectGraph", async (t) => { const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub} = t.context; const dependencyTree = { @@ -104,8 +106,14 @@ test.serial("ui5Framework translator should throw an error when framework versio ], "Sapui5Resolver#install should be called with expected args"); t.is(ProjectProcessorStub.callCount, 1, "ProjectProcessor#constructor should be called once"); - t.deepEqual(ProjectProcessorStub.getCall(0).args, [{libraryMetadata}], - "ProjectProcessor#constructor should be called with expected args"); + const projectProcessorConstructorArgs = ProjectProcessorStub.getCall(0).args[0]; + t.deepEqual(projectProcessorConstructorArgs.libraryMetadata, libraryMetadata, + "Correct libraryMetadata provided to ProjectProcessor"); + t.is(projectProcessorConstructorArgs.graph._rootProjectName, + "fake-root-of-application.a-framework-dependency-graph", + "Correct graph provided to ProjectProcessor"); + t.falsy(projectProcessorConstructorArgs.workspace, + "No workspace provided to ProjectProcessor"); t.is(addProjectToGraphStub.callCount, 3, "ProjectProcessor#getProject should be called 3 times"); t.deepEqual(addProjectToGraphStub.getCall(0).args[0], referencedLibraries[0], @@ -127,7 +135,7 @@ test.serial("ui5Framework translator should throw an error when framework versio ], "Traversed graph in correct order"); }); -test.serial("enrichProjectGraph (with versionOverride)", async (t) => { +test.serial("enrichProjectGraph: With versionOverride", async (t) => { const { sinon, ui5Framework, utils, Sapui5ResolverStub, Sapui5ResolverResolveVersionStub, Sapui5ResolverInstallStub @@ -417,6 +425,40 @@ test.serial("enrichProjectGraph should throw for framework project with dependen "Threw with expected error message"); }); +test.serial("enrichProjectGraph should throw for incorrect framework name", async (t) => { + const {ui5Framework, sinon} = t.context; + const dependencyTree = { + id: "project", + version: "1.2.3", + path: applicationAPath, + configuration: { + specVersion: "2.0", + type: "application", + metadata: { + name: "application.a" + }, + framework: { + name: "SAPUI5", + version: "1.2.3", + libraries: [ + { + name: "lib1", + optional: true + } + ] + } + } + }; + + const provider = new DependencyTreeProvider({dependencyTree}); + const projectGraph = await projectGraphBuilder(provider); + + sinon.stub(projectGraph.getRoot(), "getFrameworkName").returns("Pony5"); + const err = await t.throwsAsync(ui5Framework.enrichProjectGraph(projectGraph)); + t.is(err.message, `Unknown framework.name "Pony5" for project application.a. Must be "OpenUI5" or "SAPUI5"`, + "Threw with expected error message"); +}); + test.serial("enrichProjectGraph should ignore root project without framework configuration", async (t) => { const {ui5Framework} = t.context; const dependencyTree = { @@ -921,4 +963,486 @@ test.serial("utils.declareFrameworkDependenciesInGraph: No deprecation warnings ], `Root project has correct dependencies`); }); -// TODO test: ProjectProcessor +test.serial("ProjectProcessor: Add project to graph", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns(), + addProject: sinon.stub() + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "library.e": { + id: "lib.e.id", + version: "1000.0.0", + path: libraryEPath, + dependencies: [], + optionalDependencies: [] + } + }, + graph: graphMock + }); + + await projectProcessor.addProjectToGraph("library.e"); + t.is(graphMock.getProject.callCount, 1, "graph#getProject got called once"); + t.is(graphMock.getProject.getCall(0).args[0], "library.e", "graph#getProject got called with the correct argument"); + t.is(graphMock.addProject.callCount, 1, "graph#addProject got called once"); + t.is(graphMock.addProject.getCall(0).args[0].getName(), "library.e", + "graph#addProject got called with the correct project"); +}); + +test.serial("ProjectProcessor: Add same project twice", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns(), + addProject: sinon.stub() + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "library.e": { + id: "lib.e.id", + version: "1000.0.0", + path: libraryEPath, + dependencies: [], + optionalDependencies: [] + } + }, + graph: graphMock + }); + + await projectProcessor.addProjectToGraph("library.e"); + await projectProcessor.addProjectToGraph("library.e"); + t.is(graphMock.getProject.callCount, 1, "graph#getProject got called once"); + t.is(graphMock.getProject.getCall(0).args[0], "library.e", "graph#getProject got called with the correct argument"); + t.is(graphMock.addProject.callCount, 1, "graph#addProject got called once"); + t.is(graphMock.addProject.getCall(0).args[0].getName(), "library.e", + "graph#addProject got called with the correct project"); +}); + +test.serial("ProjectProcessor: Project already in graph", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns("project"), + addProject: sinon.stub() + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "library.e": { + id: "lib.e.id", + version: "1000.0.0", + path: libraryEPath, + dependencies: [], + optionalDependencies: [] + } + }, + graph: graphMock + }); + + await projectProcessor.addProjectToGraph("library.e"); + t.is(graphMock.getProject.callCount, 1, "graph#getProject got called once"); + t.is(graphMock.getProject.getCall(0).args[0], "library.e", "graph#getProject got called with the correct argument"); + t.is(graphMock.addProject.callCount, 0, "graph#addProject never got called"); +}); + +test.serial("ProjectProcessor: Add project with dependencies to graph", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns(), + addProject: sinon.stub(), + declareDependency: sinon.stub() + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "library.e": { + id: "lib.e.id", + version: "1000.0.0", + path: libraryEPath, + dependencies: ["library.d"], + optionalDependencies: [] + }, + "library.d": { + id: "lib.d.id", + version: "120000.0.0", + path: libraryDPath, + dependencies: [], + optionalDependencies: [] + } + }, + graph: graphMock + }); + + await projectProcessor.addProjectToGraph("library.e"); + t.is(graphMock.getProject.callCount, 2, "graph#getProject got called twice"); + t.is(graphMock.getProject.getCall(0).args[0], "library.e", "graph#getProject got called with the correct argument"); + t.is(graphMock.getProject.getCall(1).args[0], "library.d", "graph#getProject got called with the correct argument"); + t.is(graphMock.addProject.callCount, 2, "graph#addProject got called twice"); + t.is(graphMock.addProject.getCall(0).args[0].getName(), "library.d", + "graph#addProject got called with the correct project"); + t.is(graphMock.addProject.getCall(1).args[0].getName(), "library.e", + "graph#addProject got called with the correct project"); + t.is(graphMock.declareDependency.callCount, 1, "graph#declareDependency got called once"); + t.deepEqual(graphMock.declareDependency.getCall(0).args, ["library.e", "library.d"], + "graph#declareDependency got called with the correct arguments"); +}); + +test.serial("ProjectProcessor: Resolve project via workspace", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns(), + addProject: sinon.stub(), + declareDependency: sinon.stub() + }; + const libraryEProjectMock = { + getName: () => "library.e", + getFrameworkDependencies: sinon.stub().returns([{ + name: "library.d" + }]) + }; + const libraryDProjectMock = { + getName: () => "library.d", + getFrameworkDependencies: sinon.stub().returns([]) + }; + const moduleMock = { + getVersion: () => "1.0.0", + getPath: () => path.join("module", "path"), + getSpecifications: sinon.stub() + .onFirstCall().resolves({ + project: libraryDProjectMock + }) + .onSecondCall().resolves({ + project: libraryEProjectMock + }) + }; + const workspaceMock = { + getName: sinon.stub().returns("workspace name"), + getModuleByProjectName: sinon.stub().resolves(moduleMock), + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "library.e": { + id: "lib.e.id", + version: "1000.0.0", + path: libraryEPath, + dependencies: ["library.d"], + optionalDependencies: [] + }, + "library.d": { + id: "lib.d.id", + version: "120000.0.0", + path: libraryDPath, + dependencies: [], + optionalDependencies: [] + } + }, + graph: graphMock, + workspace: workspaceMock + }); + + await projectProcessor.addProjectToGraph("library.e"); + t.is(graphMock.getProject.callCount, 2, "graph#getProject got called twice"); + t.is(graphMock.getProject.getCall(0).args[0], "library.e", "graph#getProject got called with the correct argument"); + t.is(graphMock.getProject.getCall(1).args[0], "library.d", "graph#getProject got called with the correct argument"); + t.is(graphMock.addProject.callCount, 2, "graph#addProject got called once"); + t.is(graphMock.addProject.getCall(0).args[0].getName(), "library.d", + "graph#addProject got called with the correct project"); + t.is(graphMock.addProject.getCall(1).args[0].getName(), "library.e", + "graph#addProject got called with the correct project"); + t.is(graphMock.declareDependency.callCount, 1, "graph#declareDependency got called once"); + t.deepEqual(graphMock.declareDependency.getCall(0).args, ["library.e", "library.d"], + "graph#declareDependency got called with the correct arguments"); +}); + +test.serial("ProjectProcessor: Resolve project via workspace with additional dependency", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns(), + addProject: sinon.stub(), + declareDependency: sinon.stub() + }; + const libraryEProjectMock = { + getName: () => "library.e", + getFrameworkDependencies: sinon.stub().returns([{ + name: "library.d" + }]) + }; + const libraryDProjectMock = { + getName: () => "library.d", + getFrameworkDependencies: sinon.stub().returns([]) + }; + const moduleMock = { + getVersion: () => "1.0.0", + getPath: () => path.join("module", "path"), + getSpecifications: sinon.stub() + .onFirstCall().resolves({ + project: libraryEProjectMock + }) + .onSecondCall().resolves({ + project: libraryDProjectMock + }) + }; + const workspaceMock = { + getName: sinon.stub().returns("workspace name"), + getModuleByProjectName: sinon.stub().resolves(moduleMock), + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "library.e": { + id: "lib.e.id", + version: "1000.0.0", + path: libraryEPath, + dependencies: [], // Dependency to library.d is only declared in workspace-resolved library.e + optionalDependencies: [] + }, + "library.d": { + id: "lib.d.id", + version: "120000.0.0", + path: libraryDPath, + dependencies: [], + optionalDependencies: [] + } + }, + graph: graphMock, + workspace: workspaceMock + }); + + await projectProcessor.addProjectToGraph("library.e"); + t.is(graphMock.getProject.callCount, 2, "graph#getProject got called twice"); + t.is(graphMock.getProject.getCall(0).args[0], "library.e", "graph#getProject got called with the correct argument"); + t.is(graphMock.getProject.getCall(1).args[0], "library.d", "graph#getProject got called with the correct argument"); + t.is(graphMock.addProject.callCount, 2, "graph#addProject got called once"); + t.is(graphMock.addProject.getCall(0).args[0].getName(), "library.e", + "graph#addProject got called with the correct project"); + t.is(graphMock.addProject.getCall(1).args[0].getName(), "library.d", + "graph#addProject got called with the correct project"); + t.is(graphMock.declareDependency.callCount, 1, "graph#declareDependency got called once"); + t.deepEqual(graphMock.declareDependency.getCall(0).args, ["library.e", "library.d"], + "graph#declareDependency got called with the correct arguments"); +}); + +test.serial("ProjectProcessor: Resolve project via workspace with additional, unknown dependency", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns(), + addProject: sinon.stub(), + declareDependency: sinon.stub() + }; + const libraryEProjectMock = { + getName: () => "library.e", + getFrameworkDependencies: sinon.stub().returns([{ + name: "library.xyz" + }]) + }; + const libraryDProjectMock = { + getName: () => "library.d", + getFrameworkDependencies: sinon.stub().returns([]) + }; + const moduleMock = { + getVersion: () => "1.0.0", + getPath: () => path.join("module", "path"), + getSpecifications: sinon.stub() + .onFirstCall().resolves({ + project: libraryEProjectMock + }) + .onSecondCall().resolves({ + project: libraryDProjectMock + }) + }; + const workspaceMock = { + getName: sinon.stub().returns("workspace name"), + getModuleByProjectName: sinon.stub().resolves(moduleMock), + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "library.e": { + id: "lib.e.id", + version: "1000.0.0", + path: libraryEPath, + dependencies: ["library.d"], + optionalDependencies: [] + }, + "library.d": { + id: "lib.d.id", + version: "120000.0.0", + path: libraryDPath, + dependencies: [], + optionalDependencies: [] + } + }, + graph: graphMock, + workspace: workspaceMock + }); + + await t.throwsAsync(projectProcessor.addProjectToGraph("library.e"), { + message: + "Unable to find dependency library.xyz, required by project library.e " + + "(resolved via workspace name workspace) " + + "in current set of libraries. Try adding it temporarily to the root project's dependencies" + }, "Threw with expected error message"); +}); + +test.serial("ProjectProcessor: Resolve project via workspace with cyclic dependency", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns(), + addProject: sinon.stub(), + declareDependency: sinon.stub() + }; + const libraryEProjectMock = { + getName: () => "library.e", + getFrameworkDependencies: sinon.stub().returns([{ + name: "library.d" + }]) + }; + const libraryDProjectMock = { + getName: () => "library.d", + getFrameworkDependencies: sinon.stub().returns([{ + name: "library.e" // Cyclic dependency in workspace project + }]) + }; + const moduleMock = { + getVersion: () => "1.0.0", + getPath: () => path.join("module", "path"), + getSpecifications: sinon.stub() + .onFirstCall().resolves({ + project: libraryEProjectMock + }) + .onSecondCall().resolves({ + project: libraryDProjectMock + }) + }; + const workspaceMock = { + getName: sinon.stub().returns("workspace name"), + getModuleByProjectName: sinon.stub().resolves(moduleMock), + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "library.e": { + id: "lib.e.id", + version: "1000.0.0", + path: libraryEPath, + dependencies: ["library.d"], + optionalDependencies: [] + }, + "library.d": { + id: "lib.d.id", + version: "120000.0.0", + path: libraryDPath, + dependencies: [], + optionalDependencies: [] + } + }, + graph: graphMock, + workspace: workspaceMock + }); + + await t.throwsAsync(projectProcessor.addProjectToGraph("library.e"), { + message: + "ui5Framework:ProjectPreprocessor: Detected cyclic dependency chain: " + + "library.e -> *library.d* -> *library.d*" + }, "Threw with expected error message"); +}); + +test.serial("ProjectProcessor: Resolve project via workspace with distant cyclic dependency", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns(), + addProject: sinon.stub(), + declareDependency: sinon.stub() + }; + const libraryEProjectMock = { + getName: () => "library.e", + getFrameworkDependencies: sinon.stub().returns([{ + name: "library.d" + }]) + }; + const libraryDProjectMock = { + getName: () => "library.d", + getFrameworkDependencies: sinon.stub().returns([{ + name: "library.f" + }]) + }; + const libraryFProjectMock = { + getName: () => "library.f", + getFrameworkDependencies: sinon.stub().returns([{ + name: "library.e" // Cyclic dependency in workspace project + }]) + }; + const moduleMock = { + getVersion: () => "1.0.0", + getPath: () => path.join("module", "path"), + getSpecifications: sinon.stub() + .onFirstCall().resolves({ + project: libraryEProjectMock + }) + .onSecondCall().resolves({ + project: libraryDProjectMock + }) + .onThirdCall().resolves({ + project: libraryFProjectMock + }) + }; + const workspaceMock = { + getName: sinon.stub().returns("workspace name"), + getModuleByProjectName: sinon.stub().resolves(moduleMock), + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "library.e": { + id: "lib.e.id", + version: "1000.0.0", + path: libraryEPath, + dependencies: ["library.d"], + optionalDependencies: [] + }, + "library.d": { + id: "lib.d.id", + version: "120000.0.0", + path: libraryDPath, + dependencies: ["library.f"], + optionalDependencies: [] + }, + "library.f": { + id: "lib.f.id", + version: "1.0.0", + path: libraryFPath, + dependencies: [], + optionalDependencies: [] + }, + }, + graph: graphMock, + workspace: workspaceMock + }); + + await t.throwsAsync(projectProcessor.addProjectToGraph("library.e"), { + message: + "ui5Framework:ProjectPreprocessor: Detected cyclic dependency chain: " + + "library.e -> *library.d* -> library.f -> *library.d*" + }, "Threw with expected error message"); +}); + +test.serial("ProjectProcessor: Project missing in metadata", async (t) => { + const {sinon} = t.context; + const {ProjectProcessor} = t.context.utils; + const graphMock = { + getProject: sinon.stub().returns(), + addProject: sinon.stub() + }; + const projectProcessor = new ProjectProcessor({ + libraryMetadata: { + "lib.x": {} + }, + graph: graphMock + }); + + await t.throwsAsync(projectProcessor.addProjectToGraph("lib.a"), { + message: "Failed to find library lib.a in dist packages metadata.json" + }, "Threw with expected error message"); +}); diff --git a/test/lib/graph/projectGraphBuilder.js b/test/lib/graph/projectGraphBuilder.js index 51489cbb9..df042ec74 100644 --- a/test/lib/graph/projectGraphBuilder.js +++ b/test/lib/graph/projectGraphBuilder.js @@ -249,6 +249,29 @@ test("Node depends on itself", async (t) => { "Threw with expected error message"); }); +test("Cyclic dependencies", async (t) => { + t.context.getRootNode.resolves(createNode({ + id: "id1", + name: "project-1" + })); + t.context.getDependencies + .onFirstCall().resolves([ + createNode({ + id: "id2", + name: "project-2" + }), + ]) + .onSecondCall().resolves([ + createNode({ + id: "id1", + name: "project-1" + }), + ]); + const graph = await projectGraphBuilder(t.context.provider); + t.deepEqual(graph.getDependencies("project-1"), ["project-2"], "Cyclic dependency has been added"); + t.deepEqual(graph.getDependencies("project-2"), ["project-1"], "Cyclic dependency has been added"); +}); + test("Nested node with same id is ignored", async (t) => { t.context.getRootNode.resolves(createNode({ id: "id1", @@ -263,7 +286,7 @@ test("Nested node with same id is ignored", async (t) => { t.context.getDependencies.onSecondCall().resolves([ createNode({ id: "id1", - name: "project-3" + name: "project-3" // name will be ignored, since the first "id1" node is being used }), ]); const graph = await projectGraphBuilder(t.context.provider); diff --git a/test/lib/graph/providers/NodePackageDependencies.integration.js b/test/lib/graph/providers/NodePackageDependencies.integration.js index 8aba7f989..842442cb1 100644 --- a/test/lib/graph/providers/NodePackageDependencies.integration.js +++ b/test/lib/graph/providers/NodePackageDependencies.integration.js @@ -14,6 +14,7 @@ const applicationFPath = path.join(__dirname, "..", "..", "..", "fixtures", "app const applicationGPath = path.join(__dirname, "..", "..", "..", "fixtures", "application.g"); const errApplicationAPath = path.join(__dirname, "..", "..", "..", "fixtures", "err.application.a"); const cycleDepsBasePath = path.join(__dirname, "..", "..", "..", "fixtures", "cyclic-deps", "node_modules"); +const libraryDOverridePath = path.join(__dirname, "..", "..", "..", "fixtures", "library.d-adtl-deps"); import projectGraphBuilder from "../../../../lib/graph/projectGraphBuilder.js"; import NodePackageDependenciesProvider from "../../../../lib/graph/providers/NodePackageDependencies.js"; @@ -27,18 +28,18 @@ test.afterEach.always((t) => { }); function testGraphCreationBfs(...args) { - return _testGraphCreation(...args, true); + return _testGraphCreation(true, ...args); } function testGraphCreationDfs(...args) { - return _testGraphCreation(...args, false); + return _testGraphCreation(false, ...args); } -async function _testGraphCreation(t, npmProvider, expectedOrder, bfs) { +async function _testGraphCreation(bfs, t, npmProvider, expectedOrder, workspace) { if (bfs === undefined) { throw new Error("Test error: Parameter 'bfs' must be specified"); } - const projectGraph = await projectGraphBuilder(npmProvider); + const projectGraph = await projectGraphBuilder(npmProvider, workspace); const callbackStub = t.context.sinon.stub().resolves(); if (bfs) { await projectGraph.traverseBreadthFirst(callbackStub); @@ -67,6 +68,37 @@ test("AppA: project with collection dependency", async (t) => { ]); }); +test("AppA: project with workspace overrides", async (t) => { + const workspace = { + getName: () => "workspace name", + getModuleByNodeId: t.context.sinon.stub().resolves(undefined).onFirstCall().resolves({ + // This version of library.d has an additional dependency to library.f, + // which in turn has a dependency to library.g + getPath: () => libraryDOverridePath, + getVersion: () => "1.0.0", + }) + }; + const npmProvider = new NodePackageDependenciesProvider({ + cwd: applicationAPath + }); + const graph = await testGraphCreationDfs(t, npmProvider, [ + "library.g", // Added through workspace override of library.d + "library.a", + "library.b", + "library.c", + "library.f", // Added through workspace override of library.d + "library.d", + "application.a", + ], workspace); + + t.is(workspace.getModuleByNodeId.callCount, 2, "Workspace#getModuleByNodeId got called twice"); + t.is(workspace.getModuleByNodeId.getCall(0).args[0], "library.d", + "Workspace#getModuleByNodeId got called with correct argument on first call"); + t.is(workspace.getModuleByNodeId.getCall(1).args[0], "collection", + "Workspace#getModuleByNodeId got called with correct argument on second call"); + t.is(graph.getProject("library.d").getVersion(), "2.0.0", "Version from override is used"); +}); + test("AppC: project with dependency with optional dependency resolved through root project", async (t) => { const npmProvider = new NodePackageDependenciesProvider({ cwd: applicationCPath @@ -188,7 +220,7 @@ test("AppCycleD: cyclic npm deps - Cycles everywhere", async (t) => { const error = await t.throwsAsync(testGraphCreationDfs(t, npmProvider, [])); t.is(error.message, - `Detected cyclic dependency chain: application.cycle.d -> module.h* -> module.i -> module.k -> module.h*`); + `Detected cyclic dependency chain: application.cycle.d -> *module.h* -> module.i -> module.k -> *module.h*`); }); test("AppCycleE: cyclic npm deps - Cycle via devDependency", async (t) => {