Skip to content

Commit bf0a088

Browse files
committed
feat: support collapsing/overwriting technically-illegal duplicate plugin/preset PluginItems
1 parent 72b06e4 commit bf0a088

File tree

14 files changed

+309
-10
lines changed

14 files changed

+309
-10
lines changed

README.md

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -318,8 +318,23 @@ this package.
318318
This is used to configure babel. If provided, the object will be
319319
[`lodash.mergeWith`][lodash.mergewith]'d with the [defaults][25] and each [test
320320
object's `babelOptions`][26]/[fixture's `babelOptions`][27], with the latter
321-
taking precedence. Note that arrays will be concatenated and explicitly
322-
undefined values will unset previously defined values during merging.
321+
taking precedence.
322+
323+
Be aware that arrays will be concatenated and explicitly undefined values will
324+
unset previously defined values during merging.
325+
326+
> [!IMPORTANT]
327+
>
328+
> For `babel-plugin-tester@>=12`, [duplicate entries][2] in
329+
> [`babelOptions.plugins`][55] and [`babelOptions.presets`][73] are reduced,
330+
> with latter entries _completely overwriting_ any that came before. In other
331+
> words: the last duplicate plugin or preset configuration wins. **They are not
332+
> merged.** This makes it easy to provide an alternative one-off configuration
333+
> for a plugin or preset that is also used elsewhere, such as a project's root
334+
> `babel.config.js` file.
335+
>
336+
> Attempting the same with `babel-plugin-tester@<12` will cause babel [to
337+
> throw][2] since duplicate entries are technically not allowed.
323338
324339
Also note that [`babelOptions.babelrc`][28] and [`babelOptions.configFile`][29]
325340
are set to `false` by default, which disables automatic babel configuration
@@ -329,6 +344,8 @@ To simply reuse your project's [`babel.config.js`][31] or some other
329344
configuration file, set `babelOptions` like so:
330345

