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

Issue:
[Feature] Add order option
Hello,

In a project I am developing, I have an issue with copying files with the same name from multiple sources.

Configuration of copy webpack plugin is as follows:
```
new CopyWebpackPlugin([
            { from: 'node_modules/engine/static', test: /\.(css|html|png)$/, force: true },
            { from: 'node_modules/subengine/static', test: /\.(css|html|png)$/, force: true },
            { from: './static', test: /\.(css|html|png)$/, force: true },
        ]),
```

The project has two dependencies, let's call them `engine` and `subengine`. They are both bundled within node_modules.

Each of these has its own graphical assets. In case the file with an exact same name and path is in more than one repo, then one from "project" is more important than one from `subengine`, and one from `subengine` is more important than file from `engine`. 

Let's say each of these contains an `abc.png` file in its own `static` folder. The intended result is to have `abc.png` come from projects's own `./static` folder, but it instead, the one from `subengine` is used.

HOWEVER.... which is where the fun begins...

If I do this:
```
new CopyWebpackPlugin([
            { from: 'node_modules/engine/static', test: /\.(css|html|png)$/, force: true },
            { from: 'node_modules/subengine/static', test: /\.(css|html|png)$/, force: true },
            { from: './static', test: /\.(css|html|png)$/, force: true },
        ], {debug: 'debug'}),
```

...then the order is correct, and `abc.png` is taken from project's `static` folder.

This might be random behavior and just a coincidence, but it really seems having debug on fixes the issue every time. 

I am using:
`"webpack": "~4.17.2",` (I would like not to update further for now because then the build breaks elsewhere)
`"copy-webpack-plugin": "^4.6.0",`

Build on Windows 10, Node 8.4.0

Pull request:
Feat: added `priority` 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**
- [ ] **code refactor**
- [x] **test update** <!-- if bug or feature is checked, this should be too -->
- [ ] **typo fix**
- [x] **metadata update**

### Motivation / Use-Case

Fixed #318 

### Breaking Changes

No

### Additional Info

No


