Repository: webpack-contrib/copy-webpack-plugin (js)

Issue:
Add an option to fail the build if a file is missing
<!--
  Issues are so 🔥

  If you remove or skip this template, you'll make the 🐼 sad and the mighty god
  of Github will appear and pile-drive the close button from a great height
  while making animal noises.

  👉🏽 Need support, advice, or help? Don't open an issue!
  Head to StackOverflow or https://gitter.im/webpack/webpack.
-->

- Operating System: macOS 10.15.1
- Node Version: 10.16.3
- NPM Version: 6.13.1
- webpack Version: 3.3.9
- copy-webpack-plugin Version: 5.1.1

### Feature Proposal

When specifying a concrete file in `from` param and the file is not found, only a warning is generated.
Add a parameter such as `mandatory: true` which is false by default. When set to true and the file is not found - make the build fail.

### Feature Use Case
In `webpack.config.js` we have the following config:

```
  plugins: [
    new CleanWebpackPlugin(),
    new CopyPlugin([
      { from: "../services/templates", to: "templates" },
      {
        from: "../services/salesforce_auth.dev.json",
        flatten: true,
        to: output_dir
      },
      {
        from: "../services/salesforce_auth.prod.json",
        flatten: true,
        to: output_dir
      }
    ]),
    new webpack.NoEmitOnErrorsPlugin()
  ]
```
Both files are in `.gitignore`for obvious reasons. A new developer who didn't have the files, used webpack-cli to bundle the package, and didn't notice the warning message in the console. As a result, one of our services failed to communicate with salesforce and caused data loss. 
https://github.com/webpack-contrib/copy-webpack-plugin/issues/31 suggested to use `NoErrorsPlugin`, but it has no effect on the build.

Pull request description:
feat: implement the `noErrorOnMissing` option
<!--
  HOLY CRAP a Pull Request. We ❤️ those!

  If you remove or skip this template, you'll make the 🐼 sad and the mighty god
  of Github will appear and pile-drive the close button from a great height
  while making animal noises.

  Please place an x (no spaces!) in all [ ] that apply
-->

This PR contains a:

- [ ] **bugfix**
- [x] new **feature**
- [x] **code refactor**
- [x] **test update** <!-- if bug or feature is checked, this should be too -->
- [ ] **typo fix**
- [ ] **metadata update**

### Motivation / Use-Case

implement new options `noErrorOnMissing` + improve schema

### Breaking Changes

Yes, for schema

### Additional Info

fixes #431 fixes #433

