diff --git a/scripts/internal-rules/index.js b/scripts/internal-rules/index.js index 8a54edf1a6..fcd258cb9c 100644 --- a/scripts/internal-rules/index.js +++ b/scripts/internal-rules/index.js @@ -13,6 +13,7 @@ const RULES_DIRECTORIES = [ const rules = [ {id: 'fix-snapshot-test', directories: TEST_DIRECTORIES}, {id: 'prefer-negative-boolean-attribute', directories: RULES_DIRECTORIES}, + {id: 'no-test-only', directories: TEST_DIRECTORIES}, ]; const isFileInsideDirectory = (filename, directory) => filename.startsWith(directory + path.sep); diff --git a/scripts/internal-rules/no-test-only.js b/scripts/internal-rules/no-test-only.js new file mode 100644 index 0000000000..6ad644f84a --- /dev/null +++ b/scripts/internal-rules/no-test-only.js @@ -0,0 +1,74 @@ +'use strict'; +const path = require('node:path'); + +const messageId = path.basename(__filename, '.js'); + +module.exports = { + create(context) { + if (path.basename(context.physicalFilename) === 'snapshot-rule-tester.mjs') { + return {}; + } + + return { + MemberExpression(node) { + if ( + !( + !node.computed + && !node.optional + && node.object.type === 'Identifier' + && node.object.name === 'test' + && node.property.type === 'Identifier' + && node.property.name === 'only' + ) + ) { + return; + } + + const isTaggedTemplateExpression = node.parent.type === 'TaggedTemplateExpression' && node.parent.tag === node; + const isCallee = !isTaggedTemplateExpression + && node.parent.type === 'CallExpression' + && node.parent.callee === node + && !node.parent.optional + && node.parent.arguments.length === 1; + + const problem = {node, messageId}; + + if (isTaggedTemplateExpression) { + problem.fix = fixer => fixer.remove(node); + } + + if (isCallee) { + problem.fix = function * (fixer) { + const {sourceCode} = context; + const openingParenToken = sourceCode.getTokenAfter(node); + const closingParenToken = sourceCode.getLastToken(node.parent); + if (openingParenToken.value !== '(' || closingParenToken.value !== ')') { + return; + } + + yield fixer.remove(node); + yield fixer.remove(openingParenToken); + yield fixer.remove(closingParenToken); + + // Trailing comma + const tokenBefore = sourceCode.getTokenBefore(closingParenToken); + + if (tokenBefore.value !== ',') { + return; + } + + yield fixer.remove(tokenBefore); + }; + } + + context.report(problem); + }, + }; + }, + meta: { + fixable: 'code', + messages: { + [messageId]: '`test.only` should only be used for debugging purposes. Please remove it before committing.', + }, + }, +}; diff --git a/test/utils/snapshot-rule-tester.mjs b/test/utils/snapshot-rule-tester.mjs index 3f651cdbe8..46cc903095 100644 --- a/test/utils/snapshot-rule-tester.mjs +++ b/test/utils/snapshot-rule-tester.mjs @@ -60,7 +60,7 @@ function normalizeTests(tests) { const additionalProperties = getAdditionalProperties( testCase, - ['code', 'options', 'filename', 'parserOptions', 'parser', 'globals'], + ['code', 'options', 'filename', 'parserOptions', 'parser', 'globals', 'only'], ); if (additionalProperties.length > 0) { @@ -154,11 +154,11 @@ class SnapshotRuleTester { const {valid, invalid} = normalizeTests(tests); for (const [index, testCase] of valid.entries()) { - const {code, filename} = testCase; + const {code, filename, only} = testCase; const verifyConfig = getVerifyConfig(ruleId, config, testCase); defineParser(linter, verifyConfig.parser); - test( + (only ? test.only : test)( `valid(${index + 1}): ${code}`, t => { const messages = verify(linter, code, verifyConfig, {filename}); @@ -168,12 +168,12 @@ class SnapshotRuleTester { } for (const [index, testCase] of invalid.entries()) { - const {code, options, filename} = testCase; + const {code, options, filename, only} = testCase; const verifyConfig = getVerifyConfig(ruleId, config, testCase); defineParser(linter, verifyConfig.parser); const runVerify = code => verify(linter, code, verifyConfig, {filename}); - test( + (only ? test.only : test)( `invalid(${index + 1}): ${code}`, t => { const messages = runVerify(code); diff --git a/test/utils/test.mjs b/test/utils/test.mjs index c7f75c5478..47f85a7872 100644 --- a/test/utils/test.mjs +++ b/test/utils/test.mjs @@ -7,8 +7,8 @@ import SnapshotRuleTester from './snapshot-rule-tester.mjs'; import defaultOptions from './default-options.mjs'; import parsers from './parsers.mjs'; -function normalizeTests(tests) { - return tests.map(test => typeof test === 'string' ? {code: test} : test); +function normalizeTestCase(testCase) { + return typeof testCase === 'string' ? {code: testCase} : {...testCase}; } function normalizeInvalidTest(test, rule) { @@ -34,22 +34,45 @@ function normalizeInvalidTest(test, rule) { } function normalizeParser(options) { - const { + let { parser, parserOptions, } = options; if (parser) { - if (parser.name) { - options.parser = parser.name; + if (parser.mergeParserOptions) { + parserOptions = parser.mergeParserOptions(parserOptions); } - if (parser.mergeParserOptions) { - options.parserOptions = parser.mergeParserOptions(parserOptions); + if (parser.name) { + parser = parser.name; } } - return options; + return {...options, parser, parserOptions}; +} + +// https://github.com/tc39/proposal-array-is-template-object +const isTemplateObject = value => Array.isArray(value?.raw); +// https://github.com/tc39/proposal-string-cooked +const cooked = (raw, ...substitutions) => String.raw({raw}, ...substitutions); + +function only(...arguments_) { + /* + ```js + only`code`; + ``` + */ + if (isTemplateObject(arguments_[0])) { + return {code: cooked(...arguments_), only: true}; + } + + /* + ```js + only('code'); + only({code: 'code'}); + */ + return {...normalizeTestCase(arguments_[0]), only: true}; } class Tester { @@ -98,8 +121,8 @@ class Tester { } = tests; testerOptions = normalizeParser(testerOptions); - valid = normalizeTests(valid).map(test => normalizeParser(test)); - invalid = normalizeTests(invalid).map(test => normalizeParser(test)); + valid = valid.map(testCase => normalizeParser(normalizeTestCase(testCase))); + invalid = invalid.map(testCase => normalizeParser(normalizeTestCase(testCase))); const tester = new SnapshotRuleTester(test, { ...testerOptions, @@ -126,6 +149,7 @@ function getTester(importMeta) { const tester = new Tester(ruleId); const runTest = Tester.prototype.runTest.bind(tester); runTest.snapshot = Tester.prototype.snapshot.bind(tester); + runTest.only = only; for (const [parserName, parserSettings] of Object.entries(parsers)) { Reflect.defineProperty(runTest, parserName, { @@ -152,10 +176,11 @@ function getTester(importMeta) { }; } -const addComment = (test, comment) => { - const {code, output} = test; +const addComment = (testCase, comment) => { + testCase = normalizeTestCase(testCase); + const {code, output} = testCase; const fixedTest = { - ...test, + ...testCase, code: `${code}\n/* ${comment} */`, }; if (Object.prototype.hasOwnProperty.call(fixedTest, 'output') && typeof output === 'string') { @@ -169,8 +194,8 @@ const avoidTestTitleConflict = (tests, comment) => { const {valid, invalid} = tests; return { ...tests, - valid: normalizeTests(valid).map(test => addComment(test, comment)), - invalid: normalizeTests(invalid).map(test => addComment(test, comment)), + valid: valid.map(testCase => addComment(testCase, comment)), + invalid: invalid.map(testCase => addComment(testCase, comment)), }; };