UNPKG

20.4 kBJavaScriptView Raw
1/**
2 * @fileoverview Mocha test wrapper
3 * @author Ilya Volodin
4 */
5"use strict";
6
7/* global describe, it */
8
9/*
10 * This is a wrapper around mocha to allow for DRY unittests for eslint
11 * Format:
12 * RuleTester.run("{ruleName}", {
13 * valid: [
14 * "{code}",
15 * { code: "{code}", options: {options}, globals: {globals}, parser: "{parser}", settings: {settings} }
16 * ],
17 * invalid: [
18 * { code: "{code}", errors: {numErrors} },
19 * { code: "{code}", errors: ["{errorMessage}"] },
20 * { code: "{code}", options: {options}, globals: {globals}, parser: "{parser}", settings: {settings}, errors: [{ message: "{errorMessage}", type: "{errorNodeType}"}] }
21 * ]
22 * });
23 *
24 * Variables:
25 * {code} - String that represents the code to be tested
26 * {options} - Arguments that are passed to the configurable rules.
27 * {globals} - An object representing a list of variables that are
28 * registered as globals
29 * {parser} - String representing the parser to use
30 * {settings} - An object representing global settings for all rules
31 * {numErrors} - If failing case doesn't need to check error message,
32 * this integer will specify how many errors should be
33 * received
34 * {errorMessage} - Message that is returned by the rule on failure
35 * {errorNodeType} - AST node type that is returned by they rule as
36 * a cause of the failure.
37 */
38
39//------------------------------------------------------------------------------
40// Requirements
41//------------------------------------------------------------------------------
42
43const lodash = require("lodash"),
44 assert = require("assert"),
45 util = require("util"),
46 validator = require("../config/config-validator"),
47 ajv = require("../util/ajv"),
48 Linter = require("../linter"),
49 Environments = require("../config/environments"),
50 SourceCodeFixer = require("../util/source-code-fixer"),
51 interpolate = require("../util/interpolate");
52
53//------------------------------------------------------------------------------
54// Private Members
55//------------------------------------------------------------------------------
56
57/*
58 * testerDefaultConfig must not be modified as it allows to reset the tester to
59 * the initial default configuration
60 */
61const testerDefaultConfig = { rules: {} };
62let defaultConfig = { rules: {} };
63
64/*
65 * List every parameters possible on a test case that are not related to eslint
66 * configuration
67 */
68const RuleTesterParameters = [
69 "code",
70 "filename",
71 "options",
72 "errors",
73 "output"
74];
75
76const hasOwnProperty = Function.call.bind(Object.hasOwnProperty);
77
78/**
79 * Clones a given value deeply.
80 * Note: This ignores `parent` property.
81 *
82 * @param {any} x - A value to clone.
83 * @returns {any} A cloned value.
84 */
85function cloneDeeplyExcludesParent(x) {
86 if (typeof x === "object" && x !== null) {
87 if (Array.isArray(x)) {
88 return x.map(cloneDeeplyExcludesParent);
89 }
90
91 const retv = {};
92
93 for (const key in x) {
94 if (key !== "parent" && hasOwnProperty(x, key)) {
95 retv[key] = cloneDeeplyExcludesParent(x[key]);
96 }
97 }
98
99 return retv;
100 }
101
102 return x;
103}
104
105/**
106 * Freezes a given value deeply.
107 *
108 * @param {any} x - A value to freeze.
109 * @returns {void}
110 */
111function freezeDeeply(x) {
112 if (typeof x === "object" && x !== null) {
113 if (Array.isArray(x)) {
114 x.forEach(freezeDeeply);
115 } else {
116 for (const key in x) {
117 if (key !== "parent" && hasOwnProperty(x, key)) {
118 freezeDeeply(x[key]);
119 }
120 }
121 }
122 Object.freeze(x);
123 }
124}
125
126//------------------------------------------------------------------------------
127// Public Interface
128//------------------------------------------------------------------------------
129
130// default separators for testing
131const DESCRIBE = Symbol("describe");
132const IT = Symbol("it");
133
134/**
135 * This is `it` default handler if `it` don't exist.
136 * @this {Mocha}
137 * @param {string} text - The description of the test case.
138 * @param {Function} method - The logic of the test case.
139 * @returns {any} Returned value of `method`.
140 */
141function itDefaultHandler(text, method) {
142 try {
143 return method.apply(this);
144 } catch (err) {
145 if (err instanceof assert.AssertionError) {
146 err.message += ` (${util.inspect(err.actual)} ${err.operator} ${util.inspect(err.expected)})`;
147 }
148 throw err;
149 }
150}
151
152/**
153 * This is `describe` default handler if `describe` don't exist.
154 * @this {Mocha}
155 * @param {string} text - The description of the test case.
156 * @param {Function} method - The logic of the test case.
157 * @returns {any} Returned value of `method`.
158 */
159function describeDefaultHandler(text, method) {
160 return method.apply(this);
161}
162
163class RuleTester {
164
165 /**
166 * Creates a new instance of RuleTester.
167 * @param {Object} [testerConfig] Optional, extra configuration for the tester
168 * @constructor
169 */
170 constructor(testerConfig) {
171
172 /**
173 * The configuration to use for this tester. Combination of the tester
174 * configuration and the default configuration.
175 * @type {Object}
176 */
177 this.testerConfig = lodash.merge(
178
179 // we have to clone because merge uses the first argument for recipient
180 lodash.cloneDeep(defaultConfig),
181 testerConfig,
182 { rules: { "rule-tester/validate-ast": "error" } }
183 );
184
185 /**
186 * Rule definitions to define before tests.
187 * @type {Object}
188 */
189 this.rules = {};
190 this.linter = new Linter();
191 }
192
193 /**
194 * Set the configuration to use for all future tests
195 * @param {Object} config the configuration to use.
196 * @returns {void}
197 */
198 static setDefaultConfig(config) {
199 if (typeof config !== "object") {
200 throw new TypeError("RuleTester.setDefaultConfig: config must be an object");
201 }
202 defaultConfig = config;
203
204 // Make sure the rules object exists since it is assumed to exist later
205 defaultConfig.rules = defaultConfig.rules || {};
206 }
207
208 /**
209 * Get the current configuration used for all tests
210 * @returns {Object} the current configuration
211 */
212 static getDefaultConfig() {
213 return defaultConfig;
214 }
215
216 /**
217 * Reset the configuration to the initial configuration of the tester removing
218 * any changes made until now.
219 * @returns {void}
220 */
221 static resetDefaultConfig() {
222 defaultConfig = lodash.cloneDeep(testerDefaultConfig);
223 }
224
225
226 /*
227 * If people use `mocha test.js --watch` command, `describe` and `it` function
228 * instances are different for each execution. So `describe` and `it` should get fresh instance
229 * always.
230 */
231 static get describe() {
232 return (
233 this[DESCRIBE] ||
234 (typeof describe === "function" ? describe : describeDefaultHandler)
235 );
236 }
237
238 static set describe(value) {
239 this[DESCRIBE] = value;
240 }
241
242 static get it() {
243 return (
244 this[IT] ||
245 (typeof it === "function" ? it : itDefaultHandler)
246 );
247 }
248
249 static set it(value) {
250 this[IT] = value;
251 }
252
253 /**
254 * Define a rule for one particular run of tests.
255 * @param {string} name The name of the rule to define.
256 * @param {Function} rule The rule definition.
257 * @returns {void}
258 */
259 defineRule(name, rule) {
260 this.rules[name] = rule;
261 }
262
263 /**
264 * Adds a new rule test to execute.
265 * @param {string} ruleName The name of the rule to run.
266 * @param {Function} rule The rule to test.
267 * @param {Object} test The collection of tests to run.
268 * @returns {void}
269 */
270 run(ruleName, rule, test) {
271
272 const testerConfig = this.testerConfig,
273 requiredScenarios = ["valid", "invalid"],
274 scenarioErrors = [],
275 linter = this.linter;
276
277 if (lodash.isNil(test) || typeof test !== "object") {
278 throw new TypeError(`Test Scenarios for rule ${ruleName} : Could not find test scenario object`);
279 }
280
281 requiredScenarios.forEach(scenarioType => {
282 if (lodash.isNil(test[scenarioType])) {
283 scenarioErrors.push(`Could not find any ${scenarioType} test scenarios`);
284 }
285 });
286
287 if (scenarioErrors.length > 0) {
288 throw new Error([
289 `Test Scenarios for rule ${ruleName} is invalid:`
290 ].concat(scenarioErrors).join("\n"));
291 }
292
293
294 linter.defineRule(ruleName, Object.assign({}, rule, {
295
296 // Create a wrapper rule that freezes the `context` properties.
297 create(context) {
298 freezeDeeply(context.options);
299 freezeDeeply(context.settings);
300 freezeDeeply(context.parserOptions);
301
302 return (typeof rule === "function" ? rule : rule.create)(context);
303 }
304 }));
305
306 linter.defineRules(this.rules);
307
308 const ruleMap = linter.getRules();
309
310 /**
311 * Run the rule for the given item
312 * @param {string|Object} item Item to run the rule against
313 * @returns {Object} Eslint run result
314 * @private
315 */
316 function runRuleForItem(item) {
317 let config = lodash.cloneDeep(testerConfig),
318 code, filename, beforeAST, afterAST;
319
320 if (typeof item === "string") {
321 code = item;
322 } else {
323 code = item.code;
324
325 /*
326 * Assumes everything on the item is a config except for the
327 * parameters used by this tester
328 */
329 const itemConfig = lodash.omit(item, RuleTesterParameters);
330
331 /*
332 * Create the config object from the tester config and this item
333 * specific configurations.
334 */
335 config = lodash.merge(
336 config,
337 itemConfig
338 );
339 }
340
341 if (item.filename) {
342 filename = item.filename;
343 }
344
345 if (Object.prototype.hasOwnProperty.call(item, "options")) {
346 assert(Array.isArray(item.options), "options must be an array");
347 config.rules[ruleName] = [1].concat(item.options);
348 } else {
349 config.rules[ruleName] = 1;
350 }
351
352 const schema = validator.getRuleOptionsSchema(rule);
353
354 /*
355 * Setup AST getters.
356 * The goal is to check whether or not AST was modified when
357 * running the rule under test.
358 */
359 linter.defineRule("rule-tester/validate-ast", () => ({
360 Program(node) {
361 beforeAST = cloneDeeplyExcludesParent(node);
362 },
363 "Program:exit"(node) {
364 afterAST = node;
365 }
366 }));
367
368 if (schema) {
369 ajv.validateSchema(schema);
370
371 if (ajv.errors) {
372 const errors = ajv.errors.map(error => {
373 const field = error.dataPath[0] === "." ? error.dataPath.slice(1) : error.dataPath;
374
375 return `\t${field}: ${error.message}`;
376 }).join("\n");
377
378 throw new Error([`Schema for rule ${ruleName} is invalid:`, errors]);
379 }
380 }
381
382 validator.validate(config, "rule-tester", ruleMap.get.bind(ruleMap), new Environments());
383
384 return {
385 messages: linter.verify(code, config, filename, true),
386 beforeAST,
387 afterAST: cloneDeeplyExcludesParent(afterAST)
388 };
389 }
390
391 /**
392 * Check if the AST was changed
393 * @param {ASTNode} beforeAST AST node before running
394 * @param {ASTNode} afterAST AST node after running
395 * @returns {void}
396 * @private
397 */
398 function assertASTDidntChange(beforeAST, afterAST) {
399 if (!lodash.isEqual(beforeAST, afterAST)) {
400
401 // Not using directly to avoid performance problem in node 6.1.0. See #6111
402 // eslint-disable-next-line no-restricted-properties
403 assert.deepEqual(beforeAST, afterAST, "Rule should not modify AST.");
404 }
405 }
406
407 /**
408 * Check if the template is valid or not
409 * all valid cases go through this
410 * @param {string|Object} item Item to run the rule against
411 * @returns {void}
412 * @private
413 */
414 function testValidTemplate(item) {
415 const result = runRuleForItem(item);
416 const messages = result.messages;
417
418 assert.strictEqual(messages.length, 0, util.format("Should have no errors but had %d: %s",
419 messages.length, util.inspect(messages)));
420
421 assertASTDidntChange(result.beforeAST, result.afterAST);
422 }
423
424 /**
425 * Asserts that the message matches its expected value. If the expected
426 * value is a regular expression, it is checked against the actual
427 * value.
428 * @param {string} actual Actual value
429 * @param {string|RegExp} expected Expected value
430 * @returns {void}
431 * @private
432 */
433 function assertMessageMatches(actual, expected) {
434 if (expected instanceof RegExp) {
435
436 // assert.js doesn't have a built-in RegExp match function
437 assert.ok(
438 expected.test(actual),
439 `Expected '${actual}' to match ${expected}`
440 );
441 } else {
442 assert.strictEqual(actual, expected);
443 }
444 }
445
446 /**
447 * Check if the template is invalid or not
448 * all invalid cases go through this.
449 * @param {string|Object} item Item to run the rule against
450 * @returns {void}
451 * @private
452 */
453 function testInvalidTemplate(item) {
454 assert.ok(item.errors || item.errors === 0,
455 `Did not specify errors for an invalid test of ${ruleName}`);
456
457 const result = runRuleForItem(item);
458 const messages = result.messages;
459
460
461 if (typeof item.errors === "number") {
462 assert.strictEqual(messages.length, item.errors, util.format("Should have %d error%s but had %d: %s",
463 item.errors, item.errors === 1 ? "" : "s", messages.length, util.inspect(messages)));
464 } else {
465 assert.strictEqual(
466 messages.length, item.errors.length,
467 util.format(
468 "Should have %d error%s but had %d: %s",
469 item.errors.length, item.errors.length === 1 ? "" : "s", messages.length, util.inspect(messages)
470 )
471 );
472
473 const hasMessageOfThisRule = messages.some(m => m.ruleId === ruleName);
474
475 for (let i = 0, l = item.errors.length; i < l; i++) {
476 const error = item.errors[i];
477 const message = messages[i];
478
479 assert(!message.fatal, `A fatal parsing error occurred: ${message.message}`);
480 assert(hasMessageOfThisRule, "Error rule name should be the same as the name of the rule being tested");
481
482 if (typeof error === "string" || error instanceof RegExp) {
483
484 // Just an error message.
485 assertMessageMatches(message.message, error);
486 } else if (typeof error === "object") {
487
488 /*
489 * Error object.
490 * This may have a message, node type, line, and/or
491 * column.
492 */
493 if (error.message) {
494 assertMessageMatches(message.message, error.message);
495 }
496
497 if (error.messageId) {
498 const hOP = Object.hasOwnProperty.call.bind(Object.hasOwnProperty);
499
500 // verify that `error.message` is `undefined`
501 assert.strictEqual(error.message, void 0, "Error should not specify both a message and a messageId.");
502 if (!hOP(rule, "meta") || !hOP(rule.meta, "messages")) {
503 assert.fail("Rule must specify a messages hash in `meta`");
504 }
505 if (!hOP(rule.meta.messages, error.messageId)) {
506 const friendlyIDList = `[${Object.keys(rule.meta.messages).map(key => `'${key}'`).join(", ")}]`;
507
508 assert.fail(`Invalid messageId '${error.messageId}'. Expected one of ${friendlyIDList}.`);
509 }
510
511 let expectedMessage = rule.meta.messages[error.messageId];
512
513 if (error.data) {
514 expectedMessage = interpolate(expectedMessage, error.data);
515 }
516
517 assertMessageMatches(message.message, expectedMessage);
518 }
519
520 if (error.type) {
521 assert.strictEqual(message.nodeType, error.type, `Error type should be ${error.type}, found ${message.nodeType}`);
522 }
523
524 if (error.hasOwnProperty("line")) {
525 assert.strictEqual(message.line, error.line, `Error line should be ${error.line}`);
526 }
527
528 if (error.hasOwnProperty("column")) {
529 assert.strictEqual(message.column, error.column, `Error column should be ${error.column}`);
530 }
531
532 if (error.hasOwnProperty("endLine")) {
533 assert.strictEqual(message.endLine, error.endLine, `Error endLine should be ${error.endLine}`);
534 }
535
536 if (error.hasOwnProperty("endColumn")) {
537 assert.strictEqual(message.endColumn, error.endColumn, `Error endColumn should be ${error.endColumn}`);
538 }
539 } else {
540
541 // Message was an unexpected type
542 assert.fail(message, null, "Error should be a string, object, or RegExp.");
543 }
544 }
545 }
546
547 if (item.hasOwnProperty("output")) {
548 if (item.output === null) {
549 assert.strictEqual(
550 messages.filter(message => message.fix).length,
551 0,
552 "Expected no autofixes to be suggested"
553 );
554 } else {
555 const fixResult = SourceCodeFixer.applyFixes(item.code, messages);
556
557 // eslint-disable-next-line no-restricted-properties
558 assert.equal(fixResult.output, item.output, "Output is incorrect.");
559 }
560 }
561
562 assertASTDidntChange(result.beforeAST, result.afterAST);
563 }
564
565 /*
566 * This creates a mocha test suite and pipes all supplied info through
567 * one of the templates above.
568 */
569 RuleTester.describe(ruleName, () => {
570 RuleTester.describe("valid", () => {
571 test.valid.forEach(valid => {
572 RuleTester.it(typeof valid === "object" ? valid.code : valid, () => {
573 testValidTemplate(valid);
574 });
575 });
576 });
577
578 RuleTester.describe("invalid", () => {
579 test.invalid.forEach(invalid => {
580 RuleTester.it(invalid.code, () => {
581 testInvalidTemplate(invalid);
582 });
583 });
584 });
585 });
586 }
587}
588
589RuleTester[DESCRIBE] = RuleTester[IT] = null;
590
591module.exports = RuleTester;