Repository: ember-cli/eslint-plugin-ember
Pull request #767 (2020-04-11 00:19:44)

Add new rule `prefer-ember-test-helpers`
New Rule: Use ember test-helper util function in tests instead of native window method
ID: prefer-ember-test-helpers
Fixes https://github.com/ember-cli/eslint-plugin-ember/issues/676

See all the wonderfully helpful comments from @bmish over at the original PR created on my fork: https://github.com/fierysunset/eslint-plugin-ember/pull/1


Patch:
diff --git a/README.md b/README.md
index 124b26d1..88d51d61 100644
--- a/README.md
+++ b/README.md
@@ -187,6 +187,7 @@ Rules are grouped by category to help you understand their purpose. Each rule ha
 | :white_check_mark: | [no-test-import-export](./docs/rules/no-test-import-export.md) | disallow importing of "-test.js" in a test file and exporting from a test file |
 | :white_check_mark: | [no-test-module-for](./docs/rules/no-test-module-for.md) | disallow usage of `moduleFor`, `moduleForComponent`, etc |
 |  | [no-test-this-render](./docs/rules/no-test-this-render.md) | disallow usage of the `this.render` in tests, recommending to use @ember/test-helpers' `render` instead. |
+|  | [prefer-ember-test-helpers](./docs/rules/prefer-ember-test-helpers.md) | enforce usage of `@ember/test-helpers` methods over native window methods |
 
 <!--RULES_TABLE_END-->
 