331346
```javascript
347+
// file: /repos/my-project/tests/unit-plugin.test.ts
348+
332349
import path from 'node:path';
333350
import { pluginTester } from 'babel-plugin-tester';
334351

@@ -795,9 +812,12 @@ of which are optional:
795812
796813
This is used to configure babel. Properties specified here override
797814
([`lodash.mergeWith`][lodash.mergewith]) those from the [`babelOptions`][65]
798-
option provided to babel-plugin-tester. Note that arrays will be concatenated
799-
and explicitly undefined values will unset previously defined values during
800-
merging.
815+
option provided to babel-plugin-tester.
816+
817+
Note that arrays will be concatenated, explicitly undefined values will unset
818+
previously defined values, and (as of `babel-plugin-tester@>=12`) duplicate
819+
plugin/preset configurations will override each other (last configuration wins)
820+
during merging.
801821
802822
###### `pluginOptions`
803823
@@ -1027,9 +1047,12 @@ which are optional:
10271047

10281048
This is used to configure babel. Properties specified here override
10291049
([`lodash.mergeWith`][lodash.mergewith]) those from the [`babelOptions`][65]
1030-
option provided to babel-plugin-tester. Note that arrays will be concatenated
1031-
and explicitly undefined values will unset previously defined values during
1032-
merging.
1050+
option provided to babel-plugin-tester.
1051+
1052+
Note that arrays will be concatenated, explicitly undefined values will unset
1053+
previously defined values, and (as of `babel-plugin-tester@>=12`) duplicate
1054+
plugin/preset configurations will override each other (last configuration wins)
1055+
during merging.
10331056

10341057
###### `pluginOptions`
10351058

@@ -1687,6 +1710,8 @@ work, and you do not mind the [default run order][100], you can leverage
16871710
[babel's automatic configuration loading][101] via the `babelOptions.babelrc`
16881711
and/or `babelOptions.configFile` options.
16891712

1713+
> [!TIP]
1714+
>
16901715
> Fixtures provided via the [`fixtures`][35] option **do not** need to provide a
16911716
> separate `babelOptions.filename` since it will be set automatically. This
16921717
> section only applies to [test objects][42].
@@ -1967,6 +1992,8 @@ The following [debug namespaces][109] are available for activation:
19671992
- `babel-plugin-tester:tester:read-code`
19681993
- `babel-plugin-tester:tester:eol`
19691994
- `babel-plugin-tester:tester:finalize`
1995+
- `babel-plugin-tester:tester:finalize:order`
1996+
- `babel-plugin-tester:tester:finalize:duplicates`
19701997

19711998
<!-- lint enable list-item-style -->
19721999

@@ -2257,6 +2284,8 @@ specification. Contributions of any kind welcome!
22572284
[x-repo-sponsor]: https://github.com/sponsors/Xunnamius
22582285
[x-repo-support]: /.github/SUPPORT.md
22592286
[1]: https://www.npmjs.com/package/babel-plugin-tester?activeTab=versions
2287+
[2]:
2288+
https://stackoverflow.com/questions/52798987/babel-7-fails-with-single-plugin-saying-duplicate-plugin-preset-detected
22602289
[3]: https://babeljs.io/docs/en/presets
22612290
[4]: https://jestjs.io
22622291
[5]: https://mochajs.org
@@ -2311,6 +2340,7 @@ specification. Contributions of any kind welcome!
23112340
[52]: #fixtureoutputext-1
23122341
[53]: #titlenumbering
23132342
[54]: #filepath
2343+
[55]: https://babeljs.io/docs/options#plugins
23142344
[56]: #outputjs
23152345
[57]: #endofline
23162346
[58]: #execjs
@@ -2330,6 +2360,7 @@ specification. Contributions of any kind welcome!
23302360
[71]:
23312361
https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/no-instanceof-array.md
23322362
[72]: https://nodejs.org/api/vm.html#vm-executing-javascript
2363+
[73]: https://babeljs.io/docs/options#presets
23332364
[77]: https://stackoverflow.com/a/32750746/1367414
23342365
[78]: #outputraw
23352366
[79]: #teardown-1

src/plugin-tester.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import stripIndent from 'strip-indent~3';
1313
import { ErrorMessage } from 'universe:errors.ts';
1414
import { $type } from 'universe:symbols.ts';
1515

16+
import type { PluginItem } from '@babel/core';
1617
import type { Class } from 'type-fest';
1718

1819
import type {
@@ -830,6 +831,7 @@ function pluginTester(options: PluginTesterOptions = {}) {
830831

831832
verbose3('partially constructed fixture-based test object: %O', testConfig);
832833

834+
// ! Invariant: test plugin/preset is always first/last respectively
833835
if (plugin) {
834836
testConfig.babelOptions.plugins.push([
835837
plugin,
@@ -843,6 +845,8 @@ function pluginTester(options: PluginTesterOptions = {}) {
843845
}
844846

845847
finalizePluginAndPresetRunOrder(testConfig.babelOptions);
848+
reduceDuplicatePluginsAndPresets(testConfig.babelOptions);
849+
846850
verbose3('finalized fixture-based test object: %O', testConfig);
847851

848852
validateTestConfig(testConfig);
@@ -998,6 +1002,8 @@ function pluginTester(options: PluginTesterOptions = {}) {
9981002
}
9991003

10001004
finalizePluginAndPresetRunOrder(testConfig.babelOptions);
1005+
reduceDuplicatePluginsAndPresets(testConfig.babelOptions);
1006+
10011007
verbose3('finalized test object: %O', testConfig);
10021008

10031009
validateTestConfig(testConfig, {
@@ -1630,7 +1636,7 @@ function trimAndFixLineEndings(
16301636
function finalizePluginAndPresetRunOrder(
16311637
babelOptions: PluginTesterOptions['babelOptions']
16321638
) {
1633-
const { verbose: verbose2 } = getDebuggers('finalize', debug1);
1639+
const { verbose: verbose2 } = getDebuggers('finalize:order', debug1);
16341640

16351641
if (babelOptions?.plugins) {
16361642
babelOptions.plugins = babelOptions.plugins.filter((p) => {
@@ -1686,6 +1692,116 @@ function finalizePluginAndPresetRunOrder(
16861692
verbose2('finalized test object plugin and preset run order');
16871693
}
16881694

1695+
/**
1696+
* Collapses duplicate plugin/preset entries down to a single plugin/preset
1697+
* entry. The last duplicate plugin/preset (i.e. highest index) wins, the rest
1698+
* are essentially deleted.
1699+
*
1700+
* This function accounts for the three generic babel plugin/preset
1701+
* configurations: string, 2-tuple, and 3-tuple.
1702+
*
1703+
* @see {@link PluginItem}
1704+
*/
1705+
function reduceDuplicatePluginsAndPresets(
1706+
babelOptions: PluginTesterOptions['babelOptions']
1707+
) {
1708+
const { verbose: verbose2 } = getDebuggers('finalize:duplicates', debug1);
1709+
1710+
if (babelOptions?.plugins) {
1711+
const plugins: typeof babelOptions.plugins = [];
1712+
1713+
babelOptions.plugins.forEach((incomingPlugin) => {
1714+
if (incomingPlugin && typeof incomingPlugin !== 'symbol') {
1715+
const incomingPluginName = pluginOrPresetToName(incomingPlugin);
1716+
1717+
if (incomingPluginName) {
1718+
const duplicatedPluginIndex = plugins.findIndex((outgoingPlugin) => {
1719+
if (outgoingPlugin && typeof outgoingPlugin !== 'symbol') {
1720+
const outgoingPluginName = pluginOrPresetToName(outgoingPlugin);
1721+
return outgoingPluginName === incomingPluginName;
1722+
}
1723+
});
1724+
1725+
if (duplicatedPluginIndex !== -1) {
1726+
verbose2(
1727+
'collapsed duplicate plugin configuration for %O (at index %O)',
1728+
incomingPluginName,
1729+
duplicatedPluginIndex
1730+
);
1731+
1732+
plugins[duplicatedPluginIndex] = incomingPlugin;
1733+
return;
1734+
}
1735+
}
1736+
}
1737+
1738+
plugins.push(incomingPlugin);
1739+
});
1740+
1741+
babelOptions.plugins = plugins;
1742+
}
1743+
1744+
if (babelOptions?.presets) {
1745+
const presets: typeof babelOptions.presets = [];
1746+
1747+
babelOptions.presets.forEach((incomingPreset) => {
1748+
if (incomingPreset && typeof incomingPreset !== 'symbol') {
1749+
const incomingPresetName = pluginOrPresetToName(incomingPreset);
1750+
1751+
if (incomingPresetName) {
1752+
const duplicatedPresetIndex = presets.findIndex((outgoingPreset) => {
1753+
if (outgoingPreset && typeof outgoingPreset !== 'symbol') {
1754+
const outgoingPresetName = pluginOrPresetToName(outgoingPreset);
1755+
return outgoingPresetName === incomingPresetName;
1756+
}
1757+
});
1758+
1759+
if (duplicatedPresetIndex !== -1) {
1760+
verbose2(
1761+
'collapsed duplicate preset configuration for %O (at index %O)',
1762+
incomingPresetName,
1763+
duplicatedPresetIndex
1764+
);
1765+
1766+
presets[duplicatedPresetIndex] = incomingPreset;
1767+
return;
1768+
}
1769+
}
1770+
}
1771+
1772+
presets.push(incomingPreset);
1773+
});
1774+
1775+
babelOptions.presets = presets;
1776+
}
1777+
1778+
verbose2('collapsed duplicate test object plugins and presets');
1779+
1780+
/**
1781+
* Note that, due to how flexible Babel configuration is, not all plugins or
1782+
* presets will have a name.
1783+
*
1784+
* This function is a _much_ more generic version of `tryInferPluginName`.
1785+
* TODO: perhaps they should be merged?
1786+
*/
1787+
function pluginOrPresetToName(pluginOrPreset: PluginItem) {
1788+
if (typeof pluginOrPreset === 'string') {
1789+
return pluginOrPreset;
1790+
}
1791+
1792+
if (Array.isArray(pluginOrPreset)) {
1793+
const candidate = pluginOrPreset.at(2) ?? pluginOrPreset.at(0);
1794+
return typeof candidate === 'string' ? candidate : undefined;
1795+
}
1796+
1797+
if (typeof pluginOrPreset === 'object' && 'name' in pluginOrPreset) {
1798+
return typeof pluginOrPreset.name === 'string' ? pluginOrPreset.name : undefined;
1799+
}
1800+
1801+
return undefined;
1802+
}
1803+
}
1804+
16891805
/**
16901806
* Determines if `numericPrefix` equals at least one number or is covered by at
16911807
* least one range Range in the `ranges` array.

test/fixtures/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ structure similar to the following:
88

99
```text
1010
fixture-name-here
11-
└── fixture
11+
└── fixture (or some more specific sub-name)
1212
   └── ... (one or more fixture-specific files like `code.js` or `options.js`)
