Skip to content

Commit 8ffc676

Browse files
authored
[FIX] ui5Framework: Prevent install of libraries within workspace (#589)
JIRA: CPOUI5FOUNDATION-643
1 parent 94bcd99 commit 8ffc676

File tree

5 files changed

+848
-53
lines changed

5 files changed

+848
-53
lines changed

lib/graph/helpers/ui5Framework.js

+82-18
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,60 @@ const utils = {
208208
);
209209
}
210210
},
211+
/**
212+
* This logic needs to stay in sync with the dependency definitions for the
213+
* sapui5/distribution-metadata package.
214+
*
215+
* @param {@ui5/project/specifications/Project} project
216+
*/
217+
async getFrameworkLibraryDependencies(project) {
218+
let dependencies = [];
219+
let optionalDependencies = [];
220+
221+
if (project.getId().startsWith("@sapui5/")) {
222+
project.getFrameworkDependencies().forEach((dependency) => {
223+
if (dependency.optional) {
224+
// Add optional dependency to optionalDependencies
225+
optionalDependencies.push(dependency.name);
226+
} else if (!dependency.development) {
227+
// Add non-development dependency to dependencies
228+
dependencies.push(dependency.name);
229+
}
230+
});
231+
} else if (project.getId().startsWith("@openui5/")) {
232+
const packageResource = await project.getRootReader().byPath("/package.json");
233+
const packageInfo = JSON.parse(await packageResource.getString());
234+
235+
dependencies = Object.keys(
236+
packageInfo.dependencies || {}
237+
).map(($) => $.replace("@openui5/", "")); // @sapui5 dependencies must not be defined in package.json
238+
optionalDependencies = Object.keys(
239+
packageInfo.devDependencies || {}
240+
).map(($) => $.replace("@openui5/", "")); // @sapui5 dependencies must not be defined in package.json
241+
}
242+
243+
return {dependencies, optionalDependencies};
244+
},
245+
async getWorkspaceFrameworkLibraryMetadata({workspace, projectGraph}) {
246+
const libraryMetadata = Object.create(null);
247+
const ui5Modules = await workspace.getModules();
248+
for (const ui5Module of ui5Modules) {
249+
const {project} = await ui5Module.getSpecifications();
250+
// Only framework projects that are not already part of the projectGraph should be handled.
251+
// Otherwise they would be available twice which is checked
252+
// after installing via checkForDuplicateFrameworkProjects
253+
if (project?.isFrameworkProject?.() && !projectGraph.getProject(project.getName())) {
254+
const metadata = libraryMetadata[project.getName()] = Object.create(null);
255+
metadata.id = project.getId();
256+
metadata.path = project.getRootPath();
257+
metadata.version = project.getVersion();
258+
const {dependencies, optionalDependencies} = await utils.getFrameworkLibraryDependencies(project);
259+
metadata.dependencies = dependencies;
260+
metadata.optionalDependencies = optionalDependencies;
261+
}
262+
}
263+
return libraryMetadata;
264+
},
211265
ProjectProcessor
212266
};
213267

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

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