diff --git a/lib/index.js b/lib/index.js
index cede51e0..dbcd56c2 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -61,6 +61,7 @@ module.exports = {
     'order-in-controllers': require('./rules/order-in-controllers'),
     'order-in-models': require('./rules/order-in-models'),
     'order-in-routes': require('./rules/order-in-routes'),
+    'prefer-ember-test-helpers': require('./rules/prefer-ember-test-helpers'),
     'require-computed-macros': require('./rules/require-computed-macros'),
     'require-computed-property-dependencies': require('./rules/require-computed-property-dependencies'),
     'require-return-from-computed': require('./rules/require-return-from-computed'),


Test patch:
diff --git a/docs/rules/prefer-ember-test-helpers.md b/docs/rules/prefer-ember-test-helpers.md
new file mode 100644
index 00000000..4b592110
--- /dev/null
+++ b/docs/rules/prefer-ember-test-helpers.md
@@ -0,0 +1,64 @@
+# prefer-ember-test-helpers
+
+This rule ensures the correct Ember test helper is imported when using methods that have a native window counterpart.
+
+There are currently 3 Ember test helper methods that have a native window counterpart:
+
+* blur
+* find
+* focus
+
+If these methods are not properly imported from Ember's test-helpers suite, and the native window method version is used instead, any intended asynchronous functions won't work as intended, which can cause tests to fail silently.
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```js
+test('foo', async (assert) => {
+  await blur('.some-element');
+});
+```
+
+```js
+test('foo', async (assert) => {
+  await find('.some-element');
+});
+```
+
+```js
+test('foo', async (assert) => {
+  await focus('.some-element');
+});
+```
+
+Examples of **correct** code for this rule:
+
+```js
+import { blur } from '@ember/test-helpers';
+
+test('foo', async (assert) => {
+  await blur('.some-element');
+});
+```
+
+```js
+import { find } from '@ember/test-helpers';
+
+test('foo', async (assert) => {
+  await find('.some-element');
+});
+```
+
+```js
+import { focus } from '@ember/test-helpers';
+
+test('foo', async (assert) => {
+  await focus('.some-element');
+});
+```
+
+## References
+
+* [Web API Window Methods](https://developer.mozilla.org/en-US/docs/Web/API/Window#Methods)
+* [Ember Test Helpers API Methods](https://github.com/emberjs/ember-test-helpers/blob/master/API.md)
diff --git a/lib/rules/prefer-ember-test-helpers.js b/lib/rules/prefer-ember-test-helpers.js
new file mode 100644
index 00000000..5f7ee679
--- /dev/null
+++ b/lib/rules/prefer-ember-test-helpers.js
@@ -0,0 +1,112 @@
+'use strict';
+
+const emberUtils = require('../utils/ember');
+
+const getImportName = (node, namedImportIdentifier) => {
+  return node.specifiers
+    .filter((specifier) => {
+      return (
+        (specifier.type === 'ImportSpecifier' &&
+          specifier.imported.name === namedImportIdentifier) ||
+        (!namedImportIdentifier && specifier.type === 'ImportDefaultSpecifier')
+      );
+    })
+    .map((specifier) => specifier.local.name)
+    .pop();
+};
+
+//-------------------------------------------------------------------------------------
+// Rule Definition
+//-------------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'enforce usage of `@ember/test-helpers` methods over native window methods',
+      category: 'Testing',
+      recommended: false,
+      url:
+        'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/prefer-ember-test-helpers.md',
+    },
+    schema: [],
+  },
+
+  create: (context) => {
+    if (!emberUtils.isTestFile(context.getFilename())) {
+      return {};
+    }
+
+    let hasBlurFunction = undefined;
+    let hasFindFunction = undefined;
+    let hasFocusFunction = undefined;
+
+    const markMethodsAsPresent = (fnName) => {
+      if (fnName === 'blur') {
+        hasBlurFunction = true;
+      } else if (fnName === 'find') {
+        hasFindFunction = true;
+      } else if (fnName === 'focus') {
+        hasFocusFunction = true;
+      }
+    };
+
+    const showErrorMessage = (node, methodName) => {
+      context.report({
+        data: { methodName },
+        message: 'Import the `{{methodName}}()` method from @ember/test-helpers',
+        node,
+      });
+    };
+
+    return {
+      ImportDeclaration(node) {
+        hasBlurFunction = getImportName(node, 'blur');
+        hasFindFunction = getImportName(node, 'find');
+        hasFocusFunction = getImportName(node, 'focus');
+      },
+      FunctionDeclaration(node) {
+        const fnName = node.id.name;
+
+        markMethodsAsPresent(fnName);
+      },
+      FunctionExpression(node) {
+        const nodeParent = node.parent;
+
+        if (nodeParent && nodeParent.type === 'VariableDeclarator') {
+          const fnName = nodeParent.id.name;
+
+          markMethodsAsPresent(fnName);
+        }
+      },
+      ArrowFunctionExpression(node) {
+        const nodeParent = node.parent;
+
+        if (nodeParent && nodeParent.type === 'VariableDeclarator') {
+          const fnName = nodeParent.id.name;
+
+          markMethodsAsPresent(fnName);
+        }
+      },
+      CallExpression(node) {
+        if (!hasBlurFunction) {
+          if (node.callee.name === 'blur') {
+            showErrorMessage(node, 'blur');
+          }
+        }
+
+        if (!hasFindFunction) {
+          if (node.callee.name === 'find') {
+            showErrorMessage(node, 'find');
+          }
+        }
+
+        if (!hasFocusFunction) {
+          if (node.callee.name === 'focus') {
+            showErrorMessage(node, 'focus');
+          }
+        }
+      },
+    };
+  },
+};
diff --git a/tests/lib/rules/prefer-ember-test-helpers.js b/tests/lib/rules/prefer-ember-test-helpers.js
new file mode 100644
index 00000000..6945c74c
--- /dev/null
+++ b/tests/lib/rules/prefer-ember-test-helpers.js
@@ -0,0 +1,260 @@
+'use strict';
+
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/prefer-ember-test-helpers');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2018,
+    sourceType: 'module',
+  },
+});
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const TEST_FILE_NAME = 'some-test.js';
+const REGULAR_FILE_NAME = 'regular-file.js';
+
+ruleTester.run('prefer-ember-test-helpers', rule, {
+  valid: [
+    // Native methods in regular files
+    {
+      filename: REGULAR_FILE_NAME,
+      code: "blur('.some-element');",
+    },
+    {
+      filename: REGULAR_FILE_NAME,
+      code: "find('.some-element');",
+    },
+    {
+      filename: REGULAR_FILE_NAME,
+      code: "focus('.some-element');",
+    },
+
+    // Ember test helper method properly imported
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { blur } from '@ember/test-helpers';
+
+      test('foo', async (assert) => {
+        await blur('.some-element');
+      });`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { find } from '@ember/test-helpers';
+
+      test('foo', async (assert) => {
+        await find('.some-element');
+      });`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { focus } from '@ember/test-helpers';
+
+      test('foo', async (assert) => {
+        await focus('.some-element');
+      });`,
+    },
+
+    // Wrong method on import from Ember test helpers
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { blur } from '@ember/test-helpers';
+
+      test('foo', async (assert) => {
+        await blur.wrongFunction();
+      });`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { find } from '@ember/test-helpers';
+
+      test('foo', async (assert) => {
+        await find.wrongFunction();
+      });`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { focus } from '@ember/test-helpers';
+
+      test('foo', async (assert) => {
+        await focus.wrongFunction();
+      });`,
+    },
+
+    // Method on unrelated object called
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { blur } from '@ember/test-helpers';
+
+      test('foo', async (assert) => {
+        await WrongObject.blur();
+      });`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { find } from '@ember/test-helpers';
+
+      test('foo', async (assert) => {
+        await WrongObject.find();
+      });`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { focus } from '@ember/test-helpers';
+
+      test('foo', async (assert) => {
+        await WrongObject.focus();
+      });`,
+    },
+
+    // Method properly imported from Ember test helpers with aliased name
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { blur as myBlurName } from '@ember/test-helpers';
+
+      myBlurName();`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { find as myFindName } from '@ember/test-helpers';
+
+      myFindName();`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { focus as myFocusName } from '@ember/test-helpers';
+
+      myFocusName();`,
+    },
+
+    // Method imported from any source
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { blur } from 'irrelevant-import-path';
+
+      blur('.some-element');`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { find } from 'irrelevant-import-path';
+
+      find('.some-element');`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `import { focus } from 'irrelevant-import-path';
+
+      focus('.some-element');`,
+    },
+
+    // Function declaration within test file
+    {
+      filename: TEST_FILE_NAME,
+      code: `function blur(el) { console.log('blurring from element!'); }
+
+      blur('.some-element')`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `function find(el) { console.log('finding element!'); }
+
+      find('.some-element')`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `function focus(el) { console.log('focusing element!'); }
+
+      focus('.some-element')`,
+    },
+
+    // Function expression within test file
+    {
+      filename: TEST_FILE_NAME,
+      code: `const blur = function(el) { console.log('blurring from element!'); }
+
+      blur('.some-element')`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `const find = function(el) { console.log('finding element!'); }
+
+      find('.some-element')`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `const focus = function(el) { console.log('focusing element!'); }
+
+      focus('.some-element')`,
+    },
+
+    // Arrow Function declaration within test file
+    {
+      filename: TEST_FILE_NAME,
+      code: `const blur = (el) => { console.log('blurring from element!'); }
+
+      blur('.some-element')`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `const find = (el) => { console.log('finding element!'); }
+
+      find('.some-element')`,
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `const focus = (el) => { console.log('focusing element!'); }
+
+      focus('.some-element')`,
+    },
+  ],
+
+  invalid: [
+    {
+      filename: TEST_FILE_NAME,
+      code: `test('foo', async (assert) => {
+        await blur('.some-element');
+      });`,
+      output: null,
+      errors: [
+        {
+          message: 'Import the `blur()` method from @ember/test-helpers',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `test('foo', async (assert) => {
+        await find('.some-element');
+      });`,
+      output: null,
+      errors: [
+        {
+          message: 'Import the `find()` method from @ember/test-helpers',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      filename: TEST_FILE_NAME,
+      code: `test('foo', async (assert) => {
+        await focus('.some-element');
+      });`,
+      output: null,
+      errors: [
+        {
+          message: 'Import the `focus()` method from @ember/test-helpers',
+          type: 'CallExpression',
+        },
+      ],
+    },
+  ],
+});