Patch:
diff --git a/README.md b/README.md
index af0b3ea..b20837a 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,7 @@ module.exports = {
 |           [`filter`](#filter)           |     `{Function}`     |                   `undefined`                   | Allows to filter copied assets.                                                                                                                        |
 |           [`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).                                                             |
+|         [`priority`](#priority)         |      `{Number}`      |                       `0`                       | Allows you to specify the copy priority.                                                                                                               |
 |        [`transform`](#transform)        |      `{Object}`      |                   `undefined`                   | Allows to modify the file contents. Enable `transform` caching. You can use `{ transform: {cache: { key: 'my-cache-key' }} }` to invalidate the cache. |
 | [`noErrorOnMissing`](#noerroronmissing) |     `{Boolean}`      |                     `false`                     | Doesn't generate an error on missing file(s).                                                                                                          |
 |             [`info`](#info)             | `{Object\|Function}` |                   `undefined`                   | Allows to add assets info.                                                                                                                             |
@@ -462,6 +463,41 @@ module.exports = {
 };
 ```
 
+#### `priority`
+
+Type: `Number`
+Default: `0`
+
+Allows to specify the priority of copying files with the same destination name.
+Files for patterns with higher priority will be copied later.
+To overwrite files, the [`force`](#force) option must be enabled.
+
+**webpack.config.js**
+
+```js
+module.exports = {
+  plugins: [
+    new CopyPlugin({
+      patterns: [
+        // Сopied second and will overwrite "dir_2/file.txt"
+        {
+          from: "dir_1/file.txt",
+          to: "newfile.txt",
+          force: true,
+          priority: 10,
+        },
+        // Сopied first
+        {
+          from: "dir_2/file.txt",
+          to: "newfile.txt",
+          priority: 5,
+        },
+      ],
+    }),
+  ],
+};
+```
+
 #### `transform`
 
 Type: `Function|Object`
diff --git a/src/index.js b/src/index.js
index 7d7d462..ad77115 100644
--- a/src/index.js
+++ b/src/index.js
@@ -70,6 +70,7 @@ class CopyPlugin {
   }
 
   static async runPattern(
+    assetMap,
     compiler,
     compilation,
     logger,
@@ -313,7 +314,13 @@ class CopyPlugin {
           path.relative(compiler.context, absoluteFilename)
         );
 
-        return { absoluteFilename, sourceFilename, filename, toType };
+        return {
+          absoluteFilename,
+          sourceFilename,
+          filename,
+          toType,
+          priority: pattern.priority || 0,
+        };
       })
     );
 
@@ -322,7 +329,13 @@ class CopyPlugin {
     try {
       assets = await Promise.all(
         files.map(async (file) => {
-          const { absoluteFilename, sourceFilename, filename, toType } = file;
+          const {
+            absoluteFilename,
+            sourceFilename,
+            filename,
+            toType,
+            priority,
+          } = file;
           const info =
             typeof pattern.info === "function"
               ? pattern.info(file) || {}
@@ -578,6 +591,12 @@ class CopyPlugin {
             result.filename = normalizePath(result.filename);
           }
 
+          if (!assetMap.has(priority)) {
+            assetMap.set(priority, []);
+          }
+
+          assetMap.get(priority).push(result);
+
           // eslint-disable-next-line consistent-return
           return result;
         })
@@ -612,13 +631,14 @@ class CopyPlugin {
         async (unusedAssets, callback) => {
           logger.log("starting to add additional assets...");
 
-          let assets;
+          const assetMap = new Map();
 
           try {
-            assets = await Promise.all(
+            await Promise.all(
               this.patterns.map((item, index) =>
                 limit(async () =>
                   CopyPlugin.runPattern(
+                    assetMap,
                     compiler,
                     compilation,
                     logger,
@@ -637,10 +657,12 @@ class CopyPlugin {
             return;
           }
 
+          const assets = [...assetMap.entries()].sort((a, b) => a[0] - b[0]);
+
           // Avoid writing assets inside `p-limit`, because it creates concurrency.
           // It could potentially lead to an error - 'Multiple assets emit different content to the same filename'
           assets
-            .reduce((acc, val) => acc.concat(val), [])
+            .reduce((acc, val) => acc.concat(val[1]), [])
             .filter(Boolean)
             .forEach((asset) => {
               const {
diff --git a/src/options.json b/src/options.json
index 68f7272..725ff76 100644
--- a/src/options.json
+++ b/src/options.json
@@ -33,6 +33,9 @@
         "force": {
           "type": "boolean"
         },
+        "priority": {
+          "type": "number"
+        },
         "info": {
           "anyOf": [
             {


Test patch:
diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap
index a08f0ef..fd384cb 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?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "[""]" value 1`] = `
@@ -40,7 +40,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":"dir","info":"string"}]" 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?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }
+   non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
    Details:
     * options.patterns[0].info should be one of these:
       object { … } | function
@@ -53,7 +53,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":"dir","info":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] should be one of these:
-   non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }
+   non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
    Details:
     * options.patterns[0].info should be one of these:
       object { … } | function
@@ -88,7 +88,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","transform":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] should be one of these:
-   non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }
+   non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
    Details:
     * options.patterns[0].transform should be one of these:
       function | object { transformer?, cache? }
@@ -103,10 +103,25 @@ exports[`validate options should throw an error on the "patterns" option with "[
  - options.patterns[0].context should be a string."
 `;
 
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","priority":"5"}]" value 1`] = `
+"Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
+ - options.patterns[0].priority should be a number."
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","priority":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].priority should be a number."
+`;
+
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir"}]" value 1`] = `
+"Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
+ - options.patterns[0].priority should be a number."
+`;
+
 exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":true,"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?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }
+   non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
    Details:
     * options.patterns[0].to should be one of these:
       string | function
@@ -134,71 +149,71 @@ 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?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, 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?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, 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?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "patterns" option with "undefined" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "/test/" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "[]" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "{"foo":"bar"}" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "{}" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "1" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "false" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" option with "test" value 1`] = `
 "Invalid options object. Copy Plugin has been initialized using an options object that does not match the API schema.
  - options misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
 
 exports[`validate options should throw an error on the "unknown" 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 misses the property 'patterns'. Should be:
-   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
diff --git a/test/priority-option.test.js b/test/priority-option.test.js
new file mode 100644
index 0000000..cd95427
--- /dev/null
+++ b/test/priority-option.test.js
@@ -0,0 +1,57 @@
+import { run } from "./helpers/run";
+
+describe("priority option", () => {
+  it("should copy without specifying priority option", (done) => {
+    run({
+      expectedAssetKeys: [],
+      patterns: [
+        {
+          from: "dir (86)/file.txt",
+          to: "newfile.txt",
+          force: true,
+        },
+        {
+          from: "file.txt",
+          to: "newfile.txt",
+          force: true,
+        },
+      ],
+    })
+      .then(({ stats }) => {
+        const { info } = stats.compilation.getAsset("newfile.txt");
+
+        expect(info.sourceFilename).toEqual("file.txt");
+
+        done();
+      })
+      .catch(done);
+  });
+
+  it("should copy with specifying priority option", (done) => {
+    run({
+      expectedAssetKeys: [],
+      patterns: [
+        {
+          from: "dir (86)/file.txt",
+          to: "newfile.txt",
+          force: true,
+          priority: 10,
+        },
+        {
+          from: "file.txt",
+          to: "newfile.txt",
+          force: true,
+          priority: 5,
+        },
+      ],
+    })
+      .then(({ stats }) => {
+        const { info } = stats.compilation.getAsset("newfile.txt");
+
+        expect(info.sourceFilename).toEqual("dir (86)/file.txt");
+
+        done();
+      })
+      .catch(done);
+  });
+});
diff --git a/test/validate-options.test.js b/test/validate-options.test.js
index 7b85e15..26ffbdc 100644
--- a/test/validate-options.test.js
+++ b/test/validate-options.test.js
@@ -133,6 +133,13 @@ describe("validate options", () => {
             },
           },
         ],
+        [
+          {
+            from: "test.txt",
+            to: "dir",
+            priority: 5,
+          },
+        ],
       ],
       failure: [
         // eslint-disable-next-line no-undefined
@@ -242,6 +249,27 @@ describe("validate options", () => {
             filter: "test",
           },
         ],
+        [
+          {
+            from: "test.txt",
+            to: "dir",
+            priority: "5",
+          },
+        ],
+        [
+          {
+            from: "test.txt",
+            to: "dir",
+            priority: () => {},
+          },
+        ],
+        [
+          {
+            from: "test.txt",
+            to: "dir",
+            priority: true,
+          },
+        ],
       ],
     },
     options: {