Skip to content

Move rules settings to ESLint shared config: part 2 - check imports #239

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Oct 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@ module.exports = {
statements: 100,
},
// TODO drop this custom threshold in v4
'./lib/detect-testing-library-utils.ts': {
branches: 50,
functions: 90,
lines: 90,
statements: 90,
},
'./lib/node-utils.ts': {
branches: 90,
functions: 90,
Expand Down
10 changes: 3 additions & 7 deletions lib/create-testing-library-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ESLintUtils, TSESLint } from '@typescript-eslint/experimental-utils';
import { getDocsUrl } from './utils';
import {
detectTestingLibraryUtils,
DetectionHelpers,
EnhancedRuleCreate,
} from './detect-testing-library-utils';

// These 2 types are copied from @typescript-eslint/experimental-utils
Expand All @@ -20,13 +20,9 @@ export function createTestingLibraryRule<
name: string;
meta: CreateRuleMeta<TMessageIds>;
defaultOptions: Readonly<TOptions>;
create: (
context: Readonly<TSESLint.RuleContext<TMessageIds, TOptions>>,
optionsWithDefault: Readonly<TOptions>,
detectionHelpers: Readonly<DetectionHelpers>
) => TRuleListener;
create: EnhancedRuleCreate<TOptions, TMessageIds, TRuleListener>;
}>
): TSESLint.RuleModule<TMessageIds, TOptions, TRuleListener> {
): TSESLint.RuleModule<TMessageIds, TOptions> {
const { create, ...remainingConfig } = config;

return ESLintUtils.RuleCreator(getDocsUrl)({
Expand Down
115 changes: 90 additions & 25 deletions lib/detect-testing-library-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';

export type TestingLibrarySettings = {
'testing-library/module'?: string;
};

export type TestingLibraryContext<
TOptions extends readonly unknown[],
TMessageIds extends string
> = Readonly<
TSESLint.RuleContext<TMessageIds, TOptions> & {
settings: TestingLibrarySettings;
}
>;

export type EnhancedRuleCreate<
TOptions extends readonly unknown[],
TMessageIds extends string,
TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener
> = (
context: TestingLibraryContext<TOptions, TMessageIds>,
optionsWithDefault: Readonly<TOptions>,
detectionHelpers: Readonly<DetectionHelpers>
) => TRuleListener;

export type DetectionHelpers = {
getIsImportingTestingLibrary: () => boolean;
getIsTestingLibraryImported: () => boolean;
canReportErrors: () => boolean;
};

/**
Expand All @@ -11,50 +35,91 @@ export function detectTestingLibraryUtils<
TOptions extends readonly unknown[],
TMessageIds extends string,
TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener
>(
ruleCreate: (
context: Readonly<TSESLint.RuleContext<TMessageIds, TOptions>>,
optionsWithDefault: Readonly<TOptions>,
detectionHelpers: Readonly<DetectionHelpers>
) => TRuleListener
) {
>(ruleCreate: EnhancedRuleCreate<TOptions, TMessageIds, TRuleListener>) {
return (
context: Readonly<TSESLint.RuleContext<TMessageIds, TOptions>>,
context: TestingLibraryContext<TOptions, TMessageIds>,
optionsWithDefault: Readonly<TOptions>
): TRuleListener => {
let isImportingTestingLibrary = false;
): TSESLint.RuleListener => {
Copy link
Member Author

@Belco90 Belco90 Oct 21, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've replaced returned type from TRuleListener to TSESLint.RuleListener since detectTestingLibraryUtils modifies the received rules listener to add or remove some so it can end being a different set of rule listener.

let isImportingTestingLibraryModule = false;
let isImportingCustomModule = false;

// TODO: init here options based on shared ESLint config
// Init options based on shared ESLint settings
const customModule = context.settings['testing-library/module'];

// helpers for Testing Library detection
// Helpers for Testing Library detection.
const helpers: DetectionHelpers = {
getIsImportingTestingLibrary() {
return isImportingTestingLibrary;
/**
* Gets if Testing Library is considered as imported or not.
*
* By default, it is ALWAYS considered as imported. This is what we call
* "aggressive reporting" so we don't miss TL utils reexported from
* custom modules.
*
* However, there is a setting to customize the module where TL utils can
* be imported from: "testing-library/module". If this setting is enabled,
* then this method will return `true` ONLY IF a testing-library package
* or custom module are imported.
*/
getIsTestingLibraryImported() {
if (!customModule) {
return true;
}

return isImportingTestingLibraryModule || isImportingCustomModule;
},

/**
* Wraps all conditions that must be met to report rules.
*/
canReportErrors() {
return this.getIsTestingLibraryImported();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's been a long time since I've seen a this 😄

},
};

// instructions for Testing Library detection
// Instructions for Testing Library detection.
const detectionInstructions: TSESLint.RuleListener = {
/**
* This ImportDeclaration rule listener will check if Testing Library related
* modules are loaded. Since imports happen first thing in a file, it's
* safe to use `isImportingTestingLibraryModule` and `isImportingCustomModule`
* since they will have corresponding value already updated when reporting other
* parts of the file.
*/
ImportDeclaration(node: TSESTree.ImportDeclaration) {
isImportingTestingLibrary = /testing-library/g.test(
node.source.value as string
);
if (!isImportingTestingLibraryModule) {
// check only if testing library import not found yet so we avoid
// to override isImportingTestingLibraryModule after it's found
isImportingTestingLibraryModule = /testing-library/g.test(
node.source.value as string
);
}

if (!isImportingCustomModule) {
// check only if custom module import not found yet so we avoid
// to override isImportingCustomModule after it's found
const importName = String(node.source.value);
isImportingCustomModule = importName.endsWith(customModule);
}
},
};

// update given rule to inject Testing Library detection
const ruleInstructions = ruleCreate(context, optionsWithDefault, helpers);
const enhancedRuleInstructions = Object.assign({}, ruleInstructions);
const enhancedRuleInstructions: TSESLint.RuleListener = {};

const allKeys = new Set(
Object.keys(detectionInstructions).concat(Object.keys(ruleInstructions))
);

Object.keys(detectionInstructions).forEach((instruction) => {
(enhancedRuleInstructions as TSESLint.RuleListener)[instruction] = (
node
) => {
// Iterate over ALL instructions keys so we can override original rule instructions
// to prevent their execution if conditions to report errors are not met.
allKeys.forEach((instruction) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I iterate through all keys to be able to prevent original rule instructions.

enhancedRuleInstructions[instruction] = (node) => {
if (instruction in detectionInstructions) {
detectionInstructions[instruction](node);
}

if (ruleInstructions[instruction]) {
if (helpers.canReportErrors() && ruleInstructions[instruction]) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is where the plugin prevents original rule instructions to be executed if conditions are not met!

Copy link
Collaborator

@gndelia gndelia Oct 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit lost in understanding what's the difference between the method canReportErrors and getIsTestingLibraryImported - given the first one just calls the second

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in addition to that, if I understand correctly, we would just execute the rules if TL is imported, otherwise, nothing gets executed - is that correct?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm anticipating next PR with canReportErrors so it warps all necessary checks to know if rules should report errors or not. That's just knowing if Testing Library is imported from now, but in the next PR I'll add the shared option to customize the file names pattern which should be checked by regexp, so the user can set to just report from this plugin on files named *.test.js. This is interesting when you have different kind of tests files (.test.js for Testing Library and .spec.js for Cypress) or just to reduce the scope of the plugin. So in the next PR canReportErrors will check more things and will be in charge of wrap all necessary checks in the future if others needed.

in addition to that, if I understand correctly, we would just execute the rules if TL is imported, otherwise, nothing gets executed - is that correct?

Yes, but by default TL is always considered as imported due to the "aggressive reporting" mode.

return ruleInstructions[instruction](node);
}
};
Expand Down
3 changes: 1 addition & 2 deletions lib/rules/no-node-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,10 @@ export default createTestingLibraryRule<Options, MessageIds>({
},
defaultOptions: [],

create: (context, _, helpers) => {
create(context) {
function showErrorForNodeAccess(node: TSESTree.MemberExpression) {
isIdentifier(node.property) &&
ALL_RETURNING_NODES.includes(node.property.name) &&
helpers.getIsImportingTestingLibrary() &&
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not necessary anymore! ✨

context.report({
node: node,
loc: node.property.loc.start,
Expand Down
122 changes: 122 additions & 0 deletions tests/create-testing-library-rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { createRuleTester } from './lib/test-utils';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this test file to check both createTestingLibraryRule + detectTestingLibraryUtils together, covering as much edge cases as possible.

import rule, { RULE_NAME } from './fake-rule';

const ruleTester = createRuleTester({
ecmaFeatures: {
jsx: true,
},
});

ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `
// case: nothing related to Testing Library at all
import { shallow } from 'enzyme';

const wrapper = shallow(<MyComponent />);
`,
},
{
code: `
// case: render imported from other than custom module
import { render } from '@somewhere/else'

const utils = render();
`,
settings: {
'testing-library/module': 'test-utils',
},
},
{
code: `
// case: prevent import which should trigger an error since it's imported
// from other than custom module
import { foo } from 'report-me'
`,
settings: {
'testing-library/module': 'test-utils',
},
},
],
invalid: [
{
code: `
// case: import module forced to be reported
import { foo } from 'report-me'
`,
errors: [{ line: 3, column: 7, messageId: 'fakeError' }],
},
{
code: `
// case: render imported from any module by default (aggressive reporting)
import { render } from '@somewhere/else'
import { somethingElse } from 'another-module'

const utils = render();
`,
errors: [
{
line: 6,
column: 21,
messageId: 'fakeError',
},
],
},
{
code: `
// case: render imported from Testing Library module
import { render } from '@testing-library/react'
import { somethingElse } from 'another-module'

const utils = render();
`,
errors: [
{
line: 6,
column: 21,
messageId: 'fakeError',
},
],
},
{
code: `
// case: render imported from config custom module
import { render } from 'test-utils'
import { somethingElse } from 'another-module'

const utils = render();
`,
settings: {
'testing-library/module': 'test-utils',
},
errors: [
{
line: 6,
column: 21,
messageId: 'fakeError',
},
],
},
{
code: `
// case: render imported from Testing Library module if
// custom module setup
import { render } from '@testing-library/react'
import { somethingElse } from 'another-module'

const utils = render();
`,
settings: {
'testing-library/module': 'test-utils',
},
errors: [
{
line: 7,
column: 21,
messageId: 'fakeError',
},
],
},
],
});
55 changes: 55 additions & 0 deletions tests/fake-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fake rule trying to report stuff asap to be able to run the previous test file.

* @file Fake rule to be able to test createTestingLibraryRule and
* detectTestingLibraryUtils properly
*/
import { TSESTree } from '@typescript-eslint/experimental-utils';
import { createTestingLibraryRule } from '../lib/create-testing-library-rule';

export const RULE_NAME = 'fake-rule';
type Options = [];
type MessageIds = 'fakeError';

export default createTestingLibraryRule<Options, MessageIds>({
name: RULE_NAME,
meta: {
type: 'problem',
docs: {
description: 'Fake rule to test rule maker and detection helpers',
category: 'Possible Errors',
recommended: false,
},
messages: {
fakeError: 'fake error reported',
},
fixable: null,
schema: [],
},
defaultOptions: [],
create(context) {
const reportRenderIdentifier = (node: TSESTree.Identifier) => {
if (node.name === 'render') {
context.report({
node,
messageId: 'fakeError',
});
}
};

const checkImportDeclaration = (node: TSESTree.ImportDeclaration) => {
// This is just to check that defining an `ImportDeclaration` doesn't
// override `ImportDeclaration` from `detectTestingLibraryUtils`

if (node.source.value === 'report-me') {
context.report({
node,
messageId: 'fakeError',
});
}
};

return {
'CallExpression Identifier': reportRenderIdentifier,
ImportDeclaration: checkImportDeclaration,
};
},
});
Loading