Patch:
diff --git a/README.md b/README.md
index 80b64ef..0b3a8bf 100644
--- a/README.md
+++ b/README.md
@@ -74,18 +74,19 @@ module.exports = {
 
 ### Patterns
 
-|                Name                 |        Type         |                     Default                     | Description                                                                                           |
-| :---------------------------------: | :-----------------: | :---------------------------------------------: | :---------------------------------------------------------------------------------------------------- |
-|           [`from`](#from)           |     `{String}`      |                   `undefined`                   | Glob or path from where we сopy files.                                                                |
-|             [`to`](#to)             |     `{String}`      |            `compiler.options.output`            | Output path.                                                                                          |
-|        [`context`](#context)        |     `{String}`      | `options.context \|\| compiler.options.context` | A path that determines how to interpret the `from` path.                                              |
-|    [`globOptions`](#globoptions)    |     `{Object}`      |                   `undefined`                   | [Options][glob-options] passed to the glob pattern matching library including `ignore` option.        |
-|         [`toType`](#totype)         |     `{String}`      |                   `undefined`                   | Determinate what is `to` option - directory, file or template.                                        |
-|          [`force`](#force)          |     `{Boolean}`     |                     `false`                     | Overwrites files already in `compilation.assets` (usually added by other plugins/loaders).            |
-|        [`flatten`](#flatten)        |     `{Boolean}`     |                     `false`                     | Removes all directory references and only copies file names.                                          |
-|      [`transform`](#transform)      |    `{Function}`     |                   `undefined`                   | Allows to modify the file contents.                                                                   |
-| [`cacheTransform`](#cacheTransform) | `{Boolean\|Object}` |                     `false`                     | Enable `transform` caching. You can use `{ cache: { key: 'my-cache-key' } }` to invalidate the cache. |
-|  [`transformPath`](#transformpath)  |    `{Function}`     |                   `undefined`                   | Allows to modify the writing path.                                                                    |
+|                  Name                   |        Type         |                     Default                     | Description                                                                                           |
+| :-------------------------------------: | :-----------------: | :---------------------------------------------: | :---------------------------------------------------------------------------------------------------- |
+|             [`from`](#from)             |     `{String}`      |                   `undefined`                   | Glob or path from where we сopy files.                                                                |
+|               [`to`](#to)               |     `{String}`      |            `compiler.options.output`            | Output path.                                                                                          |
+|          [`context`](#context)          |     `{String}`      | `options.context \|\| compiler.options.context` | A path that determines how to interpret the `from` path.                                              |
+|      [`globOptions`](#globoptions)      |     `{Object}`      |                   `undefined`                   | [Options][glob-options] passed to the glob pattern matching library including `ignore` option.        |
+|           [`toType`](#totype)           |     `{String}`      |                   `undefined`                   | Determinate what is `to` option - directory, file or template.                                        |
+|            [`force`](#force)            |     `{Boolean}`     |                     `false`                     | Overwrites files already in `compilation.assets` (usually added by other plugins/loaders).            |
+|          [`flatten`](#flatten)          |     `{Boolean}`     |                     `false`                     | Removes all directory references and only copies file names.                                          |
+|        [`transform`](#transform)        |    `{Function}`     |                   `undefined`                   | Allows to modify the file contents.                                                                   |
+|   [`cacheTransform`](#cacheTransform)   | `{Boolean\|Object}` |                     `false`                     | Enable `transform` caching. You can use `{ cache: { key: 'my-cache-key' } }` to invalidate the cache. |
+|    [`transformPath`](#transformpath)    |    `{Function}`     |                   `undefined`                   | Allows to modify the writing path.                                                                    |
+| [`noErrorOnMissing`](#noerroronmissing) |     `{Boolean}`     |                     `false`                     | Doesn't generate an error on missing file(s).                                                         |
 
 #### `from`
 
@@ -533,6 +534,28 @@ module.exports = {
 };
 ```
 
+### `noErrorOnMissing`
+
+Type: `Boolean`
+Default: `false`
+
+Doesn't generate an error on missing file(s);
+
+```js
+module.exports = {
+  plugins: [
+    new CopyPlugin({
+      patterns: [
+        {
+          from: path.resolve(__dirname, 'missing-file.txt'),
+          noErrorOnMissing: true,
+        },
+      ],
+    }),
+  ],
+};
+```
+
 ### Options
 
 |             Name              |    Type    | Default | Description                                      |
diff --git a/src/options.json b/src/options.json
index c16c726..aa94941 100644
--- a/src/options.json
+++ b/src/options.json
@@ -2,6 +2,7 @@
   "definitions": {
     "ObjectPattern": {
       "type": "object",
+      "additionalProperties": false,
       "properties": {
         "from": {
           "type": "string",
@@ -13,6 +14,9 @@
         "context": {
           "type": "string"
         },
+        "globOptions": {
+          "type": "object"
+        },
         "toType": {
           "enum": ["dir", "file", "template"]
         },
@@ -37,6 +41,9 @@
         },
         "transformPath": {
           "instanceof": "Function"
+        },
+        "noErrorOnMissing": {
+          "type": "boolean"
         }
       },
       "required": ["from"]
diff --git a/src/preProcessPattern.js b/src/preProcessPattern.js
index a4318f6..126b172 100644
--- a/src/preProcessPattern.js
+++ b/src/preProcessPattern.js
@@ -20,6 +20,8 @@ export default async function preProcessPattern(globalRef, pattern) {
     pattern.context = path.join(context, pattern.context);
   }
 
+  pattern.noErrorOnMissing = pattern.noErrorOnMissing || false;
+
   // Todo remove this in next major
   const isToDirectory =
     path.extname(pattern.to) === '' || pattern.to.slice(-1) === path.sep;
diff --git a/src/processPattern.js b/src/processPattern.js
index 9a6de04..3f4faa5 100644
--- a/src/processPattern.js
+++ b/src/processPattern.js
@@ -8,6 +8,7 @@ import createPatternGlob from './utils/createPatternGlob';
 
 export default async function processPattern(globalRef, pattern) {
   const { logger, output, compilation } = globalRef;
+
   createPatternGlob(pattern, globalRef);
 
   logger.log(
@@ -17,6 +18,10 @@ export default async function processPattern(globalRef, pattern) {
   const paths = await globby(pattern.glob, pattern.globOptions);
 
   if (paths.length === 0) {
+    if (pattern.noErrorOnMissing) {
+      return Promise.resolve();
+    }
+
     const newWarning = new Error(
       `unable to locate '${pattern.from}' at '${pattern.absoluteFrom}'`
     );
diff --git a/src/utils/createPatternGlob.js b/src/utils/createPatternGlob.js
index b35bb43..ca8a9fd 100644
--- a/src/utils/createPatternGlob.js
+++ b/src/utils/createPatternGlob.js
@@ -3,8 +3,6 @@ import path from 'path';
 import normalizePath from 'normalize-path';
 import globParent from 'glob-parent';
 
-/* eslint-disable no-param-reassign */
-
 function getAbsoluteContext(context) {
   const result = normalizePath(path.resolve(context));
 
@@ -18,6 +16,7 @@ function getAbsoluteContext(context) {
 function createPatternGlob(pattern, globalRef) {
   const { logger, compilation } = globalRef;
 
+  // eslint-disable-next-line no-param-reassign
   pattern.globOptions = Object.assign(
     {
       cwd: pattern.context,
@@ -30,8 +29,10 @@ function createPatternGlob(pattern, globalRef) {
     case 'dir':
       logger.debug(`determined '${pattern.absoluteFrom}' is a directory`);
       logger.debug(`add ${pattern.absoluteFrom} as contextDependencies`);
+
       compilation.contextDependencies.add(pattern.absoluteFrom);
 
+      /* eslint-disable no-param-reassign */
       pattern.context = pattern.absoluteFrom;
       pattern.glob = path.posix.join(
         getAbsoluteContext(pattern.absoluteFrom),
@@ -42,20 +43,23 @@ function createPatternGlob(pattern, globalRef) {
       if (typeof pattern.globOptions.dot === 'undefined') {
         pattern.globOptions.dot = true;
       }
+      /* eslint-enable no-param-reassign */
 
       break;
-
     case 'file':
       logger.debug(`determined '${pattern.absoluteFrom}' is a file`);
       logger.debug(`add ${pattern.absoluteFrom} as fileDependencies`);
+
       compilation.fileDependencies.add(pattern.absoluteFrom);
 
+      /* eslint-disable no-param-reassign */
       pattern.context = path.dirname(pattern.absoluteFrom);
       pattern.glob = getAbsoluteContext(pattern.absoluteFrom);
 
       if (typeof pattern.globOptions.dot === 'undefined') {
         pattern.globOptions.dot = true;
       }
+      /* eslint-enable no-param-reassign */
 
       break;
 
@@ -68,8 +72,10 @@ function createPatternGlob(pattern, globalRef) {
       );
 
       logger.debug(`add ${contextDependencies} as contextDependencies`);
+
       compilation.contextDependencies.add(contextDependencies);
 
+      /* eslint-disable no-param-reassign */
       pattern.fromType = 'glob';
       pattern.glob = path.isAbsolute(pattern.fromOrigin)
         ? pattern.fromOrigin
@@ -77,6 +83,7 @@ function createPatternGlob(pattern, globalRef) {
             getAbsoluteContext(pattern.context),
             pattern.fromOrigin
           );
+    /* eslint-enable no-param-reassign */
   }
 
   return pattern;


Test patch:
diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap
index e4d88ec..ef01331 100644
--- a/test/__snapshots__/validate-options.test.js.snap
+++ b/test/__snapshots__/validate-options.test.js.snap
@@ -14,7 +14,7 @@ exports[`validate options should throw an error on the "options" option with "{"
 exports[`validate options should throw an error on the "patterns" option with "" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns should be an array:
-   [non-empty string | object { from, to?, context?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, … }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "[""]" value 1`] = `
@@ -27,6 +27,11 @@ exports[`validate options should throw an error on the "patterns" option with "[
  - options.patterns should be an non-empty array."
 `;
 
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"","to":"dir","context":"context","noErrorOnMissing":"true"}]" value 1`] = `
+"Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
+ - options.patterns[0].noErrorOnMissing should be a boolean."
+`;
+
 exports[`validate options should throw an error on the "patterns" option with "[{"from":"","to":"dir","context":"context"}]" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns[0].from should be an non-empty string."
@@ -61,7 +66,7 @@ exports[`validate options should throw an error on the "patterns" option with "[
 exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context"}]" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns[0] should be one of these:
-   non-empty string | object { from, to?, context?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, … }
+   non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }
    Details:
     * options.patterns[0].cacheTransform should be one of these:
       boolean | object { … }
@@ -100,19 +105,19 @@ exports[`validate options should throw an error on the "patterns" option with "[
 exports[`validate options should throw an error on the "patterns" option with "{}" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns should be an array:
-   [non-empty string | object { from, to?, context?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, … }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "true" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns should be an array:
-   [non-empty string | object { from, to?, context?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, … }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "true" value 2`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options.patterns should be an array:
-   [non-empty string | object { from, to?, context?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, … }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, toType?, force?, flatten?, transform?, cacheTransform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "/test/" value 1`] = `
diff --git a/test/globOptions-option.test.js b/test/globOptions-option.test.js
index 020163b..0f61196 100644
--- a/test/globOptions-option.test.js
+++ b/test/globOptions-option.test.js
@@ -245,7 +245,6 @@ describe('globOptions ignore option', () => {
       ],
       patterns: [
         {
-          ignore: ['file.*'],
           from: 'file.txt',
           globOptions: {
             ignore: ['**/file.*'],
diff --git a/test/noErrorOnMissing.test.js b/test/noErrorOnMissing.test.js
new file mode 100644
index 0000000..7f325bd
--- /dev/null
+++ b/test/noErrorOnMissing.test.js
@@ -0,0 +1,51 @@
+import { runEmit } from './helpers/run';
+
+describe('noErrorOnMissing option', () => {
+  describe('is a file', () => {
+    it('should work', (done) => {
+      runEmit({
+        expectedAssetKeys: [],
+        patterns: [
+          {
+            from: 'unknown.unknown',
+            noErrorOnMissing: true,
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+  });
+
+  describe('is a directory', () => {
+    it('should work', (done) => {
+      runEmit({
+        expectedAssetKeys: [],
+        patterns: [
+          {
+            from: 'unknown',
+            noErrorOnMissing: true,
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+  });
+
+  describe('is a glob', () => {
+    it('should work', (done) => {
+      runEmit({
+        expectedAssetKeys: [],
+        patterns: [
+          {
+            from: '*.unknown',
+            noErrorOnMissing: true,
+          },
+        ],
+      })
+        .then(done)
+        .catch(done);
+    });
+  });
+});
diff --git a/test/validate-options.test.js b/test/validate-options.test.js
index ab5500e..1f9ddcb 100644
--- a/test/validate-options.test.js
+++ b/test/validate-options.test.js
@@ -58,6 +58,7 @@ describe('validate options', () => {
             transform: () => {},
             cacheTransform: true,
             transformPath: () => {},
+            noErrorOnMissing: true,
           },
         ],
         [
@@ -175,6 +176,14 @@ describe('validate options', () => {
             context: 'context',
           },
         ],
+        [
+          {
+            from: '',
+            to: 'dir',
+            context: 'context',
+            noErrorOnMissing: 'true',
+          },
+        ],
       ],
     },
     options: {