Skip to content

Commit

Permalink
add no-deprecated-mount-options
Browse files Browse the repository at this point in the history
  • Loading branch information
snoozbuster committed Jun 8, 2022
1 parent f335463 commit 762feaf
Show file tree
Hide file tree
Showing 7 changed files with 543 additions and 0 deletions.
105 changes: 105 additions & 0 deletions docs/rules/no-deprecated-mount-options.md
@@ -0,0 +1,105 @@
# Checks that mount and shallowMount options are valid for the current version of VTU. (vue-test-utils/no-deprecated-mount-options)

The `--fix` option on the command line can automatically fix some of the problems reported by this rule.

## Rule Details

This rule reports when mount options that are deprecated or unsupported in the current version of VTU are used.

### Options

This rule has an object option:

- `ignoreMountOptions` can be set to an array of property names that are ignored when checking for deprecated mount options.
- This option is primarily useful when using a compatibility layer for Vue 3 or VTU 2 such as `@vue/compat` or `vue-test-utils-compat`.

Examples of **incorrect** code for this rule:

```js
/* eslint vue-test-utils/no-deprecated-mount-options: "error" */
import { mount } from '@vue/test-utils';

// VTU 1
mount(MyComponent, {
attachToDocument: true,
});

mount(MyComponent, {
computed: { /* ... */ }
methods: { /* ... */ },
});

// VTU 2
mount(MyComponent, {
propsData: { /* ... */ }
})

mount(MyComponent, {
stubs: [/* ... */]
})
```

Examples of **correct** code for this rule:

```js
/* eslint vue-test-utils/no-deprecated-mount-options: "error" */
import { mount } from '@vue/test-utils';

// VTU 1
mount(MyComponent, {
attachTo: document.body,
});

mount(MyComponent, {
mixins: [
/* ... */
],
});

// VTU 2
mount(MyComponent, {
props: {
/* ... */
},
});

mount(MyComponent, {
global: {
stubs: [
/* ... */
],
},
});
```

Examples of **correct** code with the `{ "ignoreMountOptions": ["store", "scopedSlots"] }` option:

```js
/* eslint vue-test-utils/no-deprecated-mount-options: ["error", { "ignoreMountOptions": ["store", "scopedSlots"] }] */
import { mount } from '@vue/test-utils';

// VTU 2
mount(MyComponent, {
store: createStore(/* ... */),
});

mount(MyComponent, {
scopedSlots: {
/* ... */
},
});
```

## Limitations

- This rule cannot detect mount options if they are passed via a variable (eg, `let context = { methods: {} }; mount(Foo, context)` will never error).
- This rule cannot detect mount options passed via object spread or if the mount option keys are not identifiers (eg, `mount(Foo, { ...context }))` and `mount(Foo, { ['methods']: {} })` will never error).

## When Not To Use It

- You don't plan to update to Vue 3/VTU 2

## Further Reading

