Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
chore(no-large-snapshots): convert to typescript (#376)
  • Loading branch information
G-Rath authored and SimenB committed Aug 11, 2019
1 parent 1833255 commit 851931d
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 37 deletions.
@@ -1,21 +1,30 @@
import { RuleTester } from 'eslint';
import {
AST_NODE_TYPES,
TSESLint,
TSESTree,
} from '@typescript-eslint/experimental-utils';
import rule from '../no-large-snapshots';

const noLargeSnapshots = rule.create.bind(rule);

const ruleTester = new RuleTester({
const ruleTester = new TSESLint.RuleTester({
parserOptions: {
ecmaVersion: 2015,
},
});

const generateSnapshotLines = lines => `\`\n${'line\n'.repeat(lines)}\``;
const generateSnapshotLines = (lines: number) =>
`\`\n${'line\n'.repeat(lines)}\``;

const generateExportsSnapshotString = (lines, title = 'a big component 1') =>
`exports[\`${title}\`] = ${generateSnapshotLines(lines - 1)};`;
const generateExportsSnapshotString = (
lines: number,
title: string = 'a big component 1',
) => `exports[\`${title}\`] = ${generateSnapshotLines(lines - 1)};`;

const generateExpectInlineSnapsCode = (lines, matcher) =>
`expect(something).${matcher}(${generateSnapshotLines(lines)});`;
const generateExpectInlineSnapsCode = (
lines: number,
matcher: 'toMatchInlineSnapshot' | 'toThrowErrorMatchingInlineSnapshot',
) => `expect(something).${matcher}(${generateSnapshotLines(lines)});`;

ruleTester.run('no-large-snapshots', rule, {
valid: [
Expand Down Expand Up @@ -156,7 +165,9 @@ ruleTester.run('no-large-snapshots', rule, {
});

describe('no-large-snapshots', () => {
const buildBaseNode = type => ({
const buildBaseNode = <Type extends AST_NODE_TYPES>(
type: Type,
): TSESTree.BaseNode & { type: Type } => ({
type,
range: [0, 1],
loc: {
Expand Down Expand Up @@ -190,8 +201,8 @@ describe('no-large-snapshots', () => {

expect(() =>
ExpressionStatement({
...buildBaseNode('ExpressionStatement'),
expression: buildBaseNode('JSXClosingFragment'),
...buildBaseNode(AST_NODE_TYPES.ExpressionStatement),
expression: buildBaseNode(AST_NODE_TYPES.JSXClosingFragment),
}),
).toThrow(
'All paths for whitelistedSnapshots must be absolute. You can use JS config and `path.resolve`',
Expand Down
81 changes: 56 additions & 25 deletions src/rules/no-large-snapshots.js → src/rules/no-large-snapshots.ts
@@ -1,20 +1,35 @@
import {
AST_NODE_TYPES,
TSESLint,
TSESTree,
} from '@typescript-eslint/experimental-utils';
import { isAbsolute } from 'path';
import { getDocsUrl, getStringValue } from './util';
import {
createRule,
getAccessorValue,
isExpectMember,
isSupportedAccessor,
} from './tsUtils';

const reportOnViolation = (context, node) => {
const lineLimit =
context.options[0] && Number.isFinite(context.options[0].maxSize)
? context.options[0].maxSize
: 50;
interface RuleOptions {
maxSize?: number;
whitelistedSnapshots?: Record<string, Array<string | RegExp>>;
}

type MessageId = 'noSnapshot' | 'tooLongSnapshots';

type RuleContext = TSESLint.RuleContext<MessageId, [RuleOptions]>;

const reportOnViolation = (
context: RuleContext,
node: TSESTree.CallExpression | TSESTree.ExpressionStatement,
{ maxSize: lineLimit = 50, whitelistedSnapshots = {} }: RuleOptions,
) => {
const startLine = node.loc.start.line;
const endLine = node.loc.end.line;
const lineCount = endLine - startLine;
const whitelistedSnapshots =
context.options &&
context.options[0] &&
context.options[0].whitelistedSnapshots;

const allPathsAreAbsolute = Object.keys(whitelistedSnapshots || {}).every(
const allPathsAreAbsolute = Object.keys(whitelistedSnapshots).every(
isAbsolute,
);

Expand All @@ -26,17 +41,23 @@ const reportOnViolation = (context, node) => {

let isWhitelisted = false;

if (whitelistedSnapshots) {
if (
whitelistedSnapshots &&
node.type === AST_NODE_TYPES.ExpressionStatement &&
'left' in node.expression &&
isExpectMember(node.expression.left)
) {
const fileName = context.getFilename();
const whitelistedSnapshotsInFile = whitelistedSnapshots[fileName];

if (whitelistedSnapshotsInFile) {
const snapshotName = getStringValue(node.expression.left.property);
const snapshotName = getAccessorValue(node.expression.left.property);
isWhitelisted = whitelistedSnapshotsInFile.some(name => {
if (name.test && typeof name.test === 'function') {
if (name instanceof RegExp) {
return name.test(snapshotName);
}
return name === snapshotName;

return snapshotName;
});
}
}
Expand All @@ -50,16 +71,20 @@ const reportOnViolation = (context, node) => {
}
};

export default {
export default createRule<[RuleOptions], MessageId>({
name: __filename,
meta: {
docs: {
url: getDocsUrl(__filename),
category: 'Best Practices',
description: 'disallow large snapshots',
recommended: false,
},
messages: {
noSnapshot: '`{{ lineCount }}`s should begin with lowercase',
tooLongSnapshots:
'Expected Jest snapshot to be smaller than {{ lineLimit }} lines but was {{ lineCount }} lines long',
},
type: 'suggestion',
schema: [
{
type: 'object',
Expand All @@ -78,28 +103,34 @@ export default {
},
],
},
create(context) {
defaultOptions: [{}],
create(context, [options]) {
if (context.getFilename().endsWith('.snap')) {
return {
ExpressionStatement(node) {
reportOnViolation(context, node);
reportOnViolation(context, node, options);
},
};
} else if (context.getFilename().endsWith('.js')) {
return {
CallExpression(node) {
const propertyName =
node.callee.property && node.callee.property.name;
if (
propertyName === 'toMatchInlineSnapshot' ||
propertyName === 'toThrowErrorMatchingInlineSnapshot'
'property' in node.callee &&
(isSupportedAccessor(
node.callee.property,
'toMatchInlineSnapshot',
) ||
isSupportedAccessor(
node.callee.property,
'toThrowErrorMatchingInlineSnapshot',
))
) {
reportOnViolation(context, node);
reportOnViolation(context, node, options);
}
},
};
}

return {};
},
};
});
52 changes: 52 additions & 0 deletions src/rules/tsUtils.ts
Expand Up @@ -108,6 +108,14 @@ export const isStringNode = <V extends string>(
export const getStringValue = <S extends string>(node: StringNode<S>): S =>
isTemplateLiteral(node) ? node.quasis[0].value.raw : node.value;

/**
* Represents a `MemberExpression` with a "known" `property`.
*/
interface KnownMemberExpression<Name extends string = string>
extends TSESTree.MemberExpression {
property: AccessorNode<Name>;
}

/**
* An `Identifier` with a known `name` value - i.e `expect`.
*/
Expand Down Expand Up @@ -184,6 +192,13 @@ export const hasOnlyOneArgument = (
): call is CallExpressionWithSingleArgument =>
call.arguments && call.arguments.length === 1;

/**
* An `Identifier` with a known `name` value - i.e `expect`.
*/
interface KnownIdentifier<Name extends string> extends TSESTree.Identifier {
name: Name;
}

/**
* Gets the value of the given `AccessorNode`,
* account for the different node types.
Expand All @@ -206,6 +221,43 @@ type AccessorNode<Specifics extends string = string> =
| StringNode<Specifics>
| KnownIdentifier<Specifics>;

interface ExpectCall extends TSESTree.CallExpression {
callee: AccessorNode<'expect'>;
parent: TSESTree.Node;
}

/**
* Represents a `MemberExpression` that comes after an `ExpectCall`.
*/
interface ExpectMember<
PropertyName extends ExpectPropertyName = ExpectPropertyName,
Parent extends TSESTree.Node | undefined = TSESTree.Node | undefined
> extends KnownMemberExpression<PropertyName> {
object: ExpectCall | ExpectMember;
parent: Parent;
}

export const isExpectMember = <
Name extends ExpectPropertyName = ExpectPropertyName
>(
node: TSESTree.Node,
name?: Name,
): node is ExpectMember<Name> =>
node.type === AST_NODE_TYPES.MemberExpression &&
isSupportedAccessor(node.property, name);

/**
* Represents all the jest matchers.
*/
type MatcherName = string /* & not ModifierName */;
type ExpectPropertyName = ModifierName | MatcherName;

export enum ModifierName {
not = 'not',
rejects = 'rejects',
resolves = 'resolves',
}

interface JestExpectIdentifier extends TSESTree.Identifier {
name: 'expect';
}
Expand Down
2 changes: 0 additions & 2 deletions src/rules/util.js
Expand Up @@ -102,8 +102,6 @@ export const isFunction = node =>
(node.type === 'FunctionExpression' ||
node.type === 'ArrowFunctionExpression');

export const getStringValue = arg => arg.quasis[0].value.raw;

/**
* Generates the URL to documentation for the given rule name. It uses the
* package version to build the link to a tagged version of the
Expand Down

0 comments on commit 851931d

Please sign in to comment.