Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FIX] ui5Framework: Prevent install of libraries within workspace #589

Merged
merged 12 commits into from
Mar 31, 2023
100 changes: 82 additions & 18 deletions lib/graph/helpers/ui5Framework.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,60 @@ const utils = {
);
}
},
/**
* This logic needs to stay in sync with the dependency definitions for the
* sapui5/distribution-metadata package.
*
* @param {@ui5/project/specifications/Project} project
*/
async getFrameworkLibraryDependencies(project) {
d3xter666 marked this conversation as resolved.
Show resolved Hide resolved
let dependencies = [];
let optionalDependencies = [];

if (project.getId().startsWith("@sapui5/")) {
project.getFrameworkDependencies().forEach((dependency) => {
if (dependency.optional) {
// Add optional dependency to optionalDependencies
optionalDependencies.push(dependency.name);
} else if (!dependency.development) {
// Add non-development dependency to dependencies
dependencies.push(dependency.name);
}
});
} else if (project.getId().startsWith("@openui5/")) {
const packageResource = await project.getRootReader().byPath("/package.json");
const packageInfo = JSON.parse(await packageResource.getString());

dependencies = Object.keys(
packageInfo.dependencies || {}
).map(($) => $.replace("@openui5/", "")); // @sapui5 dependencies must not be defined in package.json
optionalDependencies = Object.keys(
packageInfo.devDependencies || {}
).map(($) => $.replace("@openui5/", "")); // @sapui5 dependencies must not be defined in package.json
}

return {dependencies, optionalDependencies};
},
async getWorkspaceFrameworkLibraryMetadata({workspace, projectGraph}) {
const libraryMetadata = Object.create(null);
const ui5Modules = await workspace.getModules();
for (const ui5Module of ui5Modules) {
const {project} = await ui5Module.getSpecifications();
// Only framework projects that are not already part of the projectGraph should be handled.
// Otherwise they would be available twice which is checked
// after installing via checkForDuplicateFrameworkProjects
if (project?.isFrameworkProject?.() && !projectGraph.getProject(project.getName())) {
const metadata = libraryMetadata[project.getName()] = Object.create(null);
metadata.id = project.getId();
metadata.path = project.getRootPath();
metadata.version = project.getVersion();
const {dependencies, optionalDependencies} = await utils.getFrameworkLibraryDependencies(project);
metadata.dependencies = dependencies;
metadata.optionalDependencies = optionalDependencies;
}
}
return libraryMetadata;
},
ProjectProcessor
};

