diff --git a/README.md b/README.md index ccb563d432..6101281808 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Rules are grouped by category to help you understand their purpose. Each rule ha |:---|:--------|:------------| | | [computed-property-getters](./docs/rules/computed-property-getters.md) | enforce the consistent use of getters in computed properties | | :white_check_mark: | [no-arrow-function-computed-properties](./docs/rules/no-arrow-function-computed-properties.md) | disallow arrow functions in computed properties | +| :wrench: | [no-assignment-of-untracked-properties-used-in-tracking-contexts](./docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md) | disallow assignment of untracked properties that are used as computed property dependencies | | :car: | [no-computed-properties-in-native-classes](./docs/rules/no-computed-properties-in-native-classes.md) | disallow using computed properties in native classes | | :white_check_mark: | [no-deeply-nested-dependent-keys-with-each](./docs/rules/no-deeply-nested-dependent-keys-with-each.md) | disallow usage of deeply-nested computed property dependent keys with `@each` | | :white_check_mark::wrench: | [no-duplicate-dependent-keys](./docs/rules/no-duplicate-dependent-keys.md) | disallow repeating computed property dependent keys | diff --git a/docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md b/docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md new file mode 100644 index 0000000000..148a86ba9b --- /dev/null +++ b/docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md @@ -0,0 +1,76 @@ +# no-assignment-of-untracked-properties-used-in-tracking-contexts + +:wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule. + +Ember 3.13 added an assertion that fires when using assignment `this.x = 123` on an untracked property that is used in a tracking context such as a computed property. + +> You attempted to update "propertyX" to "valueY", +but it is being tracked by a tracking context, such as a template, computed property, or observer. +> +> In order to make sure the context updates properly, you must invalidate the property when updating it. +> +> You can mark the property as `@tracked`, or use `@ember/object#set` to do this. + +## Rule Details + +This rule catches assignments of untracked properties that are used as computed property dependency keys. + +## Examples + +Examples of **incorrect** code for this rule: + +```js +import { computed } from '@ember/object'; +import Component from '@ember/component'; + +class MyComponent extends Component { + @computed('x') get myProp() { + return this.x; + } + myFunction() { + this.x = 123; // Not okay to use assignment here. + } +} +``` + +Examples of **correct** code for this rule: + +```js +import { computed, set } from '@ember/object'; +import Component from '@ember/component'; + +class MyComponent extends Component { + @computed('x') get myProp() { + return this.x; + } + myFunction() { + set(this, 'x', 123); // Okay because it uses set. + } +} +``` + +```js +import { computed, set } from '@ember/object'; +import Component from '@ember/component'; +import { tracked } from '@glimmer/tracking'; + +class MyComponent extends Component { + @tracked x; + @computed('x') get myProp() { + return this.x; + } + myFunction() { + this.x = 123; // Okay because `x` is a tracked property. + } +} +``` + +## Migration + +The autofixer for this rule will update assignments to use `set`. Alternatively, you can begin using tracked properties. + +## References + +* [Spec](https://api.emberjs.com/ember/release/functions/@ember%2Fobject/set) for `set()` +* [Spec](https://api.emberjs.com/ember/3.16/functions/@glimmer%2Ftracking/tracked) for `@tracked` +* [Guide](https://guides.emberjs.com/release/upgrading/current-edition/tracked-properties/) for tracked properties diff --git a/lib/index.js b/lib/index.js index e86767f0a4..2502aab21d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -14,6 +14,7 @@ module.exports = { 'new-module-imports': require('./rules/new-module-imports'), 'no-actions-hash': require('./rules/no-actions-hash'), 'no-arrow-function-computed-properties': require('./rules/no-arrow-function-computed-properties'), + 'no-assignment-of-untracked-properties-used-in-tracking-contexts': require('./rules/no-assignment-of-untracked-properties-used-in-tracking-contexts'), 'no-attrs-in-components': require('./rules/no-attrs-in-components'), 'no-attrs-snapshot': require('./rules/no-attrs-snapshot'), 'no-capital-letters-in-routes': require('./rules/no-capital-letters-in-routes'), diff --git a/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js b/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js new file mode 100644 index 0000000000..2a13e35e8c --- /dev/null +++ b/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js @@ -0,0 +1,276 @@ +'use strict'; + +const emberUtils = require('../utils/ember'); +const types = require('../utils/types'); +const decoratorUtils = require('../utils/decorators'); +const javascriptUtils = require('../utils/javascript'); +const propertySetterUtils = require('../utils/property-setter'); +const assert = require('assert'); +const { getImportIdentifier } = require('../utils/import'); +const { + expandKeys, + keyExistsAsPrefixInList, +} = require('../utils/computed-property-dependent-keys'); + +const ERROR_MESSAGE = + "Use `set(this, 'propertyName', 'value')` instead of assignment for untracked properties that are used as computed property dependencies (or convert to using tracked properties)."; + +/** + * Gets the list of string dependent keys from a computed property. + * + * @param {Node} node - the computed property node + * @returns {String[]} - the list of string dependent keys from this computed property + */ +function getComputedPropertyDependentKeys(node) { + if (!node.arguments) { + return []; + } + + return expandKeys( + node.arguments + .filter((arg) => arg.type === 'Literal' && typeof arg.value === 'string') + .map((node) => node.value) + ); +} + +/** + * Gets a list of computed property dependency keys used inside a class. + * + * @param {Node} nodeClass - Node for the class + * @returns {String[]} - list of dependent keys used inside the class + */ +function findComputedPropertyDependentKeys(nodeClass, computedImportName) { + if (types.isClassDeclaration(nodeClass)) { + // Native JS class. + return javascriptUtils.flatMap(nodeClass.body.body, (node) => { + const computedDecorator = decoratorUtils.findDecorator(node, computedImportName); + if (computedDecorator) { + return getComputedPropertyDependentKeys(computedDecorator.expression); + } else { + return []; + } + }); + } else if (types.isCallExpression(nodeClass)) { + // Classic class. + return javascriptUtils.flatMap( + nodeClass.arguments.filter(types.isObjectExpression), + (classObject) => { + return javascriptUtils.flatMap(classObject.properties, (node) => { + if ( + types.isProperty(node) && + emberUtils.isComputedProp(node.value) && + node.value.arguments + ) { + return getComputedPropertyDependentKeys(node.value); + } else { + return []; + } + }); + } + ); + } else { + assert(false, 'Unexpected node type for a class.'); + } + + return []; +} + +/** + * Gets a list of tracked properties used inside a class. + * + * @param {Node} nodeClass - Node for the class + * @returns {String[]} - list of tracked properties used inside the class + */ +function findTrackedProperties(nodeClassDeclaration, trackedImportName) { + return nodeClassDeclaration.body.body + .filter( + (node) => + types.isClassProperty(node) && + decoratorUtils.hasDecorator(node, trackedImportName) && + types.isIdentifier(node.key) + ) + .map((node) => node.key.name); +} + +class Stack { + constructor() { + this.stack = new Array(); + } + pop() { + return this.stack.pop(); + } + push(item) { + this.stack.push(item); + } + peek() { + return this.stack.length > 0 ? this.stack[this.stack.length - 1] : undefined; + } + size() { + return this.stack.length; + } +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'disallow assignment of untracked properties that are used as computed property dependencies', + category: 'Computed Properties', + recommended: false, + url: + 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md', + }, + fixable: 'code', + schema: [], + }, + + ERROR_MESSAGE, + + create(context) { + if (emberUtils.isTestFile(context.getFilename())) { + // This rule does not apply to test files. + return {}; + } + + // State being tracked for this file. + let computedImportName = undefined; + let trackedImportName = undefined; + let setImportName = undefined; + + // State being tracked for the current class we're inside. + const classStack = new Stack(); + + return { + ImportDeclaration(node) { + if (node.source.value === '@ember/object') { + computedImportName = + computedImportName || getImportIdentifier(node, '@ember/object', 'computed'); + setImportName = setImportName || getImportIdentifier(node, '@ember/object', 'set'); + } else if (node.source.value === '@glimmer/tracking') { + trackedImportName = + trackedImportName || getImportIdentifier(node, '@glimmer/tracking', 'tracked'); + } + }, + + // Native JS class: + ClassDeclaration(node) { + // Gather computed property dependent keys from this class. + const computedPropertyDependentKeys = new Set( + findComputedPropertyDependentKeys(node, computedImportName) + ); + + // Gather tracked properties from this class. + const trackedProperties = new Set(findTrackedProperties(node, trackedImportName)); + + // Keep track of whether we're inside a Glimmer component. + const isGlimmerComponent = emberUtils.isGlimmerComponent(context, node); + + classStack.push({ + node, + computedPropertyDependentKeys, + trackedProperties, + isGlimmerComponent, + }); + }, + + CallExpression(node) { + // Classic class: + if (emberUtils.isAnyEmberCoreModule(context, node)) { + // Gather computed property dependent keys from this class. + const computedPropertyDependentKeys = new Set( + findComputedPropertyDependentKeys(node, computedImportName) + ); + + // No tracked properties in classic classes. + const trackedProperties = new Set(); + + // Keep track of whether we're inside a Glimmer component. + const isGlimmerComponent = emberUtils.isGlimmerComponent(context, node); + + classStack.push({ + node, + computedPropertyDependentKeys, + trackedProperties, + isGlimmerComponent, + }); + } + }, + + 'ClassDeclaration:exit'(node) { + if (classStack.size() > 0 && classStack.peek().node === node) { + // Leaving current (native) class. + classStack.pop(); + } + }, + + 'CallExpression:exit'(node) { + if (classStack.size() > 0 && classStack.peek().node === node) { + // Leaving current (classic) class. + classStack.pop(); + } + }, + + AssignmentExpression(node) { + if (classStack.size() === 0) { + // Not inside a class. + return; + } + + // Ensure this is an assignment with `this.x = ` or `this.x.y = `. + if (!propertySetterUtils.isThisSet(node)) { + return; + } + + const currentClass = classStack.peek(); + + const sourceCode = context.getSourceCode(); + const nodeTextLeft = sourceCode.getText(node.left); + const nodeTextRight = sourceCode.getText(node.right); + const propertyName = nodeTextLeft.replace('this.', ''); + + if (currentClass.isGlimmerComponent && propertyName.startsWith('args.')) { + // The Glimmer component args hash is automatically tracked so ignored it. + return; + } + + if ( + !currentClass.computedPropertyDependentKeys.has(propertyName) && + !keyExistsAsPrefixInList( + [...currentClass.computedPropertyDependentKeys.keys()], + propertyName + ) + ) { + // Haven't seen this property as a computed property dependent key so ignore it. + return; + } + + if (currentClass.trackedProperties.has(propertyName)) { + // Assignment is fine with tracked properties so ignore it. + return; + } + + context.report({ + node, + message: ERROR_MESSAGE, + fix(fixer) { + if (setImportName) { + // `set` is already imported. + return fixer.replaceText( + node, + `${setImportName}(this, '${propertyName}', ${nodeTextRight})` + ); + } else { + // Need to add an import statement for `set`. + const sourceCode = context.getSourceCode(); + return [ + fixer.insertTextBefore(sourceCode.ast, "import { set } from '@ember/object';\n"), + fixer.replaceText(node, `set(this, '${propertyName}', ${nodeTextRight})`), + ]; + } + }, + }); + }, + }; + }, +}; diff --git a/lib/utils/ember.js b/lib/utils/ember.js index 5c068fa9dc..c30d42023b 100644 --- a/lib/utils/ember.js +++ b/lib/utils/ember.js @@ -16,6 +16,7 @@ module.exports = { isTestFile, isEmberCoreModule, + isAnyEmberCoreModule, isEmberComponent, isGlimmerComponent, isEmberController, @@ -24,6 +25,8 @@ module.exports = { isEmberService, isEmberArrayProxy, isEmberObjectProxy, + isEmberObject, + isEmberHelper, isEmberProxy, isSingleLineFn, @@ -74,6 +77,8 @@ const CORE_MODULE_IMPORT_PATHS = { Service: '@ember/service', ArrayProxy: '@ember/array/proxy', ObjectProxy: '@ember/object/proxy', + EmberObject: '@ember/object', + Helper: '@ember/component/helper', }; function isClassicEmberCoreModule(node, module, filePath) { @@ -222,6 +227,21 @@ function isEmberCoreModule(context, node, moduleName) { return false; } +function isAnyEmberCoreModule(context, node) { + return ( + isEmberComponent(context, node) || + isGlimmerComponent(context, node) || + isEmberController(context, node) || + isEmberMixin(context, node) || + isEmberRoute(context, node) || + isEmberService(context, node) || + isEmberArrayProxy(context, node) || + isEmberObjectProxy(context, node) || + isEmberObject(context, node) || + isEmberHelper(context, node) + ); +} + function isEmberComponent(context, node) { return isEmberCoreModule(context, node, 'Component'); } @@ -254,6 +274,14 @@ function isEmberObjectProxy(context, node) { return isEmberCoreModule(context, node, 'ObjectProxy'); } +function isEmberObject(context, node) { + return isEmberCoreModule(context, node, 'EmberObject'); +} + +function isEmberHelper(context, node) { + return isEmberCoreModule(context, node, 'Helper'); +} + function isEmberProxy(context, node) { return isEmberArrayProxy(context, node) || isEmberObjectProxy(context, node); } diff --git a/tests/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js b/tests/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js new file mode 100644 index 0000000000..fe91e04363 --- /dev/null +++ b/tests/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js @@ -0,0 +1,344 @@ +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts'); +const RuleTester = require('eslint').RuleTester; + +const { ERROR_MESSAGE } = rule; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parser: require.resolve('babel-eslint'), + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, +}); + +ruleTester.run('no-assignment-of-untracked-properties-used-in-tracking-contexts', rule, { + valid: [ + { + // Assignment of property which is not used as a dependent key. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @computed get prop1() {} + @computed() get prop2() {} + @computed('random') get prop3() {} + myFunction() { this.x = 123; } + }`, + filename: '/components/foo.js', + }, + { + // Assignment of tracked property. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + import { tracked } from '@glimmer/tracking'; + class MyClass extends Component { + @tracked x + @computed('x') get prop() {} + myFunction() { this.x = 123; } + }`, + filename: '/components/foo.js', + }, + { + // Assignment of tracked property (with aliased `tracked` import). + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + import { tracked as t } from '@glimmer/tracking'; + class MyClass extends Component { + @t x + @computed('x') get prop() {} + myFunction() { this.x = 123; } + }`, + filename: '/components/foo.js', + }, + { + // Assignment of property in the Glimmer component args hash which is automatically tracked. + code: ` + import { computed } from '@ember/object'; + import Component from '@glimmer/component'; + class MyClass extends Component { + @computed('args.x') get prop() {} + myFunction() { this.args.x = 123; } + }`, + filename: '/components/foo.js', + }, + { + // Assignment of dependent key property but outside the class. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @computed('x') get prop() {} + } + this.x = 123;`, + filename: '/components/foo.js', + }, + { + // Assignment missing `this.`. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @computed('x') get prop() {} + myFunction() { x = 123; } + }`, + filename: '/components/foo.js', + }, + { + // Test files should be ignored. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @computed('x') get prop() {} + myFunction() { this.x = 123; } + }`, + filename: '/components/foo-test.js', + }, + { + // Not an Ember module file. + code: ` + import { computed } from '@ember/object'; + import SomeThing from 'random'; + SomeThing.extends({ + prop: computed('x', function() {}), + myFunction() { this.x = 123; } + })`, + filename: '/random/foo.js', + }, + { + // Assignment in separate class from computed property. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + class Class1 extends Component { + @computed('x') get myProp() {} + } + class Class2 extends Component { + myFunction() { this.x = 123; } + }`, + filename: '/components/foo.js', + }, + { + // Classic class. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + Component.extends({ + prop: computed('x') // This is here to make sure it is not considered in the next class. + }); + Component.extends({ + ...someProp.getProperties('propA', 'propB'), + prop1: computed(), + prop2: computed(function() {}), + prop3: computed('random', function() {}), + myFunction() { this.x = 123; } + })`, + filename: '/components/foo.js', + }, + ], + invalid: [ + { + // Assignment of dependent key property. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + import { tracked } from '@glimmer/tracking'; + class Class1 extends Component { + @tracked x; // This is included just to make sure it gets ignored in the next class. + } + class Class2 extends Component { + @computed('x') get myProp() {} + myFunction() { this.x = 123; } + }`, + filename: '/components/foo.js', + output: ` + import { set } from '@ember/object'; +import { computed } from '@ember/object'; + import Component from '@ember/component'; + import { tracked } from '@glimmer/tracking'; + class Class1 extends Component { + @tracked x; // This is included just to make sure it gets ignored in the next class. + } + class Class2 extends Component { + @computed('x') get myProp() {} + myFunction() { set(this, 'x', 123); } + }`, + errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }], + }, + { + // Assignment of dependent key property (with dependent key brace expansion / assignment property is substring of dependent key). + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @computed('x.{a,b}.@each.z') get myProp() {} + myFunction() { this.x = 123; } + }`, + filename: '/components/foo.js', + output: ` + import { set } from '@ember/object'; +import { computed } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @computed('x.{a,b}.@each.z') get myProp() {} + myFunction() { set(this, 'x', 123); } + }`, + errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }], + }, + { + // Assignment of dependent key property (with aliased `computed` import). + code: ` + import { computed as c } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @c('x') get myProp() {} + myFunction() { this.x = 123; } + }`, + filename: '/components/foo.js', + output: ` + import { set } from '@ember/object'; +import { computed as c } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @c('x') get myProp() {} + myFunction() { set(this, 'x', 123); } + }`, + errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }], + }, + { + // Assignment of dependent key property (should use aliased `set` import instead of adding a new import). + code: ` + import { computed as c, set as s } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @c('x') get myProp() {} + myFunction() { this.x = 123; } + }`, + filename: '/components/foo.js', + output: ` + import { computed as c, set as s } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @c('x') get myProp() {} + myFunction() { s(this, 'x', 123); } + }`, + errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }], + }, + { + // Assignment of dependent key property (inside generic class that does not extend from an Ember module) + code: ` + import { computed } from '@ember/object'; + class MyClass { + @computed('x') get myProp() {} + myFunction() { this.x = 123; } + }`, + filename: '/components/foo.js', + output: ` + import { set } from '@ember/object'; +import { computed } from '@ember/object'; + class MyClass { + @computed('x') get myProp() {} + myFunction() { set(this, 'x', 123); } + }`, + errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }], + }, + { + // `args` should not be treated special outside of Glimmer components. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @computed('args.x') get prop() {} + myFunction() { this.args.x = 123; } + }`, + output: ` + import { set } from '@ember/object'; +import { computed } from '@ember/object'; + import Component from '@ember/component'; + class MyClass extends Component { + @computed('args.x') get prop() {} + myFunction() { set(this, 'args.x', 123); } + }`, + filename: '/components/foo.js', + errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }], + }, + { + // Manages state of inner class separate from outer class. + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + class Class1 extends Component { + @computed('x') get myProp() {} + function1() { + class InternalStateTracker { + @tracked x; + innerFunction() { + this.x = 123; // Allowed because tracked in this inner class. + } + } + } + function2() { + this.x = 123; + } + }`, + output: ` + import { set } from '@ember/object'; +import { computed } from '@ember/object'; + import Component from '@ember/component'; + class Class1 extends Component { + @computed('x') get myProp() {} + function1() { + class InternalStateTracker { + @tracked x; + innerFunction() { + this.x = 123; // Allowed because tracked in this inner class. + } + } + } + function2() { + set(this, 'x', 123); + } + }`, + filename: '/components/foo.js', + errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }], + }, + { + // Assignment of dependent key property (classic class). + code: ` + import { computed } from '@ember/object'; + import Component from '@ember/component'; + import { tracked } from '@glimmer/tracking'; + class MyClass extends Component { + @tracked x // This is here to ensure that it is ignored in the subsequent class. + } + Component.extends({ + myProp: computed('x', function() {}), + myFunction() { this.x = 123; } + })`, + output: ` + import { set } from '@ember/object'; +import { computed } from '@ember/object'; + import Component from '@ember/component'; + import { tracked } from '@glimmer/tracking'; + class MyClass extends Component { + @tracked x // This is here to ensure that it is ignored in the subsequent class. + } + Component.extends({ + myProp: computed('x', function() {}), + myFunction() { set(this, 'x', 123); } + })`, + filename: '/components/foo.js', + errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }], + }, + ], +}); diff --git a/tests/lib/utils/ember-test.js b/tests/lib/utils/ember-test.js index b6a51ea702..1550073cfc 100644 --- a/tests/lib/utils/ember-test.js +++ b/tests/lib/utils/ember-test.js @@ -635,6 +635,72 @@ describe('isEmberObjectProxy', () => { }); }); +describe('isEmberObject', () => { + it('should detect when using local module', () => { + const context = new FauxContext('EmberObject.extend()'); + const node = context.ast.body[0].expression; + expect(emberUtils.isEmberObject(context, node)).toBeTruthy(); + }); + + it('should not detect when using local module with wrong name', () => { + const context = new FauxContext('SomethingElse.extend()'); + const node = context.ast.body[0].expression; + expect(emberUtils.isEmberObject(context, node)).toBeFalsy(); + }); + + it('should detect when using native classes', () => { + const context = new FauxContext(` + import EmberObject from '@ember/object'; + class MyObject extends EmberObject {} + `); + + const node = context.ast.body[1]; + expect(emberUtils.isEmberObject(context, node)).toBeTruthy(); + }); + + it('should not detect when using native classes if the import path is incorrect', () => { + const context = new FauxContext(` + import EmberObject from '@something-else/object'; + class MyObject extends EmberObject {} + `); + const node = context.ast.body[1]; + expect(emberUtils.isEmberObject(context, node)).toBeFalsy(); + }); +}); + +describe('isEmberHelper', () => { + it('should detect when using local module', () => { + const context = new FauxContext('Helper.extend()'); + const node = context.ast.body[0].expression; + expect(emberUtils.isEmberHelper(context, node)).toBeTruthy(); + }); + + it('should not detect when using local module with wrong name', () => { + const context = new FauxContext('SomethingElse.extend()'); + const node = context.ast.body[0].expression; + expect(emberUtils.isEmberHelper(context, node)).toBeFalsy(); + }); + + it('should detect when using native classes', () => { + const context = new FauxContext(` + import Helper from '@ember/component/helper'; + class MyHelper extends Helper {} + `); + + const node = context.ast.body[1]; + expect(emberUtils.isEmberHelper(context, node)).toBeTruthy(); + }); + + it('should not detect when using native classes if the import path is incorrect', () => { + const context = new FauxContext(` + import Helper from '@something-else/component/helper'; + class MyHelper extends Helper {} + `); + const node = context.ast.body[1]; + expect(emberUtils.isEmberHelper(context, node)).toBeFalsy(); + }); +}); + describe('isEmberProxy', () => { it('should detect ArrayProxy example', () => { const context = new FauxContext(`