diff --git a/lib/lbt/analyzer/JSModuleAnalyzer.js b/lib/lbt/analyzer/JSModuleAnalyzer.js index 396acc7e3..4ade9d323 100644 --- a/lib/lbt/analyzer/JSModuleAnalyzer.js +++ b/lib/lbt/analyzer/JSModuleAnalyzer.js @@ -317,7 +317,7 @@ class JSModuleAnalyzer { if ( currentScope.set.size > 0 ) { info.requiresTopLevelScope = true; info.exposedGlobals = Array.from(currentScope.set.keys()); - // console.log(info.name, info.exposedGlobals); + // console.log(info.name, "exposed globals", info.exposedGlobals, "ignoredGlobals", info.ignoredGlobals); } return; diff --git a/lib/lbt/bundle/Resolver.js b/lib/lbt/bundle/Resolver.js index 843c0772d..0da795922 100644 --- a/lib/lbt/bundle/Resolver.js +++ b/lib/lbt/bundle/Resolver.js @@ -56,8 +56,21 @@ class BundleResolver { return match; } - function isMultiModule(moduleInfo) { - return moduleInfo && moduleInfo.subModules.length > 0 && !/(?:^|\/)library.js$/.test(moduleInfo.name); + function checkForDecomposableBundle(resource) { + if ( resource == null + || resource.info == null + || resource.info.subModules.length === 0 + || /(?:^|\/)library.js$/.test(resource.info.name) ) { + return {resource, decomposable: false}; + } + + return Promise.all( + resource.info.subModules.map((sub) => pool.findResource(sub).catch(() => false)) + ).then((modules) => { + // it might look more natural to expect 'all' embedded modules to exist in the pool, + // but expecting only 'some' module to exist is a more conservative approach + return ({resource, decomposable: modules.some(($) => ($))}); + }); } function checkAndAddResource(resourceName, depth, msg) { @@ -74,50 +87,55 @@ class BundleResolver { // remember that we have seen this module already visitedResources[resourceName] = resourceName; - done = pool.findResourceWithInfo(resourceName).then( function(resource) { - const dependencyInfo = resource && resource.info; - let promises = []; - - if ( isMultiModule(dependencyInfo) ) { - // multi modules are not added, only their pieces (sub modules) - promises = dependencyInfo.subModules.map( (included) => { - return checkAndAddResource(included, depth + 1, - "**** error: missing submodule " + included + ", included by " + resourceName); - }); - } else if ( resource != null ) { - // trace.trace(" checking dependencies of " + resource.name ); - selectedResources[resourceName] = resourceName; - selectedResourcesSequence.push(resourceName); - - // trace.info(" collecting %s", resource.name); - - // add dependencies, if 'resolve' is configured - if ( section.resolve && dependencyInfo ) { - promises = dependencyInfo.dependencies.map( function(required) { - // ignore conditional dependencies if not configured - if ( !section.resolveConditional - && dependencyInfo.isConditionalDependency(required) ) { - return; - } - - return checkAndAddResource( required, depth + 1, - "**** error: missing module " + required + ", required by " + resourceName); - }); + done = pool.findResourceWithInfo(resourceName) + .catch( (err) => { + // if the caller provided an error message, log it + if ( msg ) { + log.error(msg); } + // return undefined + }) + .then( (resource) => checkForDecomposableBundle(resource) ) + .then( ({resource, decomposable}) => { + const dependencyInfo = resource && resource.info; + let promises = []; + + if ( decomposable ) { + // bundles are not added, only their embedded modules + promises = dependencyInfo.subModules.map( (included) => { + return checkAndAddResource(included, depth + 1, + "**** error: missing submodule " + included + ", included by " + resourceName); + }); + } else if ( resource != null ) { + // trace.trace(" checking dependencies of " + resource.name ); + selectedResources[resourceName] = resourceName; + selectedResourcesSequence.push(resourceName); + + // trace.info(" collecting %s", resource.name); + + // add dependencies, if 'resolve' is configured + if ( section.resolve && dependencyInfo ) { + promises = dependencyInfo.dependencies.map( function(required) { + // ignore conditional dependencies if not configured + if ( !section.resolveConditional + && dependencyInfo.isConditionalDependency(required) ) { + return; + } + + return checkAndAddResource( required, depth + 1, + "**** error: missing module " + required + ", required by " + resourceName); + }); + } - // add renderer, if 'renderer' is configured and if it exists - if ( section.renderer ) { - const rendererModuleName = UI5ClientConstants.getRendererName( resourceName ); - promises.push( checkAndAddResource( rendererModuleName, depth + 1) ); + // add renderer, if 'renderer' is configured and if it exists + if ( section.renderer ) { + const rendererModuleName = UI5ClientConstants.getRendererName( resourceName ); + promises.push( checkAndAddResource( rendererModuleName, depth + 1) ); + } } - } - return Promise.all( promises.filter( ($) => $ ) ); - }, function(err) { - if ( msg ) { - log.error(msg); - } - }); // what todo after resource has been visited? + return Promise.all( promises.filter( ($) => $ ) ); + }); if ( dependencyTracker != null ) { dependencyTracker.endVisitDependency(resourceName); diff --git a/lib/lbt/resources/ResourcePool.js b/lib/lbt/resources/ResourcePool.js index 9a6ef72c9..2624eddeb 100644 --- a/lib/lbt/resources/ResourcePool.js +++ b/lib/lbt/resources/ResourcePool.js @@ -61,35 +61,38 @@ async function determineDependencyInfo(resource, rawInfo, pool) { const info = new ModuleInfo(resource.name); info.size = resource.fileSize; if ( /\.js$/.test(resource.name) ) { + // console.log("analyzing %s", resource.file); + const code = await resource.buffer(); + info.size = code.length; + const promises = []; + try { + const ast = esprima.parseScript(code.toString(), {comment: true}); + jsAnalyzer.analyze(ast, resource.name, info); + new XMLCompositeAnalyzer(pool).analyze(ast, resource.name, info); + } catch (error) { + log.error("failed to parse or analyze %s:", resource.name, error); + } if ( rawInfo ) { - // modules for which a raw-info was modelled should not be analyzed - // otherwise, we detect the internal dependencies of sap-viz.js, but can't handle them - // as we don't have access to the individual modules - + info.rawModule = true; // console.log("adding preconfigured dependencies for %s:", resource.name, rawInfo.dependencies); rawInfo.dependencies.forEach( (dep) => info.addDependency(dep) ); - } else { - // console.log("analyzing %s", resource.file); - const code = await resource.buffer(); - info.size = code.length; - const promises = []; - try { - const ast = esprima.parseScript(code.toString(), {comment: true}); - jsAnalyzer.analyze(ast, resource.name, info); - new XMLCompositeAnalyzer(pool).analyze(ast, resource.name, info); - } catch (error) { - log.error("failed to parse or analyze %s:", resource.name, error); + if ( rawInfo.requiresTopLevelScope ) { + info.requiresTopLevelScope = true; } - if ( /(?:^|\/)Component\.js/.test(resource.name) ) { - promises.push( - new ComponentAnalyzer(pool).analyze(resource, info), - new SmartTemplateAnalyzer(pool).analyze(resource, info), - new FioriElementsAnalyzer(pool).analyze(resource, info) - ); + if ( rawInfo.ignoredGlobals ) { + info.ignoredGlobals = rawInfo.ignoredGlobals; } - - await Promise.all(promises); } + if ( /(?:^|\/)Component\.js/.test(resource.name) ) { + promises.push( + new ComponentAnalyzer(pool).analyze(resource, info), + new SmartTemplateAnalyzer(pool).analyze(resource, info), + new FioriElementsAnalyzer(pool).analyze(resource, info) + ); + } + + await Promise.all(promises); + // console.log(info); } else if ( /\.view.xml$/.test(resource.name) ) { const xmlView = await resource.buffer(); diff --git a/lib/tasks/bundlers/generateLibraryPreload.js b/lib/tasks/bundlers/generateLibraryPreload.js index 7e103096b..e940617a1 100644 --- a/lib/tasks/bundlers/generateLibraryPreload.js +++ b/lib/tasks/bundlers/generateLibraryPreload.js @@ -9,11 +9,23 @@ function getBundleDefinition(namespace) { name: `${namespace}/library-preload.js`, defaultFileTypes: [".js", ".fragment.xml", ".view.xml", ".properties", ".json"], sections: [ + { + // exclude the content of sap-ui-core by declaring it as 'provided' + mode: "provided", + filters: [ + "ui5loader-autoconfig.js", + "sap/ui/core/Core.js" + ], + resolve: true + }, { mode: "preload", filters: [ `${namespace}/`, `!${namespace}/.library`, + `!${namespace}/designtime/`, + `!${namespace}/**/*.designtime.js`, + `!${namespace}/**/*.support.js`, `!${namespace}/themes/`, `!${namespace}/cldr/`, `!${namespace}/messagebundle*`, @@ -21,7 +33,6 @@ function getBundleDefinition(namespace) { "*.js", "sap/base/", "sap/ui/base/", - "sap/ui/xml/", "sap/ui/dom/", "sap/ui/events/", "sap/ui/model/", @@ -29,16 +40,21 @@ function getBundleDefinition(namespace) { "sap/ui/util/", "sap/ui/Global.js", - // files are already part of sap-ui-core.js - "!sap/ui/thirdparty/baseuri.js", - "!sap/ui/thirdparty/es6-promise.js", - "!sap/ui/thirdparty/es6-string-methods.js", - "!sap/ui/thirdparty/mdn-object-assign.js", - "!jquery.sap.global.js", - "!ui5loader-autoconfig.js", - "!ui5loader.js", - "!ui5loader-amd.js", - "!sap-ui-*.js" + // include only thirdparty that is very likely to be used + "sap/ui/thirdparty/crossroads.js", + "sap/ui/thirdparty/caja-htmlsanitizer.js", + "sap/ui/thirdparty/hasher.js", + "sap/ui/thirdparty/signals.js", + "sap/ui/thirdparty/jquery-mobile-custom.js", + "sap/ui/thirdparty/jqueryui/jquery-ui-core.js", + "sap/ui/thirdparty/jqueryui/jquery-ui-position.js", + + // other excludes (not required for productive scenarios) + "!sap-ui-*.js", + "!sap/ui/core/support/", + "!sap/ui/core/plugin/DeclarativeSupport.js", + "!sap/ui/core/plugin/LessSupport.js" + ], resolve: false, resolveConditional: false, @@ -56,6 +72,9 @@ function getBundleDefinition(namespace) { filters: [ `${namespace}/`, `!${namespace}/.library`, + `!${namespace}/designtime/`, + `!${namespace}/**/*.designtime.js`, + `!${namespace}/**/*.support.js`, `!${namespace}/themes/`, `!${namespace}/messagebundle*` ], @@ -232,7 +251,11 @@ module.exports = function({workspace, dependencies, options}) { const libraryNamespace = libraryNamespaceMatch[1]; return moduleBundler({ options: { - bundleDefinition: getBundleDefinition(libraryNamespace) + bundleDefinition: getBundleDefinition(libraryNamespace), + bundleOptions: { + optimize: true, + usePredefineCalls: true + } }, resources }).then(([bundle]) => { diff --git a/test/expected/build/sap.ui.core/preload/resources/sap/ui/core/library-preload.js b/test/expected/build/sap.ui.core/preload/resources/sap/ui/core/library-preload.js index 4b4ccfb76..dbdb964c1 100644 --- a/test/expected/build/sap.ui.core/preload/resources/sap/ui/core/library-preload.js +++ b/test/expected/build/sap.ui.core/preload/resources/sap/ui/core/library-preload.js @@ -2,8 +2,6 @@ jQuery.sap.registerPreloadedModules({ "version":"2.0", "modules":{ - "sap/ui/core/Core.js":function(){ -}, "sap/ui/core/some.js":function(){/*! * ${copyright} */ diff --git a/test/lib/lbt/bundle/Resolver.js b/test/lib/lbt/bundle/Resolver.js new file mode 100644 index 000000000..9998ef421 --- /dev/null +++ b/test/lib/lbt/bundle/Resolver.js @@ -0,0 +1,232 @@ +const test = require("ava"); + +const Resolver = require("../../../../lib/lbt/bundle/Resolver"); +const ResourcePool = require("../../../../lib/lbt/resources/ResourcePool"); + +const sortedCopy = (array) => array.slice().sort(); + +const TRIVIAL_MODULE = "sap.ui.define([], function() {});"; + +class MockPool extends ResourcePool { + constructor(data) { + super(); + for ( const [name, content] of Object.entries(data) ) { + this.addResource({ + name, + buffer: async () => content + }); + } + } +} + +test.serial("resolve without resolving dependencies", async (t) => { + const pool = new MockPool({ + "app.js": ` + sap.ui.define(['lib/mod1', 'lib/mod2'], function() { + return function() { + sap.ui.require(['lib/mod3'], function(){}); + }; + });`, + "lib/mod1.js": TRIVIAL_MODULE, + "lib/mod2.js": TRIVIAL_MODULE, + "lib/mod3.js": TRIVIAL_MODULE + }); + + const bundleDefinition = { + name: "bundle.js", + sections: [ + { + mode: "preload", + filters: [ + "app.js", + ], + resolve: false + } + ] + }; + + const resolver = new Resolver(pool); + + // act + const resolvedBundle = await resolver.resolve(bundleDefinition); + + // assert + t.true(resolvedBundle != null, "resolve() should return a bundle"); + t.is(resolvedBundle.sections.length, 1, "bundle should contain 1 section"); + t.deepEqual( + sortedCopy(resolvedBundle.sections[0].modules), + [ + "app.js" + ], "bundle should only contain the specified module"); +}); + + +test.serial("resolve with resolving static dependencies", async (t) => { + const pool = new MockPool({ + "app.js": ` + sap.ui.define(['lib/mod1', 'lib/mod2'], function() { + return function() { + sap.ui.require(['lib/mod3'], function(){}); + }; + });`, + "lib/mod1.js": "sap.ui.define(['./mod4'], function() {});", + "lib/mod2.js": TRIVIAL_MODULE, + "lib/mod3.js": TRIVIAL_MODULE, + "lib/mod4.js": TRIVIAL_MODULE + }); + + const bundleDefinition = { + name: "bundle.js", + sections: [ + { + mode: "preload", + filters: [ + "app.js", + ], + resolve: true + } + ] + }; + + const resolver = new Resolver(pool); + + // act + const resolvedBundle = await resolver.resolve(bundleDefinition); + + // assert + t.true(resolvedBundle != null, "resolve() should return a bundle"); + t.is(resolvedBundle.sections.length, 1, "bundle should contain 1 section"); + t.deepEqual( + sortedCopy(resolvedBundle.sections[0].modules), + [ + "app.js", + "lib/mod1.js", + "lib/mod2.js", + // "lib/mod3.js" // conditional dependency from app.js to lib/mod3 is NOT included + "lib/mod4.js", + ], "bundle should contain the expected modules"); +}); + +test.serial("resolve, with resolving also conditional dependencies", async (t) => { + const pool = new MockPool({ + "app.js": ` + sap.ui.define(['lib/mod1', 'lib/mod2'], function() { + return function() { + sap.ui.require(['lib/mod3'], function(){}); + }; + });`, + "lib/mod1.js": "sap.ui.define(['./mod4'], function() {});", + "lib/mod2.js": TRIVIAL_MODULE, + "lib/mod3.js": TRIVIAL_MODULE, + "lib/mod4.js": TRIVIAL_MODULE + }); + + const bundleDefinition = { + name: "bundle.js", + sections: [ + { + mode: "preload", + filters: [ + "app.js", + ], + resolve: true, + resolveConditional: true + } + ] + }; + + const resolver = new Resolver(pool); + + // act + const resolvedBundle = await resolver.resolve(bundleDefinition); + + // assert + t.true(resolvedBundle != null, "resolve() should return a bundle"); + t.is(resolvedBundle.sections.length, 1, "bundle should contain 1 section"); + t.deepEqual( + sortedCopy(resolvedBundle.sections[0].modules), + [ + "app.js", + "lib/mod1.js", + "lib/mod2.js", + "lib/mod3.js", // conditional dependency from app.js to lib/mod3 MUST BE included + "lib/mod4.js", + ], "bundle should contain the expected modules"); +}); + +test.serial("embedd a decomposable bundle", async (t) => { + const pool = new MockPool({ + "lib/mod1.js": TRIVIAL_MODULE, + "lib/mod2.js": "sap.ui.define(['lib/mod4'], function() {});", + "lib/mod3.js": TRIVIAL_MODULE, + "lib/mod4.js": TRIVIAL_MODULE, + "vendor/decomposable-bundle.js": ` + define("embedded/mod1", function() {}); + define("lib/mod2", function() {}); + define("embedded/mod3", function() {});` + }); + + const bundleDefinition = { + name: "bundle.js", + sections: [ + { + mode: "preload", + filters: [ + "vendor/" + ], + resolve: true + } + ] + }; + + const resolver = new Resolver(pool); + + const resolvedBundle = await resolver.resolve(bundleDefinition); + + t.true(resolvedBundle != null, "resolve() should return a bundle"); + t.is(resolvedBundle.sections.length, 1, "bundle should contain 1 section"); + t.deepEqual( + sortedCopy(resolvedBundle.sections[0].modules), + [ + "lib/mod2.js", + "lib/mod4.js" + ], "new bundle should contain the available modules of the decomposed bundle"); +}); + +test.serial("embedd a non-decomposable bundle", async (t) => { + const pool = new MockPool({ + "lib/mod1.js": TRIVIAL_MODULE, + "lib/mod2.js": TRIVIAL_MODULE, + "lib/mod3.js": "sap.ui.define(['lib/mod4'], function() {});", + "lib/mod4.js": TRIVIAL_MODULE, + "vendor/non-decomposable-bundle.js": ` + define("external/mod1", function() {}); + define("external/mod2", function() {}); + define("external/mod3", function() {});` + }); + + const bundleDefinition = { + name: "bundle.js", + sections: [ + { + mode: "preload", + filters: [ + "vendor/" + ], + resolve: true + } + ] + }; + + const resolver = new Resolver(pool); + + const resolvedBundle = await resolver.resolve(bundleDefinition); + + t.true(resolvedBundle != null, "resolve() should return a bundle"); + t.is(resolvedBundle.sections.length, 1, "bundle should contain 1 section"); + t.deepEqual( + sortedCopy(resolvedBundle.sections[0].modules), + [ + "vendor/non-decomposable-bundle.js" + ], "new bundle should contain the non-decomposable bundle"); +}); diff --git a/test/lib/lbt/resources/LibraryFileAnalyzer.js b/test/lib/lbt/resources/LibraryFileAnalyzer.js new file mode 100644 index 000000000..8d2c2e916 --- /dev/null +++ b/test/lib/lbt/resources/LibraryFileAnalyzer.js @@ -0,0 +1,62 @@ +const test = require("ava"); +const LibraryFileAnalyzer = require("../../../../lib/lbt/resources/LibraryFileAnalyzer"); + +test("extract packaging info from .library file", (t) => { + const libraryFile = `\ + + + sap.ui.core + (c) 2019 SAP SE + 1.99.0 + Some doc. + + + + + + + + + + + + + + +`; + + const expectedInfos = [ + { + name: "vendor/blanket.js", + dependencies: [], + ignoredGlobals: undefined + }, + { + name: "vendor/crossroads.js", + dependencies: ["vendor/signals.js"], + ignoredGlobals: ["foo", "bar"] + }, + { + name: "vendor/hasher.js", + dependencies: ["vendor/signals.js"], + ignoredGlobals: undefined + }, + { + name: "vendor/require.js", + dependencies: [], + ignoredGlobals: undefined + } + ]; + + const actual = LibraryFileAnalyzer.getDependencyInfos(libraryFile); + + t.deepEqual(Object.keys(actual), expectedInfos.map((exp) => exp.name), + "Method should return the expected set of modules"); + expectedInfos.forEach(({name, dependencies, ignoredGlobals}) => { + t.true(actual[name] != null, "expected info should exist"); + t.is(actual[name].rawModule, true, "info should have rawModule marker"); + t.is(actual[name].name, name, "info should have expected module id"); + t.deepEqual(actual[name].dependencies, dependencies, "info should have the expected dependencies"); + t.deepEqual(actual[name].ignoredGlobals, ignoredGlobals, "ignoredGlobals should have the expected value"); + }); +}); diff --git a/test/lib/lbt/resources/ResourcePool.js b/test/lib/lbt/resources/ResourcePool.js index b96af61f3..061b7cad1 100644 --- a/test/lib/lbt/resources/ResourcePool.js +++ b/test/lib/lbt/resources/ResourcePool.js @@ -1,4 +1,5 @@ const test = require("ava"); +const ModuleInfo = require("../../../../lib/lbt/resources/ModuleInfo"); const ResourcePool = require("../../../../lib/lbt/resources/ResourcePool"); const ResourceFilterList = require("../../../../lib/lbt/resources/ResourceFilterList"); @@ -203,12 +204,20 @@ test("addResource twice", async (t) => { t.is(resourcePool._resourcesByName.size, 1, "resource a was added to _resourcesByName map"); }); -test.serial("addResource: library", async (t) => { +test.serial("addResource: library and eval raw module info", async (t) => { const resourcePool = new ResourcePool(); + const infoA = new ModuleInfo("moduleA.js"); + infoA.rawModule = true; + infoA.addDependency("123.js"); + infoA.ignoredGlobals = ["foo", "bar"]; + const infoB = new ModuleInfo("moduleB.js"); + infoB.rawModule = true; + infoB.addDependency("456.js"); + const stubGetDependencyInfos = sinon.stub(LibraryFileAnalyzer, "getDependencyInfos").returns({ - myKeyA: "123", - myKeyB: "456" + "moduleA.js": infoA, + "moduleB.js": infoB }); const library = { @@ -216,11 +225,43 @@ test.serial("addResource: library", async (t) => { buffer: async () => "" }; await resourcePool.addResource(library); - t.deepEqual(resourcePool._resources, [library], "library a has been added to resources array twice"); + const moduleA = { + name: "moduleA.js", + buffer: async () => "var foo,bar,some;" + }; + await resourcePool.addResource(moduleA); + const moduleB = { + name: "moduleB.js", + buffer: async () => "var foo,bar,some; jQuery.sap.require(\"moduleC\");" + }; + await resourcePool.addResource(moduleB); + + t.deepEqual(resourcePool._resources, [library, moduleA, moduleB], "resources have been added to resources array"); t.is(resourcePool._resourcesByName.get("a.library"), library, "library a has been added to the _resourcesByName map"); - t.is(resourcePool._resourcesByName.size, 1, "library a was added to _resourcesByName map"); - t.deepEqual(resourcePool._rawModuleInfos.get("myKeyA"), "123", "module info has been added to _rawModuleInfos"); - t.deepEqual(resourcePool._rawModuleInfos.get("myKeyB"), "456", "module info has been added to _rawModuleInfos"); + t.is(resourcePool._resourcesByName.size, 3, "library a was added to _resourcesByName map"); + t.deepEqual(resourcePool._rawModuleInfos.get("moduleA.js"), infoA, "module info has been added to _rawModuleInfos"); + t.deepEqual(resourcePool._rawModuleInfos.get("moduleB.js"), infoB, "module info has been added to _rawModuleInfos"); + + const actualResourceA = await resourcePool.findResourceWithInfo("moduleA.js"); + t.true(actualResourceA.info instanceof ModuleInfo); + t.deepEqual(actualResourceA.info.dependencies, ["123.js"], + "configured dependencies should have been dded"); + t.true(actualResourceA.info.requiresTopLevelScope); + t.deepEqual(actualResourceA.info.exposedGlobals, ["foo", "bar", "some"], + "global names should be known from analsyis step"); + t.deepEqual(actualResourceA.info.ignoredGlobals, ["foo", "bar"], + "ignored globals should have been taken from .library"); + + const actualResourceB = await resourcePool.findResourceWithInfo("moduleB.js"); + t.true(actualResourceB.info instanceof ModuleInfo); + t.deepEqual(actualResourceB.info.dependencies, ["moduleC.js", "jquery.sap.global.js", "456.js"], + "dependencies from analsyis and raw info should have been merged"); + t.true(actualResourceB.info.requiresTopLevelScope); + t.deepEqual(actualResourceB.info.exposedGlobals, ["foo", "bar", "some"], + "global names should be known from analsyis step"); + t.deepEqual(actualResourceB.info.ignoredGlobals, undefined); + stubGetDependencyInfos.restore(); }); +