Completely refactor build system (#205)

The goals of this refactoring are the following:

- Improve code quality of the build system
- Prepare code base for adding code coverage
- Prepare code base for exposing the build system as a package export,
  so platforms can build their `cordova.js` during their build (or
  creation) process. This would make the JS build using coho obsolete.
- Prepare code base to remove dependency on Grunt

The build process now lives under `build-tools` (was `tasks`). It does
not depend on Grunt anymore but is written in plain Node.js.

The original Grunt interface for building (as used by coho) was
preserved and is implemented in `Gruntfile.js` using the new build
system. The `platformName` option and support for getting platform paths
from the `cordova-platforms` key in `package.json` have been removed
from the Grunt interface, but neither of those are used in coho.

The logic that is specific to the test build has been extracted from
the rest of the build. It now lives in `test/build.js` and is run
automatically during `npm test`.

The following dependencies have been added:

- execa
- fs-extra
- globby

I have taken extra care to preserve the exact format of the built file.
This means that the correctness of the refactoring can be verified by
simply diffing the build artifacts in `pkg` created with and without
this change.
diff --git a/Gruntfile.js b/Gruntfile.js
index 9d5d936..3f8ae47 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -16,6 +16,10 @@
  * specific language governing permissions and limitations
  * under the License.
 */
+
+const path = require('path');
+const { build, collectModules } = require('./build-tools');
+
 module.exports = function (grunt) {
 
     grunt.initConfig({
@@ -24,7 +28,6 @@
             'android': {},
             'ios': {},
             'osx': {},
-            'test': {},
             'windows': { useWindowsLineEndings: true },
             'browser': {},
             'electron': {}
@@ -32,12 +35,39 @@
         clean: ['pkg']
     });
 
+    // custom tasks
+    grunt.registerMultiTask('compile', 'Packages cordova.js', function () {
+        const done = this.async();
+
+        const platformPath = path.resolve(`../cordova-${this.target}`);
+        const platformPkgPath = path.join(platformPath, 'package');
+        const platformModulesPath = path.join(platformPath, 'cordova-js-src');
+
+        build({
+            platformName: this.target,
+            platformVersion: grunt.option('platformVersion') ||
+                             require(platformPkgPath).version,
+            extraModules: collectModules(platformModulesPath)
+        })
+            .then(cordovaJs => {
+                // if we are using windows line endings, we will also add the BOM
+                if (this.data.useWindowsLineEndings) {
+                    cordovaJs = '\ufeff' + cordovaJs.split(/\r?\n/).join('\r\n');
+                }
+
+                // Write out the bundle
+                const baseName = `cordova.${this.target}.js`;
+                const fileName = path.join('pkg', baseName);
+                grunt.file.write(fileName, cordovaJs);
+
+                console.log(`Generated ${fileName}`);
+            })
+            .then(done, done);
+    });
+
     // external tasks
     grunt.loadNpmTasks('grunt-contrib-clean');
 
-    // custom tasks
-    grunt.loadTasks('tasks');
-
     // defaults
     grunt.registerTask('default', ['compile']);
 };
diff --git a/build-tools/build.js b/build-tools/build.js
new file mode 100644
index 0000000..0b6fc87
--- /dev/null
+++ b/build-tools/build.js
@@ -0,0 +1,42 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const execa = require('execa');
+const bundle = require('./bundle');
+const scripts = require('./scripts');
+const modules = require('./modules');
+const { pkgRoot } = require('./common');
+
+module.exports = function build (userConfig) {
+    const config = Object.assign({ preprocess: x => x }, userConfig);
+
+    return Promise.all([
+        scripts(config),
+        modules(config),
+        getCommitId()
+    ])
+        .then(([ scripts, modules, commitId ]) => {
+            Object.assign(config, { commitId });
+            return bundle(scripts, modules, config);
+        });
+};
+
+function getCommitId () {
+    return execa.stdout('git', ['rev-parse', 'HEAD'], { cwd: pkgRoot });
+}
diff --git a/build-tools/bundle.js b/build-tools/bundle.js
new file mode 100644
index 0000000..2090587
--- /dev/null
+++ b/build-tools/bundle.js
@@ -0,0 +1,63 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+module.exports = function bundle (scripts, modules, config) {
+    const context = Object.assign({
+        modules: modules.map(m => m.contents).join('\n'),
+        includeScript: name => scripts[name].contents
+    }, config);
+
+    return bundleTemplate(context);
+};
+
+const bundleTemplate = ({
+    commitId,
+    platformName,
+    platformVersion,
+    includeScript,
+    modules
+}) => `
+// Platform: ${platformName}
+// ${commitId}
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+\x20
+     http://www.apache.org/licenses/LICENSE-2.0
+\x20
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/
+;(function() {
+var PLATFORM_VERSION_BUILD_LABEL = '${platformVersion}';
+${includeScript('require')}
+${modules}
+window.cordova = require('cordova');
+${includeScript('bootstrap')}
+})();
+`.trim();
diff --git a/build-tools/common.js b/build-tools/common.js
new file mode 100644
index 0000000..784881c
--- /dev/null
+++ b/build-tools/common.js
@@ -0,0 +1,62 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const fs = require('fs-extra');
+const path = require('path');
+const globby = require('globby');
+
+const pkgRoot = path.join(__dirname, '..');
+
+module.exports = {
+    pkgRoot,
+
+    values (obj) {
+        return Object.keys(obj).map(key => obj[key]);
+    },
+
+    readContents (f) {
+        return fs.readFile(f.path, 'utf8')
+            .then(contents => Object.assign({}, f, { contents }));
+    },
+
+    // Strips the license header.
+    // Basically only the first multi-line comment up to to the closing */
+    stripLicenseHeader (f) {
+        const LICENSE_REGEX = /^\s*\/\*[\s\S]+?\*\/\s*/;
+        const withoutLicense = f.contents.replace(LICENSE_REGEX, '');
+        return Object.assign({}, f, { contents: withoutLicense });
+    },
+
+    // TODO format path relative to pkg.json
+    prependFileComment (f) {
+        const comment = `// file: ${f.path}`;
+        const contents = [comment, f.contents].join('\n');
+        return Object.assign({}, f, { contents });
+    },
+
+    collectModules (dir) {
+        return globby.sync(['**/*.js'], { cwd: dir })
+            .map(fileName => ({
+                path: path.relative(pkgRoot, path.join(dir, fileName)),
+                moduleId: fileName.slice(0, -3)
+            }))
+            .map(file => ({ [file.moduleId]: file }))
+            .reduce((result, fragment) => Object.assign(result, fragment), {});
+    }
+};
diff --git a/build-tools/index.js b/build-tools/index.js
new file mode 100644
index 0000000..61e54fc
--- /dev/null
+++ b/build-tools/index.js
@@ -0,0 +1,23 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+module.exports = {
+    build: require('./build'),
+    collectModules: require('./common').collectModules
+};
diff --git a/build-tools/modules.js b/build-tools/modules.js
new file mode 100644
index 0000000..4ae28b8
--- /dev/null
+++ b/build-tools/modules.js
@@ -0,0 +1,80 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const path = require('path');
+
+const {
+    readContents,
+    stripLicenseHeader,
+    prependFileComment,
+    values,
+    pkgRoot,
+    collectModules
+} = require('./common');
+
+module.exports = function modules (config) {
+    for (const m of values(config.extraModules)) {
+        if (m.path.startsWith('../')) {
+            m.path = path.resolve(m.path);
+        }
+    }
+
+    const commonModules = collectCommonModules();
+    const modules = values(Object.assign(commonModules, config.extraModules));
+    modules.sort((a, b) => a.moduleId.localeCompare(b.moduleId));
+    return Promise.all(modules.map(modulePipeline(config)));
+};
+
+function collectCommonModules () {
+    const modules = collectModules(path.join(pkgRoot, 'src/common'));
+    modules[''] = {
+        moduleId: '',
+        path: path.relative(pkgRoot, path.join(pkgRoot, 'src/cordova.js'))
+    };
+    return modules;
+}
+
+function modulePipeline (config) {
+    return f => Promise.resolve(f)
+        .then(readContents)
+        .then(config.preprocess)
+        .then(stripLicenseHeader)
+        .then(addModuleNamespace('cordova'))
+        .then(wrapInModuleContext)
+        .then(prependFileComment);
+}
+
+function addModuleNamespace (ns) {
+    return m => {
+        const moduleId = path.posix.join(ns, m.moduleId);
+        return Object.assign({}, m, { moduleId });
+    };
+}
+
+function wrapInModuleContext (f) {
+    const contents = moduleTemplate({ id: f.moduleId, body: f.contents });
+    return Object.assign({}, f, { contents });
+}
+
+const moduleTemplate = ({ id, body }) => `
+define("${id}", function(require, exports, module) {
+
+${body}
+});
+`.trimLeft();
diff --git a/build-tools/scripts.js b/build-tools/scripts.js
new file mode 100644
index 0000000..4fec388
--- /dev/null
+++ b/build-tools/scripts.js
@@ -0,0 +1,61 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const path = require('path');
+const {
+    readContents,
+    stripLicenseHeader,
+    prependFileComment,
+    values,
+    pkgRoot,
+    collectModules
+} = require('./common');
+
+module.exports = function scripts (config) {
+    const scripts = values(collectScripts());
+    return Promise.all(scripts.map(scriptPipeline(config)))
+        .then(indexByModuleId);
+};
+
+function collectScripts () {
+    const scripts = collectModules(path.join(pkgRoot, 'src/scripts'));
+    for (const script of ['require', 'bootstrap']) {
+        if (script in scripts) continue;
+        throw new Error(`Could not find required script '${script}.js'`);
+    }
+    return scripts;
+}
+
+function scriptPipeline (config) {
+    return f => Promise.resolve(f)
+        .then(readContents)
+        .then(config.preprocess)
+        .then(stripLicenseHeader)
+        .then(prependEmptyLine)
+        .then(prependFileComment);
+}
+
+function prependEmptyLine (f) {
+    return Object.assign({}, f, { contents: '\n' + f.contents });
+}
+
+function indexByModuleId (files) {
+    return files
+        .reduce((acc, f) => Object.assign(acc, { [f.moduleId]: f }), {});
+}
diff --git a/package.json b/package.json
index ade7cb8..5d55ae4 100644
--- a/package.json
+++ b/package.json
@@ -16,9 +16,10 @@
   },
   "scripts": {
     "eslint": "eslint --ignore-path .gitignore .",
-    "pretest": "grunt compile:test",
+    "pretest": "npm run build:test",
     "test": "npm run eslint && karma start",
-    "build": "grunt compile"
+    "build": "grunt compile",
+    "build:test": "node test/build.js pkg/cordova.test.js"
   },
   "license": "Apache-2.0",
   "contributors": [
@@ -67,7 +68,11 @@
       "email": "stevengill97@gmail.com"
     }
   ],
