From 121a3d8630a669820a4dee8d783d77e4038787de Mon Sep 17 00:00:00 2001 From: Jordan Date: Mon, 11 Feb 2019 07:11:03 +1100 Subject: [PATCH] 100% Test Coverage (#19) * #8 Fleshed out unit tests, achieving 100% coverage * Revised unit test names and added comments to define a rough spec * Ignored difficult to reproduce failure recovery code from code coverage * Removed unnecessary checks from internal class `BundleSource` * Separated merge and validation logic into multiple files to improve maintainability * Fixed error in duplicate detection logic of config validation * #21 Fixed issue where a hang could occur due to a paused internal stream * #22 Fixed flaw in results callback data collection where styles would replace styles within the same bundle * Improved logging, particularly around source identification * Removed redundant double usage of nyc test coverage wrapper * Updated `CHANGELOG.md` and `README.md` --- .travis.yml | 2 + CHANGELOG.md | 10 + README.md | 2 + package-lock.json | 355 ++++++++++---------- package.json | 10 +- src/bundles-processor.test.ts | 103 +++++- src/bundles-processor.ts | 78 ++++- src/catcher.test.ts | 17 +- src/catcher.ts | 10 +- src/config.test.ts | 511 ----------------------------- src/config.ts | 322 ------------------ src/config/config.ts | 89 +++++ src/config/merge-bundle.test.ts | 301 +++++++++++++++++ src/config/merge-bundle.ts | 44 +++ src/config/merge-configs.test.ts | 166 ++++++++++ src/config/merge-configs.ts | 54 +++ src/config/validate-bundle.test.ts | 197 +++++++++++ src/config/validate-bundle.ts | 64 ++++ src/config/validate-config.test.ts | 125 +++++++ src/config/validate-config.ts | 55 ++++ src/log-levels.ts | 24 ++ src/main.test.ts | 336 +++++++++++++++++-- src/main.ts | 30 +- 23 files changed, 1816 insertions(+), 1089 deletions(-) delete mode 100644 src/config.test.ts delete mode 100644 src/config.ts create mode 100644 src/config/config.ts create mode 100644 src/config/merge-bundle.test.ts create mode 100644 src/config/merge-bundle.ts create mode 100644 src/config/merge-configs.test.ts create mode 100644 src/config/merge-configs.ts create mode 100644 src/config/validate-bundle.test.ts create mode 100644 src/config/validate-bundle.ts create mode 100644 src/config/validate-config.test.ts create mode 100644 src/config/validate-config.ts create mode 100644 src/log-levels.ts diff --git a/.travis.yml b/.travis.yml index 4dccde83..f4031806 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,5 @@ +os: + - linux language: node_js node_js: - "8" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d974597..50de5193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +This release focused on internal refactoring and improving test coverage. Many discoverd bugs have been fixed. + +### Fixed +- Potential deadlock in bundles processor. +- Swallowing of exceptions raised within bundler factory and bundler factory streams. +- Virtual path rules in a config not being correctly checked for duplicate matchers. +- Fixed edge case where if the first file in a bundle failed to a resolve a file the resulting exception would never bubble up due to the source stream never being unpaused. #21 +- Fixed issue where bundles using both `scripts` and `styles` would result in only files emitted as part of `styles` bundling appearing in the results callback data. #22 +- Other undocumented assorted edge cases. + ## [3.0.0-rc.1] - 2019-01-09 This release focuses on simplifying the package for UserFrosting 4 to improve maintainability. Features unsupported by UF4 are largely removed. diff --git a/README.md b/README.md index d18231fd..6fba5a22 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Orchastrates JS and CSS bundle creation in a highly efficient and configurable manner. +**CAUTION** The implementation currently produces a great deal backpressure. This can result in signficiant RAM usage. Projects dealing with a significant number resources are better off not using this tool until the custom stream source is implemented in v4. + ## Install ```bash diff --git a/package-lock.json b/package-lock.json index b5da4192..c311b2b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,13 +26,13 @@ } }, "@ava/babel-preset-transform-test-files": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@ava/babel-preset-transform-test-files/-/babel-preset-transform-test-files-4.0.0.tgz", - "integrity": "sha512-V9hYHA/ZLb4I8imrrG8PT0mzgThjWWmahPV+mrQUZobVnsekBUDrf0JsfXVm4guS3binWxWn+MmQt+V81hTizA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@ava/babel-preset-transform-test-files/-/babel-preset-transform-test-files-4.0.1.tgz", + "integrity": "sha512-D7Z92B8Rgsj35JZveKJGwpUDuBKLiRKH6eyKpNmDHy7TJjr8y3VSDr3bUK+O456F3SkkBXrUihQuMrr39nWQhQ==", "dev": true, "requires": { "@ava/babel-plugin-throws-helper": "^3.0.0", - "babel-plugin-espower": "^3.0.0" + "babel-plugin-espower": "^3.0.1" } }, "@ava/write-file-atomic": { @@ -78,12 +78,12 @@ } }, "@babel/generator": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.2.2.tgz", - "integrity": "sha512-I4o675J/iS8k+P38dvJ3IBGqObLXyQLTxtrR4u9cSUJOURvafeEWb/pFMOTwtNrmq73mJzyF6ueTbO1BtN0Zeg==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.3.2.tgz", + "integrity": "sha512-f3QCuPppXxtZOEm5GWPra/uYUjmNQlu9pbAD8D/9jze4pTY83rTtB1igTBSwvkeNlC5gR24zFFkz+2WHLFQhqQ==", "dev": true, "requires": { - "@babel/types": "^7.2.2", + "@babel/types": "^7.3.2", "jsesc": "^2.5.1", "lodash": "^4.17.10", "source-map": "^0.5.0", @@ -222,14 +222,14 @@ } }, "@babel/helpers": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.2.0.tgz", - "integrity": "sha512-Fr07N+ea0dMcMN8nFpuK6dUIT7/ivt9yKQdEEnjVS83tG2pHwPi03gYmk/tyuwONnZ+sY+GFFPlWGgCtW1hF9A==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.3.1.tgz", + "integrity": "sha512-Q82R3jKsVpUV99mgX50gOPCWwco9Ec5Iln/8Vyu4osNIOQgSrd9RFrQeUvmvddFNoLwMyOUWU+5ckioEKpDoGA==", "dev": true, "requires": { "@babel/template": "^7.1.2", "@babel/traverse": "^7.1.5", - "@babel/types": "^7.2.0" + "@babel/types": "^7.3.0" } }, "@babel/highlight": { @@ -244,9 +244,9 @@ } }, "@babel/parser": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.2.3.tgz", - "integrity": "sha512-0LyEcVlfCoFmci8mXx8A5oIkpkOgyo8dRHtxBnK9RRBwxO2+JZPNsqtVEZQ7mJFPxnXF9lfmU24mHOPI0qnlkA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.3.2.tgz", + "integrity": "sha512-QzNUC2RO1gadg+fs21fi0Uu0OuGNzRKEmgCxoLNzbCdoprLwjfmZwzUrpUNfJPaVRwBpDY47A17yYEGWyRelnQ==", "dev": true }, "@babel/plugin-proposal-async-generator-functions": { @@ -261,9 +261,9 @@ } }, "@babel/plugin-proposal-object-rest-spread": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.2.0.tgz", - "integrity": "sha512-1L5mWLSvR76XYUQJXkd/EEQgjq8HHRP6lQuZTTg0VA4tTGPpGemmCdAfQIz1rzEuWAm+ecP8PyyEm30jC1eQCg==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.3.2.tgz", + "integrity": "sha512-DjeMS+J2+lpANkYLLO+m6GjoTMygYglKmRe6cDTbFv3L9i6mmiE8fe6B8MtCSLZpVXscD5kn7s6SgtHrDoBWoA==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", @@ -379,9 +379,9 @@ } }, "@babel/types": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.2.2.tgz", - "integrity": "sha512-fKCuD6UFUMkR541eDWL+2ih/xFZBXPOg/7EQFeTluMDebfqR4jrpaCjLhkWlQS4hT6nRa2PMEgXKbRB5/H2fpg==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.3.2.tgz", + "integrity": "sha512-3Y6H8xlUlpbGR+XvawiH0UXehqydTmNmEpozWcXymqwcrwYAl5KMvKtQ+TF6f6E08V6Jur7v/ykdDSF+WDEIXQ==", "dev": true, "requires": { "esutils": "^2.0.2", @@ -405,9 +405,9 @@ "dev": true }, "@types/node": { - "version": "10.12.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.9.tgz", - "integrity": "sha512-eajkMXG812/w3w4a1OcBlaTwsFPO5F7fJ/amy+tieQxEMWBlbV1JGSjkFM+zkHNf81Cad+dfIRA+IBkvmvdAeA==", + "version": "10.12.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.21.tgz", + "integrity": "sha512-CBgLNk4o3XMnqMc0rhb6lc77IwShMEglz05deDcn2lQxyXEZivfwgYJu7SMha9V5XcrP6qZuevTHV/QrN2vjKQ==", "dev": true }, "@types/vinyl": { @@ -437,9 +437,9 @@ } }, "ansi-escapes": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.1.0.tgz", - "integrity": "sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", "dev": true }, "ansi-regex": { @@ -470,6 +470,17 @@ "requires": { "micromatch": "^3.1.4", "normalize-path": "^2.1.1" + }, + "dependencies": { + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + } } }, "argparse": { @@ -498,9 +509,9 @@ "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=" }, "array-differ": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", - "integrity": "sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-2.0.3.tgz", + "integrity": "sha1-AZW7AMzM8nEQbv7kpHhkiLcYBxI=", "dev": true }, "array-find-index": { @@ -562,16 +573,16 @@ "dev": true }, "ava": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ava/-/ava-1.0.1.tgz", - "integrity": "sha512-wTb9D14fytTeSOyNu+if6nzSni+MyEn9xgpJki2V5MiTbwMVtt4Svh40gCIos3k5Jqp1wXPCTZLIGKLpI965fQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ava/-/ava-1.2.1.tgz", + "integrity": "sha512-EHqbPGdd8aNvlvRNL7liD1J9Auf9kByHj5Zi7zF7Z5ukn2ZStZgVBf7LSqirKIOWScB3XZzFQbO59SnTvzD5kA==", "dev": true, "requires": { "@ava/babel-preset-stage-4": "^2.0.0", - "@ava/babel-preset-transform-test-files": "^4.0.0", + "@ava/babel-preset-transform-test-files": "^4.0.1", "@ava/write-file-atomic": "^2.2.0", "@babel/core": "^7.2.2", - "@babel/generator": "^7.2.2", + "@babel/generator": "^7.3.0", "@babel/plugin-syntax-async-generators": "^7.2.0", "@babel/plugin-syntax-object-rest-spread": "^7.2.0", "@babel/plugin-syntax-optional-catch-binding": "^7.2.0", @@ -583,7 +594,7 @@ "array-uniq": "^2.0.0", "arrify": "^1.0.0", "bluebird": "^3.5.3", - "chalk": "^2.4.1", + "chalk": "^2.4.2", "chokidar": "^2.0.4", "chunkd": "^1.0.0", "ci-parallel-vars": "^1.0.0", @@ -596,17 +607,17 @@ "concordance": "^4.0.0", "convert-source-map": "^1.6.0", "currently-unhandled": "^0.4.1", - "debug": "^4.1.0", + "debug": "^4.1.1", "del": "^3.0.0", "dot-prop": "^4.2.0", "emittery": "^0.4.1", "empower-core": "^1.2.0", "equal-length": "^1.0.0", "escape-string-regexp": "^1.0.5", - "esm": "^3.0.84", + "esm": "^3.1.3", "figures": "^2.0.0", "find-up": "^3.0.0", - "get-port": "^4.0.0", + "get-port": "^4.1.0", "globby": "^7.1.1", "ignore-by-default": "^1.0.0", "import-local": "^2.0.0", @@ -628,22 +639,22 @@ "md5-hex": "^2.0.0", "meow": "^5.0.0", "ms": "^2.1.1", - "multimatch": "^2.1.0", + "multimatch": "^3.0.0", "observable-to-promise": "^0.5.0", "ora": "^3.0.0", - "package-hash": "^2.0.0", + "package-hash": "^3.0.0", "pkg-conf": "^2.1.0", "plur": "^3.0.1", "pretty-ms": "^4.0.0", "require-precompiled": "^0.1.0", "resolve-cwd": "^2.0.0", "slash": "^2.0.0", - "source-map-support": "^0.5.9", + "source-map-support": "^0.5.10", "stack-utils": "^1.0.2", "strip-ansi": "^5.0.0", "strip-bom-buf": "^1.0.0", "supertap": "^1.0.0", - "supports-color": "^5.5.0", + "supports-color": "^6.1.0", "trim-off-newlines": "^1.0.1", "trim-right": "^1.0.1", "unique-temp-dir": "^1.0.0", @@ -651,9 +662,9 @@ } }, "babel-plugin-espower": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-espower/-/babel-plugin-espower-3.0.0.tgz", - "integrity": "sha512-f2IUz5kQyrwXnShcv7tvGxf76QkrEl00ENYgd6R0VMrz4xqlwBLZXFs5vse2vehs1Z+T2sXTP3UWX2QxMorzzw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-espower/-/babel-plugin-espower-3.0.1.tgz", + "integrity": "sha512-Ms49U7VIAtQ/TtcqRbD6UBmJBUCSxiC3+zPc+eGqxKUIFO1lTshyEDRUjhoAbd2rWfwYf3cZ62oXozrd8W6J0A==", "dev": true, "requires": { "@babel/generator": "^7.0.0", @@ -727,9 +738,9 @@ } }, "binary-extensions": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.12.0.tgz", - "integrity": "sha512-DYWGk01lDcxeS/K9IHPGWfT8PsJmbXRtRd2Sx72Tnb8pcYZQFF1oSDb8hJtS1vhp212q1Rzi5dUf9+nq0o9UIg==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.0.tgz", + "integrity": "sha512-EgmjVLMn22z7eGGv3kcnHwSnJXmFHjISTY9E/S5lIcTD3Oxw05QTcBLNkJFzcb3cNueUdF/IN4U+d78V0zO8Hw==", "dev": true }, "bluebird": { @@ -804,12 +815,6 @@ "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", "dev": true }, - "builtin-modules": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", - "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", - "dev": true - }, "cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -877,6 +882,17 @@ "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } } }, "changelog-updater": { @@ -886,24 +902,23 @@ "dev": true }, "chokidar": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", - "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.0.tgz", + "integrity": "sha512-5t6G2SH8eO6lCvYOoUpaRnF5Qfd//gd7qJAkwRUw9qlGVkiQ13uwQngqbWWaurOsaAm9+kUGbITADxt6H0XFNQ==", "dev": true, "requires": { "anymatch": "^2.0.0", - "async-each": "^1.0.0", - "braces": "^2.3.0", - "fsevents": "^1.2.2", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", "glob-parent": "^3.1.0", - "inherits": "^2.0.1", + "inherits": "^2.0.3", "is-binary-path": "^1.0.0", "is-glob": "^4.0.0", - "lodash.debounce": "^4.0.8", - "normalize-path": "^2.1.1", + "normalize-path": "^3.0.0", "path-is-absolute": "^1.0.0", - "readdirp": "^2.0.0", - "upath": "^1.0.5" + "readdirp": "^2.2.1", + "upath": "^1.1.0" } }, "chunkd": { @@ -1122,9 +1137,9 @@ "dev": true }, "core-js": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.1.tgz", - "integrity": "sha512-L72mmmEayPJBejKIWe2pYtGis5r0tQ5NaJekdhyXgeMQTpJoBsH0NL4ElY2LfSoV15xeQWKQ+XTTOZdyero5Xg==", + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.4.tgz", + "integrity": "sha512-05qQ5hXShcqGkPZpXEFLIpxayZscVD2kuMBZewxiIPPEagukO4mqgPA9CWhUvFBJfy3ODdK2p9xyHh7FTU9/7A==", "dev": true }, "core-util-is": { @@ -1323,12 +1338,11 @@ } }, "dir-glob": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz", - "integrity": "sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", "dev": true, "requires": { - "arrify": "^1.0.1", "path-type": "^3.0.0" } }, @@ -1391,9 +1405,9 @@ "dev": true }, "esm": { - "version": "3.0.84", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.0.84.tgz", - "integrity": "sha512-SzSGoZc17S7P+12R9cg21Bdb7eybX25RnIeRZ80xZs+VZ3kdQKzqTp2k4hZJjR7p9l0186TTXSgrxzlMDBktlw==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.3.tgz", + "integrity": "sha512-qkokqXI9pblukGvc0gG1FHMwKWjriIyCgDKbpzgtlis5tQ21dIFPL5s7ffcSVdE+k9Zw7R5ZC/dl0z/Ib5m1Pw==" }, "espower-location-detector": { "version": "1.0.0", @@ -1660,9 +1674,9 @@ "dev": true }, "fsevents": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", - "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.7.tgz", + "integrity": "sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw==", "dev": true, "optional": true, "requires": { @@ -1688,7 +1702,7 @@ "optional": true }, "are-we-there-yet": { - "version": "1.1.4", + "version": "1.1.5", "bundled": true, "dev": true, "optional": true, @@ -1700,21 +1714,19 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "chownr": { - "version": "1.0.1", + "version": "1.1.1", "bundled": true, "dev": true, "optional": true @@ -1722,20 +1734,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -1753,7 +1762,7 @@ } }, "deep-extend": { - "version": "0.5.1", + "version": "0.6.0", "bundled": true, "dev": true, "optional": true @@ -1802,7 +1811,7 @@ } }, "glob": { - "version": "7.1.2", + "version": "7.1.3", "bundled": true, "dev": true, "optional": true, @@ -1822,12 +1831,12 @@ "optional": true }, "iconv-lite": { - "version": "0.4.21", + "version": "0.4.24", "bundled": true, "dev": true, "optional": true, "requires": { - "safer-buffer": "^2.1.0" + "safer-buffer": ">= 2.1.2 < 3" } }, "ignore-walk": { @@ -1852,8 +1861,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -1865,7 +1873,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -1880,7 +1887,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1888,21 +1894,19 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { - "version": "2.2.4", + "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { - "safe-buffer": "^5.1.1", + "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "minizlib": { - "version": "1.1.0", + "version": "1.2.1", "bundled": true, "dev": true, "optional": true, @@ -1914,7 +1918,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -1926,7 +1929,7 @@ "optional": true }, "needle": { - "version": "2.2.0", + "version": "2.2.4", "bundled": true, "dev": true, "optional": true, @@ -1937,18 +1940,18 @@ } }, "node-pre-gyp": { - "version": "0.10.0", + "version": "0.10.3", "bundled": true, "dev": true, "optional": true, "requires": { "detect-libc": "^1.0.2", "mkdirp": "^0.5.1", - "needle": "^2.2.0", + "needle": "^2.2.1", "nopt": "^4.0.1", "npm-packlist": "^1.1.6", "npmlog": "^4.0.2", - "rc": "^1.1.7", + "rc": "^1.2.7", "rimraf": "^2.6.1", "semver": "^5.3.0", "tar": "^4" @@ -1965,13 +1968,13 @@ } }, "npm-bundled": { - "version": "1.0.3", + "version": "1.0.5", "bundled": true, "dev": true, "optional": true }, "npm-packlist": { - "version": "1.1.10", + "version": "1.2.0", "bundled": true, "dev": true, "optional": true, @@ -1995,8 +1998,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -2008,7 +2010,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -2048,12 +2049,12 @@ "optional": true }, "rc": { - "version": "1.2.7", + "version": "1.2.8", "bundled": true, "dev": true, "optional": true, "requires": { - "deep-extend": "^0.5.1", + "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" @@ -2083,16 +2084,16 @@ } }, "rimraf": { - "version": "2.6.2", + "version": "2.6.3", "bundled": true, "dev": true, "optional": true, "requires": { - "glob": "^7.0.5" + "glob": "^7.1.3" } }, "safe-buffer": { - "version": "5.1.1", + "version": "5.1.2", "bundled": true, "dev": true }, @@ -2109,7 +2110,7 @@ "optional": true }, "semver": { - "version": "5.5.0", + "version": "5.6.0", "bundled": true, "dev": true, "optional": true @@ -2130,7 +2131,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -2161,17 +2161,17 @@ "optional": true }, "tar": { - "version": "4.4.1", + "version": "4.4.8", "bundled": true, "dev": true, "optional": true, "requires": { - "chownr": "^1.0.1", + "chownr": "^1.1.1", "fs-minipass": "^1.2.5", - "minipass": "^2.2.4", - "minizlib": "^1.1.0", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", "mkdirp": "^0.5.0", - "safe-buffer": "^5.1.1", + "safe-buffer": "^5.1.2", "yallist": "^3.0.2" } }, @@ -2182,12 +2182,12 @@ "optional": true }, "wide-align": { - "version": "1.1.2", + "version": "1.1.3", "bundled": true, "dev": true, "optional": true, "requires": { - "string-width": "^1.0.2" + "string-width": "^1.0.2 || 2" } }, "wrappy": { @@ -2196,7 +2196,7 @@ "dev": true }, "yallist": { - "version": "3.0.2", + "version": "3.0.3", "bundled": true, "dev": true } @@ -2265,9 +2265,9 @@ } }, "globals": { - "version": "11.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.9.0.tgz", - "integrity": "sha512-5cJVtyXWH8PiJPVLZzzoIizXx944O4OmRro5MWKx5fT4MgcN7OfaMutPeaTdJCCURwbWdhhcCWcKIffPnmTzBg==", + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.10.0.tgz", + "integrity": "sha512-0GZF1RiPKU97IHUO5TORo9w1PwrH/NBPl+fS7oMLdaTRiYmYbwK4NWoZWrAdd0/abG9R2BU+OiwyQpTpE6pdfQ==", "dev": true }, "globby": { @@ -2355,6 +2355,15 @@ } } }, + "hasha": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-3.0.0.tgz", + "integrity": "sha1-UqMvq4Vp1BymmmH/GiFPjrfIvTk=", + "dev": true, + "requires": { + "is-stream": "^1.0.1" + } + }, "hosted-git-info": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", @@ -2469,15 +2478,6 @@ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "dev": true }, - "is-builtin-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", - "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", - "dev": true, - "requires": { - "builtin-modules": "^1.0.0" - } - }, "is-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", @@ -3045,15 +3045,15 @@ "dev": true }, "multimatch": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-2.1.0.tgz", - "integrity": "sha1-nHkGoi+0wCkZ4vX3UWG0zb1LKis=", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-3.0.0.tgz", + "integrity": "sha512-22foS/gqQfANZ3o+W7ST2x25ueHDVNWl/b9OlGcLpy/iKxjCpvcNCM51YCenUi7Mt/jAjjqv8JwZRs8YP5sRjA==", "dev": true, "requires": { - "array-differ": "^1.0.0", - "array-union": "^1.0.1", - "arrify": "^1.0.0", - "minimatch": "^3.0.0" + "array-differ": "^2.0.3", + "array-union": "^1.0.2", + "arrify": "^1.0.1", + "minimatch": "^3.0.4" } }, "nan": { @@ -3083,25 +3083,22 @@ } }, "normalize-package-data": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", - "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", "dev": true, "requires": { "hosted-git-info": "^2.1.4", - "is-builtin-module": "^1.0.0", + "resolve": "^1.10.0", "semver": "2 || 3 || 4 || 5", "validate-npm-package-license": "^3.0.1" } }, "normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", - "dev": true, - "requires": { - "remove-trailing-separator": "^1.0.1" - } + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true }, "npm-run-path": { "version": "2.0.2", @@ -3282,14 +3279,14 @@ "dev": true }, "package-hash": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-2.0.0.tgz", - "integrity": "sha1-eK4ybIngWk2BO2hgGXevBcANKg0=", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-3.0.0.tgz", + "integrity": "sha512-lOtmukMDVvtkL84rJHI7dpTYq+0rli8N2wlnqUcBuDWCfVhRUfOmnR9SsoHFMLpACvEV60dX7rd0rFaYDZI+FA==", "dev": true, "requires": { - "graceful-fs": "^4.1.11", + "graceful-fs": "^4.1.15", + "hasha": "^3.0.0", "lodash.flattendeep": "^4.4.0", - "md5-hex": "^2.0.0", "release-zalgo": "^1.0.0" } }, @@ -3747,9 +3744,9 @@ "dev": true }, "resolve": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.9.0.tgz", - "integrity": "sha512-TZNye00tI67lwYvzxCxHGjwTNlUV70io54/Ed4j6PscB8xVfuBJpRenI/o6dVk0cY0PYTY27AgCoGGxRnYuItQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.10.0.tgz", + "integrity": "sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg==", "dev": true, "requires": { "path-parse": "^1.0.6" @@ -4055,9 +4052,9 @@ } }, "source-map-support": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.9.tgz", - "integrity": "sha512-gR6Rw4MvUlYy83vP0vxoVNzM6t8MUXqNuRsuBmBHQDu1Fh6X015FrLdgoDKcNdkwGubozq0P4N0Q37UyFVr1EA==", + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.10.tgz", + "integrity": "sha512-YfQ3tQFTK/yzlGJuX8pTwa4tifQj4QS2Mj7UegOu8jAz59MqIiMGPXxQhVQiIMNzayuUSF/jEuVnfFF5JqybmQ==", "dev": true, "requires": { "buffer-from": "^1.0.0", @@ -4256,9 +4253,9 @@ } }, "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", "dev": true, "requires": { "has-flag": "^3.0.0" @@ -4358,9 +4355,9 @@ "dev": true }, "typescript": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.1.6.tgz", - "integrity": "sha512-tDMYfVtvpb96msS1lDX9MEdHrW4yOuZ4Kdc4Him9oU796XldPYF/t2+uKoX0BBa0hXXwDlqYQbXY5Rzjzc5hBA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.3.3.tgz", + "integrity": "sha512-Y21Xqe54TBVp+VDSNbuDYdGw0BpoR/Q6wo/+35M8PAU0vipahnyduJWirxxdxjsAkS7hue53x2zp8gz7F05u0A==", "dev": true }, "uid2": { @@ -4634,9 +4631,9 @@ "dev": true }, "write-file-atomic": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.3.0.tgz", - "integrity": "sha512-xuPeK4OdjWqtfi59ylvVL0Yn35SF3zgcAcv7rBPFHVaEapaDr4GdGgm3j7ckTwH9wHL7fGmgfAnb0+THrHb8tA==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.2.tgz", + "integrity": "sha512-s0b6vB3xIVRLWywa6X9TOMA7k9zio0TMOsl9ZnDkliA/cfJlpHXAscj0gbHVJiTdIuAYpIyqS5GW91fqm6gG5g==", "dev": true, "requires": { "graceful-fs": "^4.1.11", diff --git a/package.json b/package.json index 9c7bdec1..e031a33f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "types": "./dist/main.d.ts", "scripts": { "pretest": "tsc", - "test": "nyc ava", + "test": "ava", "prepublishOnly": "tsc", "prevsion": "npm test", "version": "changelog-updater && git add CHANGELOG.md", @@ -41,18 +41,18 @@ "url": "https://github.com/userfrosting/gulp-uf-bundle-assets/issues" }, "dependencies": { - "esm": "^3.0.84", + "esm": "^3.2.3", "just-extend": "^4.0.2", "plugin-error": "^1.0.1", "vinyl": "^2.2.0" }, "devDependencies": { "@types/just-extend": "^1.1.0", - "@types/node": "^10.12.9", + "@types/node": "^10.12.21", "@types/vinyl": "^2.0.2", - "ava": "^1.0.1", + "ava": "^1.2.1", "changelog-updater": "^1.1.0", - "typescript": "^3.1.6" + "typescript": "^3.3.3" }, "engines": { "node": ">=8.0.0" diff --git a/src/bundles-processor.test.ts b/src/bundles-processor.test.ts index 402b077a..d2022d07 100644 --- a/src/bundles-processor.test.ts +++ b/src/bundles-processor.test.ts @@ -4,15 +4,20 @@ import Vinyl from "vinyl"; import { BundlerStreamFactory } from "./main"; import { Transform, TransformCallback, Readable } from "stream"; -test("BundlesProcessor with iterable inputs empty", async t => { +/** + * Should return empty results. + */ +test("Empty inputs", async t => { const files: Map = new Map(); const bundles: Map = new Map(); TestBundler(t, [[], new Map()], await BundlesProcessor(files, bundles, BundleStreamFactory, () => {})); - }); -test("BundlesProcessor with files but no bundles", async t => { +/** + * Should return empty results. + */ +test("Files but no bundles", async t => { const files: Map = new Map(); files.set("test", [MakeVinyl("test", "test"), 0]); const bundles: Map = new Map(); @@ -20,7 +25,24 @@ test("BundlesProcessor with files but no bundles", async t => { TestBundler(t, [[], new Map()], await BundlesProcessor(files, bundles, BundleStreamFactory, () => {})); }); -test("BundlesProcessor with files and bundles", async t => { +/** + * Should return empty bundle in results. + */ +test("Empty bundle but no files", async t => { + const files: Map = new Map(); + const bundles: Map = new Map(); + bundles.set('test', []); + + const results = new Map(); + results.set('test', []); + + TestBundler(t, [[], results], await BundlesProcessor(files, bundles, BundleStreamFactory, () => {})); +}); + +/** + * Should have tangible results. + */ +test("Normal files and bundles", async t => { const files: Map = new Map(); files.set("test", [MakeVinyl("test", "test"), 0]); const bundles: Map = new Map(); @@ -35,6 +57,73 @@ test("BundlesProcessor with files and bundles", async t => { TestBundler(t, [resultChunks, resultPaths], await BundlesProcessor(files, bundles, BundleStreamFactory, () => {})); }); +/** + * Should throw an exception indicating that no file could be resolved. + */ +test("Unsatisfiable bundles", async t => { + const files: Map = new Map(); + files.set("test2", [MakeVinyl("test", "test"), 0]); + const bundles: Map = new Map(); + bundles.set("test", ["test"]); + + await t.throwsAsync(() => BundlesProcessor(files, bundles, BundleStreamFactory, () => {}), 'No file could be resolved for "test".'); +}); + +/** + * Should return tangible results and complain that a chunk can't be included in results callback. + * TODO Verify that complaint is voiced. + */ +test("Non-Vinyl chunks emitted by bundle factory", async t => { + class TestNonVinylTransform extends TestTransform { + _transform(chunk: any, encoding: string, callback: TransformCallback): void { + this.push(chunk); + this.push("what's a vinyl?"); + callback(); + } + } + + const NonVinylBundleStreamFactory: BundlerStreamFactory = (src: Readable): Transform => { + return src.pipe(new TestNonVinylTransform()); + }; + + const files: Map = new Map(); + files.set("test", [MakeVinyl("test", "test"), 0]); + const bundles: Map = new Map(); + bundles.set("test", ["test"]); + + const resultChunks: any[] = [ + MakeVinyl("test", "test"), + "what's a vinyl?" + ]; + const resultPaths: Map = new Map(); + resultPaths.set("test", [new Vinyl({contents: null, path: "test"})]); + + TestBundler(t, [resultChunks, resultPaths], await BundlesProcessor(files, bundles, NonVinylBundleStreamFactory, () => {})); +}); + +/** + * Should rethrow the exception generated by the bundle factory. + * TODO Verify that errors emitted at the end of bundling are rethrown. + */ +test("Bundle factory that throws exception (before stream)", async t => { + const ErrorBundleStreamFactory: BundlerStreamFactory = (src: Readable): Transform => { + throw new Error("RIP"); + }; + + const files: Map = new Map(); + files.set("test", [MakeVinyl("test", "test"), 0]); + const bundles: Map = new Map(); + bundles.set("test", ["test"]); + + await t.throwsAsync(() => BundlesProcessor(files, bundles, ErrorBundleStreamFactory, () => {}), "RIP"); +}); + +/** + * Simplfiies verification of results. + * @param t Execution context to run assertions with. + * @param expected The expected result. + * @param actual The actual result. + */ function TestBundler(t: ExecutionContext, expected: [any[], Map], actual: [any[], Map]): void { // Check result chunks (order insensitive) t.deepEqual(expected[0].sort(), actual[0].sort()); @@ -48,7 +137,7 @@ function TestBundler(t: ExecutionContext, expected: [any[], Map } /** - * Simple stub for testing purposes + * Simple stub transform stream for testing purposes */ class TestTransform extends Transform { constructor() { @@ -74,6 +163,10 @@ function MakeVinyl(contents: string, path: string): Vinyl { }); } +/** + * Bundle factory for testing purposes. Uses test transform. + * @param src Source stream. + */ const BundleStreamFactory: BundlerStreamFactory = (src: Readable): Transform => { return src.pipe(new TestTransform()); }; diff --git a/src/bundles-processor.ts b/src/bundles-processor.ts index d427b3ce..969e182c 100644 --- a/src/bundles-processor.ts +++ b/src/bundles-processor.ts @@ -2,7 +2,7 @@ import Vinyl, { isVinyl } from "vinyl"; import { Catcher } from "./catcher"; import { BundlerStreamFactory } from "./main"; import { Readable } from "stream"; -import { LogLevel } from "./config"; +import { LogLevel } from "./log-levels"; /** * Processes provided bundle definitions. @@ -16,6 +16,8 @@ export async function BundlesProcessor( bundleStreamFactory: BundlerStreamFactory, logger: (value: string, level: LogLevel) => void ): Promise<[any[], Map]> { + const Logger = (msg: string, lvl: LogLevel) => logger(`BundlesProcessor > ${msg}`, lvl); + try { // Track results const resultFileInfo: Map = new Map(); @@ -23,19 +25,44 @@ export async function BundlesProcessor( // Process each bundle for (const [name, paths] of bundles) { - logger(`Building bundle "${name}"`, LogLevel.Normal); - - // Create catcher - const catcher = new Catcher(logger); + Logger(`Building bundle "${name}"`, LogLevel.Normal); // Build bundler with source and bundle name - logger("Invoking provided bundler", LogLevel.Silly); - bundleStreamFactory(new BundleSource(files, paths), name) - .pipe(catcher); + Logger("Invoking provided bundler", LogLevel.Silly); + + // Wrap to permit catching and reporting of errors + const chunks = await new Promise(async (resolve, reject) => { + try { + // Create catcher + const catcher = new Catcher(Logger); + + // Create bundle source (and handle errors) + const source = new BundleSource(files, paths, Logger) + .on("error", (e) => { + // Bundle stream will be unpiped automatically + Logger("Error emitted by bundle source", LogLevel.Scream); + reject(e); + }); + + // Run provided transform stream factory + bundleStreamFactory(source, name) + .on("error", (e) => { + // Catcher will be unpiped automatically. + Logger("Error emitted by provided joiner", LogLevel.Scream); + reject(e); + }) + .pipe(catcher); + + // Resolve on catcher completion + resolve(await catcher.Collected); + } + catch (e) { + reject(e); + } + }); // Catch results - logger("Catching outputs", LogLevel.Silly); - const chunks = await catcher.Collected; + Logger("Catching outputs", LogLevel.Silly); // Add to resultPaths and resultChunks const resultFiles: Vinyl[] = []; @@ -46,6 +73,7 @@ export async function BundlesProcessor( resultFile.contents = null; resultFiles.push(resultFile); } + else Logger("Non-Vinyl or Vinyl without path chunk was recieved from bundle factory. Information was not captured.", LogLevel.Complain); // Store the chunk resultChunks.push(chunk); @@ -55,11 +83,11 @@ export async function BundlesProcessor( resultFileInfo.set(name, resultFiles) } - logger("Returning bundling results", LogLevel.Silly); + Logger("Returning bundling results", LogLevel.Silly); return [resultChunks, resultFileInfo]; } catch (error) { - logger("BundlesProcessor completed with error", LogLevel.Scream); + Logger("BundlesProcessor completed with error", LogLevel.Scream); throw error; } } @@ -78,11 +106,13 @@ class BundleSource extends Readable { */ private readonly files: Map; + private readonly Logger: (value: string, level: LogLevel) => void; + /** * @param files File map to retrieve files from. * @param paths Paths to use as keys in file map. */ - constructor(files: Map, paths: string[]) { + constructor(files: Map, paths: string[], logger: (value: string, level: LogLevel) => void) { super({ objectMode: true }); @@ -91,6 +121,11 @@ class BundleSource extends Readable { // Copy array to we can reduce it as we go this.paths = paths.slice(0); + + // Prevent Catcher from becoming stuck when nothing will be coming through + if (this.paths.length === 0) this.resume(); + + this.Logger = (msg, lvl) => logger(`BundleSource > ${msg}`, lvl); } /** @@ -99,9 +134,20 @@ class BundleSource extends Readable { _read() { if (this.paths.length > 0) { const path = this.paths.shift(); - if (this.files.has(path)) this.push(this.files.get(path)[0].clone()); - else new Error(`No file could be resolved for ${path}.`); + this.Logger(`Locating "${path}"...`, LogLevel.Silly); + if (this.files.has(path)) { + const file = this.files.get(path)[0].clone(); + this.Logger(`Found, pushing "${file.path}" on through.`, LogLevel.Silly); + this.push(file); + } + else { + this.Logger(`Couldn't find "${path}"!`, LogLevel.Silly); + this.emit("error", new Error(`No file could be resolved for "${path}".`)); + } + } + else { + this.Logger("Nothing left to send", LogLevel.Silly); + this.push(null); } - else this.push(null); } } diff --git a/src/catcher.test.ts b/src/catcher.test.ts index f6aff7b5..8cee92bd 100644 --- a/src/catcher.test.ts +++ b/src/catcher.test.ts @@ -2,20 +2,29 @@ import test, { ExecutionContext } from "ava"; import { Catcher } from "./catcher"; import { Readable } from "stream" -test("Catcher catches all stream content", async t => { +/** + * Should return all arbitrary objects pushed into stream. + */ +test("Range of stream objects", async t => { const input = [{}, "test", 21]; const result = [{}, "test", 21]; await TestCatcher(t, input, result); }); -test("Catcher when no content is read into the stream", async t => { +/** + * Should return nothing when there is nothing to collect. + */ +test("Stream with no content", async t => { const input = []; const result = []; await TestCatcher(t, input, result); }); +/** + * Should return all strings 100,000 strings. + */ test("Catcher when there is a lot of content in the stream", async t => { const input = []; for (let i = 0; i <= 100_000; i++) @@ -27,11 +36,13 @@ test("Catcher when there is a lot of content in the stream", async t => { await TestCatcher(t, input, result); }); +// TODO Replicate edge case where catcher would never resolve with an empty source. + /** * Runs common test logic. * @param t Test context from test function callback. * @param input Data to feed into stream. - * @param result Expected output data (sensitive insensitive) + * @param result Expected output data. */ async function TestCatcher(t: ExecutionContext, input: any[], result: any[]): Promise { const catcher = new Catcher(() => {}); diff --git a/src/catcher.ts b/src/catcher.ts index ff5af85b..39612256 100644 --- a/src/catcher.ts +++ b/src/catcher.ts @@ -1,9 +1,8 @@ import { Transform, TransformCallback } from "stream"; -import { LogLevel } from "./config"; +import { LogLevel } from "./log-levels"; /** * All this does is collect all stream data and once all read resolves a promise with the collected chunks. - * TODO Handle when the stream source has no chunks to pass */ export class Catcher extends Transform { /** @@ -26,12 +25,15 @@ export class Catcher extends Transform { */ private Resolve?: (value?: any[] | PromiseLike) => void; + /** + * @param logger Used for logging events and errors. + */ constructor(logger: (value: string, level: LogLevel) => void) { super({ objectMode: true }); - this.Logger = logger; + this.Logger = (msg, lvl) => logger(`Catcher > ${msg}`, lvl); // Set promise this.Logger("Creating promise with external completion source", LogLevel.Silly); @@ -58,7 +60,9 @@ export class Catcher extends Transform { */ _flush(callback: TransformCallback): void { this.Logger("Starting resolution of catcher promise", LogLevel.Silly); + // Ensure promise has had chance to run const resolver = () => { + /* istanbul ignore else */ if (this.Resolve) { this.Resolve(this.Results); this.Logger("Catcher promise has resolved", LogLevel.Silly); diff --git a/src/config.test.ts b/src/config.test.ts deleted file mode 100644 index bba7f499..00000000 --- a/src/config.test.ts +++ /dev/null @@ -1,511 +0,0 @@ -import test from "ava"; -import { MergeRawConfigs, Config, Bundle, MergeBundle, ValidateRawConfig, ValidateBundle } from "./config"; - -/** - * MergeConfigs(RawConfig[]):RawConfig - */ - -test("MergeConfigs(RawConfig[]):RawConfig with single empty object", t => { - t.deepEqual(MergeRawConfigs([{}]), {}); -}); - -test("MergeConfigs(RawConfig[]):RawConfig with multiple empty objects", t => { - t.deepEqual(MergeRawConfigs([{}, {}, {}]), {}); -}); - -test("MergeConfigs(RawConfig[]):RawConfig with single object", t => { - const input1: Config = { - bundle: { - testBundle: { - scripts: [ - "foo.js" - ] - } - } - }; - const output: Config = { - bundle: { - testBundle: { - scripts: [ - "foo.js" - ] - } - } - }; - - t.deepEqual(MergeRawConfigs([input1]), output); -}); - -test("MergeConfigs(RawConfig[]):RawConfig with multiple objects", t => { - const input1: Config = { - bundle: { - testBundle: { - scripts: [ - "foo.js" - ] - } - } - }; - const input2: Config = { - bundle: { - testBundle: { - scripts: [ - "bar.js" - ] - } - } - }; - const input3: Config = { - bundle: { - testBundle: { - styles: [ - "foo.css" - ] - } - } - }; - const output: Config = { - bundle: { - testBundle: { - styles: [ - "foo.css" - ] - } - } - }; - - t.deepEqual(MergeRawConfigs([input1, input2, input3]), output); -}); - -test("MergeConfigs(RawConfig[]):RawConfig identifies error source when MergeBundle(Bundle,Bundle) fails", t => { - const input1: Config = { - bundle: { - testBundle: { - scripts: [ - "foo.js" - ] - } - } - }; - const input2: Config = { - bundle: { - testBundle: { - scripts: [ - "bar.js" - ], - options: { - sprinkle: { - onCollision: "badCollisionHandler" - } - } - } - } - }; - - t.throws(() => MergeRawConfigs([input1, input2]), "Exception raised while merging bundle 'testBundle' in the raw configuration at index '1'.\nError: Unexpected input 'badCollisionHandler' for 'onCollision' option of next bundle."); -}); - -/** - * MergeBundle(Bundle,Bundle):Bundle - */ - -test("MergeBundle(Bundle,Bundle):Bundle with empty objects", t => { - const existingBundle: Bundle = { - - }; - const nextBundle: Bundle = { - - }; - const output: Bundle = { - - }; - t.deepEqual(MergeBundle(existingBundle, nextBundle), output); -}); - -test("MergeBundle(Bundle,Bundle):Bundle with no collision rules set", t => { - const existingBundle: Bundle = { - scripts: [ - "foo.js" - ] - }; - const nextBundle: Bundle = { - scripts: [ - "bar.js", - "zeta.js" - ], - styles: [ - "foo.css" - ] - }; - const output: Bundle = { - scripts: [ - "bar.js", - "zeta.js" - ], - styles: [ - "foo.css" - ] - }; - t.deepEqual(MergeBundle(existingBundle, nextBundle), output); -}); - -test("MergeBundle(Bundle,Bundle):Bundle with merge collision rules set", t => { - const existingBundle: Bundle = { - scripts: [ - "foo.js" - ] - }; - const nextBundle: Bundle = { - scripts: [ - "bar.js", - "zeta.js" - ], - styles: [ - "foo.css" - ], - options: { - sprinkle: { - onCollision: "merge" - } - } - }; - const output: Bundle = { - scripts: [ - "foo.js", - "bar.js", - "zeta.js" - ], - styles: [ - "foo.css" - ], - options: { - sprinkle: { - onCollision: "merge" - } - } - }; - t.deepEqual(MergeBundle(existingBundle, nextBundle), output); -}); - -test("MergeBundle(Bundle,Bundle):Bundle with merge collision rules set and arrays with common items", t => { - const existingBundle: Bundle = { - scripts: [ - "foo.js" - ] - }; - const nextBundle: Bundle = { - scripts: [ - "bar.js", - "foo.js", - "zeta.js" - ], - styles: [ - "foo.css" - ], - options: { - sprinkle: { - onCollision: "merge" - } - } - }; - const output: Bundle = { - scripts: [ - "foo.js", - "bar.js", - "zeta.js" - ], - styles: [ - "foo.css" - ], - options: { - sprinkle: { - onCollision: "merge" - } - } - }; - t.deepEqual(MergeBundle(existingBundle, nextBundle), output); -}); - -test("MergeBundle(Bundle,Bundle):Bundle with ignore collision rules set (1)", t => { - const existingBundle: Bundle = { - scripts: [ - "foo.js" - ] - }; - const nextBundle: Bundle = { - scripts: [ - "bar.js", - "zeta.js" - ], - styles: [ - "foo.css" - ], - options: { - sprinkle: { - onCollision: "ignore" - } - } - }; - const output: Bundle = { - scripts: [ - "foo.js" - ] - }; - t.deepEqual(MergeBundle(existingBundle, nextBundle), output); -}); - -test("MergeBundle(Bundle,Bundle):Bundle with ignore collision rules set (2)", t => { - const existingBundle: Bundle = { - scripts: [ - "foo.js" - ] - }; - const nextBundle: Bundle = { - scripts: [ - "bar.js", - "zeta.js" - ], - styles: [ - "foo.css" - ], - options: { - sprinkle: { - onCollision: "replace" - } - } - }; - const output: Bundle = { - scripts: [ - "bar.js", - "zeta.js" - ], - styles: [ - "foo.css" - ], - options: { - sprinkle: { - onCollision: "replace" - } - } - }; - t.deepEqual(MergeBundle(existingBundle, nextBundle), output); -}); - -test("MergeBundle(Bundle,Bundle):Bundle with error collision rules set", t => { - const existingBundle: Bundle = { - scripts: [ - "foo.js" - ] - }; - const nextBundle: Bundle = { - scripts: [ - "bar.js", - "zeta.js" - ], - styles: [ - "foo.css" - ], - options: { - sprinkle: { - onCollision: "error" - } - } - }; - t.throws(() => MergeBundle(existingBundle, nextBundle), "The bundle has been previously defined, and the bundle's 'onCollision' property is set to 'error'."); -}); - -/** - * ValidateRawConfig(RawConfig):void - */ - -test("ValidateRawConfig(RawConfig):void with empty object", t => { - t.notThrows(() => ValidateRawConfig({} - )); -}); - -test("ValidateRawConfig(RawConfig):void with empty object bundle key", t => { - const input: any = { - bundle: {} - } - t.notThrows(() => ValidateRawConfig(input - )); -}); - -test("ValidateRawConfig(RawConfig):void with invalid bundle key", t => { - const input: any = { - bundle: "a string" - } - t.throws(() => ValidateRawConfig(input - ), `Property "bundle" must be an object and not null.`); -}); - -test("ValidateRawConfig(RawConfig):void with valid virtual path rules", t => { - const input: Config = { - VirtualPathRules: [ - ["test", "testtest"] - ] - } - t.notThrows(() => ValidateRawConfig(input - )); -}); - -test("ValidateRawConfig(RawConfig):void with invalid empty matcher virtual path rules", t => { - const input: Config = { - VirtualPathRules: [ - ["", "testtest"] - ] - } - t.throws(() => ValidateRawConfig(input - ), `Value matcher of property "VirtualPathRules" is empty.`); -}); - -test("ValidateRawConfig(RawConfig):void with invalid empty replacement virtual path rules", t => { - const input: Config = { - VirtualPathRules: [ - ["test", ""] - ] - } - t.throws(() => ValidateRawConfig(input - ), `Value replacement of property "VirtualPathRules" is empty.`); -}); - -/** - * ValidateBundle(Bundle,string):void - */ - -// Param 1 Base - -test("ValidateBundle(Bundle,string):void with empty object", t => { - t.notThrows(() => ValidateBundle({}, "test" - )); -}); - -test("ValidateBundle(Bundle,string):void with non-object first paramater", t => { - const input1: any = "a string"; - t.throws(() => ValidateBundle(input1, "test" - ), "Property bundle>test must be an object and not null."); -}); - -// Param 2 - -test("ValidateBundle(Bundle,string):void with non-string second paramater", t => { - const input2: any = 22; - t.throws(() => ValidateBundle({}, input2 - ), "Bundle name must be a string."); -}); - -// Param 1 Scripts - -test("ValidateBundle(Bundle,string):void with non-array for scripts", t => { - const input1: any = { - scripts: "a string" - }; - t.throws(() => ValidateBundle(input1, "test" - ), "Property bundle>test>scripts must be an array."); -}); - -test("ValidateBundle(Bundle,string):void with array containing non-strings for scripts", t => { - const input1: any = { - scripts: [ - "foo.js", - () => "magic.js", - 22 - ] - }; - t.throws(() => ValidateBundle(input1, "test" - ), "All indexes of bundle>test>scripts must be a string."); -}); - -test("ValidateBundle(Bundle,string):void with valid array for scripts", t => { - const input1: Bundle = { - scripts: [ - "foo.js", - "bar.js" - ] - }; - t.notThrows(() => ValidateBundle(input1, "test" - )); -}); - -// Param 1 scripts - -test("ValidateBundle(Bundle,string):void with non-array for styles", t => { - const input1: any = { - styles: "a string" - }; - t.throws(() => ValidateBundle(input1, "test" - ), "Property bundle>test>styles must be an array."); -}); - -test("ValidateBundle(Bundle,string):void with array containing non-strings for styles", t => { - const input1: any = { - styles: [ - "foo.css", - () => "magic.css", - 22 - ] - }; - t.throws(() => ValidateBundle(input1, "test" - ), "All indexes of bundle>test>styles must be a string."); -}); - -test("ValidateBundle(Bundle,string):void with valid array for styles", t => { - const input1: Bundle = { - styles: [ - "foo.css", - "bar.css" - ] - }; - t.notThrows(() => ValidateBundle(input1, "test" - )); -}); - -// Param 1 Options - -test("ValidateBundle(Bundle,string):void with non-object for options", t => { - const input1: any = { - options: 22 - }; - t.throws(() => ValidateBundle(input1, "test" - ), "Property bundle>test>options must be an object and not null."); -}); - -test("ValidateBundle(Bundle,string):void with non-object for options>sprinkle", t => { - const input1: any = { - options: { - sprinkle: 22 - } - }; - t.throws(() => ValidateBundle(input1, "test" - ), "Property bundle>test>options>sprinkle must be an object and not null."); -}); - -test("ValidateBundle(Bundle,string):void ensures onCollision option is valid", t => { - // replace - const input1: Bundle = { - options: { - sprinkle: { - onCollision: "replace" - } - } - }; - t.notThrows(() => ValidateBundle(input1, "test" - )); - // merge - input1.options.sprinkle.onCollision = "merge"; - t.notThrows(() => ValidateBundle(input1, "test" - )); - // error - input1.options.sprinkle.onCollision = "error"; - t.notThrows(() => ValidateBundle(input1, "test" - )); - // ignore - input1.options.sprinkle.onCollision = "ignore"; - t.notThrows(() => ValidateBundle(input1, "test" - )); - - // Bad - input1.options.sprinkle.onCollision = "an invalid collision reaction"; - t.throws(() => ValidateBundle(input1, "test" - ), "Property bundle>test>options>sprinkle>onCollision must be a valid rule."); -}); diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index fbe9c45f..00000000 --- a/src/config.ts +++ /dev/null @@ -1,322 +0,0 @@ -import Extend from "just-extend"; -import { resolve as resolvePath } from "path"; - -/** - * Merges a collection of configurations. - * No validation is conducted, it is expected that provided inputs are all valid. - * - * `bundle->(BundleName)->options->sprinkle->onCollision = (replace|merge|ignore|error)` may be used to modify treatment of collided bundles. - */ -export function MergeRawConfigs(rawConfigs: Config[]): Config { - // No point doing processing if we've got only 1 item - if (rawConfigs.length === 1) return rawConfigs[0]; - - let outConfig: Config = {}; - - // Merge configs into base - rawConfigs.forEach(config => { - // Prevent modification of input - let nextConfig = Extend(true, {}, config) as Config; - - // Merge all bundle definitions into nextConfig (to handle collision logic correctly) - if (outConfig.bundle) { - // Ensure nextConfig has a bundle key - if (!nextConfig.bundle) - nextConfig.bundle = {}; - - for (const bundleName in outConfig.bundle) { - if (outConfig.bundle.hasOwnProperty(bundleName)) { - // Conduct merge if already defined on nextConfig - if (nextConfig.bundle.hasOwnProperty(bundleName)) { - try { - nextConfig.bundle[bundleName] = MergeBundle(outConfig.bundle[bundleName], nextConfig.bundle[bundleName]); - } - catch (exception) { - throw new Error(`Exception raised while merging bundle '${bundleName}' in the raw configuration at index '${rawConfigs.indexOf(config)}'.\n${exception}`); - } - } - // Otherwise just set it - else nextConfig.bundle[bundleName] = outConfig.bundle[bundleName]; - - // Remove existing bundle from outConfig - delete outConfig.bundle[bundleName]; - } - } - } - - // Merge objects - Extend(true, outConfig, nextConfig); - }); - - return outConfig; -} - -/** - * Merges 2 bundles, respecting the collision logic of the second bundle if specified. - * @param existingBundle Bundle to merge into. - * @param nextBundle Bundle bringing new content. - */ -export function MergeBundle(existingBundle: Bundle, nextBundle: Bundle): Bundle { - // Determine collision resolution strategy - let collisionReaction = CollisionReactions.replace; - - if (nextBundle.options - && nextBundle.options.sprinkle - && nextBundle.options.sprinkle.onCollision) { - collisionReaction = CollisionReactions[nextBundle.options.sprinkle.onCollision]; - } - - // Do merge - switch (collisionReaction) { - // Replace - Return the next bundle - case CollisionReactions.replace: - return nextBundle; - // Merge - case CollisionReactions.merge: { - // TODO Worth noting that there is no typing for Merge currently - // Merge arrays manually if needed - if (existingBundle.scripts && nextBundle.scripts) - nextBundle.scripts = [...new Set([...existingBundle.scripts, ...nextBundle.scripts])]; - if (existingBundle.styles && nextBundle.styles) - nextBundle.styles = [...new Set([...existingBundle.styles, ...nextBundle.styles])]; - - return Extend(true, existingBundle, nextBundle); - } - // Ignore - Return existing bundle - case CollisionReactions.ignore: - return existingBundle; - // Error, better known as EVERYBODY PANIC! - case CollisionReactions.error: - throw new Error(`The bundle has been previously defined, and the bundle's 'onCollision' property is set to 'error'.`); - default: - throw new Error(`Unexpected input '${nextBundle.options.sprinkle.onCollision}' for 'onCollision' option of next bundle.`); - } -} - -/** - * Throws an exception if the provided raw config contains invalid data. - * @param config Raw configuration to validate. - */ -export function ValidateRawConfig(config: Config): void { - // If bundle key exists, value must be an object - if ("bundle" in config) { - const bundles = config.bundle; - - if (typeof bundles !== "object" || bundles === null) { - throw new Error(`Property "bundle" must be an object and not null.`); - } - else { - // Each property must be an object (for owned properties) - for (const bundleName in bundles) { - if (bundles.hasOwnProperty(bundleName)) { - ValidateBundle(bundles[bundleName], bundleName); - } - } - } - } - - // If PathTransform key exists, value must be array - if ("VirtualPathRules" in config) { - const virtualPathRules = config.VirtualPathRules; - - if (!Array.isArray(virtualPathRules)) { - throw new Error(`Property "VirtualPathRules" must be an object and not null.`); - } - else { - // Matchers must all be unique, and all values must be not empty - const matchers = []; - for (const [matcher, replacement] of virtualPathRules) { - // Must be non-empty - if (matcher === "") { - throw new Error(`Value matcher of property "VirtualPathRules" is empty.`); - } - if (replacement === "") { - throw new Error(`Value replacement of property "VirtualPathRules" is empty.`); - } - - const resolved = resolvePath(matcher); - if (matchers.indexOf(resolved) !== -1) { - throw new Error(`Value matcher of property "VirtualPathRules" is a duplicate: "${matcher}"`); - } - else - matchers.push(matcher); - } - } - } -} - -/** - * Throws an exception if the provided bundle is invalid. - * @param bundle Bundle to analyse. - * @param name Name of bundle. - */ -export function ValidateBundle(bundle: Bundle, name: string): void { - if (typeof name !== "string") - throw new Error("Bundle name must be a string."); - - if (typeof bundle !== "object" || bundle === null) - throw new Error(`Property bundle>${name} must be an object and not null.`); - - // If scripts key exists, it must be an array of strings - if ("scripts" in bundle) { - const scripts = bundle.scripts; - - if (!Array.isArray(scripts)) - throw new Error(`Property bundle>${name}>scripts must be an array.`); - - scripts.forEach(path => { - if (typeof path !== "string") - throw new Error(`All indexes of bundle>${name}>scripts must be a string.`); - }); - } - - // If styles key exists, it must be an array of strings - if ("styles" in bundle) { - const styles = bundle.styles; - - if (!Array.isArray(styles)) - throw new Error(`Property bundle>${name}>styles must be an array.`); - - styles.forEach(path => { - if (typeof path !== "string") - throw new Error(`All indexes of bundle>${name}>styles must be a string.`); - }); - } - - // If options key exists, it must be an object - if ("options" in bundle) { - const options = bundle.options; - - if (typeof options !== "object" || options === null) - throw new Error(`Property bundle>${name}>options must be an object and not null.`); - - // If sprinkle key exists, value must be an object - if ("sprinkle" in options) { - const sprinkle = options.sprinkle; - - if (typeof sprinkle !== "object" || sprinkle === null) - throw new Error(`Property bundle>${name}>options>sprinkle must be an object and not null.`); - - // If onCollision exists, value must be a string and match a set of values - if ("onCollision" in sprinkle) { - if (typeof sprinkle.onCollision !== "string") - throw new Error(`Property bundle>${name}>options>sprinkle>onCollision must be a string.`); - if (["replace", "merge", "ignore", "error"].indexOf(sprinkle.onCollision) === -1) - throw new Error(`Property bundle>${name}>options>sprinkle>onCollision must be a valid rule.`); - } - } - } -} - -/** - * Root object of raw configuration. - */ -export interface Config { - /** - * Bundle definitions. - */ - bundle?: Bundles; - - /** - * Defines path transformations that are used for overriding files. - * Later rules result in a higher preference. - * Duplicate matcher paths will end up being ignored. - * All paths should be relative, or undefined behaviour may occur. - */ - VirtualPathRules?: [string, string][]; - - /** - * Base path bundle resources will be resolved against. - * Use to match against virtual path rules if they are used. - * Defaults to current working directory. - */ - BundlesVirtualBasePath?: string; - - /** - * Optional logger that will be used throughout bundling process. - * @param value Message to log. - * @param level Log level for message. - */ - Logger?(value: string, level: LogLevel): void; -} - -/** - * Log levels. - */ -export enum LogLevel { - /** - * A lot will be tagged as silly. - * These messages should only be used for debugging. - * Creativity may be required to distill anything useful. - */ - Silly, - /** - * General events of note such as progress milestones. - */ - Normal, - /** - * Anything that is notable but doesn't result in an error will be logged with this. - * This includes any detected bad practises or habits. - */ - Complain, - /** - * Shit is about to hit the fan. Expect an unrecoverable error very soon. - */ - Scream -} - -/** - * Map of bundles. - */ -interface Bundles { - [x: string]: Bundle; -} - -/** - * Represents an asset bundle - */ -export interface Bundle { - scripts?: string[]; - styles?: string[]; - options?: Options; -} - -/** - * Represents an asset bundles root options node. - */ -interface Options { - sprinkle?: SprinkleOptions; -} - -/** - * Options relevent to UserFrosting's Sprinkle system. - */ -interface SprinkleOptions { - /** - * - */ - onCollision?: CollisionReactions | string; -} - -/** - * Rules for how a bundle collision may be treated. - */ -enum CollisionReactions { - /** - * Replace the existing bundle. - */ - replace, - /** - * Merge with the existing bundle, with order preserved as much as possible. - * Colliding arrays will be prepended to the existing, keep an eye out for duplicates. - */ - merge, - /** - * Leave the existing bundle alone. - */ - ignore, - /** - * Throw an error on encountering an already defined bundle. - */ - error -} diff --git a/src/config/config.ts b/src/config/config.ts new file mode 100644 index 00000000..befe331d --- /dev/null +++ b/src/config/config.ts @@ -0,0 +1,89 @@ +import { LogLevel } from "../log-levels"; + +/** + * Rules for how a bundle collision may be treated. + */ +export enum CollisionReactions { + /** + * Replace the existing bundle. + */ + replace, + /** + * Merge with the existing bundle, with order preserved as much as possible. + * Colliding arrays will be prepended to the existing, keep an eye out for duplicates. + */ + merge, + /** + * Leave the existing bundle alone. + */ + ignore, + /** + * Throw an error on encountering an already defined bundle. + */ + error +} + +/** + * Options relevent to UserFrosting's Sprinkle system. + */ +interface SprinkleOptions { + /** + * TODO + */ + onCollision?: CollisionReactions | string; +} + +/** + * Represents an asset bundles root options node. + */ +interface Options { + sprinkle?: SprinkleOptions; +} + +/** + * Represents an asset bundle + */ +export interface Bundle { + scripts?: string[]; + styles?: string[]; + options?: Options; +} + +/** + * Root object of raw configuration. + */ +export interface Config { + /** + * Bundle definitions. + */ + bundle?: Bundles; + + /** + * Defines path transformations that are used for overriding files. + * Later rules result in a higher preference. + * Duplicate matcher paths will end up being ignored. + * All paths should be relative, or undefined behaviour may occur. + */ + VirtualPathRules?: [string, string][]; + + /** + * Base path bundle resources will be resolved against. + * Use to match against virtual path rules if they are used. + * Defaults to current working directory. + */ + BundlesVirtualBasePath?: string; + + /** + * Optional logger that will be used throughout bundling process. + * @param value Message to log. + * @param level Log level for message. + */ + Logger?(value: string, level: LogLevel): void; +} + +/** + * Map of bundles. + */ +interface Bundles { + [x: string]: Bundle; +} diff --git a/src/config/merge-bundle.test.ts b/src/config/merge-bundle.test.ts new file mode 100644 index 00000000..bc424aba --- /dev/null +++ b/src/config/merge-bundle.test.ts @@ -0,0 +1,301 @@ +import test from "ava"; +import MergeBundle from "./merge-bundle"; +import { Bundle } from "./config"; + +/** + * Should return empty results. + */ +test("Empty objects", t => { + const existingBundle: Bundle = {}; + const nextBundle: Bundle = {}; + const output: Bundle = {}; + t.deepEqual(MergeBundle(existingBundle, nextBundle), output); +}); + +/** + * Should return incoming bundle (2nd input) when no collision rule is set (replace). + */ +test("No collision rule set", t => { + const existingBundle: Bundle = { + scripts: [ + "foo.js" + ], + styles: [ + "foo.css" + ] + }; + const nextBundle: Bundle = { + scripts: [ + "bar.js", + "zeta.js" + ] + }; + const output: Bundle = { + scripts: [ + "bar.js", + "zeta.js" + ] + }; + t.deepEqual(MergeBundle(existingBundle, nextBundle), output); +}); + +/** + * Should return incoming bundle (2nd input) when replace collision rule is set. + */ +test("Collision rule set to replace.", t => { + const existingBundle: Bundle = { + scripts: [ + "foo.js" + ] + }; + const nextBundle: Bundle = { + scripts: [ + "bar.js", + "zeta.js" + ], + styles: [ + "foo.css" + ], + options: { + sprinkle: { + onCollision: "replace" + } + } + }; + const output: Bundle = { + scripts: [ + "bar.js", + "zeta.js" + ], + styles: [ + "foo.css" + ], + options: { + sprinkle: { + onCollision: "replace" + } + } + }; + t.deepEqual(MergeBundle(existingBundle, nextBundle), output); +}); + +/** + * Should return results of a logical merge of the bundles when merge collision rule is used. + * Merged arrays should be concatenated with contents of first being at top. + */ +test("Collision rule set to merge without duplicate resources", t => { + const existingBundle: Bundle = { + scripts: [ + "foo.js" + ] + }; + const nextBundle: Bundle = { + scripts: [ + "bar.js", + "zeta.js" + ], + styles: [ + "foo.css" + ], + options: { + sprinkle: { + onCollision: "merge" + } + } + }; + const output: Bundle = { + scripts: [ + "foo.js", + "bar.js", + "zeta.js" + ], + styles: [ + "foo.css" + ], + options: { + sprinkle: { + onCollision: "merge" + } + } + }; + t.deepEqual(MergeBundle(existingBundle, nextBundle), output); +}); + +/** + * Should return results of a logical merge of the bundles when merge collision rule is used. + * Merged arrays should be concatenated with contents of first being at top, and later duplicates removed. + */ +test("Collision rule set to merge with duplicate resources", t => { + const existingBundle: Bundle = { + scripts: [ + "foo.js" + ], + styles: [ + "test.css" + ] + }; + const nextBundle: Bundle = { + scripts: [ + "bar.js", + "foo.js", + "zeta.js" + ], + styles: [ + "test.css" + ], + options: { + sprinkle: { + onCollision: "merge" + } + } + }; + const output: Bundle = { + scripts: [ + "foo.js", + "bar.js", + "zeta.js" + ], + styles: [ + "test.css" + ], + options: { + sprinkle: { + onCollision: "merge" + } + } + }; + t.deepEqual(MergeBundle(existingBundle, nextBundle), output); +}); + +/** + * Should return results of a logical merge of the bundles when merge collision rule is used. + * Merged arrays should be concatenated with contents of first being at top, and later duplicates removed. + */ +test("Collision rule set to merge with empty target bundle", t => { + const existingBundle: Bundle = {}; + const nextBundle: Bundle = { + scripts: [ + "bar.js", + "foo.js", + "zeta.js" + ], + options: { + sprinkle: { + onCollision: "merge" + } + } + }; + const output: Bundle = { + scripts: [ + "bar.js", + "foo.js", + "zeta.js" + ], + options: { + sprinkle: { + onCollision: "merge" + } + } + }; + t.deepEqual(MergeBundle(existingBundle, nextBundle), output); +}); + +/** + * Should return target bundle (1st input) when ignore collision rule is used. + */ +test("Collision rules set to ignore", t => { + const existingBundle: Bundle = { + scripts: [ + "foo.js" + ] + }; + const nextBundle: Bundle = { + scripts: [ + "bar.js", + "zeta.js" + ], + styles: [ + "foo.css" + ], + options: { + sprinkle: { + onCollision: "ignore" + } + } + }; + const output: Bundle = { + scripts: [ + "foo.js" + ] + }; + t.deepEqual(MergeBundle(existingBundle, nextBundle), output); +}); + +/** + * Should throw an error when error collision rule is set. + */ +test("Collision rule set to error", t => { + const existingBundle: Bundle = { + scripts: [ + "foo.js" + ] + }; + const nextBundle: Bundle = { + scripts: [ + "bar.js", + "zeta.js" + ], + styles: [ + "foo.css" + ], + options: { + sprinkle: { + onCollision: "error" + } + } + }; + t.throws( + () => MergeBundle(existingBundle, nextBundle), + "The bundle has been previously defined, and the bundle's 'onCollision' property is set to 'error'." + ); +}); + +/** + * Should use collision rules of incoming bundle. + */ +test("Ignore collision rule on target bundle", t => { + const existingBundle: Bundle = { + scripts: [ + "foo.js" + ], + options: { + sprinkle: { + onCollision: "error" + } + } + }; + const nextBundle: Bundle = { + scripts: [ + "bar.js", + "zeta.js" + ], + styles: [ + "foo.css" + ] + }; + const output: Bundle = { + scripts: [ + "bar.js", + "zeta.js" + ], + styles: [ + "foo.css" + ] + }; + t.notThrows( + () => MergeBundle(existingBundle, nextBundle), + "The bundle has been previously defined, and the bundle's 'onCollision' property is set to 'error'." + ); + t.deepEqual(MergeBundle(existingBundle, nextBundle), output); + +}); diff --git a/src/config/merge-bundle.ts b/src/config/merge-bundle.ts new file mode 100644 index 00000000..f9ebf7ab --- /dev/null +++ b/src/config/merge-bundle.ts @@ -0,0 +1,44 @@ +import { Bundle, CollisionReactions } from "./config"; +import Extend from "just-extend"; + +/** + * Merges 2 bundles, respecting the collision logic of the second bundle if specified. + * @param existingBundle Bundle to merge into. + * @param nextBundle Bundle bringing new content. + */ +export default function MergeBundle(existingBundle: Bundle, nextBundle: Bundle): Bundle { + // Determine collision resolution strategy + let collisionReaction = CollisionReactions.replace; + + if (nextBundle.options + && nextBundle.options.sprinkle + && nextBundle.options.sprinkle.onCollision) { + collisionReaction = CollisionReactions[nextBundle.options.sprinkle.onCollision]; + } + + // Do merge + switch (collisionReaction) { + // Replace - Return the next bundle + case CollisionReactions.replace: + return nextBundle; + // Merge + case CollisionReactions.merge: { + // Merge arrays manually if needed + if (existingBundle.scripts && nextBundle.scripts) + nextBundle.scripts = [...new Set([...existingBundle.scripts, ...nextBundle.scripts])]; + if (existingBundle.styles && nextBundle.styles) + nextBundle.styles = [...new Set([...existingBundle.styles, ...nextBundle.styles])]; + + // TODO Worth noting that there is no typing for Extend currently + return Extend(true, existingBundle, nextBundle); + } + // Ignore - Return existing bundle + case CollisionReactions.ignore: + return existingBundle; + // Error, better known as EVERYBODY PANIC! + case CollisionReactions.error: + throw new Error(`The bundle has been previously defined, and the bundle's 'onCollision' property is set to 'error'.`); + default: + throw new Error(`Unexpected input '${nextBundle.options.sprinkle.onCollision}' for 'onCollision' option of next bundle.`); + } +} diff --git a/src/config/merge-configs.test.ts b/src/config/merge-configs.test.ts new file mode 100644 index 00000000..b5865f57 --- /dev/null +++ b/src/config/merge-configs.test.ts @@ -0,0 +1,166 @@ +import test from "ava"; +import MergeConfig from "./merge-configs"; +import { Config } from "./config"; + +/** + * Should return empty object. + */ +test("Single empty object", t => { + t.deepEqual(MergeConfig([{}]), {}); +}); + +/** + * Should return empty object. + */ +test("Multiple empty objects", t => { + t.deepEqual(MergeConfig([{}, {}, {}]), {}); +}); + +/** + * Should return object with empty bundle key. + */ +test("First object with bundle property and second object empty", t => { + t.deepEqual(MergeConfig([{ bundle: {}}, {}]), { bundle: {}}); +}); + +/** + * Should return object equviliant to input. + */ +test("Single object", t => { + const config: Config = { + bundle: { + testBundle: { + scripts: [ + "foo.js" + ] + } + } + }; + const expected: Config = { + bundle: { + testBundle: { + scripts: [ + "foo.js" + ] + } + } + }; + + t.deepEqual(MergeConfig([config]), expected); +}); + +/** + * Should return result of logical merge of inputs. + * Bundles should be merged according to merge rules the incoming bundle defines, defaulting to replacement. + */ +test("Multiple objects", t => { + const config1: Config = { + bundle: { + testBundle1: { + scripts: [ + "foo.js" + ] + } + } + }; + const config2: Config = { + bundle: { + testBundle2: { + scripts: [ + "bar.js" + ] + } + } + }; + const config3: Config = { + bundle: { + testBundle1: { + styles: [ + "foo.css" + ] + } + } + }; + const expected: Config = { + bundle: { + testBundle1: { + styles: [ + "foo.css" + ] + }, + testBundle2: { + scripts: [ + "bar.js" + ] + } + } + }; + + t.deepEqual(MergeConfig([config1, config2, config3]), expected); +}); + +/** + * Should throw when an invalid collision rule is specified on an incoming bundle. + */ +test("Colliding bundle with invalid collision rule on incoming bundle", t => { + const config1: Config = { + bundle: { + testBundle: { + scripts: [ + "foo.js" + ] + } + } + }; + const config2: Config = { + bundle: { + testBundle: { + scripts: [ + "bar.js" + ], + options: { + sprinkle: { + onCollision: "badCollisionHandler" + } + } + } + } + }; + + t.throws( + () => MergeConfig([config1, config2]), + "Exception raised while merging bundle 'testBundle' in the raw configuration at index '1'.\n" + + "Error: Unexpected input 'badCollisionHandler' for 'onCollision' option of next bundle." + ); +}); + +/** + * Should now throw when an invalid collision rule is specified on a target bundle. + */ +test("Colliding bundle with invalid collision rule on target bundle", t => { + const input1: Config = { + bundle: { + testBundle: { + scripts: [ + "foo.js" + ], + options: { + sprinkle: { + onCollision: "badCollisionHandler" + } + } + } + } + }; + const input2: Config = { + bundle: { + testBundle: { + scripts: [ + "bar.js" + ] + } + } + }; + + t.notThrows(() => MergeConfig([input1, input2])); +}); diff --git a/src/config/merge-configs.ts b/src/config/merge-configs.ts new file mode 100644 index 00000000..381efa71 --- /dev/null +++ b/src/config/merge-configs.ts @@ -0,0 +1,54 @@ +import { Config } from "./config"; +import MergeBundle from "./merge-bundle"; +import Extend from "just-extend"; + +/** + * Merges a collection of configurations. + * No validation is conducted, it is expected that provided inputs are all valid. + * + * `bundle->(BundleName)->options->sprinkle->onCollision = (replace|merge|ignore|error)` may be used to modify treatment of collided bundles. + */ +export default function MergeConfigs(rawConfigs: Config[]): Config { + // No point doing processing if we've got only 1 item + if (rawConfigs.length === 1) return rawConfigs[0]; + + let outConfig: Config = {}; + + // Merge configs into base + rawConfigs.forEach(config => { + // Prevent modification of input + let nextConfig = Extend(true, {}, config) as Config; + + // Merge all bundle definitions into nextConfig (to handle collision logic correctly) + if (outConfig.bundle) { + // Ensure nextConfig has a bundle key + if (!nextConfig.bundle) + nextConfig.bundle = {}; + + for (const bundleName in outConfig.bundle) { + /* istanbul ignore else */ + if (outConfig.bundle.hasOwnProperty(bundleName)) { + // Conduct merge if already defined on nextConfig + if (nextConfig.bundle.hasOwnProperty(bundleName)) { + try { + nextConfig.bundle[bundleName] = MergeBundle(outConfig.bundle[bundleName], nextConfig.bundle[bundleName]); + } + catch (exception) { + throw new Error(`Exception raised while merging bundle '${bundleName}' in the raw configuration at index '${rawConfigs.indexOf(config)}'.\n${exception}`); + } + } + // Otherwise just set it + else nextConfig.bundle[bundleName] = outConfig.bundle[bundleName]; + + // Remove existing bundle from outConfig + delete outConfig.bundle[bundleName]; + } + } + } + + // Merge objects + Extend(true, outConfig, nextConfig); + }); + + return outConfig; +} diff --git a/src/config/validate-bundle.test.ts b/src/config/validate-bundle.test.ts new file mode 100644 index 00000000..f953c3ea --- /dev/null +++ b/src/config/validate-bundle.test.ts @@ -0,0 +1,197 @@ +import test from "ava"; +import { Bundle } from "./config"; +import ValidateBundle from "./validate-bundle"; + +/** + * Should complete without throwing. + */ +test("Empty object", t => { + t.notThrows(() => ValidateBundle({}, "test")); +}); + +/** + * Should thow if the bundle is not an object. + */ +test("Non-object bundle", t => { + const bundle: any = "a string"; + t.throws( + () => ValidateBundle(bundle, "test"), + "Property bundle>test must be an object and not null." + ); +}); + +/** + * Should throw if the bundle name is not a string. + */ +test("Non-string bundle name", t => { + const bundleName: any = 22; + t.throws( + () => ValidateBundle({}, bundleName), + "Bundle name must be a string." + ); +}); + +/** + * Should throw if the scripts property of bundle is not an array. + */ +test("Non-array for scripts", t => { + const bundle: any = { + scripts: "a string" + }; + t.throws( + () => ValidateBundle(bundle, "test"), + "Property bundle>test>scripts must be an array." + ); +}); + +/** + * Should throw if an index of scripts array of bundle is not a string. + */ +test("Array containing non-strings for scripts", t => { + const bundle: any = { + scripts: [ + "foo.js", + () => "magic.js", + 22 + ] + }; + t.throws( + () => ValidateBundle(bundle, "test"), + "All indexes of bundle>test>scripts must be a string." + ); +}); + +/** + * Should complete without throwing. + */ +test("Valid array for scripts", t => { + const bundle: Bundle = { + scripts: [ + "foo.js", + "bar.js" + ] + }; + t.notThrows(() => ValidateBundle(bundle, "test")); +}); + +/** + * Should throw if the styles property of bundle is not an array. + */ +test("Non-array for styles", t => { + const bundle: any = { + styles: "a string" + }; + t.throws( + () => ValidateBundle(bundle, "test"), + "Property bundle>test>styles must be an array." + ); +}); + +/** + * Should throw if an index of styles array of bundle is not a string. + */ +test("Array containing non-strings for styles", t => { + const bundle: any = { + styles: [ + "foo.css", + () => "magic.css", + 22 + ] + }; + t.throws( + () => ValidateBundle(bundle, "test"), + "All indexes of bundle>test>styles must be a string." + ); +}); + +/** + * Should complete without throwing. + */ +test("Valid array for styles", t => { + const bundle: Bundle = { + styles: [ + "foo.css", + "bar.css" + ] + }; + t.notThrows(() => ValidateBundle(bundle, "test")); +}); + +/** + * Should throw if the options property of bundle is not an object. + */ +test("Non-object for options", t => { + const bundle: any = { + options: 22 + }; + t.throws( + () => ValidateBundle(bundle, "test"), + "Property bundle>test>options must be an object and not null." + ); +}); + +/** + * Should throw if the sprinkle property of options of bundle is not an object. + */ +test("Non-object for options>sprinkle", t => { + const bundle: any = { + options: { + sprinkle: 22 + } + }; + t.throws( + () => ValidateBundle(bundle, "test"), + "Property bundle>test>options>sprinkle must be an object and not null." + ); +}); + +/** + * Should complete without throwing for all valid collision rules. + * Should throw when given an invalid collision rule. + */ +test("All possible collision rules", t => { + // replace + const bundle: Bundle = { + options: { + sprinkle: { + onCollision: "replace" + } + } + }; + t.notThrows(() => ValidateBundle(bundle, "test")); + // merge + bundle.options.sprinkle.onCollision = "merge"; + t.notThrows(() => ValidateBundle(bundle, "test")); + // error + bundle.options.sprinkle.onCollision = "error"; + t.notThrows(() => ValidateBundle(bundle, "test")); + // ignore + bundle.options.sprinkle.onCollision = "ignore"; + t.notThrows(() => ValidateBundle(bundle, "test")); + + // Bad + bundle.options.sprinkle.onCollision = "an invalid collision reaction"; + t.throws( + () => ValidateBundle(bundle, "test"), + "Property bundle>test>options>sprinkle>onCollision must be a valid rule." + ); +}); + +/** + * Should throw if the sprinkle property of options of bundle is not an object. + */ +test("Non-string for options>sprinkle>onCollision", t => { + const bundle: any = { + options: { + sprinkle: { + onCollision: { + is: "a complicated beast" + } + } + } + }; + t.throws( + () => ValidateBundle(bundle, "test"), + "Property bundle>test>options>sprinkle>onCollision must be a string." + ); +}); diff --git a/src/config/validate-bundle.ts b/src/config/validate-bundle.ts new file mode 100644 index 00000000..4e1084c8 --- /dev/null +++ b/src/config/validate-bundle.ts @@ -0,0 +1,64 @@ +import { Bundle } from "./config"; + +/** + * Throws an exception if the provided bundle is invalid. + * @param bundle Bundle to analyse. + * @param name Name of bundle. + */ +export default function ValidateBundle(bundle: Bundle, name: string): void { + if (typeof name !== "string") + throw new Error("Bundle name must be a string."); + + if (typeof bundle !== "object" || bundle === null) + throw new Error(`Property bundle>${name} must be an object and not null.`); + + // If scripts key exists, it must be an array of strings + if ("scripts" in bundle) { + const scripts = bundle.scripts; + + if (!Array.isArray(scripts)) + throw new Error(`Property bundle>${name}>scripts must be an array.`); + + scripts.forEach(path => { + if (typeof path !== "string") + throw new Error(`All indexes of bundle>${name}>scripts must be a string.`); + }); + } + + // If styles key exists, it must be an array of strings + if ("styles" in bundle) { + const styles = bundle.styles; + + if (!Array.isArray(styles)) + throw new Error(`Property bundle>${name}>styles must be an array.`); + + styles.forEach(path => { + if (typeof path !== "string") + throw new Error(`All indexes of bundle>${name}>styles must be a string.`); + }); + } + + // If options key exists, it must be an object + if ("options" in bundle) { + const options = bundle.options; + + if (typeof options !== "object" || options === null) + throw new Error(`Property bundle>${name}>options must be an object and not null.`); + + // If sprinkle key exists, value must be an object + if ("sprinkle" in options) { + const sprinkle = options.sprinkle; + + if (typeof sprinkle !== "object" || sprinkle === null) + throw new Error(`Property bundle>${name}>options>sprinkle must be an object and not null.`); + + // If onCollision exists, value must be a string and match a set of values + if ("onCollision" in sprinkle) { + if (typeof sprinkle.onCollision !== "string") + throw new Error(`Property bundle>${name}>options>sprinkle>onCollision must be a string.`); + if (["replace", "merge", "ignore", "error"].indexOf(sprinkle.onCollision) === -1) + throw new Error(`Property bundle>${name}>options>sprinkle>onCollision must be a valid rule.`); + } + } + } +} diff --git a/src/config/validate-config.test.ts b/src/config/validate-config.test.ts new file mode 100644 index 00000000..9e68e669 --- /dev/null +++ b/src/config/validate-config.test.ts @@ -0,0 +1,125 @@ +import test from "ava"; +import ValidateConfig from "./validate-config"; +import { Config } from "./config"; +import { resolve as resolvePath } from "path"; + +/** + * Should complete without throwing. + */ +test("Empty object", t => { + t.notThrows(() => ValidateConfig({})); +}); + +/** + * Should complete without throwing. + */ +test("Empty object bundle property", t => { + const config: any = { + bundle: { + foo: {} + } + } + t.notThrows(() => ValidateConfig(config)); +}); + +/** + * Should complete without throwing. + */ +test("Valid bundle property", t => { + const config: Config = { + bundle: {} + } + t.notThrows(() => ValidateConfig(config)); +}); + +/** + * Should throw when bundle property is not an object. + */ +test("Non-object bundle property", t => { + const config: any = { + bundle: "a string" + } + t.throws( + () => ValidateConfig(config), + `Property "bundle" must be an object and not null.` + ); +}); + +/** + * Should complete without throwing. + */ +test("Valid virtual path rules", t => { + const config: Config = { + VirtualPathRules: [ + ["test", "testtest"] + ] + } + t.notThrows(() => ValidateConfig(config)); +}); + +/** + * Should throw when an invalid empty matcher is used for virtual path rules. + */ +test("Empty matcher virtual path rules", t => { + const config: Config = { + VirtualPathRules: [ + ["", "testtest"] + ] + } + t.throws( + () => ValidateConfig(config), + `Value matcher of property "VirtualPathRules" is empty.` + ); +}); + +/** + *Should throw when an invalid empty replacement is used for virtual path rules. + */ +test("Empty replacement virtual path rules", t => { + const config: Config = { + VirtualPathRules: [ + ["test", ""] + ] + } + t.throws( + () => ValidateConfig(config), + `Value replacement of property "VirtualPathRules" is empty.` + ); +}); + +/** + *Should throw when an invalid empty replacement is used for virtual path rules. + */ +test("Non-array for replacement virtual path rules", t => { + const config1: any = { + VirtualPathRules: "not-an-array!" + } + t.throws( + () => ValidateConfig(config1), + `Property "VirtualPathRules" must be an object and not null.` + ); + + const config2: any = { + VirtualPathRules: null + } + t.throws( + () => ValidateConfig(config2), + `Property "VirtualPathRules" must be an object and not null.` + ); +}); + +/** + * Should throw when a matcher has a dupliate. + */ +test("Duplicate matcher virtual path rules", t => { + const config: Config = { + VirtualPathRules: [ + ["dup", "testtest"], + ["dup", "test"] + ] + } + t.throws( + () => ValidateConfig(config), + `Value matcher of property "VirtualPathRules" has a duplicate "dup" which resolves to "${resolvePath("dup")}"` + ); +}); diff --git a/src/config/validate-config.ts b/src/config/validate-config.ts new file mode 100644 index 00000000..39bb40fa --- /dev/null +++ b/src/config/validate-config.ts @@ -0,0 +1,55 @@ +import { resolve as resolvePath } from "path"; +import { Config } from "./config"; +import ValidateBundle from "./validate-bundle"; + +/** + * Throws an exception if the provided raw config contains invalid data. + * @param config Raw configuration to validate. + */ +export default function ValidateConfig(config: Config): void { + // If bundle key exists, value must be an object + if ("bundle" in config) { + const bundles = config.bundle; + + if (typeof bundles !== "object" || bundles === null) { + throw new Error(`Property "bundle" must be an object and not null.`); + } + else { + // Each property must be an object (for owned properties) + for (const bundleName in bundles) { + if (bundles.hasOwnProperty(bundleName)) { + ValidateBundle(bundles[bundleName], bundleName); + } + } + } + } + + // If PathTransform key exists, value must be array + if ("VirtualPathRules" in config) { + const virtualPathRules = config.VirtualPathRules; + + if (!Array.isArray(virtualPathRules)) { + throw new Error(`Property "VirtualPathRules" must be an object and not null.`); + } + else { + // Matchers must all be unique, and all values must be not empty + const matchers = []; + for (const [matcher, replacement] of virtualPathRules) { + // Must be non-empty + if (matcher === "") { + throw new Error(`Value matcher of property "VirtualPathRules" is empty.`); + } + if (replacement === "") { + throw new Error(`Value replacement of property "VirtualPathRules" is empty.`); + } + + const resolved = resolvePath(matcher); + if (matchers.indexOf(resolved) !== -1) { + throw new Error(`Value matcher of property "VirtualPathRules" has a duplicate "${matcher}" which resolves to "${resolved}"`); + } + else + matchers.push(resolved); + } + } + } +} diff --git a/src/log-levels.ts b/src/log-levels.ts new file mode 100644 index 00000000..4ae4a72d --- /dev/null +++ b/src/log-levels.ts @@ -0,0 +1,24 @@ +/** + * Log levels. + */ +export enum LogLevel { + /** + * A lot will be tagged as silly. + * These messages should only be used for debugging. + * Creativity may be required to distill anything useful. + */ + Silly, + /** + * General events of note such as progress milestones. + */ + Normal, + /** + * Anything that is notable but doesn't result in an error will be logged with this. + * This includes any detected bad practises or habits. + */ + Complain, + /** + * Shit is about to hit the fan. Expect an unrecoverable error very soon. + */ + Scream +} diff --git a/src/main.test.ts b/src/main.test.ts index 3953af05..6ae1b811 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,59 +1,327 @@ -import test from "ava"; +import test, { ExecutionContext } from "ava"; import Bundler, { Bundlers } from "./main"; -import { Config } from "./config"; -import { Transform, Readable } from "stream"; +import { Readable, Stream } from "stream"; import { Catcher } from "./catcher"; import Vinyl from "vinyl"; +import { Config } from "./config/config"; +import { SimplePluginError } from "plugin-error"; +import { resolve as resolvePath } from "path"; +import { stringify } from "querystring"; -test("Bundler basic scenario", async t => { - const config: Config = { +/** + * Generic joiner to use for mocking the bundling of resources. + */ +const Joiner: Bundlers = { + Scripts: stream => stream, + Styles: stream => stream +}; +/** + * Should complete without throwing, return all files from input stream, and have an empty map returned to the callback. + */ +test("Bundler basic success scenario", async t => { + // Create bundler args + const args: BundlerArgs = { + Config: {}, + Joiner, + BundleResultsCb: results => t.deepEqual(results, new Map()) }; - const joiner: Bundlers = { - Scripts: () => { - return new Transform({ - objectMode: true - }); - }, - Styles: () => { - return new Transform({ - objectMode: true - }); - } - } - // Results - const assetMapResult: Map = new Map(); - const streamResult: any[] = [ + // Define inputs + const streamInputs = [ {}, "test", 21 ]; - // Stream source + // Define expected outputs + const expected = [ + {}, + "test", + 21 + ]; + + // Test + await testBundlerResults(t, args, streamInputs, expected); +}); + +/** + * Should complete without throwing, return all files from input stream, and have Vinyl null file objects sent to results callback. + */ +test("Bundler complex success scenario 1", async t => { + // Create bundler args + const args: BundlerArgs = { + Config: { + bundle: { + test: { + styles: [ + "test.css" + ], + scripts: [ + "test.js" + ] + } + }, + Logger: () => {} + }, + Joiner, + BundleResultsCb: results => testBundlerResultsCallbackData(t, results, new Map([ ['test', [new Vinyl( {path: resolvePath("test.css")}), new Vinyl( {path: resolvePath("test.js")})]]])) + }; + + // Define inputs + const streamInputs = [ + new Vinyl({ + path: resolvePath("test.css"), + contents: Buffer.from(".test { color: #121435; }") + }), + new Vinyl({ + path: resolvePath("test.js"), + contents: Buffer.from("const the = 'thing';") + }) + ]; + + // Define expected outputs + const expected = [ + new Vinyl({ + path: resolvePath("test.css"), + contents: Buffer.from(".test { color: #121435; }") + }), + // Returned by joiner + new Vinyl({ + path: resolvePath("test.css"), + contents: Buffer.from(".test { color: #121435; }") + }), + new Vinyl({ + path: resolvePath("test.js"), + contents: Buffer.from("const the = 'thing';") + }), + // Returned by joiner + new Vinyl({ + path: resolvePath("test.js"), + contents: Buffer.from("const the = 'thing';") + }) + ]; + + // Test + await testBundlerResults(t, args, streamInputs, expected); +}); + +/** + * Should complete without throwing, and return all files from input stream. + */ +test("Bundler complex success scenario 2", async t => { + // Create bundler args + const args: BundlerArgs = { + Config: { + bundle: { + test1: { + styles: [ + "magicdir/test.css" + ] + }, + test2: { + scripts: [ + "magicdir/test.js" + ] + } + }, + VirtualPathRules: [ + ['testdir', 'magicdir'], + ['tdir', 'magicdir'] + ] + }, + Joiner + }; + + // Define inputs + const streamInputs = [ + new Vinyl({ + path: resolvePath("tdir/test.css"), + contents: Buffer.from(".test { color: #121435; }") + }), + // Will end up being ignored + new Vinyl({ + path: resolvePath("testdir/test.css"), + contents: Buffer.from(".test { color: #121435; }") + }), + // Will end up being ignored + new Vinyl({ + path: resolvePath("testdir/test.js"), + contents: Buffer.from("const the = 'thing';") + }), + new Vinyl({ + path: resolvePath("tdir/test.js"), + contents: Buffer.from("const the = 'thing';") + }) + ]; + + // Define expected outputs + const expected = [ + new Vinyl({ + path: resolvePath("tdir/test.css"), + contents: Buffer.from(".test { color: #121435; }") + }), + // Returned by joiner + new Vinyl({ + path: resolvePath("tdir/test.css"), + contents: Buffer.from(".test { color: #121435; }") + }), + new Vinyl({ + path: resolvePath("tdir/test.js"), + contents: Buffer.from("const the = 'thing';") + }), + // Returned by joiner + new Vinyl({ + path: resolvePath("tdir/test.js"), + contents: Buffer.from("const the = 'thing';") + }) + ]; + + // Test + await testBundlerResults(t, args, streamInputs, expected); +}); + +/** + * Should throw. + */ +test("Bundler basic failure scenario", async t => { + // Create bundler args + const args: BundlerArgs = { + Config: { + bundle: { + test: { + styles: [ + "testpath.css", + "test.css" + ] + } + }, + }, + Joiner + }; + + // Define inputs + const streamInputs = [ + new Vinyl({contents: Buffer.from("test"), path: "testpath.css"}) + ]; + + // Test + t.plan(1); + + try { + await bundlerExceptionHoist(args, streamInputs) + } + catch (e) { + t.is((e as SimplePluginError).message, `No file could be resolved for "${resolvePath("./testpath.css")}".`); + } +}); + +/** + * Compares virtual files based on their declared path, returning a number that indicates their position. + * @param a Entity one + * @param b Entity two + */ +function vinylComparator(a: Vinyl, b: Vinyl): number { + return a.path.localeCompare(b.path); +} + +interface BundlerArgs { + Config: Config; + Joiner: Bundlers; + BundleResultsCb?: (results: Map) => void; +} + +/** + * Creates bundler and data source stream. + * @param args Arguments to be passed to bundler. + * @param streamContents Objects to be feed into bundler via stream. + */ +function createBundler(args: BundlerArgs, streamContents: any[]): Stream { + // Create bundler + const bundler = new Bundler(args.Config, args.Joiner, args.BundleResultsCb); + + // Build source stream const stream = new Readable({ objectMode: true, read: function() { - for (const chunk of streamResult) { + for (const chunk of streamContents) { this.push(chunk); } this.push(null); } }); - // Build results map callback test - t.plan(2); - const bundleResultsMapTest = (results: Map) => { - t.deepEqual(assetMapResult, results); - }; + // Assemble stream and return + return stream + .pipe(bundler); +} - // Run stream - const bundler = new Bundler(config, joiner, bundleResultsMapTest); +/** + * Helper class that builds bundler and verifies stream output. + * @param t Execution context used for test. + * @param args Arguments passed to bundler. + * @param streamContents Objects to be feed into bundler via stream. + * @param expected Expected result, order insensitive. + */ +async function testBundlerResults(t: ExecutionContext, args: BundlerArgs, streamContents: any[], expected: any) { + // Create bundler + const bundler = createBundler(args, streamContents); + + // Create catcher (so we can see what the results are) const catcher = new Catcher(() => {}); - stream - .pipe(bundler) + + // Run bundler + bundler .pipe(catcher); - // Check stream outputs (order is unimportant) - t.deepEqual(streamResult.sort(), (await catcher.Collected).sort()); -}); + function comparator(a, b): number { + if (a.path && b.path) { + return vinylComparator(a, b); + } + else { + if (a < b) return -1; + if (a > b) return 1; + return 0; + } + } + + // Inspect results + t.deepEqual((await catcher.Collected).sort(comparator), expected.sort(comparator)); +} + +function testBundlerResultsCallbackData(t: ExecutionContext, actual: Map, expected: Map) { + t.is(actual.size, expected.size); + actual.forEach((files, bundleName) => { + t.true(expected.has(bundleName)); + t.deepEqual(files.sort(vinylComparator), expected.get(bundleName).sort(vinylComparator)); + }); +} + +/** + * Returns a promise that will hoist bundler exceptions into an accessible scope. + * @param args Arguments passed to bundler. + * @param streamContents Objects to be feed into bundler via stream. + */ +function bundlerExceptionHoist(args: BundlerArgs, streamContents: any[]): Promise { + return new Promise((resolve, reject) => { + // Create bundler + const bundler = createBundler(args, streamContents); + + // Create catcher (so we can detect completion) + const catcher = new Catcher(() => {}); + + // Run bundler + bundler + .on("error", (e) => { + reject(e) + }) + .pipe(catcher) + .on("error", (e) => { + reject(e) + }); + + catcher.Collected.then(() => { + resolve(); + }); + }) +} diff --git a/src/main.ts b/src/main.ts index 46922181..2de559ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,16 @@ import Extend from "just-extend"; import { resolve as resolvePath } from "path"; import PluginError from "plugin-error"; -import { Readable, Transform, TransformCallback } from "stream"; +import { Readable, Transform, TransformCallback, Stream } from "stream"; import Vinyl from "vinyl"; import { BundlesProcessor } from "./bundles-processor"; -import { Config, LogLevel } from "./config"; +import { LogLevel } from "./log-levels"; import { PluginName } from "./plugin-details"; +import { Config } from "./config/config"; // Foward public exports -export { MergeRawConfigs, ValidateRawConfig } from "./config"; +export { default as MergeRawConfigs } from "./config/merge-configs"; +export { default as ValidateRawConfig } from "./config/validate-config"; /** * Assists in orchastrating bundle operations. @@ -102,7 +104,7 @@ export default class Bundler extends Transform { for (const path of bundle.scripts) { this.Logger(`Original path: ${path}`, LogLevel.Silly); const resolvedPath = resolvePath(config.BundlesVirtualBasePath + "/" + path); - this.Logger(`Resolved path: ${resolvePath}`, LogLevel.Silly); + this.Logger(`Resolved path: "${resolvedPath}"`, LogLevel.Silly); paths.push(resolvedPath); } this.ScriptBundles.set(name, paths); @@ -116,7 +118,7 @@ export default class Bundler extends Transform { for (const path of bundle.styles) { this.Logger(`Original path: ${path}`, LogLevel.Silly); const resolvedPath = resolvePath(config.BundlesVirtualBasePath + "/" + path); - this.Logger(`Resolved path: ${resolvePath}`, LogLevel.Silly); + this.Logger(`Resolved path: ${resolvedPath}`, LogLevel.Silly); paths.push(resolvedPath); } this.StyleBundles.set(name, paths); @@ -194,8 +196,9 @@ export default class Bundler extends Transform { callback(); } catch (error) { - // Shouldn't ever hit this, but ensures errors are piped properly in the worst case scenario. + /* istanbul ignore next: Applying coverage here is out of scope as errors produced cannot yet be predicted. */ this.Logger("_transform completed with error", LogLevel.Scream); + /* istanbul ignore next */ callback(new PluginError(PluginName, error)); } } @@ -210,7 +213,6 @@ export default class Bundler extends Transform { this.Logger("Starting bundling of scripts", LogLevel.Normal); let [chunks, resultsMap] = await BundlesProcessor(this.ResolvedFiles, this.ScriptBundles, this.Bundlers.Scripts, this.Logger); - for (const chunk of chunks) { this.push(chunk); } @@ -229,8 +231,14 @@ export default class Bundler extends Transform { this.push(chunk); } - for (const [name, paths] of resultsMap) - this.BundleResultsMap.set(name, paths); + for (const [name, paths] of resultsMap) { + if (this.BundleResultsMap.has(name)) { + const allPaths = this.BundleResultsMap.get(name); + allPaths.push(...paths) + this.BundleResultsMap.set(name, allPaths); + } + else this.BundleResultsMap.set(name, paths); + } this.Logger("Completed bundling of styles", LogLevel.Normal); @@ -254,7 +262,7 @@ Actual path: "${chunk.path}`, LogLevel.Silly); callback(); } catch (error) { - // Shouldn't ever hit this, but ensures errors are piped properly in the worst case scenario. + // Ideally this shouldn't ever be needed, however we *are* dealing with external data. this.Logger("_flush completed with error", LogLevel.Scream); callback(new PluginError(PluginName, error)); } @@ -283,5 +291,5 @@ export interface BundlerStreamFactory { /** * @param name Name of bundle. */ - (src: Readable, name: string): Transform; + (src: Readable, name: string): Stream; }