Repository: webpack-contrib/copy-webpack-plugin
Pull request #596 (2021-03-18 18:05:51)

Feat: added transformAll 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**
- [x] **metadata update**

### Motivation / Use-Case

Added `transformAll` option.
`transformAll` allows to modify the contents of multiple files and save the result to one file.

### Breaking Changes

No

### Additional Info

No

codecov[bot]: # [Codecov](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/596?src=pr&el=h1) Report
> Merging [#596](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/596?src=pr&el=desc) (776aa89) into [master](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/commit/4ca7f80407b868bcc8fafec5aa626435e802ae02?el=desc) (4ca7f80) will **increase** coverage by `0.47%`.
> The diff coverage is `97.82%`.

[![Impacted file tree graph](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/596/graphs/tree.svg?width=650&height=150&src=pr&token=fEoU9iBFGL)](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/596?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #596      +/-   ##
==========================================
+ Coverage   93.22%   93.69%   +0.47%     
==========================================
  Files           3        3              
  Lines         295      333      +38     
  Branches       77       90      +13     
==========================================
+ Hits          275      312      +37     
- Misses         20       21       +1     
```


| [Impacted Files](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/596?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/index.js](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/596/diff?src=pr&el=tree#diff-c3JjL2luZGV4Lmpz) | `93.73% <97.82%> (+0.49%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/596?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/596?src=pr&el=footer). Last update [4ca7f80...776aa89](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/596?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.md b/README.md
index 7b4cc6d..44510fa 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,7 @@ module.exports = {
 |            [`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. |
+|     [`transformAll`](#transformAll)     |     `{Function}`     |                   `undefined`                   | Allows you to modify the contents of multiple files and save the result to one file.                                                                   |
 | [`noErrorOnMissing`](#noerroronmissing) |     `{Boolean}`      |                     `false`                     | Doesn't generate an error on missing file(s).                                                                                                          |
 |             [`info`](#info)             | `{Object\|Function}` |                   `undefined`                   | Allows to add assets info.                                                                                                                             |
 
@@ -730,6 +731,45 @@ module.exports = {
 };
 ```
 
+#### `transformAll`
+
+Type: `Function`
+Default: `undefined`
+
+Allows you to modify the contents of multiple files and save the result to one file.
+
+> ℹ️ The `to` option must be specified and point to a file. It is allowed to use only `[contenthash]` and `[fullhash]` template strings.
+
+**webpack.config.js**
+
+```js
+module.exports = {
+  plugins: [
+    new CopyPlugin({
+      patterns: [
+        {
+          from: "src/**/*.txt",
+          to: "dest/file.txt",
+          // The `assets` argument is an assets array for the pattern.from ("src/**/*.txt")
+          transformAll(assets) {
+            const result = assets.reduce((accumulator, asset) => {
+              // The asset content can be obtained from `asset.source` using `source` method.
+              // The asset content is a [`Buffer`](https://nodejs.org/api/buffer.html) object, it could be converted to a `String` to be processed using `content.toString()`
+              const content = asset.data;
+
+              accumulator = `${accumulator}${content}\n`;
+              return accumulator;
+            }, "");
+
+            return result;
+          },
+        },
+      ],
+    }),
+  ],
+};
+```
+
 ### `noErrorOnMissing`
 
 Type: `Boolean`
diff --git a/src/index.js b/src/index.js
index ad6217a..03ec33a 100644
--- a/src/index.js
+++ b/src/index.js
@@ -69,6 +69,27 @@ class CopyPlugin {
     });
   }
 
+  static getContentHash(compiler, compilation, source) {
+    const { outputOptions } = compilation;
+    const {
+      hashDigest,
+      hashDigestLength,
+      hashFunction,
+      hashSalt,
+    } = outputOptions;
+    const hash = compiler.webpack.util.createHash(hashFunction);
+
+    if (hashSalt) {
+      hash.update(hashSalt);
+    }
+
+    hash.update(source);
+
+    const fullContentHash = hash.digest(hashDigest);
+
+    return fullContentHash.slice(0, hashDigestLength);
+  }
+
   static async runPattern(
     compiler,
     compilation,
@@ -456,7 +477,7 @@ class CopyPlugin {
             if (transform.transformer) {
               logger.log(`transforming content for '${absoluteFilename}'...`);
 
-              const buffer = result.source.source();
+              const buffer = result.source.buffer();
 
               if (transform.cache) {
                 const defaultCacheKeys = {
@@ -526,23 +547,11 @@ class CopyPlugin {
               `interpolating template '${filename}' for '${sourceFilename}'...`
             );
 
-            const { outputOptions } = compilation;
-            const {
-              hashDigest,
-              hashDigestLength,
-              hashFunction,
-              hashSalt,
-            } = outputOptions;
-            const hash = compiler.webpack.util.createHash(hashFunction);
-
-            if (hashSalt) {
-              hash.update(hashSalt);
-            }
-
-            hash.update(result.source.source());
-
-            const fullContentHash = hash.digest(hashDigest);
-            const contentHash = fullContentHash.slice(0, hashDigestLength);
+            const contentHash = CopyPlugin.getContentHash(
+              compiler,
+              compilation,
+              result.source.buffer()
+            );
             const ext = path.extname(result.sourceFilename);
             const base = path.basename(result.sourceFilename);
             const name = base.slice(0, base.length - ext.length);
@@ -634,6 +643,109 @@ class CopyPlugin {
                 }
 
                 if (assets && assets.length > 0) {
+                  if (item.transformAll) {
+                    if (typeof item.to === "undefined") {
+                      compilation.errors.push(
+                        new Error(
+                          `Invalid "pattern.to" for the "pattern.from": "${item.from}" and "pattern.transformAll" function. The "to" option must be specified.`
+                        )
+                      );
+
+                      return;
+                    }
+
+                    assets.sort((a, b) =>
+                      a.absoluteFilename > b.absoluteFilename
+                        ? 1
+                        : a.absoluteFilename < b.absoluteFilename
+                        ? -1
+                        : 0
+                    );
+
+                    const mergedEtag =
+                      assets.length === 1
+                        ? cache.getLazyHashedEtag(assets[0].source.buffer())
+                        : assets.reduce((accumulator, asset, i) => {
+                            // eslint-disable-next-line no-param-reassign
+                            accumulator = cache.mergeEtags(
+                              i === 1
+                                ? cache.getLazyHashedEtag(
+                                    accumulator.source.buffer()
+                                  )
+                                : accumulator,
+                              cache.getLazyHashedEtag(asset.source.buffer())
+                            );
+
+                            return accumulator;
+                          });
+
+                    const cacheKeys = `transformAll|${serialize({
+                      version,
+                      from: item.from,
+                      to: item.to,
+                      transformAll: item.transformAll,
+                    })}`;
+                    const eTag = cache.getLazyHashedEtag(mergedEtag);
+                    const cacheItem = cache.getItemCache(cacheKeys, eTag);
+                    let transformedAsset = await cacheItem.getPromise();
+
+                    if (!transformedAsset) {
+                      transformedAsset = { filename: item.to };
+
+                      try {
+                        transformedAsset.data = await item.transformAll(
+                          assets.map((asset) => {
+                            return {
+                              data: asset.source.buffer(),
+                              sourceFilename: asset.sourceFilename,
+                              absoluteFilename: asset.absoluteFilename,
+                            };
+                          })
+                        );
+                      } catch (error) {
+                        compilation.errors.push(error);
+
+                        return;
+                      }
+
+                      if (template.test(item.to)) {
+                        const contentHash = CopyPlugin.getContentHash(
+                          compiler,
+                          compilation,
+                          transformedAsset.data
+                        );
+
+                        const {
+                          path: interpolatedFilename,
+                          info: assetInfo,
+                        } = compilation.getPathWithInfo(
+                          normalizePath(item.to),
+                          {
+                            contentHash,
+                            chunk: {
+                              hash: contentHash,
+                              contentHash,
+                            },
+                          }
+                        );
+
+                        transformedAsset.filename = interpolatedFilename;
+                        transformedAsset.info = assetInfo;
+                      }
+
+                      const { RawSource } = compiler.webpack.sources;
+
+                      transformedAsset.source = new RawSource(
+                        transformedAsset.data
+                      );
+                      transformedAsset.force = item.force;
+
+                      await cacheItem.storePromise(transformedAsset);
+                    }
+
+                    assets = [transformedAsset];
+                  }
+
                   const priority = item.priority || 0;
 
                   if (!assetMap.has(priority)) {
diff --git a/src/options.json b/src/options.json
index 725ff76..e93ee01 100644
--- a/src/options.json
+++ b/src/options.json
@@ -27,6 +27,9 @@
         "filter": {
           "instanceof": "Function"
         },
+        "transformAll": {
+          "instanceof": "Function"
+        },
         "toType": {
           "enum": ["dir", "file", "template"]
         },


Test patch:
diff --git a/test/__snapshots__/transformAll-option.test.js.snap b/test/__snapshots__/transformAll-option.test.js.snap
new file mode 100644
index 0000000..0ead4ab
--- /dev/null
+++ b/test/__snapshots__/transformAll-option.test.js.snap
@@ -0,0 +1,21 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`cache should work with the "memory" cache: assets 1`] = `
+Object {
+  "file.txt": "new::directory/nested/deep-nested/deepnested.txt::directory/nested/nestedfile.txt::",
+}
+`;
+
+exports[`cache should work with the "memory" cache: assets 2`] = `
+Object {
+  "file.txt": "new::directory/nested/deep-nested/deepnested.txt::directory/nested/nestedfile.txt::",
+}
+`;
+
+exports[`cache should work with the "memory" cache: errors 1`] = `Array []`;
+
+exports[`cache should work with the "memory" cache: errors 2`] = `Array []`;
+
+exports[`cache should work with the "memory" cache: warnings 1`] = `Array []`;
+
+exports[`cache should work with the "memory" cache: warnings 2`] = `Array []`;
diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap
index fd384cb..e0e007d 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
+   non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
+   non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
+   non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
    Details:
     * options.patterns[0].transform should be one of these:
       function | object { transformer?, cache? }
@@ -98,6 +98,11 @@ exports[`validate options should throw an error on the "patterns" option with "[
          object { transformer?, cache? }"
 `;
 
+exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":"context","transformAll":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].transformAll should be an instance of function."
+`;
+
 exports[`validate options should throw an error on the "patterns" option with "[{"from":"test.txt","to":"dir","context":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].context should be a string."
@@ -121,7 +126,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":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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
+   non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }
    Details:
     * options.patterns[0].to should be one of these:
       string | function
@@ -149,71 +154,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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, 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?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
+   [non-empty string | object { from, to?, context?, globOptions?, filter?, transformAll?, toType?, force?, priority?, info?, transform?, transformPath?, noErrorOnMissing? }, ...] (should not have fewer than 1 item)"
 `;
diff --git a/test/transformAll-option.test.js b/test/transformAll-option.test.js
new file mode 100644
index 0000000..deca8aa
--- /dev/null
+++ b/test/transformAll-option.test.js
@@ -0,0 +1,221 @@
+import CopyPlugin from "../src";
+
+import { runEmit } from "./helpers/run";
+import { compile, getCompiler, readAssets } from "./helpers";
+
+describe("transformAll option", () => {
+  it('should be defined "assets"', (done) => {
+    runEmit({
+      expectedAssetKeys: ["file.txt"],
+      patterns: [
+        {
+          from: "file.txt",
+          to: "file.txt",
+          transformAll(assets) {
+            expect(assets).toBeDefined();
+
+            return "";
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it("should transform files", (done) => {
+    runEmit({
+      expectedAssetKeys: ["file.txt"],
+      expectedAssetContent: {
+        "file.txt":
+          "new::directory/nested/deep-nested/deepnested.txt::directory/nested/nestedfile.txt::",
+      },
+      patterns: [
+        {
+          from: "directory/**/*.txt",
+          to: "file.txt",
+          transformAll(assets) {
+            const result = assets.reduce((accumulator, asset) => {
+              const content = asset.data.toString() || asset.sourceFilename;
+              // eslint-disable-next-line no-param-reassign
+              accumulator = `${accumulator}${content}::`;
+              return accumulator;
+            }, "");
+
+            return result;
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it("should transform files when async function used", (done) => {
+    runEmit({
+      expectedAssetKeys: ["file.txt"],
+      expectedAssetContent: {
+        "file.txt":
+          "directory/directoryfile.txt::directory/nested/deep-nested/deepnested.txt::directory/nested/nestedfile.txt::",
+      },
+      patterns: [
+        {
+          from: "directory/**/*.txt",
+          to: "file.txt",
+          async transformAll(assets) {
+            const result = assets.reduce((accumulator, asset) => {
+              // eslint-disable-next-line no-param-reassign
+              accumulator = `${accumulator}${asset.sourceFilename}::`;
+              return accumulator;
+            }, "");
+
+            return result;
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it("should transform files with force option enabled", (done) => {
+    runEmit({
+      expectedAssetKeys: ["file.txt"],
+      expectedAssetContent: {
+        "file.txt":
+          "directory/directoryfile.txt::directory/nested/deep-nested/deepnested.txt::directory/nested/nestedfile.txt::",
+      },
+      patterns: [
+        {
+          from: "file.txt",
+        },
+        {
+          from: "directory/**/*.txt",
+          to: "file.txt",
+          transformAll(assets) {
+            const result = assets.reduce((accumulator, asset) => {
+              // eslint-disable-next-line no-param-reassign
+              accumulator = `${accumulator}${asset.sourceFilename}::`;
+              return accumulator;
+            }, "");
+
+            return result;
+          },
+          force: true,
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should warn when "to" option is not defined', (done) => {
+    runEmit({
+      expectedAssetKeys: [],
+      expectedErrors: [
+        new Error(
+          `Invalid "pattern.to" for the "pattern.from": "file.txt" and "pattern.transformAll" function. The "to" option must be specified.`
+        ),
+      ],
+      patterns: [
+        {
+          from: "file.txt",
+          transformAll() {
+            return "";
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it("should warn when function throw error", (done) => {
+    runEmit({
+      expectedAssetKeys: [],
+      expectedErrors: [new Error("a failure happened")],
+      patterns: [
+        {
+          from: "directory/**/*.txt",
+          to: "file.txt",
+          transformAll() {
+            // eslint-disable-next-line no-throw-literal
+            throw new Error("a failure happened");
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it("should interpolate [fullhash] and [contenthash]", (done) => {
+    runEmit({
+      expectedAssetKeys: ["4333a40fa67dfaaaefc9-ac7f6fcb65ddfcc43b2c-file.txt"],
+      expectedAssetContent: {
+        "4333a40fa67dfaaaefc9-ac7f6fcb65ddfcc43b2c-file.txt":
+          "::special::new::::::::::new::::::new::",
+      },
+      patterns: [
+        {
+          from: "**/*.txt",
+          to: "[contenthash]-[fullhash]-file.txt",
+          transformAll(assets) {
+            const result = assets.reduce((accumulator, asset) => {
+              // eslint-disable-next-line no-param-reassign
+              accumulator = `${accumulator}${asset.data}::`;
+              return accumulator;
+            }, "");
+
+            return result;
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+});
+
+describe("cache", () => {
+  it('should work with the "memory" cache', async () => {
+    const compiler = getCompiler({});
+
+    new CopyPlugin({
+      patterns: [
+        {
+          from: "directory/**/*.txt",
+          to: "file.txt",
+          transformAll(assets) {
+            const result = assets.reduce((accumulator, asset) => {
+              const content = asset.data.toString() || asset.sourceFilename;
+              // eslint-disable-next-line no-param-reassign
+              accumulator = `${accumulator}${content}::`;
+              return accumulator;
+            }, "");
+
+            return result;
+          },
+        },
+      ],
+    }).apply(compiler);
+
+    const { stats } = await compile(compiler);
+
+    expect(stats.compilation.emittedAssets.size).toBe(2);
+    expect(readAssets(compiler, stats)).toMatchSnapshot("assets");
+    expect(stats.compilation.errors).toMatchSnapshot("errors");
+    expect(stats.compilation.warnings).toMatchSnapshot("warnings");
+
+    await new Promise(async (resolve) => {
+      const { stats: newStats } = await compile(compiler);
+
+      expect(newStats.compilation.emittedAssets.size).toBe(0);
+      expect(readAssets(compiler, newStats)).toMatchSnapshot("assets");
+      expect(newStats.compilation.errors).toMatchSnapshot("errors");
+      expect(newStats.compilation.warnings).toMatchSnapshot("warnings");
+
+      resolve();
+    });
+  });
+});
diff --git a/test/validate-options.test.js b/test/validate-options.test.js
index 26ffbdc..607a869 100644
--- a/test/validate-options.test.js
+++ b/test/validate-options.test.js
@@ -140,6 +140,14 @@ describe("validate options", () => {
             priority: 5,
           },
         ],
+        [
+          {
+            from: "test.txt",
+            to: "dir",
+            context: "context",
+            transformAll: ({ existingAsset }) => existingAsset.source.source(),
+          },
+        ],
       ],
       failure: [
         // eslint-disable-next-line no-undefined
@@ -270,6 +278,14 @@ describe("validate options", () => {
             priority: true,
           },
         ],
+        [
+          {
+            from: "test.txt",
+            to: "dir",
+            context: "context",
+            transformAll: true,
+          },
+        ],
       ],
     },
     options: {