diff --git a/README.md b/README.md index dd1b088..e8f3a71 100644 --- a/README.md +++ b/README.md @@ -270,3 +270,52 @@ await page.locator('check').check(); await page.locator('input').fill('something'); ``` + +### `max-nested-describe` + +Enforces a maximum depth to nested `.describe()` calls. Useful for improving readability and parallelization of tests. + +Uses a default max depth option of `{ "max": 5 }`. + +Examples of **incorrect** code for this rule (using defaults): + +```js +test.describe('level 1', () => { + test.describe('level 2', () => { + test.describe('level 3', () => { + test.describe('level 4', () => { + test.describe('level 5', () => { + test.describe('level 6', () => { + test('this test', async ({ page }) => {}); + test('that test', async ({ page }) => {}); + }); + }); + }); + }); + }); +}); +``` + +Examples of **correct** code for this rule (using defaults): + +```js +test.describe('first level', () => { + test.describe('second level', () => { + test('this test', async ({ page }) => {}); + test('that test', async ({ page }) => {}); + }); +}); +``` + +#### Options + +The rule accepts a non-required option to override the default maximum nested describe depth (5). + +```json +{ + "playwright/max-nested-describe": [ + "error", + { "max": 3 } + ] +} +``` diff --git a/lib/index.js b/lib/index.js index 2e7aba5..cb9bf8c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,6 +6,7 @@ const noFocusedTest = require("./rules/no-focused-test"); const noSkippedTest = require("./rules/no-skipped-test"); const noWaitForTimeout = require("./rules/no-wait-for-timeout"); const noForceOption = require("./rules/no-force-option"); +const maxNestedDescribe = require("./rules/max-nested-describe"); module.exports = { configs: { @@ -24,6 +25,7 @@ module.exports = { "playwright/no-skipped-test": "warn", "playwright/no-wait-for-timeout": "warn", "playwright/no-force-option": "warn", + "playwright/max-nested-describe": "warn", }, }, "jest-playwright": { @@ -68,5 +70,6 @@ module.exports = { "no-skipped-test": noSkippedTest, "no-wait-for-timeout": noWaitForTimeout, "no-force-option": noForceOption, + "max-nested-describe": maxNestedDescribe, }, }; diff --git a/lib/rules/max-nested-describe.js b/lib/rules/max-nested-describe.js new file mode 100644 index 0000000..bfa69ab --- /dev/null +++ b/lib/rules/max-nested-describe.js @@ -0,0 +1,70 @@ + +const { isCallExpression, isDescribeCall } = require('../utils/ast'); + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + create(context) { + const { options } = context; + const defaultOptions = { max: 5 }; + const { max } = options[0] || defaultOptions; + const describeCallbackStack = []; + + function pushDescribeCallback(node) { + const { parent } = node; + + if(!isCallExpression(parent) || !isDescribeCall(parent)) { + return; + } + + describeCallbackStack.push(0); + + if (describeCallbackStack.length > max) { + context.report({ + node: parent, + messageId: 'exceededMaxDepth', + data: { depth: describeCallbackStack.length, max }, + }); + } + } + + function popDescribeCallback(node) { + const { parent } = node; + + if (isCallExpression(parent) && isDescribeCall(parent)) { + describeCallbackStack.pop(); + } + } + + return { + FunctionExpression: pushDescribeCallback, + 'FunctionExpression:exit': popDescribeCallback, + ArrowFunctionExpression: pushDescribeCallback, + 'ArrowFunctionExpression:exit': popDescribeCallback, + }; + }, + meta: { + docs: { + category: 'Best Practices', + description: 'Enforces a maximum depth to nested describe calls', + recommended: false, + url: 'https://github.com/playwright-community/eslint-plugin-playwright#max-nested-describe', + }, + messages: { + exceededMaxDepth: 'Maximum describe call depth exceeded ({{ depth }}). Maximum allowed is {{ max }}.', + }, + type: 'suggestion', + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + max: { + type: 'integer', + minimum: 0, + }, + }, + additionalProperties: false, + }, + ], + }, +}; diff --git a/lib/utils/ast.js b/lib/utils/ast.js index bd9e630..fc33757 100644 --- a/lib/utils/ast.js +++ b/lib/utils/ast.js @@ -37,8 +37,10 @@ function isObjectProperty({ object }, name) { ); } -function isStringLiteral(node) { - return node && node.type === 'Literal' && typeof node.value === 'string'; +function isStringLiteral(node, value) { + return node && node.type === 'Literal' + && typeof node.value === 'string' + && (value === undefined || node.value === value); } function isBooleanLiteral(node) { @@ -49,6 +51,58 @@ function isBinaryExpression(node) { return node && node.type === 'BinaryExpression'; } +function isCallExpression(node) { + return node && node.type === 'CallExpression'; +} + +function isMemberExpression(node) { + return node && node.type === 'MemberExpression'; +} + +function isIdentifier(node, name) { + return node + && node.type === 'Identifier' + && (name === undefined || node.name === name); +} + +function isDescribeAlias(node) { + return isIdentifier(node, 'describe'); +} + +function isDescribeProperty(node) { + const describeProperties = new Set(['parallel', 'serial', 'only', 'skip']); + + return isIdentifier(node) && describeProperties.has(node.name); +} + +function isDescribeCall(node) { + if (isDescribeAlias(node.callee)) { + return true; + } + + const callee = + node.callee.type === 'TaggedTemplateExpression' + ? node.callee.tag + : node.callee.type === 'CallExpression' + ? node.callee.callee + : node.callee; + + if(callee.type === 'MemberExpression' && isDescribeAlias(callee.property)) { + return true; + } + + if (callee.type === 'MemberExpression' && isDescribeProperty(callee.property)) { + + return callee.object.type === 'MemberExpression' + ? callee.object.object.type === 'MemberExpression' + ? isDescribeAlias(callee.object.object.property) + : isDescribeAlias(callee.object.property) + : (isDescribeAlias(callee.property) || isDescribeAlias(callee.object)); + } + + return false; +}; + module.exports = { isObject, isCalleeProperty, @@ -58,4 +112,7 @@ module.exports = { isStringLiteral, isBooleanLiteral, isBinaryExpression, + isCallExpression, + isMemberExpression, + isDescribeCall }; diff --git a/test/max-nested-describe.spec.js b/test/max-nested-describe.spec.js new file mode 100644 index 0000000..720bdbb --- /dev/null +++ b/test/max-nested-describe.spec.js @@ -0,0 +1,208 @@ +const { runRuleTester } = require('../lib/utils/rule-tester'); +const rule = require('../lib/rules/max-nested-describe'); + +const invalid = (code, options, errors) => ({ + code, + options: options || [], + errors: errors || [ + { + messageId: 'exceededMaxDepth', + }, + ], +}); + +const valid = (code, options) => ({ + code, + options: options || [] +}); + +runRuleTester('max-nested-describe', rule, { + invalid: [ + invalid(` + test.describe('foo', function() { + test.describe('bar', function () { + test.describe('baz', function () { + test.describe('qux', function () { + test.describe('quxx', function () { + test.describe('over limit', function () { + test('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + }); + }); + `), + invalid(` + describe('foo', function() { + describe('bar', function () { + describe('baz', function () { + describe('qux', function () { + describe('quxx', function () { + describe('over limit', function () { + test('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + }); + }); + `), + invalid(` + test.describe('foo', () => { + test.describe('bar', () => { + test.describe('baz', () => { + test.describe('baz1', () => { + test.describe('baz2', () => { + test.describe('baz3', () => { + test('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + + test.describe('baz4', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + + test.describe('qux', function () { + test('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }) + }); + `, [{ max: 5 }], [ + { messageId: 'exceededMaxDepth' }, + { messageId: 'exceededMaxDepth' }, + ]), + invalid(` + test.describe.only('foo', function() { + test.describe('bar', function() { + test.describe('baz', function() { + test.describe('qux', function() { + test.describe('quux', function() { + test.describe('quuz', function() { + }); + }); + }); + }); + }); + }); + `), + invalid(` + test.describe.serial.only('foo', function() { + test.describe('bar', function() { + test.describe('baz', function() { + test.describe('qux', function() { + test.describe('quux', function() { + test.describe('quuz', function() { + }); + }); + }); + }); + }); + }); + `), + invalid(` + test.describe('qux', () => { + test('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + `, [{ max: 0 }]), + invalid(` + test.describe('foo', () => { + test.describe('bar', () => { + test.describe('baz', () => { + test("test1", () => { + expect(true).toBe(true); + }); + test("test2", () => { + expect(true).toBe(true); + }); + }); + }); + }); + `, [{ max: 2 }]), + ], + valid: [ + 'test.describe("describe tests", () => {});', + 'test.describe.only("describe focus tests", () => {});', + 'test.describe.serial.only("describe serial focus tests", () => {});', + valid(` + test('foo', function () { + expect(true).toBe(true); + }); + test('bar', () => { + expect(true).toBe(true); + }); + `, [{ max: 0 }]), + valid(` + test.describe('foo', function() { + test.describe('bar', function () { + test.describe('baz', function () { + test.describe('qux', function () { + test.describe('quxx', function () { + test('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + }); + `), + valid(` + test.describe('foo', () => { + test.describe('bar', () => { + test.describe('baz', () => { + test.describe('qux', () => { + test('foo', () => { + expect(someCall().property).toBe(true); + }); + test('bar', () => { + expect(universe.answer).toBe(42); + }); + }); + test.describe('quxx', () => { + test('baz', () => { + expect(2 + 2).toEqual(4); + }); + }); + }); + }); + }); + `, [{ max: 4 }]), + valid(` + test.describe('foo', () => { + test.describe.only('bar', () => { + test.describe.skip('baz', () => { + test('something', async () => { + expect('something').toBe('something'); + }); + }); + }); + }); + `, [{ max: 3 }]), + valid(` + describe('foo', () => { + describe.only('bar', () => { + describe.skip('baz', () => { + test('something', async () => { + expect('something').toBe('something'); + }); + }); + }); + }); + `, [{ max: 3 }]) + ] +});