1313
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
'use strict';
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = {
2+
pluginOptions: {
3+
appendExtension: '.mjs'
4+
},
5+
babelOptions: {
6+
plugins: ['@babel/plugin-syntax-jsx']
7+
}
8+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
'use strict';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
babelOptions: {
3+
filename: '/fake/filepath.ts',
4+
plugins: [['@babel/plugin-syntax-typescript', { isTSX: true }]]
5+
}
6+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"fixtureOutputExt": ".mjs",
3+
"pluginOptions": {
4+
"replaceExtensions": {
5+
".ts": ".xjs"
6+
}
7+
},
8+
"babelOptions": {
9+
"plugins": [
10+
[
11+
"@babel/plugin-syntax-typescript",
12+
{ "isTSX": true, "somethingOrOther": 5 }
13+
],
14+
["@babel/plugin-syntax-jsx", { "somethingOrOther": 6 }],
15+
[
16+
"@babel/plugin-syntax-typescript",
17+
{ "isTSX": true, "somethingOrOther": 7 },
18+
"ok-duplicate-plugin-syntax-typescript"
19+
],
20+
[
21+
"@babel/plugin-syntax-jsx",
22+
{ "somethingOrOther": 8 },
23+
"ok-duplicate-plugin-syntax-jsx"
24+
]
25+
]
26+
}
27+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
'use strict';
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = {
2+
presetOptions: {
3+
appendExtension: '.mjs'
4+
},
5+
babelOptions: {
6+
presets: ['@babel/preset-react']
7+
}
8+
};

0 commit comments

Comments
 (0)