Expand All @@ -232,26 +286,31 @@ export default {
* Promise resolving with the given graph instance to allow method chaining
*/
enrichProjectGraph: async function(projectGraph, options = {}) {
const {workspace} = options;
const rootProject = projectGraph.getRoot();
const frameworkName = rootProject.getFrameworkName();
const frameworkVersion = rootProject.getFrameworkVersion();

// It is allowed omit the framework version in ui5.yaml and only provide one via the override
// It is allowed to omit the framework version in ui5.yaml and only provide one via the override
// This is a common use case for framework libraries, which generally should not define a
// framework version in their respective ui5.yaml
let version = options.versionOverride || frameworkVersion;

if (rootProject.isFrameworkProject() && !version) {
// If the root project is a framework project and no framework version is defined,
// all framework dependencies must already be part of the graph
rootProject.getFrameworkDependencies().forEach((dep) => {
if (utils.shouldIncludeDependency(dep) && !projectGraph.getProject(dep.name)) {
throw new Error(
`Missing framework dependency ${dep.name} for framework project ${rootProject.getName()}`);
}
// all framework dependencies must either be already part of the graph or part of the workspace.
// A mixed setup of framework deps within the graph AND from the workspace is currently not supported.

const someDependencyMissing = rootProject.getFrameworkDependencies().some((dep) => {
return utils.shouldIncludeDependency(dep) && !projectGraph.getProject(dep.name);
});
// All framework dependencies are already present in the graph
return projectGraph;

// If all dependencies are available there is nothing else to do here.
// In case of a workspace setup, the resolver will be created below without a version and
// will succeed in case no library needs to be actually installed.
if (!someDependencyMissing) {
return projectGraph;
}
}


Expand All @@ -267,12 +326,6 @@ export default {
);
}

if (!version) {
throw new Error(
`No framework version defined for root project ${rootProject.getName()}`
);
}

let Resolver;
if (frameworkName === "OpenUI5") {
Resolver = (await import("../../ui5Framework/Openui5Resolver.js")).default;
Expand All @@ -296,9 +349,20 @@ export default {
return projectGraph;
}

log.info(`Using ${frameworkName} version: ${version}`);
if (version) {
log.info(`Using ${frameworkName} version: ${version}`);
}

let providedLibraryMetadata;
if (workspace) {
providedLibraryMetadata = await utils.getWorkspaceFrameworkLibraryMetadata({
workspace, projectGraph
});
}
d3xter666 marked this conversation as resolved.
Show resolved Hide resolved

const resolver = new Resolver({cwd: rootProject.getRootPath(), version});
// Note: version might be undefined here and the Resolver will throw an error when calling
// #install and it can't be resolved via the provided library metadata
const resolver = new Resolver({cwd: rootProject.getRootPath(), version, providedLibraryMetadata});

let startTime;
if (log.isLevelEnabled("verbose")) {
Expand All @@ -322,7 +386,7 @@ export default {
const projectProcessor = new utils.ProjectProcessor({
libraryMetadata,
graph: frameworkGraph,
workspace: options.workspace
workspace
});

await Promise.all(referencedLibraries.map(async (libName) => {
Expand Down
35 changes: 27 additions & 8 deletions lib/ui5Framework/AbstractResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,33 @@ const VERSION_RANGE_REGEXP = /^(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
* @hideconstructor
*/
class AbstractResolver {
/* eslint-disable max-len */
/**
* @param {*} options options
* @param {string} options.version Framework version to use
* @param {string} [options.version] Framework version to use. When omitted, all libraries need to be available
* via <code>providedLibraryMetadata</code> parameter. Otherwise an error is thrown.
* @param {string} [options.cwd=process.cwd()] Working directory to resolve configurations like .npmrc
* @param {string} [options.ui5HomeDir="~/.ui5"] UI5 home directory location. This will be used to store packages,
* metadata and configuration used by the resolvers. Relative to `process.cwd()`
* @param {object.<string, @ui5/project/ui5Framework/AbstractResolver~LibraryMetadataEntry>} [options.providedLibraryMetadata]
* Resolver skips installing listed libraries and uses the dependency information to resolve their dependencies.
* <code>version</code> can be omitted in case all libraries can be resolved via the <code>providedLibraryMetadata</code>.
* Otherwise an error is thrown.
*/
constructor({cwd, version, ui5HomeDir}) {
/* eslint-enable max-len */
constructor({cwd, version, ui5HomeDir, providedLibraryMetadata}) {
if (new.target === AbstractResolver) {
throw new TypeError("Class 'AbstractResolver' is abstract");
}

if (!version) {
throw new Error(`AbstractResolver: Missing parameter "version"`);
}

// In some CI environments, the homedir might be set explicitly to a relative
// path (e.g. "./"), but tooling requires an absolute path
this._ui5HomeDir = path.resolve(
ui5HomeDir || path.join(os.homedir(), ".ui5")
);
this._cwd = cwd ? path.resolve(cwd) : process.cwd();
this._version = version;
this._providedLibraryMetadata = providedLibraryMetadata;
}

async _processLibrary(libraryName, libraryMetadata, errors) {
Expand All @@ -62,7 +66,23 @@ class AbstractResolver {

log.verbose("Processing " + libraryName);

const promises = await this.handleLibrary(libraryName);
let promises;
const providedLibraryMetadata = this._providedLibraryMetadata?.[libraryName];
if (providedLibraryMetadata) {
log.verbose(`Skipping install for ${libraryName} (provided)`);
promises = {
// Use existing metadata if library is provided from outside (e.g. workspace)
metadata: Promise.resolve(providedLibraryMetadata),
// Provided libraries are already "installed"
install: Promise.resolve({
pkgPath: providedLibraryMetadata.path
})
};
} else if (!this._version) {
throw new Error(`Unable to install library ${libraryName}. No framework version provided.`);
d3xter666 marked this conversation as resolved.
Show resolved Hide resolved
} else {
promises = await this.handleLibrary(libraryName);
}

const [metadata, {pkgPath}] = await Promise.all([
promises.metadata.then((metadata) =>
Expand Down Expand Up @@ -143,7 +163,6 @@ class AbstractResolver {
* Object containing all installed libraries with library name as key
*/

/* eslint-enable max-len */
/**
* Installs the provided libraries and their dependencies
*
Expand Down
100 changes: 92 additions & 8 deletions test/lib/graph/helpers/ui5Framework.integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ test.afterEach.always((t) => {

function defineTest(testName, {
frameworkName,
verbose = false
verbose = false,
librariesInWorkspace = null
}) {
const npmScope = frameworkName === "SAPUI5" ? "@sapui5" : "@openui5";

Expand Down Expand Up @@ -410,7 +411,66 @@ function defineTest(testName, {
const provider = new DependencyTreeProvider({dependencyTree});
const projectGraph = await projectGraphBuilder(provider);

await ui5Framework.enrichProjectGraph(projectGraph);
if (librariesInWorkspace) {
const projectNameMap = new Map();
const moduleIdMap = new Map();
librariesInWorkspace.forEach((libName) => {
const libraryDistMetadata = distributionMetadata.libraries[libName];
const module = {
getSpecifications: sinon.stub().resolves({
project: {
getName: sinon.stub().returns(libName),
getVersion: sinon.stub().returns("1.76.0-SNAPSHOT"),
getRootPath: sinon.stub().returns(path.join(fakeBaseDir, "workspace", libName)),
isFrameworkProject: sinon.stub().returns(true),
getId: sinon.stub().returns(libraryDistMetadata.npmPackageName),
getRootReader: sinon.stub().returns({
byPath: sinon.stub().resolves({
getString: sinon.stub().resolves(JSON.stringify({dependencies: {}}))
})
}),
getFrameworkDependencies: sinon.stub().callsFake(() => {
const deps = [];
libraryDistMetadata.dependencies.forEach((dep) => {
deps.push({name: dep});
});
libraryDistMetadata.optionalDependencies.forEach((optDep) => {
deps.push({name: optDep, optional: true});
});
return deps;
}),
isDeprecated: sinon.stub().returns(false),
isSapInternal: sinon.stub().returns(false),
getAllowSapInternal: sinon.stub().returns(false),
}
}),
getVersion: sinon.stub().returns("1.76.0-SNAPSHOT"),
getPath: sinon.stub().returns(path.join(fakeBaseDir, "workspace", libName)),
};
projectNameMap.set(libName, module);
moduleIdMap.set(libraryDistMetadata.npmPackageName, module);
});

const getModuleByProjectName = sinon.stub().callsFake(
async (projectName) => projectNameMap.get(projectName)
);
const getModules = sinon.stub().callsFake(
async () => {
const sortedMap = new Map([...moduleIdMap].sort((a, b) => String(a[0]).localeCompare(b[0])));
return Array.from(sortedMap.values());
}
);

const workspace = {
getName: sinon.stub().returns("test"),
getModules,
getModuleByProjectName
};

await ui5Framework.enrichProjectGraph(projectGraph, {workspace});
} else {
await ui5Framework.enrichProjectGraph(projectGraph);
}

const callbackStub = sinon.stub().resolves();
await projectGraph.traverseDepthFirst(callbackStub);
Expand Down Expand Up @@ -465,6 +525,15 @@ defineTest("ui5Framework helper should enhance project graph with UI5 framework
verbose: true
});

defineTest("ui5Framework helper should not cause install of libraries within workspace", {
frameworkName: "SAPUI5",
librariesInWorkspace: ["sap.ui.lib1", "sap.ui.lib2", "sap.ui.lib8"]
});
defineTest("ui5Framework helper should not cause install of libraries within workspace", {
frameworkName: "OpenUI5",
librariesInWorkspace: ["sap.ui.lib1", "sap.ui.lib2", "sap.ui.lib8"]
});

function defineErrorTest(testName, {
frameworkName,
failExtract = false,
Expand Down Expand Up @@ -723,8 +792,8 @@ test.serial("ui5Framework translator should not try to install anything when no
t.is(pacote.manifest.callCount, 0, "No manifest should be requested");
});

test.serial("ui5Framework translator should throw an error when framework version is not defined", async (t) => {
const {ui5Framework, projectGraphBuilder} = t.context;
test.serial("ui5Framework helper shouldn't throw when framework version and libraries are not provided", async (t) => {
const {ui5Framework, projectGraphBuilder, logStub} = t.context;

const dependencyTree = {
id: "test-id",
Expand All @@ -745,13 +814,28 @@ test.serial("ui5Framework translator should throw an error when framework versio
const provider = new DependencyTreeProvider({dependencyTree});
const projectGraph = await projectGraphBuilder(provider);

await t.throwsAsync(async () => {
await ui5Framework.enrichProjectGraph(projectGraph);
}, {message: `No framework version defined for root project test-project`}, "Correct error message");
await ui5Framework.enrichProjectGraph(projectGraph);

t.is(logStub.verbose.callCount, 5);
t.deepEqual(logStub.verbose.getCall(0).args, [
"Configuration for module test-id has been supplied directly"
]);
t.deepEqual(logStub.verbose.getCall(1).args, [
"Module test-id contains project test-project"
]);
t.deepEqual(logStub.verbose.getCall(2).args, [
"Root project test-project qualified as application project for project graph"
]);
t.deepEqual(logStub.verbose.getCall(3).args, [
"Project test-project has no framework dependencies"
]);
t.deepEqual(logStub.verbose.getCall(4).args, [
"No SAPUI5 libraries referenced in project test-project or in any of its dependencies"
]);
});

test.serial(
"SAPUI5: ui5Framework translator should throw error when using a library that is not part of the dist metadata",
"SAPUI5: ui5Framework helper should throw error when using a library that is not part of the dist metadata",
async (t) => {
const {sinon, ui5Framework, Installer, projectGraphBuilder} = t.context;

Expand Down
Loading