diff --git a/src/deploy.cmd.js b/src/deploy.cmd.js index 3c20069ed..855026cdc 100644 --- a/src/deploy.cmd.js +++ b/src/deploy.cmd.js @@ -52,6 +52,7 @@ class DeployCommand extends StaticCommand { this._dryRun = false; this._createPackages = 'auto'; this._addStrain = null; + this._enableMinify = null; } get requireConfigFile() { @@ -133,6 +134,11 @@ class DeployCommand extends StaticCommand { return this; } + withMinify(value) { + this._enableMinify = value; + return this; + } + actionName(script) { if (script.main.indexOf(path.resolve(__dirname, 'openwhisk')) === 0) { return `hlx--${script.name}`; @@ -330,6 +336,9 @@ Alternatively you can auto-add one using the {grey --add } option.`); .withTarget(this._target) .withDirectory(this.directory) .withOnlyModified(this._createPackages === 'auto'); + if (this._enableMinify !== null) { + pgkCommand.withMinify(this._enableMinify); + } await pgkCommand.run(); } diff --git a/src/package.cmd.js b/src/package.cmd.js index fd0bce022..5faabc87e 100644 --- a/src/package.cmd.js +++ b/src/package.cmd.js @@ -9,9 +9,6 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ - -'use strict'; - const chalk = require('chalk'); const glob = require('glob'); const path = require('path'); @@ -19,8 +16,7 @@ const fs = require('fs-extra'); const ProgressBar = require('progress'); const archiver = require('archiver'); const StaticCommand = require('./static.cmd.js'); -const packageCfg = require('./parcel/packager-config.js'); -const ExternalsCollector = require('./parcel/ExternalsCollector.js'); +const ActionBundler = require('./parcel/ActionBundler.js'); const { flattenDependencies } = require('./packager-utils.js'); class PackageCommand extends StaticCommand { @@ -28,6 +24,7 @@ class PackageCommand extends StaticCommand { super(logger); this._target = null; this._onlyModified = false; + this._enableMinify = true; } // eslint-disable-next-line class-methods-use-this @@ -45,6 +42,11 @@ class PackageCommand extends StaticCommand { return this; } + withMinify(value) { + this._enableMinify = value; + return this; + } + async init() { await super.init(); this._target = path.resolve(this.directory, this._target); @@ -78,7 +80,6 @@ class PackageCommand extends StaticCommand { }; return new Promise((resolve, reject) => { - const ticks = {}; const archiveName = path.basename(info.zipFile); let hadErrors = false; @@ -98,9 +99,7 @@ class PackageCommand extends StaticCommand { }); archive.on('entry', (data) => { log.debug(`${archiveName}: A ${data.name}`); - if (ticks[data.name]) { - tick('', data.name); - } + tick('', data.name); }); archive.on('warning', (err) => { log.error(`${chalk.redBright('[error] ')}Unable to create archive: ${err.message}`); @@ -112,6 +111,7 @@ class PackageCommand extends StaticCommand { hadErrors = true; reject(err); }); + archive.pipe(output); const packageJson = { name: info.name, @@ -121,44 +121,41 @@ class PackageCommand extends StaticCommand { license: 'Apache-2.0', }; - archive.pipe(output); archive.append(JSON.stringify(packageJson, null, ' '), { name: 'package.json' }); + archive.file(info.bundlePath, { name: path.basename(info.main) }); + archive.finalize(); + }); + } - info.files.forEach((file) => { - const name = path.basename(file); - archive.file(path.resolve(this._target, file), { name }); - ticks[name] = true; - }); - - // add modules that cause problems when embeded in webpack - Object.keys(info.externals).forEach((mod) => { - // if the module was linked via `npm link`, then it is a checked-out module, and should - // not be included as-is. - const modPath = info.externals[mod]; - if (modPath.indexOf('/node_modules/') < 0 && modPath.indexOf('\\node_modules\\') < 0) { - // todo: async - // todo: read .npmignore - const files = [ - ...glob.sync('!(.git|node_modules|logs|docs|coverage)/**', { - cwd: modPath, - matchBase: false, - }), - ...glob.sync('*', { - cwd: modPath, - matchBase: false, - nodir: true, - })]; - files.forEach((name) => { - archive.file(path.resolve(modPath, name), { name: `node_modules/${mod}/${name}` }); - }); - } else { - archive.directory(modPath, `node_modules/${mod}`); - ticks[`node_modules/${mod}/package.json`] = true; - } - }); + /** + * Creates the action bundles + * @param {*[]} scripts the scripts information + * @param {ProgressBar} bar the progress bar + */ + async createBundles(scripts, bar) { + const progressHandler = (percent, msg, ...args) => { + const action = msg === 'building' ? `bundling ${args[0]}` : msg; + bar.update(percent * 0.8, { action }); + }; - archive.finalize(); + // create the bundles + const bundler = new ActionBundler() + .withDirectory(this._target) + .withModulePaths(['node_modules', path.resolve(__dirname, '..', 'node_modules')]) + .withLogger(this.log) + .withProgressHandler(progressHandler) + .withMinify(this._enableMinify); + const files = {}; + scripts.forEach((script) => { + files[script.name] = path.resolve(this._target, script.main); }); + const stats = await bundler.run(files); + if (stats.errors) { + stats.errors.forEach(this.log.error); + } + if (stats.warnings) { + stats.warnings.forEach(this.log.warn); + } } async run() { @@ -196,52 +193,38 @@ class PackageCommand extends StaticCommand { scripts = scripts.filter(script => !script.zipFile); } - // generate additional infos - scripts.forEach((script) => { - /* eslint-disable no-param-reassign */ - script.name = path.basename(script.main, '.js'); - script.dirname = script.isStatic ? '' : path.dirname(script.main); - script.archiveName = `${script.name}.zip`; - script.zipFile = path.resolve(this._target, script.dirname, script.archiveName); - script.infoFile = path.resolve(this._target, script.dirname, `${script.name}.info.json`); - /* eslint-enable no-param-reassign */ - }); - - const bar = new ProgressBar('[:bar] :action :etas', { - total: scripts.length * 2, - width: 50, - renderThrottle: 1, - stream: process.stdout, - }); - - // collect all the external modules of the scripts - let steps = 0; - await Promise.all(scripts.map(async (script) => { - const collector = new ExternalsCollector() - .withDirectory(this._target) - .withExternals(Object.keys(packageCfg.externals)); - - // eslint-disable-next-line no-param-reassign - script.files = [script.main, ...script.requires].map(f => path.resolve(this._target, f)); - bar.tick(1, { - action: `analyzing ${path.basename(script.main)}`, + if (scripts.length > 0) { + // generate additional infos + scripts.forEach((script) => { + /* eslint-disable no-param-reassign */ + script.name = path.basename(script.main, '.js'); + script.bundleName = `${script.name}.bundle.js`; + script.bundlePath = path.resolve(this._target, script.bundleName); + script.dirname = script.isStatic ? '' : path.dirname(script.main); + script.archiveName = `${script.name}.zip`; + script.zipFile = path.resolve(this._target, script.dirname, script.archiveName); + script.infoFile = path.resolve(this._target, script.dirname, `${script.name}.info.json`); + /* eslint-enable no-param-reassign */ }); - // eslint-disable-next-line no-param-reassign - script.externals = await collector.collectModules(script.files); - steps += Object.keys(script.externals).length + script.files.length; - bar.tick(1, { - action: `analyzing ${path.basename(script.main)}`, + + // we reserve 80% for bundling the scripts and 20% for creating the zip files. + const bar = new ProgressBar('[:bar] :action :elapseds', { + total: scripts.length * 2 * 5, + width: 50, + renderThrottle: 1, + stream: process.stdout, }); - })); - // trigger new progress bar - bar.total += steps; + // create bundles + await this.createBundles(scripts, bar); - // package actions - await Promise.all(scripts.map(script => this.createPackage(script, bar))); + // package actions + await Promise.all(scripts.map(script => this.createPackage(script, bar))); - // write back the updated infos - await Promise.all(scripts.map(script => fs.writeJson(script.infoFile, script, { spaces: 2 }))); + // write back the updated infos + // eslint-disable-next-line max-len + await Promise.all(scripts.map(script => fs.writeJson(script.infoFile, script, { spaces: 2 }))); + } this.log.info('✅ packaging completed'); return this; diff --git a/src/parcel/ActionBundler.js b/src/parcel/ActionBundler.js new file mode 100644 index 000000000..27a7bf642 --- /dev/null +++ b/src/parcel/ActionBundler.js @@ -0,0 +1,144 @@ +/* + * Copyright 2019 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +const webpack = require('webpack'); + +/** + * Helper class that packs the script into 1 bundle using webpack. + */ +class ActionBundler { + constructor() { + this._cwd = process.cwd(); + this._modulesPaths = ['node_modules']; + this._minify = true; + this._logger = console; + this._progressHandler = null; + } + + withDirectory(d) { + this._cwd = d; + return this; + } + + withLogger(value) { + this._logger = value; + return this; + } + + withModulePaths(value) { + this._modulesPaths = value; + return this; + } + + withMinify(value) { + this._minify = value; + return this; + } + + withProgressHandler(value) { + this._progressHandler = value; + return this; + } + + async run(files) { + const options = { + target: 'node', + mode: 'none', + entry: files, + cache: true, + output: { + path: this._cwd, + filename: '[name].bundle.js', + library: 'main', + libraryTarget: 'umd', + }, + externals: [], + resolve: { + modules: this._modulesPaths, + }, + devtool: false, + optimization: { + minimize: this._minify, + }, + plugins: [], + }; + + if (this._progressHandler) { + options.plugins.push(new webpack.ProgressPlugin(this._progressHandler)); + } + + const compiler = webpack(options); + + const ignoredWarnings = [{ + message: /Critical dependency: the request of a dependency is an expression/, + resource: '/@babel/core/lib/config/files/configuration.js', + }, { + message: /Critical dependency: the request of a dependency is an expression/, + resource: '/@babel/core/lib/config/files/plugins.js', + }, { + message: /Critical dependency: the request of a dependency is an expression/, + resource: '/@babel/core/lib/config/files/plugins.js', + }, { + message: /Critical dependency: require function is used in a way in which dependencies cannot be statically extracted/, + resource: '/@adobe/htlengine/src/compiler/Compiler.js', + }, { + message: /Critical dependency: the request of a dependency is an expression/, + resource: '/@adobe/htlengine/src/runtime/Runtime.js', + }, { + message: /^Module not found: Error: Can't resolve 'bufferutil'/, + resource: '/ws/lib/buffer-util.js', + }, { + message: /^Module not found: Error: Can't resolve 'utf-8-validate'/, + resource: '/ws/lib/validation.js', + }]; + + const ignoredErrors = [{ + message: /^Module not found: Error: Can't resolve 'canvas'/, + resource: '/jsdom/lib/jsdom/utils.js', + }]; + + const matchWarning = rules => (w) => { + const msg = w.message; + const res = w.module.resource; + return !rules.find((r) => { + if (!res.endsWith(r.resource)) { + return false; + } + return r.message.test(msg); + }); + }; + + return new Promise((resolve, reject) => { + compiler.run((err, stats) => { + if (err) { + reject(err); + return; + } + + // filter out the expected warnings and errors + // eslint-disable-next-line no-param-reassign,max-len + stats.compilation.warnings = stats.compilation.warnings.filter(matchWarning(ignoredWarnings)); + // eslint-disable-next-line no-param-reassign + stats.compilation.errors = stats.compilation.errors.filter(matchWarning(ignoredErrors)); + + if (stats.hasErrors() || stats.hasWarnings()) { + resolve(stats.toJson({ + errorDetails: false, + })); + return; + } + resolve({}); + }); + }); + } +} + +module.exports = ActionBundler; diff --git a/src/parcel/ExternalsCollector.js b/src/parcel/ExternalsCollector.js deleted file mode 100644 index 29d51f77f..000000000 --- a/src/parcel/ExternalsCollector.js +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2018 Adobe. All rights reserved. - * This file is licensed 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 REPRESENTATIONS - * 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 fs = require('fs-extra'); -const webpack = require('webpack'); - -const nodeModulesRegex = new RegExp('(.*/node_modules/)((@[^/]+/)?([^/]+)).*'); - -/** - * Helper class that collects external modules from a script. Ideally, we could collect the external - * dependencies directly in parcel, but this is due to its architecture not possible. - */ -class ExternalsCollector { - constructor() { - this._cwd = process.cwd(); - this._outputFile = ''; - this._excludes = new Set(); - } - - withDirectory(d) { - this._cwd = d; - return this; - } - - withExternals(ext) { - this._excludes = new Set(ext); - return this; - } - - withOutputFile(output) { - this._outputFile = output; - return this; - } - - async collectModules(files) { - const externals = {}; - const filename = path.resolve(this._cwd, `${files[0]}.collector.tmp`); - const compiler = webpack({ - target: 'node', - mode: 'development', - entry: files, - output: { - path: this._cwd, - filename: path.relative(this._cwd, filename), - library: 'main', - libraryTarget: 'umd', - }, - resolve: { - modules: [path.resolve(__dirname, '..', '..', 'node_modules'), 'node_modules'], - }, - devtool: false, - }); - - // todo async - const pkgInfos = {}; - function resolveByPackageJson(resource) { - if (!resource || resource === '/' || path.dirname(resource) === resource) { - return []; - } - let info = pkgInfos[resource]; - if (!info) { - try { - const pkgJson = JSON.parse(fs.readFileSync(path.resolve(resource, 'package.json'), 'utf-8')); - info = [pkgJson.name, resource]; - } catch (e) { - info = resolveByPackageJson(path.dirname(resource)); - } - pkgInfos[resource] = info; - } - return info; - } - - const ext = await new Promise((resolve, reject) => { - compiler.run((err, stats) => { - if (err) { - reject(err); - return; - } - stats.compilation.modules.forEach((mod) => { - if (mod.resource) { - const m = nodeModulesRegex.exec(mod.resource); - let modName; - let modPath; - if (!m) { - // check if this is a linked package that is not inside a 'node_modules' directory - // for this, we try to find a package.json - [modName, modPath] = resolveByPackageJson(path.dirname(mod.resource)); - } else { - // eslint-disable-next-line prefer-destructuring - modName = m[2]; - modPath = m[1] + modName; - } - - if (modName && !this._excludes.has(modName)) { - // for duplicate mods, take the "more toplevel" one - if (!externals[modName] || modPath.length < externals[modName].length) { - externals[modName] = modPath; - } - } - } - }); - resolve(externals); - }); - }); - await fs.remove(filename); - - if (this._outputFile) { - await fs.writeFile(this._outputFile, JSON.stringify({ requires: ext }, null, ' '), 'utf-8'); - } - return ext; - } -} - -module.exports = ExternalsCollector; diff --git a/src/parcel/packager-config.js b/test/integration/src/broken_html.pre.js similarity index 50% rename from src/parcel/packager-config.js rename to test/integration/src/broken_html.pre.js index e1ea5d25e..5f1d6229e 100644 --- a/src/parcel/packager-config.js +++ b/test/integration/src/broken_html.pre.js @@ -1,5 +1,5 @@ /* - * Copyright 2018 Adobe. All rights reserved. + * Copyright 2019 Adobe. All rights reserved. * This file is licensed 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 @@ -9,22 +9,10 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +const foo = require('does-not-exist'); -module.exports = { - // modules that are provided by the runtime container - externals: { - express: '4.16.4', - openwhisk: '3.18.0', - 'body-parser': '1.18.3', - 'cls-hooked': '4.2.2', - request: '2.88.0', - 'request-promise': '4.2.2', - - // webpack isn't really provided by the container, but it injects itself into the list of - // deps, so we exclude it here. - webpack: true, - - // helix-cli is never useful as dependency, but it gets drawn in by static.js - '@adobe/helix-cli': true, - }, +module.exports.pre = (context, action) => { + const dynamic = 'does-also-not-exist'; + const foo2 = require(dynamic); + context.content.body = foo2() + foo(); }; diff --git a/test/testDeployCmd.js b/test/testDeployCmd.js index 2270799f4..a9a7efca8 100644 --- a/test/testDeployCmd.js +++ b/test/testDeployCmd.js @@ -368,6 +368,7 @@ describe('hlx deploy (Integration)', () => { .withEnableDirty(false) .withDryRun(true) .withTarget(buildDir) + .withMinify(false) .run(); const ref = await GitUtils.getCurrentRevision(testRoot); @@ -378,7 +379,7 @@ describe('hlx deploy (Integration)', () => { assert.ok(log.indexOf('deployment of 2 actions completed') >= 0); assert.ok(log.indexOf(`- hlx/${ref}/html`) >= 0); assert.ok(log.indexOf('- hlx/hlx--static') >= 0); - }).timeout(30000); + }).timeout(60000); it('Dry-Running works with cgi-bin', async () => { await fs.copy(CGI_BIN_TEST_DIR, testRoot); @@ -407,6 +408,7 @@ describe('hlx deploy (Integration)', () => { .withEnableAuto(false) .withEnableDirty(false) .withDryRun(true) + .withMinify(false) .withTarget(buildDir) .run(); @@ -419,7 +421,7 @@ describe('hlx deploy (Integration)', () => { assert.ok(log.indexOf(`- hlx/${ref}/html`) >= 0); assert.ok(log.indexOf(`- hlx/${ref}/cgi-bin-hello`) >= 0); assert.ok(log.indexOf('- hlx/hlx--static') >= 0); - }).timeout(30000); + }).timeout(60000); it('Deploy works', async function test() { this.timeout(60000); @@ -475,6 +477,7 @@ describe('hlx deploy (Integration)', () => { .withEnableDirty(false) .withDryRun(false) .withTarget(buildDir) + .withMinify(false) .run(); assert.equal(cmd.config.strains.get('default').package, ''); @@ -494,7 +497,7 @@ describe('hlx deploy (Integration)', () => { }); it('Failed action deploy throws', async function test() { - this.timeout(30000); + this.timeout(60000); await fs.copy(TEST_DIR, testRoot); await fs.rename(path.resolve(testRoot, 'default-config.yaml'), path.resolve(testRoot, 'helix-config.yaml')); @@ -513,8 +516,13 @@ describe('hlx deploy (Integration)', () => { this.polly.server.put('https://adobeioruntime.net/api/v1/namespaces/hlx/actions/hlx--static').intercept((req, res) => { res.sendStatus(500); }); + this.polly.server.put('https://adobeioruntime.net/api/v1/namespaces/hlx/packages/helix-services?overwrite=true').intercept((req, res) => { + res.sendStatus(201); + }); + this.polly.server.get('https://adobeioruntime.net/api/v1/web/helix/helix-services/static@latest').intercept((req, res) => { + res.sendStatus(500); + }); - let error = null; try { await new DeployCommand(logger) .withDirectory(testRoot) @@ -524,15 +532,13 @@ describe('hlx deploy (Integration)', () => { .withEnableAuto(false) .withEnableDirty(false) .withDryRun(false) + .withMinify(false) .withTarget(buildDir) .run(); + assert.fail('Expected deploy to fail.'); } catch (e) { // expected - error = e; - } - - if (!error) { - assert.fail('Expected deploy to fail.'); + assert.equal(String(e), 'Error'); } }); }); diff --git a/test/testPackageCmd.js b/test/testPackageCmd.js index 603cd6489..1cc5d911a 100644 --- a/test/testPackageCmd.js +++ b/test/testPackageCmd.js @@ -16,6 +16,7 @@ const assert = require('assert'); const fs = require('fs-extra'); const path = require('path'); const { createTestRoot, assertZipEntries } = require('./utils.js'); +const { Logger } = require('@adobe/helix-shared'); const BuildCommand = require('../src/build.cmd.js'); const PackageCommand = require('../src/package.cmd.js'); @@ -69,12 +70,57 @@ describe('hlx package (Integration)', () => { await assertZipEntries( path.resolve(buildDir, 'html.zip'), - ['package.json', 'html.js', 'html.pre.js', 'helper.js', 'third_helper.js', 'node_modules/@adobe/htlengine/package.json'], + ['package.json', 'html.js'], ); await assertZipEntries( path.resolve(buildDir, 'static.zip'), ['package.json', 'static.js'], ); + + // execute html script + { + const bundle = path.resolve(buildDir, 'html.bundle.js'); + // eslint-disable-next-line global-require,import/no-dynamic-require + const { main } = require(bundle); + const ret = await main({ + path: '/README.md', + content: { + body: '# foo', + }, + }); + delete ret.headers['Server-Timing']; + assert.deepEqual(ret, { + body: "\n\t\n\t\tExample\n\t\t\n\t\n\t\n\t\tNothing happens here, yet.\n\n\t\t

Here are a few things I know:

\n\t\t
\n\t\t\t
Requested Content
\n\t\t\t
/README.md
\n\n\t\t\t
Title
\n\t\t\t
foo
\n\t\t
\n\t\t\n\t\t
\n\t\t

foo

\n\t\t
\n\t\n", + headers: { + 'Cache-Control': 's-maxage=604800', + 'Content-Type': 'text/html', + Link: '; rel="related"', + }, + statusCode: 200, + }); + delete require.cache[require.resolve(bundle)]; + } + + // execute static script + { + const bundle = path.resolve(buildDir, 'static.bundle.js'); + // eslint-disable-next-line global-require,import/no-dynamic-require + const { main } = require(bundle); + const ret = await main({ + path: '/README.md', + repo: 'helix-cli', + owner: 'adobe', + }); + assert.deepEqual(ret, { + body: 'forbidden.', + headers: { + 'Cache-Control': 'max-age=300', + 'Content-Type': 'text/plain', + }, + statusCode: 403, + }); + delete require.cache[require.resolve(bundle)]; + } }).timeout(60000); it('package creates correct package (but excludes static)', async () => { @@ -96,6 +142,7 @@ describe('hlx package (Integration)', () => { .withTarget(buildDir) .withOnlyModified(false) .withStatic('bind') + .withMinify(false) .on('create-package', (info) => { created[info.name] = true; }) @@ -111,7 +158,7 @@ describe('hlx package (Integration)', () => { await assertZipEntries( path.resolve(buildDir, 'html.zip'), - ['package.json', 'html.js', 'html.pre.js', 'helper.js', 'third_helper.js'], + ['package.json', 'html.js'], ); assert.ok(!fs.existsSync(path.resolve(buildDir, 'static.zip')), 'static.zip should not get created'); }).timeout(60000); @@ -129,11 +176,12 @@ describe('hlx package (Integration)', () => { .withDirectory(testRoot) .withTarget(buildDir) .withOnlyModified(true) + .withMinify(false) .run(); await assertZipEntries( path.resolve(buildDir, 'xml.zip'), - ['package.json', 'xml.js', 'helper.js'], + ['package.json', 'xml.js'], ); const created = {}; @@ -142,6 +190,7 @@ describe('hlx package (Integration)', () => { .withDirectory(testRoot) .withTarget(buildDir) .withOnlyModified(true) + .withMinify(false) .on('create-package', (info) => { created[info.name] = true; }) @@ -156,4 +205,26 @@ describe('hlx package (Integration)', () => { static: true, }, 'ignored packages'); }).timeout(60000); + + it('package reports bundling errors and warnings', async () => { + const logger = Logger.getTestLogger(); + await new BuildCommand() + .withFiles([ + 'test/integration/src/broken_html.pre.js', + ]) + .withTargetDir(buildDir) + .withCacheEnabled(false) + .run(); + + await new PackageCommand(logger) + .withDirectory(testRoot) + .withTarget(buildDir) + .withOnlyModified(true) + .withMinify(false) + .run(); + + const log = await logger.getOutput(); + assert.ok(/Module not found: Error: Can't resolve 'does-not-exist'/.test(log)); + assert.ok(/Critical dependency: the request of a dependency is an expression/.test(log)); + }).timeout(60000); });