diff --git a/docs/rules/index.md b/docs/rules/index.md index 1ba5978d2..a88f14e81 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -250,6 +250,7 @@ For example: | [vue/no-restricted-v-on](./no-restricted-v-on.md) | disallow specific argument in `v-on` | | :hammer: | | [vue/no-root-v-if](./no-root-v-if.md) | disallow `v-if` directives on root element | | :hammer: | | [vue/no-setup-props-reactivity-loss](./no-setup-props-reactivity-loss.md) | disallow usages that lose the reactivity of `props` passed to `setup` | | :hammer: | +| [vue/no-shadow-native-events](./no-shadow-native-events.md) | disallow the use of event names that collide with native web event names | | :warning: | | [vue/no-static-inline-styles](./no-static-inline-styles.md) | disallow static inline `style` attributes | | :hammer: | | [vue/no-template-target-blank](./no-template-target-blank.md) | disallow target="_blank" attribute without rel="noopener noreferrer" | :bulb: | :warning: | | [vue/no-this-in-before-route-enter](./no-this-in-before-route-enter.md) | disallow `this` usage in a `beforeRouteEnter` method | | :warning: | diff --git a/docs/rules/no-shadow-native-events.md b/docs/rules/no-shadow-native-events.md new file mode 100644 index 000000000..f9581f4b9 --- /dev/null +++ b/docs/rules/no-shadow-native-events.md @@ -0,0 +1,57 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/no-shadow-native-events +description: disallow the use of event names that collide with native web event names +--- + +# vue/no-shadow-native-events + +> disallow the use of event names that collide with native web event names + +- :exclamation: _**This rule has not been released yet.**_ + +## :book: Rule Details + +This rule reports emits that shadow native HTML events. + +Using native event names for emits can lead to incorrect assumptions about an emit and cause confusion. This is caused by Vue emits behaving differently from native events. E.g. : + +- The payload of an emit can be chosen arbitrarily +- Vue emits do not bubble, while most native events do +- [Event modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers) only work on HTML events or when the original event is re-emitted as emit payload. +- When the native event is re-emitted, the `event.target` might not match the actual event-listeners location. + +The rule is mostly aimed at developers of component libraries. + + + +```vue + +``` + + + +## :wrench: Options + +Nothing. + +## :couple: Related Rules + +- [vue/no-unused-emit-declarations](./no-unused-emit-declarations.md) +- [vue/require-explicit-emits](./require-explicit-emits.md) + +## :books: Further Reading + +- [Components In-Depth - Events / Component Events](https://vuejs.org/guide/components/events.html#event-arguments) +- [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-shadow-native-events.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-shadow-native-events.js) diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md index d04dec8b3..507f47398 100644 --- a/docs/rules/require-explicit-emits.md +++ b/docs/rules/require-explicit-emits.md @@ -114,10 +114,11 @@ export default { - [vue/no-unused-emit-declarations](./no-unused-emit-declarations.md) - [vue/require-explicit-slots](./require-explicit-slots.md) +- [vue/no-shadow-native-events](./no-shadow-native-events.md) ## :books: Further Reading -- [Guide - Custom Events / Defining Custom Events](https://v3.vuejs.org/guide/component-custom-events.html#defining-custom-events) +- [Components In-Depth - Events / Component Events](https://vuejs.org/guide/components/events.html#event-arguments) - [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) ## :rocket: Version diff --git a/lib/index.js b/lib/index.js index bb4abf40f..01dd41745 100644 --- a/lib/index.js +++ b/lib/index.js @@ -160,6 +160,7 @@ const plugin = { 'no-root-v-if': require('./rules/no-root-v-if'), 'no-setup-props-destructure': require('./rules/no-setup-props-destructure'), 'no-setup-props-reactivity-loss': require('./rules/no-setup-props-reactivity-loss'), + 'no-shadow-native-events': require('./rules/no-shadow-native-events'), 'no-shared-component-data': require('./rules/no-shared-component-data'), 'no-side-effects-in-computed-properties': require('./rules/no-side-effects-in-computed-properties'), 'no-spaces-around-equal-signs-in-attribute': require('./rules/no-spaces-around-equal-signs-in-attribute'), diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js new file mode 100644 index 000000000..b13be9c6f --- /dev/null +++ b/lib/rules/no-shadow-native-events.js @@ -0,0 +1,349 @@ +/** + * @author Jonathan Carle + * See LICENSE file in root directory for full license. + */ +'use strict' + +const utils = require('../utils') +const domEvents = require('../utils/dom-events.json') +const { findVariable } = require('@eslint-community/eslint-utils') +/** + * @typedef {import('../utils').ComponentEmit} ComponentEmit + * @typedef {import('../utils').ComponentProp} ComponentProp + * @typedef {import('../utils').VueObjectData} VueObjectData + * @typedef {import('./require-explicit-emits.js').NameWithLoc} NameWithLoc + */ + +/** + * Get the name param node from the given CallExpression + * @param {CallExpression} node CallExpression + * @returns { NameWithLoc | null } + */ +function getNameParamNode(node) { + const nameLiteralNode = node.arguments[0] + if (nameLiteralNode && utils.isStringLiteral(nameLiteralNode)) { + const name = utils.getStringLiteralValue(nameLiteralNode) + if (name != null) { + return { name, loc: nameLiteralNode.loc, range: nameLiteralNode.range } + } + } + + // cannot check + return null +} + +/** + * Check if the given name matches defineEmitsNode variable name + * @param {string} name + * @param {CallExpression | undefined} defineEmitsNode + * @returns {boolean} + */ +function isEmitVariableName(name, defineEmitsNode) { + const node = defineEmitsNode?.parent + + if (node?.type === 'VariableDeclarator' && node.id.type === 'Identifier') { + return name === node.id.name + } + + return false +} + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'disallow the use of event names that collide with native web event names', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/no-shadow-native-events.html' + }, + schema: [], + messages: { + violation: + 'Use a different emit name to avoid shadowing the native event with name "{{ name }}". Consider an emit name which communicates the users intent, if applicable.' + } + }, + /** @param {RuleContext} context */ + create(context) { + /** @type {Map, emitReferenceIds: Set }>} */ + const setupContexts = new Map() + + /** + * Tracks violating emit definitions, so that calls of this emit are not reported additionally. + * @type {Set} + * */ + const definedAndReportedEmits = new Set() + + /** + * @typedef {object} VueTemplateDefineData + * @property {'export' | 'mark' | 'definition' | 'setup'} type + * @property {ObjectExpression | Program} define + * @property {CallExpression} [defineEmits] + */ + /** @type {VueTemplateDefineData | null} */ + let vueTemplateDefineData = null + + const programNode = context.getSourceCode().ast + if (utils.isScriptSetup(context)) { + // init + vueTemplateDefineData = { + type: 'setup', + define: programNode + } + } + + /** + * Verify if an emit call violates the rule of not using a native dom event name. + * @param {NameWithLoc} nameWithLoc + */ + function verifyEmit(nameWithLoc) { + const name = nameWithLoc.name.toLowerCase() + if (!domEvents.includes(name) || definedAndReportedEmits.has(name)) { + return + } + context.report({ + loc: nameWithLoc.loc, + messageId: 'violation', + data: { + name + } + }) + } + + /** + * Verify if an emit declaration violates the rule of not using a native dom event name. + * @param {ComponentEmit[]} emits + */ + const verifyEmitDeclaration = (emits) => { + for (const { node, emitName } of emits) { + if (!node || !emitName || !domEvents.includes(emitName.toLowerCase())) { + continue + } + + definedAndReportedEmits.add(emitName) + context.report({ + messageId: 'violation', + data: { name: emitName }, + loc: node.loc + }) + } + } + + const callVisitor = { + /** + * @param {CallExpression} node + * @param {VueObjectData} [info] + */ + CallExpression(node, info) { + const callee = utils.skipChainExpression(node.callee) + const nameWithLoc = getNameParamNode(node) + if (!nameWithLoc) { + // cannot check + return + } + const vueDefineNode = info ? info.node : programNode + + let emit + if (callee.type === 'MemberExpression') { + const name = utils.getStaticPropertyName(callee) + if (name === 'emit' || name === '$emit') { + emit = { name, member: callee } + } + } + + // verify setup context + const setupContext = setupContexts.get(vueDefineNode) + if (setupContext) { + const { contextReferenceIds, emitReferenceIds } = setupContext + if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) { + // verify setup(props,{emit}) {emit()} + verifyEmit(nameWithLoc) + } else if (emit && emit.name === 'emit') { + const memObject = utils.skipChainExpression(emit.member.object) + if ( + memObject.type === 'Identifier' && + contextReferenceIds.has(memObject) + ) { + // verify setup(props,context) {context.emit()} + verifyEmit(nameWithLoc) + } + } + } + + // verify $emit + if (emit && emit.name === '$emit') { + const memObject = utils.skipChainExpression(emit.member.object) + if (utils.isThis(memObject, context)) { + // verify this.$emit() + verifyEmit(nameWithLoc) + } + } + } + } + + return utils.compositingVisitors( + utils.defineTemplateBodyVisitor( + context, + { + /** @param { CallExpression } node */ + CallExpression(node) { + const callee = utils.skipChainExpression(node.callee) + const nameWithLoc = getNameParamNode(node) + if (!nameWithLoc) { + // cannot check + return + } + + // e.g. $emit() / emit() in template + if ( + callee.type === 'Identifier' && + (callee.name === '$emit' || + (vueTemplateDefineData?.defineEmits && + isEmitVariableName( + callee.name, + vueTemplateDefineData.defineEmits + ))) + ) { + verifyEmit(nameWithLoc) + } + } + }, + utils.compositingVisitors( + utils.defineScriptSetupVisitor(context, { + onDefineEmitsEnter: (node, emits) => { + verifyEmitDeclaration(emits) + if ( + vueTemplateDefineData && + vueTemplateDefineData.type === 'setup' + ) { + vueTemplateDefineData.defineEmits = node + } + + if ( + !node.parent || + node.parent.type !== 'VariableDeclarator' || + node.parent.init !== node + ) { + return + } + + const emitParam = node.parent.id + const variable = + emitParam.type === 'Identifier' + ? findVariable(utils.getScope(context, emitParam), emitParam) + : null + if (!variable) { + return + } + /** @type {Set} */ + const emitReferenceIds = new Set() + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + emitReferenceIds.add(reference.identifier) + } + setupContexts.set(programNode, { + contextReferenceIds: new Set(), + emitReferenceIds + }) + }, + ...callVisitor + }), + utils.defineVueVisitor(context, { + onSetupFunctionEnter(node, { node: vueNode }) { + const contextParam = node.params[1] + if (!contextParam) { + // no arguments + return + } + if (contextParam.type === 'RestElement') { + // cannot check + return + } + if (contextParam.type === 'ArrayPattern') { + // cannot check + return + } + /** @type {Set} */ + const contextReferenceIds = new Set() + /** @type {Set} */ + const emitReferenceIds = new Set() + if (contextParam.type === 'ObjectPattern') { + const emitProperty = utils.findAssignmentProperty( + contextParam, + 'emit' + ) + if (!emitProperty) { + return + } + const emitParam = emitProperty.value + // `setup(props, {emit})` + const variable = + emitParam.type === 'Identifier' + ? findVariable( + utils.getScope(context, emitParam), + emitParam + ) + : null + if (!variable) { + return + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + emitReferenceIds.add(reference.identifier) + } + } else if (contextParam.type === 'Identifier') { + // `setup(props, context)` + const variable = findVariable( + utils.getScope(context, contextParam), + contextParam + ) + if (!variable) { + return + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + contextReferenceIds.add(reference.identifier) + } + } + setupContexts.set(vueNode, { + contextReferenceIds, + emitReferenceIds + }) + }, + onVueObjectEnter(node) { + const emits = utils.getComponentEmitsFromOptions(node) + verifyEmitDeclaration(emits) + }, + onVueObjectExit(node, { type }) { + if ( + (!vueTemplateDefineData || + (vueTemplateDefineData.type !== 'export' && + vueTemplateDefineData.type !== 'setup')) && + (type === 'mark' || type === 'export' || type === 'definition') + ) { + vueTemplateDefineData = { + type, + define: node + } + } + setupContexts.delete(node) + }, + ...callVisitor + }) + ) + ) + ) + } +} diff --git a/lib/utils/dom-events.json b/lib/utils/dom-events.json new file mode 100644 index 000000000..c3ae47089 --- /dev/null +++ b/lib/utils/dom-events.json @@ -0,0 +1,85 @@ +[ + "copy", + "cut", + "paste", + "compositionend", + "compositionstart", + "compositionupdate", + "drag", + "dragend", + "dragenter", + "dragexit", + "dragleave", + "dragover", + "dragstart", + "drop", + "focus", + "focusin", + "focusout", + "blur", + "change", + "beforeinput", + "input", + "reset", + "submit", + "invalid", + "load", + "error", + "keydown", + "keypress", + "keyup", + "auxclick", + "click", + "contextmenu", + "dblclick", + "mousedown", + "mouseenter", + "mouseleave", + "mousemove", + "mouseout", + "mouseover", + "mouseup", + "abort", + "canplay", + "canplaythrough", + "durationchange", + "emptied", + "encrypted", + "ended", + "loadeddata", + "loadedmetadata", + "loadstart", + "pause", + "play", + "playing", + "progress", + "ratechange", + "seeked", + "seeking", + "stalled", + "suspend", + "timeupdate", + "volumechange", + "waiting", + "select", + "scroll", + "scrollend", + "touchcancel", + "touchend", + "touchmove", + "touchstart", + "pointerdown", + "pointermove", + "pointerup", + "pointercancel", + "pointerenter", + "pointerleave", + "pointerover", + "pointerout", + "wheel", + "animationstart", + "animationend", + "animationiteration", + "transitionend", + "transitionstart" +] diff --git a/tests/fixtures/typescript/src/test01.ts b/tests/fixtures/typescript/src/test01.ts index d14550843..169dfb1c7 100644 --- a/tests/fixtures/typescript/src/test01.ts +++ b/tests/fixtures/typescript/src/test01.ts @@ -7,6 +7,10 @@ export type Emits1 = { (e: 'foo' | 'bar', payload: string): void (e: 'baz', payload: number): void } +export type Emits2 = { + (e: 'click' | 'bar', payload: string): void + (e: 'keydown', payload: number): void +} export type Props2 = { a: string b?: number diff --git a/tests/lib/rules/no-shadow-native-events.js b/tests/lib/rules/no-shadow-native-events.js new file mode 100644 index 000000000..63ccca7bc --- /dev/null +++ b/tests/lib/rules/no-shadow-native-events.js @@ -0,0 +1,1162 @@ +/** + * @author Jonathan Carle + * See LICENSE file in root directory for full license. + */ +'use strict' + +const RuleTester = require('../../eslint-compat').RuleTester +const rule = require('../../../lib/rules/no-shadow-native-events') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') + +const tester = new RuleTester({ + languageOptions: { + parser: require('vue-eslint-parser'), + ecmaVersion: 2020, + sourceType: 'module' + } +}) + +tester.run('no-shadow-native-events', rule, { + valid: [ + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + // quoted + { + filename: 'test.vue', + code: ` + + + ` + }, + // unknown + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + // allowProps + { + filename: 'test.vue', + code: ` + + + ` + }, + + // + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + + // unknown emits definition + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + + // unknown props definition + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + line: 3, + column: 28, + messageId: 'violation', + endLine: 3, + endColumn: 35 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + line: 7, + column: 17, + messageId: 'violation', + endLine: 7, + endColumn: 24 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + line: 7, + column: 17, + messageId: 'violation', + endLine: 7, + endColumn: 26 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + line: 6, + column: 24, + messageId: 'violation', + endLine: 6, + endColumn: 31 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + line: 8, + column: 22, + messageId: 'violation', + endLine: 8, + endColumn: 29 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + line: 5, + column: 24, + messageId: 'violation', + endLine: 5, + endColumn: 31 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + line: 5, + column: 16, + messageId: 'violation', + endLine: 5, + endColumn: 23 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + line: 5, + column: 16, + messageId: 'violation', + endLine: 5, + endColumn: 23 + }, + { + line: 6, + column: 16, + messageId: 'violation', + endLine: 6, + endColumn: 25 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'violation', + line: 7, + column: 25, + endLine: 7, + endColumn: 32 + }, + { + messageId: 'violation', + line: 8, + column: 28, + endLine: 8, + endColumn: 37 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'violation', + line: 5, + column: 21, + endLine: 5, + endColumn: 28 + }, + { + messageId: 'violation', + line: 6, + column: 24, + endLine: 6, + endColumn: 33 + } + ] + }, + // + `, + errors: [ + { + messageId: 'violation', + line: 6, + column: 20, + endLine: 6, + endColumn: 27 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 7, + column: 9, + endLine: 7, + endColumn: 27 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 6, + column: 19, + endLine: 6, + endColumn: 39 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + messageId: 'violation', + line: 3, + column: 28, + endLine: 3, + endColumn: 35 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 32, + endLine: 3, + endColumn: 52 + }, + { + messageId: 'violation', + line: 5, + column: 12, + endLine: 5, + endColumn: 21 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'violation', + line: 3, + column: 33, + endLine: 3, + endColumn: 40 + }, + { + messageId: 'violation', + line: 5, + column: 12, + endLine: 5, + endColumn: 21 + } + ] + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 33, + endLine: 3, + endColumn: 42 + }, + { + messageId: 'violation', + line: 5, + column: 12, + endLine: 5, + endColumn: 21 + } + ] + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 21, + endLine: 3, + endColumn: 30 + }, + { + messageId: 'violation', + line: 6, + column: 12, + endLine: 6, + endColumn: 21 + } + ] + }, + { + code: ` + `, + errors: [ + { + messageId: 'violation', + line: 4, + column: 32, + endLine: 4, + endColumn: 37 + }, + { + messageId: 'violation', + line: 4, + column: 32, + endLine: 4, + endColumn: 37 + } + ], + ...getTypeScriptFixtureTestOptions() + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 27, + endLine: 3, + endColumn: 36 + }, + { + messageId: 'violation', + line: 6, + column: 32, + endLine: 6, + endColumn: 52 + } + ] + } + ] +})