-  "dependencies": {},
+  "dependencies": {
+    "execa": "^1.0.0",
+    "fs-extra": "^8.0.1",
+    "globby": "^9.2.0"
+  },
   "devDependencies": {
     "eslint": "^5.16.0",
     "eslint-config-semistandard": "^13.0.0",
@@ -83,13 +88,5 @@
     "karma-chrome-launcher": "^2.2.0",
     "karma-jasmine": "^2.0.1",
     "puppeteer": "^1.14.0"
-  },
-  "cordova-platforms": {
-    "cordova-android": "../cordova-android",
-    "cordova-ios": "../cordova-ios",
-    "cordova-windows": "../cordova-windows",
-    "cordova-osx": "../cordova-osx",
-    "cordova-browser": "../cordova-browser",
-    "cordova-electron": "../cordova-electron"
   }
 }
diff --git a/tasks/compile.js b/tasks/compile.js
deleted file mode 100644
index e5af288..0000000
--- a/tasks/compile.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
-*/
-var generate = require('./lib/packager');
-var fs = require('fs');
-var path = require('path');
-var pkgJson = require('../package.json');
-
-module.exports = function (grunt) {
-    grunt.registerMultiTask('compile', 'Packages cordova.js', function () {
-        var done = this.async();
-        var platformName = this.target;
-        var useWindowsLineEndings = this.data.useWindowsLineEndings;
-
-        // grabs --platformVersion flag
-        var flags = grunt.option.flags();
-        var platformVersion;
-        var platformPath;
-        flags.forEach(function (flag) {
-            // see if --platformVersion was passed in
-            if (flag.indexOf('platformVersion') > -1) {
-                let equalIndex = flag.indexOf('=');
-                platformVersion = flag.slice(equalIndex + 1);
-            }
-
-            // see if flags for platforms were passed in
-            // followed by custom paths
-            if (flag.indexOf(platformName) > -1) {
-                let equalIndex = flag.indexOf('=');
-                platformPath = flag.slice(equalIndex + 1);
-            }
-        });
-        // Use platformPath from package.json, no custom platform path
-        if (platformPath === undefined) {
-            platformPath = pkgJson['cordova-platforms']['cordova-' + platformName];
-        }
-        // Get absolute path to platform
-        if (platformPath) {
-            platformPath = path.resolve(platformPath);
-        }
-        if (!platformVersion) {
-            var platformPkgJson;
-
-            if (platformPath && fs.existsSync(platformPath)) {
-                platformPkgJson = require(platformPath + '/package.json');
-                platformVersion = platformPkgJson.version;
-            } else {
-                platformVersion = 'N/A';
-            }
-        }
-        generate(platformName, useWindowsLineEndings, platformVersion, platformPath, done);
-    });
-};
diff --git a/tasks/lib/bundle.js b/tasks/lib/bundle.js
deleted file mode 100644
index 7c58a01..0000000
--- a/tasks/lib/bundle.js
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-var fs = require('fs');
-var path = require('path');
-var collectFiles = require('./collect-files');
-var copyProps = require('./copy-props');
-var writeModule = require('./write-module');
-var writeScript = require('./write-script');
-var licensePath = path.join(__dirname, '..', 'templates', 'LICENSE-for-js-file.txt');
-var pkgJson = require('../../package.json');
-
-module.exports = function bundle (platform, debug, commitId, platformVersion, platformPath) {
-    var modules = collectFiles(path.join('src', 'common'));
-    var scripts = collectFiles(path.join('src', 'scripts'));
-    modules[''] = path.join('src', 'cordova.js');
-
-    // check to see if platform has cordova-js-src directory
-    if (fs.existsSync(platformPath) && fs.existsSync(path.join(platformPath, 'cordova-js-src'))) {
-        copyProps(modules, collectFiles(path.join(platformPath, 'cordova-js-src')));
-    } else {
-        // for platforms that don't have a release with cordova-js-src yet
-        // or if platform === test
-        if (platform === 'test') {
-            copyProps(modules, collectFiles(path.join('src', 'legacy-exec', platform)));
-        } else {
-            console.log('Your version of ' + platform + ' does not contain cordova-js-src. Update to a newer version of ' + platform + '.');
-            throw 'Stopped process';
-        }
-    }
-    // test doesn't support custom paths
-    if (platform === 'test') {
-        var androidPath = path.resolve(pkgJson['cordova-platforms']['cordova-android']);
-        var iosPath = path.resolve(pkgJson['cordova-platforms']['cordova-ios']);
-        var testFilesPath = path.resolve(androidPath, 'cordova-js-src', 'android');
-        // Add android platform-specific modules that have tests to the test bundle.
-        if (fs.existsSync(androidPath)) {
-            modules['android/exec'] = path.resolve(androidPath, 'cordova-js-src', 'exec.js');
-        } else {
-            // testFilesPath = path.resolve('src', 'legacy-exec', 'android', 'android');
-            // modules['android/exec'] = path.resolve('src', 'legacy-exec', 'android', 'exec.js');
-            console.log('Couldn\'t add android test files.');
-            throw 'Stopped process';
-        }
-        copyProps(modules, collectFiles(testFilesPath, 'android'));
-
-        // Add iOS platform-specific modules that have tests for the test bundle.
-        if (fs.existsSync(iosPath)) {
-            modules['ios/exec'] = path.join(iosPath, 'cordova-js-src', 'exec.js');
-        } else {
-            // modules['ios/exec'] = path.join('src', 'legacy-exec', 'ios', 'exec.js');
-            console.log('Couldn\'t add iOS test files.');
-            throw 'Stopped process';
-        }
-    }
-
-    var output = [];
-
-    output.push('// Platform: ' + platform);
-    output.push('// ' + commitId);
-
-    // write header
-    output.push('/*', fs.readFileSync(licensePath, 'utf8'), '*/');
-    output.push(';(function() {');
-
-    output.push("var PLATFORM_VERSION_BUILD_LABEL = '" + platformVersion + "';");
-
-    // write initial scripts
-    if (!scripts['require']) {
-        throw new Error("didn't find a script for 'require'");
-    }
-
-    writeScript(output, scripts['require'], debug);
-
-    // write modules
-    var moduleIds = Object.keys(modules);
-    moduleIds.sort();
-
-    for (var i = 0; i < moduleIds.length; i++) {
-        var moduleId = moduleIds[i];
-
-        writeModule(output, modules[moduleId], moduleId, debug);
-    }
-
-    output.push("window.cordova = require('cordova');");
-
-    // write final scripts
-    if (!scripts['bootstrap']) {
-        throw new Error("didn't find a script for 'bootstrap'");
-    }
-
-    writeScript(output, scripts['bootstrap'], debug);
-
-    var bootstrapPlatform = 'bootstrap-' + platform;
-    if (scripts[bootstrapPlatform]) {
-        writeScript(output, scripts[bootstrapPlatform], debug);
-    }
-
-    // write trailer
-    output.push('})();');
-
-    return output.join('\n');
-};
diff --git a/tasks/lib/collect-files.js b/tasks/lib/collect-files.js
deleted file mode 100644
index 908d2f9..0000000
--- a/tasks/lib/collect-files.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF
- * or more contributor license agreements.  See th
- * distributed with this work for additional infor
- * regarding copyright ownership.  The ASF license
- * to you under the Apache License, Version 2.0 (t
- * "License"); you may not use this file except in
- * with the License.  You may obtain a copy of the
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to
- * software distributed under the License is distr
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
- * KIND, either express or implied.  See the Licen
- * specific language governing permissions and lim
- * under the License.
- */
-var fs = require('fs');
-var path = require('path');
-var copyProps = require('./copy-props');
-var getModuleId = require('./get-module-id');
-
-function collectFiles (dir, id) {
-    if (!id) id = '';
-
-    var result = {};
-    var entries = fs.readdirSync(dir);
-
-    entries = entries.filter(function (entry) {
-        if (entry.match(/\.js$/)) { return true; }
-
-        var stat = fs.statSync(path.join(dir, entry));
-
-        if (stat.isDirectory()) { return true; }
-    });
-
-    entries.forEach(function (entry) {
-        var moduleId = (id ? id + '/' : '') + entry;
-        var fileName = path.join(dir, entry);
-
-        var stat = fs.statSync(fileName);
-        if (stat.isDirectory()) {
-            copyProps(result, collectFiles(fileName, moduleId));
-        } else {
-            moduleId = getModuleId(moduleId);
-            result[moduleId] = fileName;
-        }
-    });
-    return copyProps({}, result);
-}
-
-module.exports = collectFiles;
diff --git a/tasks/lib/compute-commit-id.js b/tasks/lib/compute-commit-id.js
deleted file mode 100644
index c3e4af1..0000000
--- a/tasks/lib/compute-commit-id.js
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-var childProcess = require('child_process');
-var fs = require('fs');
-var path = require('path');
-
-module.exports = function computeCommitId (callback, cachedGitVersion) {
-
-    if (cachedGitVersion) {
-        callback(cachedGitVersion);
-        return;
-    }
-
-    var cordovaJSDir = path.join(__dirname, '../../');
-
-    // make sure .git directory exists in cordova.js repo
-    if (fs.existsSync(path.join(__dirname, '../../.git'))) {
-        var gitPath = 'git';
-        var args = 'rev-list HEAD --max-count=1';
-        childProcess.exec(gitPath + ' ' + args, { cwd: cordovaJSDir }, function (err, stdout, stderr) {
-            var isWindows = process.platform.slice(0, 3) === 'win';
-            if (err && isWindows) {
-                gitPath = '"' + path.join(process.env['ProgramFiles'], 'Git', 'bin', 'git.exe') + '"';
-                childProcess.exec(gitPath + ' ' + args, function (err, stdout, stderr) {
-                    if (err) {
-                        console.warn('Error during git describe: ' + err);
-                        done('???');
-                    } else {
-                        done(stdout);
-                    }
-                });
-            } else if (err) {
-                console.warn('Error during git describe: ' + err);
-                done('???');
-            } else {
-                done(stdout);
-            }
-        });
-    } else {
-        // console.log('no git');
-        // Can't compute commit ID
-        done('???');
-    }
-
-    function done (stdout) {
-        var version = stdout.trim();
-        cachedGitVersion = version;
-        callback(version);
-    }
-};
diff --git a/tasks/lib/copy-props.js b/tasks/lib/copy-props.js
deleted file mode 100644
index 01fbbd8..0000000
--- a/tasks/lib/copy-props.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF
- * or more contributor license agreements.  See th
- * distributed with this work for additional infor
- * regarding copyright ownership.  The ASF license
- * to you under the Apache License, Version 2.0 (t
- * "License"); you may not use this file except in
- * with the License.  You may obtain a copy of the
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to
- * software distributed under the License is distr
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
- * KIND, either express or implied.  See the Licen
- * specific language governing permissions and lim
- * under the License.
- */
-// FIXME should just use underscore or lodash for this
-module.exports = function copyProps (target, source) {
-
-    for (var key in source) {
-        if (!source.hasOwnProperty(key)) continue;
-        target[key] = source[key];
-    }
-
-    return target;
-};
diff --git a/tasks/lib/get-module-id.js b/tasks/lib/get-module-id.js
deleted file mode 100644
index 24fbd35..0000000
--- a/tasks/lib/get-module-id.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF
- * or more contributor license agreements.  See th
- * distributed with this work for additional infor
- * regarding copyright ownership.  The ASF license
- * to you under the Apache License, Version 2.0 (t
- * "License"); you may not use this file except in
- * with the License.  You may obtain a copy of the
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to
- * software distributed under the License is distr
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
- * KIND, either express or implied.  See the Licen
- * specific language governing permissions and lim
- * under the License.
- */
-
-module.exports = function getModuleId (filename) {
-    return filename.match(/(.*)\.js$/)[1];
-};
diff --git a/tasks/lib/packager.js b/tasks/lib/packager.js
deleted file mode 100644
index 65b25c3..0000000
--- a/tasks/lib/packager.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
-*/
-var fs = require('fs');
-var path = require('path');
-var bundle = require('./bundle');
-var computeCommitId = require('./compute-commit-id');
-
-module.exports = function generate (platform, useWindowsLineEndings, platformVersion, platformPath, callback) {
-    computeCommitId(function (commitId) {
-        var outFile;
-        var time = new Date().valueOf();
-
-        var libraryRelease = bundle(platform, false, commitId, platformVersion, platformPath);
-        // if we are using windows line endings, we will also add the BOM
-        if (useWindowsLineEndings) {
-            libraryRelease = '\ufeff' + libraryRelease.split(/\r?\n/).join('\r\n');
-        }
-
-        time = new Date().valueOf() - time;
-        if (!fs.existsSync('pkg')) {
-            fs.mkdirSync('pkg');
-        }
-
-        outFile = path.join('pkg', 'cordova.' + platform + '.js');
-        fs.writeFileSync(outFile, libraryRelease, 'utf8');
-
-        console.log('generated cordova.' + platform + '.js @ ' + commitId + ' in ' + time + 'ms');
-        callback();
-    });
-};
diff --git a/tasks/lib/strip-header.js b/tasks/lib/strip-header.js
deleted file mode 100644
index f6a0aab..0000000
--- a/tasks/lib/strip-header.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF
- * or more contributor license agreements.  See th
- * distributed with this work for additional infor
- * regarding copyright ownership.  The ASF license
- * to you under the Apache License, Version 2.0 (t
- * "License"); you may not use this file except in
- * with the License.  You may obtain a copy of the
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to
- * software distributed under the License is distr
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
- * KIND, either express or implied.  See the Licen
- * specific language governing permissions and lim
- * under the License.
- */
-
-// Strips the license header.
-// Basically only the first multi-line comment up to to the closing */
-module.exports = function stripHeader (contents, fileName) {
-    var ls = contents.split(/\r?\n/);
-    while (ls[0]) {
-        if (ls[0].match(/^\s*\/\*/) || ls[0].match(/^\s*\*/)) {
-            ls.shift();
-        } else if (ls[0].match(/^\s*\*\//)) {
-            ls.shift();
-            break;
-        } else {
-            console.log('WARNING: file name ' + fileName + ' is missing the license header');
-            break;
-        }
-    }
-    return ls.join('\n');
-};
diff --git a/tasks/lib/write-contents.js b/tasks/lib/write-contents.js
deleted file mode 100644
index 02bfcd2..0000000
--- a/tasks/lib/write-contents.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF
- * or more contributor license agreements.  See th
- * distributed with this work for additional infor
- * regarding copyright ownership.  The ASF license
- * to you under the Apache License, Version 2.0 (t
- * "License"); you may not use this file except in
- * with the License.  You may obtain a copy of the
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to
- * software distributed under the License is distr
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
- * KIND, either express or implied.  See the Licen
- * specific language governing permissions and lim
- * under the License.
- */
-
-module.exports = function writeContents (oFile, fileName, contents, debug) {
-
-    if (debug) {
-        contents += '\n//@ sourceURL=' + fileName;
-        contents = 'eval(' + JSON.stringify(contents) + ')';
-        // this bit makes it easier to identify modules
-        // with syntax errors in them
-        var handler = 'console.log("exception: in ' + fileName + ': " + e);';
-        handler += 'console.log(e.stack);';
-        contents = 'try {' + contents + '} catch(e) {' + handler + '}';
-    } else {
-        contents = '// file: ' + fileName.split('\\').join('/') + '\n' + contents;
-    }
-
-    oFile.push(contents);
-};
diff --git a/tasks/lib/write-module.js b/tasks/lib/write-module.js
deleted file mode 100644
index 2395dc0..0000000
--- a/tasks/lib/write-module.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF
- * or more contributor license agreements.  See th
- * distributed with this work for additional infor
- * regarding copyright ownership.  The ASF license
- * to you under the Apache License, Version 2.0 (t
- * "License"); you may not use this file except in
- * with the License.  You may obtain a copy of the
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to
- * software distributed under the License is distr
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
- * KIND, either express or implied.  See the Licen
- * specific language governing permissions and lim
- * under the License.
- */
-var fs = require('fs');
-var path = require('path');
-var stripHeader = require('./strip-header');
-var writeContents = require('./write-contents');
-
-module.exports = function writeModule (oFile, fileName, moduleId, debug) {
-    var contents = fs.readFileSync(fileName, 'utf8');
-
-    contents = '\n' + stripHeader(contents, fileName) + '\n';
-
-    // Windows fix, '\' is an escape, but defining requires '/' -jm
-    moduleId = path.join('cordova', moduleId).split('\\').join('/');
-
-    var signature = 'function(require, exports, module)';
-
-    contents = 'define("' + moduleId + '", ' + signature + ' {' + contents + '});\n';
-
-    writeContents(oFile, fileName, contents, debug);
-};
diff --git a/tasks/lib/write-script.js b/tasks/lib/write-script.js
deleted file mode 100644
index ab4c450..0000000
--- a/tasks/lib/write-script.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF
- * or more contributor license agreements.  See th
- * distributed with this work for additional infor
- * regarding copyright ownership.  The ASF license
- * to you under the Apache License, Version 2.0 (t
- * "License"); you may not use this file except in
- * with the License.  You may obtain a copy of the
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to
- * software distributed under the License is distr
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
- * KIND, either express or implied.  See the Licen
- * specific language governing permissions and lim
- * under the License.
- */
-
-var fs = require('fs');
-var writeContents = require('./write-contents');
-var stripHeader = require('./strip-header');
-
-module.exports = function writeScript (oFile, fileName, debug) {
-    var contents = fs.readFileSync(fileName, 'utf8');
-    contents = stripHeader(contents, fileName);
-    writeContents(oFile, fileName, contents, debug);
-};
diff --git a/tasks/templates/LICENSE-for-js-file.txt b/tasks/templates/LICENSE-for-js-file.txt
deleted file mode 100644
index 20f533b..0000000
--- a/tasks/templates/LICENSE-for-js-file.txt
+++ /dev/null
@@ -1,16 +0,0 @@
- Licensed to the Apache Software Foundation (ASF) under one
- or more contributor license agreements.  See the NOTICE file
- distributed with this work for additional information
- regarding copyright ownership.  The ASF licenses this file
- to you under the Apache License, Version 2.0 (the
- "License"); you may not use this file except in compliance
- with the License.  You may obtain a copy of the License at
- 
-     http://www.apache.org/licenses/LICENSE-2.0
- 
- Unless required by applicable law or agreed to in writing,
- software distributed under the License is distributed on an
- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- KIND, either express or implied.  See the License for the
- specific language governing permissions and limitations
- under the License.
\ No newline at end of file
diff --git a/test/build.js b/test/build.js
new file mode 100755
index 0000000..f03236a
--- /dev/null
+++ b/test/build.js
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+
+const fs = require('fs-extra');
+const path = require('path');
+const { build, collectModules } = require('../build-tools');
+
+if (require.main === module) {
+    buildCordovaJsTestBundle(process.argv[2])
+        .catch(err => {
+            console.error(err);
+            process.exitCode = 1;
+        });
+}
+
+module.exports = buildCordovaJsTestBundle;
+
+// Writes the cordova-js test build bundle to bundlePath
+function buildCordovaJsTestBundle (bundlePath) {
+    return build({
+        platformName: 'test',
+        platformVersion: 'N/A',
+        extraModules: collectTestBuildModules()
+    })
+        .then(testBundle => fs.outputFile(bundlePath, testBundle));
+}
+
+function collectTestBuildModules () {
+    const pkgRoot = path.join(__dirname, '..');
+
+    // Add platform-specific modules that have tests to the test bundle.
+    const platformModules = ['android', 'ios'].map(platform => {
+        const platformPath = path.resolve(pkgRoot, `../cordova-${platform}`);
+        const modulePath = path.join(platformPath, 'cordova-js-src');
+        const modules = collectModules(modulePath);
+
+        // Prevent overwriting this platform's exec module with the next one
+        const moduleId = path.posix.join(platform, 'exec');
+        modules[moduleId] = Object.assign({}, modules.exec, { moduleId });
+
+        // Remove plugin/* modules to minimize diff to old build
+        Object.keys(modules)
+            .filter(k => k.startsWith('plugin/'))
+            .forEach(k => delete modules[k]);
+
+        return modules;
+    });
+
+    // Finally, add modules provided by test platform
+    const testModulesPath = path.join(__dirname, '../src/legacy-exec/test');
+    return Object.assign(...platformModules, collectModules(testModulesPath));
+}