244299
if (rootProject.isFrameworkProject() && !version) {
245300
// If the root project is a framework project and no framework version is defined,
246-
// all framework dependencies must already be part of the graph
247-
rootProject.getFrameworkDependencies().forEach((dep) => {
248-
if (utils.shouldIncludeDependency(dep) && !projectGraph.getProject(dep.name)) {
249-
throw new Error(
250-
`Missing framework dependency ${dep.name} for framework project ${rootProject.getName()}`);
251-
}
301+
// all framework dependencies must either be already part of the graph or part of the workspace.
302+
// A mixed setup of framework deps within the graph AND from the workspace is currently not supported.
303+
304+
const someDependencyMissing = rootProject.getFrameworkDependencies().some((dep) => {
305+
return utils.shouldIncludeDependency(dep) && !projectGraph.getProject(dep.name);
252306
});
253-
// All framework dependencies are already present in the graph
254-
return projectGraph;
307+
308+
// If all dependencies are available there is nothing else to do here.
309+
// In case of a workspace setup, the resolver will be created below without a version and
310+
// will succeed in case no library needs to be actually installed.
311+
if (!someDependencyMissing) {
312+
return projectGraph;
313+
}
255314
}
256315

257316

@@ -267,12 +326,6 @@ export default {
267326
);
268327
}
269328

270-
if (!version) {
271-
throw new Error(
272-
`No framework version defined for root project ${rootProject.getName()}`
273-
);
274-
}
275-
276329
let Resolver;
277330
if (frameworkName === "OpenUI5") {
278331
Resolver = (await import("../../ui5Framework/Openui5Resolver.js")).default;
@@ -296,9 +349,20 @@ export default {
296349
return projectGraph;
297350
}
298351

299-
log.info(`Using ${frameworkName} version: ${version}`);
352+
if (version) {
353+
log.info(`Using ${frameworkName} version: ${version}`);
354+
}
355+
356+
let providedLibraryMetadata;
357+
if (workspace) {
358+
providedLibraryMetadata = await utils.getWorkspaceFrameworkLibraryMetadata({
359+
workspace, projectGraph
360+
});
361+
}
300362

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

303367
let startTime;
304368
if (log.isLevelEnabled("verbose")) {
@@ -322,7 +386,7 @@ export default {
322386
const projectProcessor = new utils.ProjectProcessor({
323387
libraryMetadata,
324388
graph: frameworkGraph,
325-
workspace: options.workspace
389+
workspace
326390
});
327391

328392
await Promise.all(referencedLibraries.map(async (libName) => {

lib/ui5Framework/AbstractResolver.js

+27-8
Original file line numberDiff line numberDiff line change
@@ -27,29 +27,33 @@ const VERSION_RANGE_REGEXP = /^(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
2727
* @hideconstructor
2828
*/
2929
class AbstractResolver {
30+
/* eslint-disable max-len */
3031
/**
3132
* @param {*} options options
32-
* @param {string} options.version Framework version to use
33+
* @param {string} [options.version] Framework version to use. When omitted, all libraries need to be available
34+
* via <code>providedLibraryMetadata</code> parameter. Otherwise an error is thrown.
3335
* @param {string} [options.cwd=process.cwd()] Working directory to resolve configurations like .npmrc
3436
* @param {string} [options.ui5HomeDir="~/.ui5"] UI5 home directory location. This will be used to store packages,
3537
* metadata and configuration used by the resolvers. Relative to `process.cwd()`
38+
* @param {object.<string, @ui5/project/ui5Framework/AbstractResolver~LibraryMetadataEntry>} [options.providedLibraryMetadata]
39+
* Resolver skips installing listed libraries and uses the dependency information to resolve their dependencies.
40+
* <code>version</code> can be omitted in case all libraries can be resolved via the <code>providedLibraryMetadata</code>.
41+
* Otherwise an error is thrown.
3642
*/
37-
constructor({cwd, version, ui5HomeDir}) {
43+
/* eslint-enable max-len */
44+
constructor({cwd, version, ui5HomeDir, providedLibraryMetadata}) {
3845
if (new.target === AbstractResolver) {
3946
throw new TypeError("Class 'AbstractResolver' is abstract");
4047
}
4148

42-
if (!version) {
43-
throw new Error(`AbstractResolver: Missing parameter "version"`);
44-
}
45-
4649
// In some CI environments, the homedir might be set explicitly to a relative
4750
// path (e.g. "./"), but tooling requires an absolute path
4851
this._ui5HomeDir = path.resolve(
4952
ui5HomeDir || path.join(os.homedir(), ".ui5")
5053
);
5154
this._cwd = cwd ? path.resolve(cwd) : process.cwd();
5255
this._version = version;
56+
this._providedLibraryMetadata = providedLibraryMetadata;
5357
}
5458

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

6367
log.verbose("Processing " + libraryName);
6468

65-
const promises = await this.handleLibrary(libraryName);
69+
let promises;
70+
const providedLibraryMetadata = this._providedLibraryMetadata?.[libraryName];
71+
if (providedLibraryMetadata) {
72+
log.verbose(`Skipping install for ${libraryName} (provided)`);
73+
promises = {
74+
// Use existing metadata if library is provided from outside (e.g. workspace)
75+
metadata: Promise.resolve(providedLibraryMetadata),
76+
// Provided libraries are already "installed"
77+
install: Promise.resolve({
78+
pkgPath: providedLibraryMetadata.path
79+
})
80+
};
81+
} else if (!this._version) {
82+
throw new Error(`Unable to install library ${libraryName}. No framework version provided.`);
83+
} else {
84+
promises = await this.handleLibrary(libraryName);
85+
}
6686

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

146-
/* eslint-enable max-len */
147166
/**
148167
* Installs the provided libraries and their dependencies
149168
*

test/lib/graph/helpers/ui5Framework.integration.js

+92-8
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ test.afterEach.always((t) => {
135135

136136
function defineTest(testName, {
137137
frameworkName,
138-
verbose = false
138+
verbose = false,
139+
librariesInWorkspace = null
139140
}) {
140141
const npmScope = frameworkName === "SAPUI5" ? "@sapui5" : "@openui5";
141142

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

413-
await ui5Framework.enrichProjectGraph(projectGraph);
414+
if (librariesInWorkspace) {
415+
const projectNameMap = new Map();
416+
const moduleIdMap = new Map();
417+
librariesInWorkspace.forEach((libName) => {
418+
const libraryDistMetadata = distributionMetadata.libraries[libName];
419+
const module = {
420+
getSpecifications: sinon.stub().resolves({
421+
project: {
422+
getName: sinon.stub().returns(libName),
423+
getVersion: sinon.stub().returns("1.76.0-SNAPSHOT"),
424+
getRootPath: sinon.stub().returns(path.join(fakeBaseDir, "workspace", libName)),
425+
isFrameworkProject: sinon.stub().returns(true),
426+
getId: sinon.stub().returns(libraryDistMetadata.npmPackageName),
427+
getRootReader: sinon.stub().returns({
428+
byPath: sinon.stub().resolves({
429+
getString: sinon.stub().resolves(JSON.stringify({dependencies: {}}))
430+
})
431+
}),
432+
getFrameworkDependencies: sinon.stub().callsFake(() => {
433+
const deps = [];
434+
libraryDistMetadata.dependencies.forEach((dep) => {
435+
deps.push({name: dep});
436+
});
437+
libraryDistMetadata.optionalDependencies.forEach((optDep) => {
438+
deps.push({name: optDep, optional: true});
439+
});
440+
return deps;
441+
}),
442+
isDeprecated: sinon.stub().returns(false),
443+
isSapInternal: sinon.stub().returns(false),
444+
getAllowSapInternal: sinon.stub().returns(false),
445+
}
446+
}),
447+
getVersion: sinon.stub().returns("1.76.0-SNAPSHOT"),
448+
getPath: sinon.stub().returns(path.join(fakeBaseDir, "workspace", libName)),
449+
};
450+
projectNameMap.set(libName, module);
451+
moduleIdMap.set(libraryDistMetadata.npmPackageName, module);
452+
});
453+
454+
const getModuleByProjectName = sinon.stub().callsFake(
455+
async (projectName) => projectNameMap.get(projectName)
456+
);
457+
const getModules = sinon.stub().callsFake(
458+
async () => {
459+
const sortedMap = new Map([...moduleIdMap].sort((a, b) => String(a[0]).localeCompare(b[0])));
460+
return Array.from(sortedMap.values());
461+
}
462+
);
463+
464+
const workspace = {
465+
getName: sinon.stub().returns("test"),
466+
getModules,
467+
getModuleByProjectName
468+
};
469+
470+
await ui5Framework.enrichProjectGraph(projectGraph, {workspace});
471+
} else {
472+
await ui5Framework.enrichProjectGraph(projectGraph);
473+
}
414474

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

528+
defineTest("ui5Framework helper should not cause install of libraries within workspace", {
529+
frameworkName: "SAPUI5",
530+
librariesInWorkspace: ["sap.ui.lib1", "sap.ui.lib2", "sap.ui.lib8"]
531+
});
532+
defineTest("ui5Framework helper should not cause install of libraries within workspace", {
533+
frameworkName: "OpenUI5",
534+
librariesInWorkspace: ["sap.ui.lib1", "sap.ui.lib2", "sap.ui.lib8"]
535+
});
536+
468537
function defineErrorTest(testName, {
469538
frameworkName,
470539
failExtract = false,
@@ -723,8 +792,8 @@ test.serial("ui5Framework translator should not try to install anything when no
723792
t.is(pacote.manifest.callCount, 0, "No manifest should be requested");
724793
});
725794

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

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

748-
await t.throwsAsync(async () => {
749-
await ui5Framework.enrichProjectGraph(projectGraph);
750-
}, {message: `No framework version defined for root project test-project`}, "Correct error message");
817+
await ui5Framework.enrichProjectGraph(projectGraph);
818+
819+
t.is(logStub.verbose.callCount, 5);
820+
t.deepEqual(logStub.verbose.getCall(0).args, [
821+
"Configuration for module test-id has been supplied directly"
822+
]);
823+
t.deepEqual(logStub.verbose.getCall(1).args, [
824+
"Module test-id contains project test-project"
825+
]);
826+
t.deepEqual(logStub.verbose.getCall(2).args, [
827+
"Root project test-project qualified as application project for project graph"
828+
]);
829+
t.deepEqual(logStub.verbose.getCall(3).args, [
830+
"Project test-project has no framework dependencies"
831+
]);
832+
t.deepEqual(logStub.verbose.getCall(4).args, [
833+
"No SAPUI5 libraries referenced in project test-project or in any of its dependencies"
834+
]);
751835
});
752836

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

0 commit comments

Comments
 (0)