- [VTU 1 mount options](https://vue-test-utils.vuejs.org/api/options.html#context)
- [VTU 2 mount options](https://test-utils.vuejs.org/api/#mount)
3 changes: 3 additions & 0 deletions src/index.js
@@ -1,17 +1,20 @@
const noDeprecatedMountOptions = require('./rules/no-deprecated-mount-options');
const noDeprecatedWrappers = require('./rules/no-deprecated-wrapper-functions');

//------------------------------------------------------------------------------
// Plugin Definition
//------------------------------------------------------------------------------

module.exports.rules = {
'no-deprecated-mount-options': noDeprecatedMountOptions,
'no-deprecated-wrapper-functions': noDeprecatedWrappers,
};

module.exports.configs = {
recommended: {
plugins: ['vue-test-utils'],
rules: {
'vue-test-utils/no-deprecated-mount-options': 'error',
'vue-test-utils/no-deprecated-wrapper-functions': 'error',
},
},
Expand Down
36 changes: 36 additions & 0 deletions src/rules/checkVtuVersion.js
@@ -0,0 +1,36 @@
const semver = require('semver');

let cachedVtuVersion;

function detectVtuVersion() {
if (cachedVtuVersion) {
return cachedVtuVersion;
}

try {
// eslint-disable-next-line node/no-missing-require
const vtuPackageJson = require('@vue/test-utils/package.json');
if (vtuPackageJson.version) {
return (cachedVtuVersion = vtuPackageJson.version);
}
} catch {
/* intentionally empty */
}

throw new Error(
'Unable to detect installed VTU version. Please ensure @vue/test-utils is installed, or set the version explicitly.'
);
}

/**
*
* @param {string} vtuVersion VTU version to check against
* @param {string} targetVersion version to check
* @returns {boolean} if vtuVersion is greater than or equal to target version
*/
function isVtuVersionAtLeast(vtuVersion, targetVersion) {
return semver.gte(vtuVersion, targetVersion);
}

module.exports = isVtuVersionAtLeast;
module.exports.detectVtuVersion = detectVtuVersion;
1 change: 1 addition & 0 deletions src/rules/constants.js
@@ -0,0 +1 @@
module.exports.VTU_PLUGIN_SETTINGS_KEY = 'vtu';
200 changes: 200 additions & 0 deletions src/rules/no-deprecated-mount-options.js
@@ -0,0 +1,200 @@
const { get } = require('lodash');
const path = require('path');
const isVtuVersionAtLeast = require('./checkVtuVersion');
const { VTU_PLUGIN_SETTINGS_KEY } = require('./constants');
const { isVtuImport } = require('./utils');
const { detectVtuVersion } = isVtuVersionAtLeast;

/**
* @type {import('eslint').Rule.RuleModule}
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow deprecated mount options',
url: path.join(__dirname, '../../docs/rules/no-deprecated-mount-options.md'),
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
ignoreMountOptions: {
description: 'List of mount option property names to ignore',
type: 'array',
items: {
type: 'string',
},
},
},
},
], // Add a schema if the rule has options
messages: {
deprecatedMountOption:
'The mount option `{{ mountOption }}` is deprecated and will be removed in VTU 2.{{ replacementOption }}',
unknownMountOption:
'The mount option `{{ mountOption }}` is relying on component option merging and will have no effect in VTU 2.',
syncIsRemoved: 'The mount option `sync` was removed in VTU 1.0.0-beta.30 and has no effect.',
},
},

create(context) {
const allowedMountOptions = (context.options[0] && context.options[0].ignoreMountOptions) || [];
const vtuVersion = get(context.settings, [VTU_PLUGIN_SETTINGS_KEY, 'version']) || detectVtuVersion();

const sourceCode = context.getSourceCode();

const isComma = token => {
return token.type === 'Punctuator' && token.value === ',';
};

const getPropertyName = property =>
property.key.type === 'Identifier' ? property.key.name : property.key.value;

function deleteProperty(/** @type {import('eslint').Rule.RuleFixer} */ fixer, property) {
const afterProperty = sourceCode.getTokenAfter(property);
const hasComma = isComma(afterProperty);

return fixer.removeRange([property.range[0], hasComma ? afterProperty.range[1] : property.range[1]]);
}

const isVtu2 = isVtuVersionAtLeast(vtuVersion, '2.0.0');

const removedOptions = {
// deprecated or replaceable in vtu 1/vue 2
attachToDocument: {
replacementOption: 'attachTo',
fixer: (/** @type {import('eslint').Rule.RuleFixer} */ fixer, property) => [
fixer.replaceText(property.key, 'attachTo'),
fixer.replaceText(property.value, 'document.body'),
],
},
parentComponent: null,
filters: null,

// removed or moved in vtu 2
context: null,
listeners: {
replacementOption: 'props',
},
stubs: {
replacementOption: 'global.stubs',
},
mocks: {
replacementOption: 'global.mocks',
},
propsData: {
replacementOption: 'props',
},
provide: {
replacementOption: 'global.provide',
},
localVue: {
replacementOption: 'global',
},
scopedSlots: {
replacementOption: 'slots',
},

// not explicitly removed but has trivial replacement
components: {
replacementOption: 'global.components',
},
directives: {
replacementOption: 'global.directives',
},
mixins: {
replacementOption: 'global.mixins',
},
store: {
replacementOption: 'global.plugins',
},
router: {
replacementOption: 'global.plugins',
},
};

const knownValidMountOptions = isVtu2
? new Set(['attachTo', 'attrs', 'data', 'props', 'slots', 'global', 'shallow'])
: new Set([
'context',
'data',
'slots',
'scopedSlots',
'stubs',
'mocks',
'localVue',
'attachTo',
'attrs',
'propsData',
'provide',
'listeners',

// these properties technically rely on configuration merging
// with the underlying component but are common practice and
// have an autofixable replacement in VTU 2
'components',
'directives',
'mixins',
'store',
'router',
]);
// add user-whitelisted options
allowedMountOptions.forEach(opt => knownValidMountOptions.add(opt));

const mountFunctionNames = new Set(['mount', 'shallowMount']);

return {
CallExpression(node) {
if (node.callee.type !== 'Identifier' || !mountFunctionNames.has(node.callee.name)) {
return;
}
if (!isVtuImport(node.callee, context.getScope())) {
return;
}

const mountOptionsNode = node.arguments[1];
if (!mountOptionsNode || mountOptionsNode.type !== 'ObjectExpression') {
// second argument is not object literal
return;
}

// filter out object spreads
/** @type {import('estree').Property[]} */
const properties = mountOptionsNode.properties.filter(({ type }) => type === 'Property');

properties.forEach(property => {
if (property.key.type !== 'Identifier' && property.key.type !== 'Literal') {
return;
}
const keyName = getPropertyName(property);
if (keyName === 'sync' && isVtuVersionAtLeast(vtuVersion, '1.0.0-beta.30')) {
context.report({
messageId: 'syncIsRemoved',
node: property,
fix(fixer) {
return deleteProperty(fixer, property);
},
});
} else if (!knownValidMountOptions.has(keyName)) {
context.report({
messageId: !(keyName in removedOptions) ? 'unknownMountOption' : 'deprecatedMountOption',
node: property,
fix:
removedOptions[keyName] && removedOptions[keyName].fixer
? fixer => removedOptions[keyName].fixer(fixer, property, mountOptionsNode)
: undefined,
data: {
mountOption: keyName,
replacementOption: removedOptions[keyName]
? ` Use '${removedOptions[keyName].replacementOption}' instead.`
: '',
},
});
}
});
},
};
},
};

0 comments on commit 762feaf

Please sign in to comment.