eslint 10.0.0-alpha.1 → 10.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@
11
11
  //------------------------------------------------------------------------------
12
12
 
13
13
  const assert = require("node:assert"),
14
+ { existsSync, readFileSync } = require("node:fs"),
14
15
  util = require("node:util"),
15
16
  path = require("node:path"),
16
17
  equal = require("fast-deep-equal"),
@@ -387,9 +388,10 @@ function normalizeTestCase(item) {
387
388
  * Asserts that the `errors` property of an invalid test case is valid.
388
389
  * @param {number | string[]} errors The `errors` property of the invalid test case.
389
390
  * @param {string} ruleName The name of the rule being tested.
391
+ * @param {Object} [assertionOptions] The assertion options for the test case.
390
392
  * @returns {void}
391
393
  */
392
- function assertErrorsProperty(errors, ruleName) {
394
+ function assertErrorsProperty(errors, ruleName, assertionOptions = {}) {
393
395
  const isNumber = typeof errors === "number";
394
396
  const isArray = Array.isArray(errors);
395
397
 
@@ -407,12 +409,75 @@ function assertErrorsProperty(errors, ruleName) {
407
409
  }
408
410
  }
409
411
 
412
+ const { requireMessage = false, requireLocation = false } =
413
+ assertionOptions;
414
+
410
415
  if (isArray) {
411
416
  assert.ok(
412
417
  errors.length !== 0,
413
418
  "Invalid cases must have at least one error",
414
419
  );
420
+
421
+ for (const [number, error] of errors.entries()) {
422
+ if (typeof error === "string" || error instanceof RegExp) {
423
+ // Just an error message.
424
+ assert.ok(
425
+ requireMessage !== "messageId" && !requireLocation,
426
+ `errors[${number}] should be an object when 'assertionOptions.requireMessage' is 'messageId' or 'assertionOptions.requireLocation' is true.`,
427
+ );
428
+ } else if (typeof error === "object" && error !== null) {
429
+ /*
430
+ * Error object.
431
+ * This may have a message, messageId, data, line, and/or column.
432
+ */
433
+
434
+ for (const propertyName of Object.keys(error)) {
435
+ assert.ok(
436
+ errorObjectParameters.has(propertyName),
437
+ `Invalid error property name '${propertyName}'. Expected one of ${friendlyErrorObjectParameterList}.`,
438
+ );
439
+ }
440
+
441
+ if (requireMessage === "message") {
442
+ assert.ok(
443
+ !hasOwnProperty(error, "messageId") &&
444
+ hasOwnProperty(error, "message"),
445
+ `errors[${number}] should specify 'message' (and not 'messageId') when 'assertionOptions.requireMessage' is 'message'.`,
446
+ );
447
+ } else if (requireMessage === "messageId") {
448
+ assert.ok(
449
+ !hasOwnProperty(error, "message") &&
450
+ hasOwnProperty(error, "messageId"),
451
+ `errors[${number}] should specify 'messageId' (and not 'message') when 'assertionOptions.requireMessage' is 'messageId'.`,
452
+ );
453
+ }
454
+
455
+ if (hasOwnProperty(error, "message")) {
456
+ assert.ok(
457
+ !hasOwnProperty(error, "messageId"),
458
+ `errors[${number}] should not specify both 'message' and 'messageId'.`,
459
+ );
460
+ assert.ok(
461
+ !hasOwnProperty(error, "data"),
462
+ `errors[${number}] should not specify both 'data' and 'message'.`,
463
+ );
464
+ } else {
465
+ assert.ok(
466
+ hasOwnProperty(error, "messageId"),
467
+ `errors[${number}] must specify either 'messageId' or 'message'.`,
468
+ );
469
+ }
470
+ } else {
471
+ assert.fail(
472
+ `errors[${number}] must be a string, RegExp, or an object.`,
473
+ );
474
+ }
475
+ }
415
476
  } else {
477
+ assert.ok(
478
+ !requireMessage && !requireLocation,
479
+ "Invalid cases must have 'errors' value as an array",
480
+ );
416
481
  assert.ok(
417
482
  errors > 0,
418
483
  "Invalid cases must have 'error' value greater than 0",
@@ -564,13 +629,19 @@ function assertValidTestCase(item, seenTestCases) {
564
629
  * @param {Object} item The invalid test case object to check.
565
630
  * @param {Set<string>} seenTestCases Set of serialized test cases to check for duplicates.
566
631
  * @param {string} ruleName The name of the rule being tested.
632
+ * @param {Object} [assertionOptions] The assertion options for the test case.
567
633
  * @returns {void}
568
634
  * @throws {AssertionError} If the test case is not valid.
569
635
  */
570
- function assertInvalidTestCase(item, seenTestCases, ruleName) {
636
+ function assertInvalidTestCase(
637
+ item,
638
+ seenTestCases,
639
+ ruleName,
640
+ assertionOptions = {},
641
+ ) {
571
642
  assertTestCommonProperties(item);
572
643
 
573
- assertErrorsProperty(item.errors, ruleName);
644
+ assertErrorsProperty(item.errors, ruleName, assertionOptions);
574
645
 
575
646
  // 'output' is optional, but if it exists it must be a string or null
576
647
  if (hasOwnProperty(item, "output")) {
@@ -583,6 +654,190 @@ function assertInvalidTestCase(item, seenTestCases, ruleName) {
583
654
  checkDuplicateTestCase(item, seenTestCases);
584
655
  }
585
656
 
657
+ /**
658
+ * Gets the invocation location from the stack trace for later use.
659
+ * @param {Function} relative The function before the invocation point.
660
+ * @returns {string} The invocation location.
661
+ */
662
+ function getInvocationLocation(relative = getInvocationLocation) {
663
+ const dummyObject = {};
664
+ Error.captureStackTrace(dummyObject, relative);
665
+ const { stack } = dummyObject;
666
+
667
+ return stack.split("\n")[1].replace(/.*?\(/u, "").replace(/\)$/u, "");
668
+ }
669
+
670
+ /**
671
+ * Estimates the location of the test case in the source file.
672
+ * @param {Function} invoker The method that runs the tests.
673
+ * @returns {(key: string) => string} The lazy resolver for the estimated location of the test case.
674
+ */
675
+ function buildLazyTestLocationEstimator(invoker) {
676
+ const invocationLocation = getInvocationLocation(invoker);
677
+ let testLocations = null;
678
+ return key => {
679
+ if (testLocations === null) {
680
+ let [sourceFile, sourceLine = "1", sourceColumn = "1"] =
681
+ invocationLocation
682
+ .replace(/:\\/u, "\\\\") // Windows workaround for C:\\
683
+ .split(":");
684
+ sourceFile = sourceFile.replace(/\\\\/u, ":\\");
685
+ sourceLine = Number(sourceLine);
686
+ sourceColumn = Number(sourceColumn);
687
+ testLocations = { root: invocationLocation };
688
+
689
+ if (existsSync(sourceFile)) {
690
+ let content = readFileSync(sourceFile, "utf8")
691
+ .split("\n")
692
+ .slice(sourceLine - 1);
693
+ content[0] = content[0].slice(Math.max(0, sourceColumn - 1));
694
+ content = content.map(
695
+ l =>
696
+ l
697
+ .trim() // Remove whitespace
698
+ .replace(/\s*\/\/.*$(?<!,)/u, ""), // and trailing in-line comments that aren't part of the test `code`
699
+ );
700
+
701
+ // Roots
702
+ const validStartIndex = content.findIndex(line =>
703
+ /\bvalid:/u.test(line),
704
+ );
705
+ const invalidStartIndex = content.findIndex(line =>
706
+ /\binvalid:/u.test(line),
707
+ );
708
+
709
+ testLocations.valid = `${sourceFile}:${
710
+ sourceLine + validStartIndex
711
+ }`;
712
+ testLocations.invalid = `${sourceFile}:${
713
+ sourceLine + invalidStartIndex
714
+ }`;
715
+
716
+ // Scenario basics
717
+ const validEndIndex =
718
+ validStartIndex < invalidStartIndex
719
+ ? invalidStartIndex
720
+ : content.length;
721
+ const invalidEndIndex =
722
+ validStartIndex < invalidStartIndex
723
+ ? content.length
724
+ : validStartIndex;
725
+
726
+ const validLines = content.slice(
727
+ validStartIndex,
728
+ validEndIndex,
729
+ );
730
+ const invalidLines = content.slice(
731
+ invalidStartIndex,
732
+ invalidEndIndex,
733
+ );
734
+
735
+ let objectDepth = 0;
736
+ const validLineIndexes = validLines
737
+ .map((l, i) => {
738
+ // matches `key: {` and `{`
739
+ if (/^(?:\w+\s*:\s*)?\{/u.test(l)) {
740
+ objectDepth++;
741
+ }
742
+
743
+ if (objectDepth > 0) {
744
+ if (l.endsWith("}") || l.endsWith("},")) {
745
+ objectDepth--;
746
+ }
747
+
748
+ return objectDepth <= 1 && l.includes("code:")
749
+ ? i
750
+ : null;
751
+ }
752
+
753
+ return l.endsWith(",") ? i : null;
754
+ })
755
+ .filter(Boolean);
756
+ const invalidLineIndexes = invalidLines
757
+ .map((l, i) =>
758
+ l.trimStart().startsWith("errors:") ? i : null,
759
+ )
760
+ .filter(Boolean);
761
+
762
+ Object.assign(
763
+ testLocations,
764
+ {
765
+ [`valid[0]`]: `${sourceFile}:${
766
+ sourceLine + validStartIndex
767
+ }`,
768
+ },
769
+ Object.fromEntries(
770
+ validLineIndexes.map((location, validIndex) => [
771
+ `valid[${validIndex}]`,
772
+ `${sourceFile}:${
773
+ sourceLine + validStartIndex + location
774
+ }`,
775
+ ]),
776
+ ),
777
+ Object.fromEntries(
778
+ invalidLineIndexes.map((location, invalidIndex) => [
779
+ `invalid[${invalidIndex}]`,
780
+ `${sourceFile}:${
781
+ sourceLine + invalidStartIndex + location
782
+ }`,
783
+ ]),
784
+ ),
785
+ );
786
+
787
+ // Indexes for errors inside each invalid test case
788
+ invalidLineIndexes.push(invalidLines.length);
789
+
790
+ for (let i = 0; i < invalidLineIndexes.length - 1; i++) {
791
+ const start = invalidLineIndexes[i];
792
+ const end = invalidLineIndexes[i + 1];
793
+ const errorLines = invalidLines.slice(start, end);
794
+ let errorObjectDepth = 0;
795
+ const errorLineIndexes = errorLines
796
+ .map((l, j) => {
797
+ if (l.startsWith("{") || l.endsWith("{")) {
798
+ errorObjectDepth++;
799
+
800
+ if (l.endsWith("}") || l.endsWith("},")) {
801
+ errorObjectDepth--;
802
+ }
803
+
804
+ return errorObjectDepth <= 1 ? j : null;
805
+ }
806
+
807
+ if (errorObjectDepth > 0) {
808
+ if (l.endsWith("}") || l.endsWith("},")) {
809
+ errorObjectDepth--;
810
+ }
811
+
812
+ return null;
813
+ }
814
+
815
+ return l.endsWith(",") ? j : null;
816
+ })
817
+ .filter(Boolean);
818
+
819
+ Object.assign(
820
+ testLocations,
821
+ Object.fromEntries(
822
+ errorLineIndexes.map((line, errorIndex) => [
823
+ `invalid[${i}].errors[${errorIndex}]`,
824
+ `${sourceFile}:${
825
+ sourceLine +
826
+ invalidStartIndex +
827
+ start +
828
+ line
829
+ }`,
830
+ ]),
831
+ ),
832
+ );
833
+ }
834
+ }
835
+ }
836
+
837
+ return testLocations[key] || "unknown source";
838
+ };
839
+ }
840
+
586
841
  //------------------------------------------------------------------------------
587
842
  // Public Interface
588
843
  //------------------------------------------------------------------------------
@@ -763,6 +1018,11 @@ class RuleTester {
763
1018
  * @param {string} ruleName The name of the rule to run.
764
1019
  * @param {RuleDefinition} rule The rule to test.
765
1020
  * @param {{
1021
+ * assertionOptions?: {
1022
+ * requireMessage?: boolean | "message" | "messageId",
1023
+ * requireLocation?: boolean
1024
+ * requireData?: boolean | "error" | "suggestion"
1025
+ * },
766
1026
  * valid: (ValidTestCase | string)[],
767
1027
  * invalid: InvalidTestCase[]
768
1028
  * }} test The collection of tests to run.
@@ -778,6 +1038,8 @@ class RuleTester {
778
1038
  assertRule(rule, ruleName);
779
1039
  assertTest(test, ruleName);
780
1040
 
1041
+ const estimateTestLocation = buildLazyTestLocationEstimator(this.run);
1042
+
781
1043
  const baseConfig = [
782
1044
  {
783
1045
  plugins: {
@@ -1141,8 +1403,15 @@ class RuleTester {
1141
1403
  * @param {Object} item Item to run the rule against
1142
1404
  * @returns {void}
1143
1405
  * @private
1406
+ * @throws {Error} If the test case is invalid or has an invalid error.
1144
1407
  */
1145
1408
  function testInvalidTemplate(item) {
1409
+ const {
1410
+ requireMessage = false,
1411
+ requireLocation = false,
1412
+ requireData = false,
1413
+ } = test.assertionOptions ?? {};
1414
+
1146
1415
  const ruleHasMetaMessages =
1147
1416
  hasOwnProperty(rule, "meta") &&
1148
1417
  hasOwnProperty(rule.meta, "messages");
@@ -1152,6 +1421,11 @@ class RuleTester {
1152
1421
  .join(", ")}]`
1153
1422
  : null;
1154
1423
 
1424
+ assert.ok(
1425
+ ruleHasMetaMessages || requireMessage !== "messageId",
1426
+ `Assertion options can not use 'requireMessage: "messageId"' if rule under test doesn't define 'meta.messages'.`,
1427
+ );
1428
+
1155
1429
  const result = runRuleForItem(item);
1156
1430
  const messages = result.messages;
1157
1431
 
@@ -1204,357 +1478,384 @@ class RuleTester {
1204
1478
  );
1205
1479
 
1206
1480
  for (let i = 0, l = item.errors.length; i < l; i++) {
1207
- const error = item.errors[i];
1208
- const message = messages[i];
1481
+ try {
1482
+ const error = item.errors[i];
1483
+ const message = messages[i];
1209
1484
 
1210
- assert(
1211
- hasMessageOfThisRule,
1212
- "Error rule name should be the same as the name of the rule being tested",
1213
- );
1214
-
1215
- if (typeof error === "string" || error instanceof RegExp) {
1216
- // Just an error message.
1217
- assertMessageMatches(message.message, error);
1218
- assert.ok(
1219
- message.suggestions === void 0,
1220
- `Error at index ${i} has suggestions. Please convert the test error into an object and specify 'suggestions' property on it to test suggestions.`,
1485
+ assert(
1486
+ hasMessageOfThisRule,
1487
+ "Error rule name should be the same as the name of the rule being tested",
1221
1488
  );
1222
- } else if (typeof error === "object" && error !== null) {
1223
- /*
1224
- * Error object.
1225
- * This may have a message, messageId, data, line, and/or column.
1226
- */
1227
-
1228
- Object.keys(error).forEach(propertyName => {
1229
- assert.ok(
1230
- errorObjectParameters.has(propertyName),
1231
- `Invalid error property name '${propertyName}'. Expected one of ${friendlyErrorObjectParameterList}.`,
1232
- );
1233
- });
1234
1489
 
1235
- if (hasOwnProperty(error, "message")) {
1236
- assert.ok(
1237
- !hasOwnProperty(error, "messageId"),
1238
- "Error should not specify both 'message' and a 'messageId'.",
1239
- );
1490
+ if (
1491
+ typeof error === "string" ||
1492
+ error instanceof RegExp
1493
+ ) {
1494
+ // Just an error message.
1495
+ assertMessageMatches(message.message, error);
1240
1496
  assert.ok(
1241
- !hasOwnProperty(error, "data"),
1242
- "Error should not specify both 'data' and 'message'.",
1497
+ message.suggestions === void 0,
1498
+ `Error at index ${i} has suggestions. Please convert the test error into an object and specify 'suggestions' property on it to test suggestions.`,
1243
1499
  );
1244
- assertMessageMatches(
1245
- message.message,
1246
- error.message,
1247
- );
1248
- } else if (hasOwnProperty(error, "messageId")) {
1249
- assert.ok(
1250
- ruleHasMetaMessages,
1251
- "Error can not use 'messageId' if rule under test doesn't define 'meta.messages'.",
1252
- );
1253
- if (
1254
- !hasOwnProperty(
1255
- rule.meta.messages,
1256
- error.messageId,
1257
- )
1258
- ) {
1259
- assert(
1260
- false,
1261
- `Invalid messageId '${error.messageId}'. Expected one of ${friendlyIDList}.`,
1262
- );
1263
- }
1264
- assert.strictEqual(
1265
- message.messageId,
1266
- error.messageId,
1267
- `messageId '${message.messageId}' does not match expected messageId '${error.messageId}'.`,
1268
- );
1269
-
1270
- const unsubstitutedPlaceholders =
1271
- getUnsubstitutedMessagePlaceholders(
1500
+ } else if (
1501
+ typeof error === "object" &&
1502
+ error !== null
1503
+ ) {
1504
+ /*
1505
+ * Error object.
1506
+ * This may have a message, messageId, data, line, and/or column.
1507
+ */
1508
+
1509
+ if (hasOwnProperty(error, "message")) {
1510
+ assertMessageMatches(
1272
1511
  message.message,
1273
- rule.meta.messages[message.messageId],
1274
- error.data,
1512
+ error.message,
1275
1513
  );
1276
-
1277
- assert.ok(
1278
- unsubstitutedPlaceholders.length === 0,
1279
- `The reported message has ${unsubstitutedPlaceholders.length > 1 ? `unsubstituted placeholders: ${unsubstitutedPlaceholders.map(name => `'${name}'`).join(", ")}` : `an unsubstituted placeholder '${unsubstitutedPlaceholders[0]}'`}. Please provide the missing ${unsubstitutedPlaceholders.length > 1 ? "values" : "value"} via the 'data' property in the context.report() call.`,
1280
- );
1281
-
1282
- if (hasOwnProperty(error, "data")) {
1283
- /*
1284
- * if data was provided, then directly compare the returned message to a synthetic
1285
- * interpolated message using the same message ID and data provided in the test.
1286
- * See https://github.com/eslint/eslint/issues/9890 for context.
1287
- */
1288
- const unformattedOriginalMessage =
1289
- rule.meta.messages[error.messageId];
1290
- const rehydratedMessage = interpolate(
1291
- unformattedOriginalMessage,
1292
- error.data,
1514
+ } else if (hasOwnProperty(error, "messageId")) {
1515
+ assert.ok(
1516
+ ruleHasMetaMessages,
1517
+ "Error can not use 'messageId' if rule under test doesn't define 'meta.messages'.",
1293
1518
  );
1294
-
1519
+ if (
1520
+ !hasOwnProperty(
1521
+ rule.meta.messages,
1522
+ error.messageId,
1523
+ )
1524
+ ) {
1525
+ assert(
1526
+ false,
1527
+ `Invalid messageId '${error.messageId}'. Expected one of ${friendlyIDList}.`,
1528
+ );
1529
+ }
1295
1530
  assert.strictEqual(
1296
- message.message,
1297
- rehydratedMessage,
1298
- `Hydrated message "${rehydratedMessage}" does not match "${message.message}"`,
1531
+ message.messageId,
1532
+ error.messageId,
1533
+ `messageId '${message.messageId}' does not match expected messageId '${error.messageId}'.`,
1299
1534
  );
1300
- }
1301
- } else {
1302
- assert.fail(
1303
- "Test error must specify either a 'messageId' or 'message'.",
1304
- );
1305
- }
1306
-
1307
- const actualLocation = {};
1308
- const expectedLocation = {};
1309
1535
 
1310
- if (hasOwnProperty(error, "line")) {
1311
- actualLocation.line = message.line;
1312
- expectedLocation.line = error.line;
1313
- }
1536
+ const unsubstitutedPlaceholders =
1537
+ getUnsubstitutedMessagePlaceholders(
1538
+ message.message,
1539
+ rule.meta.messages[message.messageId],
1540
+ error.data,
1541
+ );
1314
1542
 
1315
- if (hasOwnProperty(error, "column")) {
1316
- actualLocation.column = message.column;
1317
- expectedLocation.column = error.column;
1318
- }
1543
+ assert.ok(
1544
+ unsubstitutedPlaceholders.length === 0,
1545
+ `The reported message has ${unsubstitutedPlaceholders.length > 1 ? `unsubstituted placeholders: ${unsubstitutedPlaceholders.map(name => `'${name}'`).join(", ")}` : `an unsubstituted placeholder '${unsubstitutedPlaceholders[0]}'`}. Please provide the missing ${unsubstitutedPlaceholders.length > 1 ? "values" : "value"} via the 'data' property in the context.report() call.`,
1546
+ );
1319
1547
 
1320
- if (hasOwnProperty(error, "endLine")) {
1321
- actualLocation.endLine = message.endLine;
1322
- expectedLocation.endLine = error.endLine;
1323
- }
1548
+ if (hasOwnProperty(error, "data")) {
1549
+ /*
1550
+ * if data was provided, then directly compare the returned message to a synthetic
1551
+ * interpolated message using the same message ID and data provided in the test.
1552
+ * See https://github.com/eslint/eslint/issues/9890 for context.
1553
+ */
1554
+ const unformattedOriginalMessage =
1555
+ rule.meta.messages[error.messageId];
1556
+ const rehydratedMessage = interpolate(
1557
+ unformattedOriginalMessage,
1558
+ error.data,
1559
+ );
1324
1560
 
1325
- if (hasOwnProperty(error, "endColumn")) {
1326
- actualLocation.endColumn = message.endColumn;
1327
- expectedLocation.endColumn = error.endColumn;
1328
- }
1561
+ assert.strictEqual(
1562
+ message.message,
1563
+ rehydratedMessage,
1564
+ `Hydrated message "${rehydratedMessage}" does not match "${message.message}"`,
1565
+ );
1566
+ } else {
1567
+ const requiresDataProperty =
1568
+ requireData === true ||
1569
+ requireData === "error";
1570
+ const hasPlaceholders =
1571
+ getMessagePlaceholders(
1572
+ rule.meta.messages[error.messageId],
1573
+ ).length > 0;
1574
+ assert.ok(
1575
+ !requiresDataProperty ||
1576
+ !hasPlaceholders,
1577
+ `Error should specify the 'data' property as the referenced message has placeholders.`,
1578
+ );
1579
+ }
1580
+ }
1329
1581
 
1330
- if (Object.keys(expectedLocation).length > 0) {
1331
- assert.deepStrictEqual(
1332
- actualLocation,
1333
- expectedLocation,
1334
- "Actual error location does not match expected error location.",
1335
- );
1336
- }
1582
+ const locationProperties = [
1583
+ "line",
1584
+ "column",
1585
+ "endLine",
1586
+ "endColumn",
1587
+ ];
1588
+ const actualLocation = {};
1589
+ const expectedLocation = {};
1590
+
1591
+ for (const key of locationProperties) {
1592
+ if (hasOwnProperty(error, key)) {
1593
+ actualLocation[key] = message[key];
1594
+ expectedLocation[key] = error[key];
1595
+ }
1596
+ }
1337
1597
 
1338
- assert.ok(
1339
- !message.suggestions ||
1340
- hasOwnProperty(error, "suggestions"),
1341
- `Error at index ${i} has suggestions. Please specify 'suggestions' property on the test error object.`,
1342
- );
1343
- if (hasOwnProperty(error, "suggestions")) {
1344
- // Support asserting there are no suggestions
1345
- const expectsSuggestions = Array.isArray(
1346
- error.suggestions,
1347
- )
1348
- ? error.suggestions.length > 0
1349
- : Boolean(error.suggestions);
1350
- const hasSuggestions =
1351
- message.suggestions !== void 0;
1352
-
1353
- if (!hasSuggestions && expectsSuggestions) {
1354
- assert.ok(
1355
- !error.suggestions,
1356
- `Error should have suggestions on error with message: "${message.message}"`,
1598
+ if (requireLocation) {
1599
+ const missingKeys = locationProperties.filter(
1600
+ key =>
1601
+ !hasOwnProperty(error, key) &&
1602
+ hasOwnProperty(message, key),
1357
1603
  );
1358
- } else if (hasSuggestions) {
1359
1604
  assert.ok(
1360
- expectsSuggestions,
1361
- `Error should have no suggestions on error with message: "${message.message}"`,
1605
+ missingKeys.length === 0,
1606
+ `Error is missing expected location properties: ${missingKeys.join(", ")}`,
1362
1607
  );
1363
- if (typeof error.suggestions === "number") {
1364
- assert.strictEqual(
1365
- message.suggestions.length,
1366
- error.suggestions,
1367
- `Error should have ${error.suggestions} suggestions. Instead found ${message.suggestions.length} suggestions`,
1608
+ }
1609
+
1610
+ if (Object.keys(expectedLocation).length > 0) {
1611
+ assert.deepStrictEqual(
1612
+ actualLocation,
1613
+ expectedLocation,
1614
+ "Actual error location does not match expected error location.",
1615
+ );
1616
+ }
1617
+
1618
+ assert.ok(
1619
+ !message.suggestions ||
1620
+ hasOwnProperty(error, "suggestions"),
1621
+ `Error at index ${i} has suggestions. Please specify 'suggestions' property on the test error object.`,
1622
+ );
1623
+ if (hasOwnProperty(error, "suggestions")) {
1624
+ // Support asserting there are no suggestions
1625
+ const expectsSuggestions = Array.isArray(
1626
+ error.suggestions,
1627
+ )
1628
+ ? error.suggestions.length > 0
1629
+ : Boolean(error.suggestions);
1630
+ const hasSuggestions =
1631
+ message.suggestions !== void 0;
1632
+
1633
+ if (!hasSuggestions && expectsSuggestions) {
1634
+ assert.ok(
1635
+ !error.suggestions,
1636
+ `Error should have suggestions on error with message: "${message.message}"`,
1368
1637
  );
1369
- } else if (Array.isArray(error.suggestions)) {
1370
- assert.strictEqual(
1371
- message.suggestions.length,
1372
- error.suggestions.length,
1373
- `Error should have ${error.suggestions.length} suggestions. Instead found ${message.suggestions.length} suggestions`,
1638
+ } else if (hasSuggestions) {
1639
+ assert.ok(
1640
+ expectsSuggestions,
1641
+ `Error should have no suggestions on error with message: "${message.message}"`,
1374
1642
  );
1375
-
1376
- error.suggestions.forEach(
1377
- (expectedSuggestion, index) => {
1378
- assert.ok(
1379
- typeof expectedSuggestion ===
1380
- "object" &&
1381
- expectedSuggestion !== null,
1382
- "Test suggestion in 'suggestions' array must be an object.",
1383
- );
1384
- Object.keys(
1385
- expectedSuggestion,
1386
- ).forEach(propertyName => {
1643
+ if (typeof error.suggestions === "number") {
1644
+ assert.strictEqual(
1645
+ message.suggestions.length,
1646
+ error.suggestions,
1647
+ `Error should have ${error.suggestions} suggestions. Instead found ${message.suggestions.length} suggestions`,
1648
+ );
1649
+ } else if (
1650
+ Array.isArray(error.suggestions)
1651
+ ) {
1652
+ assert.strictEqual(
1653
+ message.suggestions.length,
1654
+ error.suggestions.length,
1655
+ `Error should have ${error.suggestions.length} suggestions. Instead found ${message.suggestions.length} suggestions`,
1656
+ );
1657
+
1658
+ error.suggestions.forEach(
1659
+ (expectedSuggestion, index) => {
1387
1660
  assert.ok(
1388
- suggestionObjectParameters.has(
1389
- propertyName,
1390
- ),
1391
- `Invalid suggestion property name '${propertyName}'. Expected one of ${friendlySuggestionObjectParameterList}.`,
1661
+ typeof expectedSuggestion ===
1662
+ "object" &&
1663
+ expectedSuggestion !==
1664
+ null,
1665
+ "Test suggestion in 'suggestions' array must be an object.",
1392
1666
  );
1393
- });
1667
+ Object.keys(
1668
+ expectedSuggestion,
1669
+ ).forEach(propertyName => {
1670
+ assert.ok(
1671
+ suggestionObjectParameters.has(
1672
+ propertyName,
1673
+ ),
1674
+ `Invalid suggestion property name '${propertyName}'. Expected one of ${friendlySuggestionObjectParameterList}.`,
1675
+ );
1676
+ });
1394
1677
 
1395
- const actualSuggestion =
1396
- message.suggestions[index];
1397
- const suggestionPrefix = `Error Suggestion at index ${index}:`;
1678
+ const actualSuggestion =
1679
+ message.suggestions[index];
1680
+ const suggestionPrefix = `Error Suggestion at index ${index}:`;
1398
1681
 
1399
- if (
1400
- hasOwnProperty(
1401
- expectedSuggestion,
1402
- "desc",
1403
- )
1404
- ) {
1405
- assert.ok(
1406
- !hasOwnProperty(
1682
+ if (
1683
+ hasOwnProperty(
1407
1684
  expectedSuggestion,
1408
- "data",
1409
- ),
1410
- `${suggestionPrefix} Test should not specify both 'desc' and 'data'.`,
1411
- );
1412
- assert.ok(
1413
- !hasOwnProperty(
1685
+ "desc",
1686
+ )
1687
+ ) {
1688
+ assert.ok(
1689
+ !hasOwnProperty(
1690
+ expectedSuggestion,
1691
+ "data",
1692
+ ),
1693
+ `${suggestionPrefix} Test should not specify both 'desc' and 'data'.`,
1694
+ );
1695
+ assert.ok(
1696
+ !hasOwnProperty(
1697
+ expectedSuggestion,
1698
+ "messageId",
1699
+ ),
1700
+ `${suggestionPrefix} Test should not specify both 'desc' and 'messageId'.`,
1701
+ );
1702
+ assert.strictEqual(
1703
+ actualSuggestion.desc,
1704
+ expectedSuggestion.desc,
1705
+ `${suggestionPrefix} desc should be "${expectedSuggestion.desc}" but got "${actualSuggestion.desc}" instead.`,
1706
+ );
1707
+ } else if (
1708
+ hasOwnProperty(
1414
1709
  expectedSuggestion,
1415
1710
  "messageId",
1416
- ),
1417
- `${suggestionPrefix} Test should not specify both 'desc' and 'messageId'.`,
1418
- );
1419
- assert.strictEqual(
1420
- actualSuggestion.desc,
1421
- expectedSuggestion.desc,
1422
- `${suggestionPrefix} desc should be "${expectedSuggestion.desc}" but got "${actualSuggestion.desc}" instead.`,
1423
- );
1424
- } else if (
1425
- hasOwnProperty(
1426
- expectedSuggestion,
1427
- "messageId",
1428
- )
1429
- ) {
1430
- assert.ok(
1431
- ruleHasMetaMessages,
1432
- `${suggestionPrefix} Test can not use 'messageId' if rule under test doesn't define 'meta.messages'.`,
1433
- );
1434
- assert.ok(
1435
- hasOwnProperty(
1436
- rule.meta.messages,
1711
+ )
1712
+ ) {
1713
+ assert.ok(
1714
+ ruleHasMetaMessages,
1715
+ `${suggestionPrefix} Test can not use 'messageId' if rule under test doesn't define 'meta.messages'.`,
1716
+ );
1717
+ assert.ok(
1718
+ hasOwnProperty(
1719
+ rule.meta.messages,
1720
+ expectedSuggestion.messageId,
1721
+ ),
1722
+ `${suggestionPrefix} Test has invalid messageId '${expectedSuggestion.messageId}', the rule under test allows only one of ${friendlyIDList}.`,
1723
+ );
1724
+ assert.strictEqual(
1725
+ actualSuggestion.messageId,
1437
1726
  expectedSuggestion.messageId,
1438
- ),
1439
- `${suggestionPrefix} Test has invalid messageId '${expectedSuggestion.messageId}', the rule under test allows only one of ${friendlyIDList}.`,
1440
- );
1441
- assert.strictEqual(
1442
- actualSuggestion.messageId,
1443
- expectedSuggestion.messageId,
1444
- `${suggestionPrefix} messageId should be '${expectedSuggestion.messageId}' but got '${actualSuggestion.messageId}' instead.`,
1445
- );
1727
+ `${suggestionPrefix} messageId should be '${expectedSuggestion.messageId}' but got '${actualSuggestion.messageId}' instead.`,
1728
+ );
1446
1729
 
1447
- const unsubstitutedPlaceholders =
1448
- getUnsubstitutedMessagePlaceholders(
1449
- actualSuggestion.desc,
1730
+ const rawSuggestionMessage =
1450
1731
  rule.meta.messages[
1451
1732
  expectedSuggestion
1452
1733
  .messageId
1453
- ],
1454
- expectedSuggestion.data,
1455
- );
1734
+ ];
1735
+ const unsubstitutedPlaceholders =
1736
+ getUnsubstitutedMessagePlaceholders(
1737
+ actualSuggestion.desc,
1738
+ rawSuggestionMessage,
1739
+ expectedSuggestion.data,
1740
+ );
1456
1741
 
1457
- assert.ok(
1458
- unsubstitutedPlaceholders.length ===
1459
- 0,
1460
- `The message of the suggestion has ${unsubstitutedPlaceholders.length > 1 ? `unsubstituted placeholders: ${unsubstitutedPlaceholders.map(name => `'${name}'`).join(", ")}` : `an unsubstituted placeholder '${unsubstitutedPlaceholders[0]}'`}. Please provide the missing ${unsubstitutedPlaceholders.length > 1 ? "values" : "value"} via the 'data' property for the suggestion in the context.report() call.`,
1461
- );
1742
+ assert.ok(
1743
+ unsubstitutedPlaceholders.length ===
1744
+ 0,
1745
+ `The message of the suggestion has ${unsubstitutedPlaceholders.length > 1 ? `unsubstituted placeholders: ${unsubstitutedPlaceholders.map(name => `'${name}'`).join(", ")}` : `an unsubstituted placeholder '${unsubstitutedPlaceholders[0]}'`}. Please provide the missing ${unsubstitutedPlaceholders.length > 1 ? "values" : "value"} via the 'data' property for the suggestion in the context.report() call.`,
1746
+ );
1462
1747
 
1463
- if (
1748
+ if (
1749
+ hasOwnProperty(
1750
+ expectedSuggestion,
1751
+ "data",
1752
+ )
1753
+ ) {
1754
+ const unformattedMetaMessage =
1755
+ rule.meta.messages[
1756
+ expectedSuggestion
1757
+ .messageId
1758
+ ];
1759
+ const rehydratedDesc =
1760
+ interpolate(
1761
+ unformattedMetaMessage,
1762
+ expectedSuggestion.data,
1763
+ );
1764
+
1765
+ assert.strictEqual(
1766
+ actualSuggestion.desc,
1767
+ rehydratedDesc,
1768
+ `${suggestionPrefix} Hydrated test desc "${rehydratedDesc}" does not match received desc "${actualSuggestion.desc}".`,
1769
+ );
1770
+ } else {
1771
+ const requiresDataProperty =
1772
+ requireData ===
1773
+ true ||
1774
+ requireData ===
1775
+ "suggestion";
1776
+ const hasPlaceholders =
1777
+ getMessagePlaceholders(
1778
+ rawSuggestionMessage,
1779
+ ).length > 0;
1780
+ assert.ok(
1781
+ !requiresDataProperty ||
1782
+ !hasPlaceholders,
1783
+ `${suggestionPrefix} Suggestion should specify the 'data' property as the referenced message has placeholders.`,
1784
+ );
1785
+ }
1786
+ } else if (
1464
1787
  hasOwnProperty(
1465
1788
  expectedSuggestion,
1466
1789
  "data",
1467
1790
  )
1468
1791
  ) {
1469
- const unformattedMetaMessage =
1470
- rule.meta.messages[
1471
- expectedSuggestion
1472
- .messageId
1473
- ];
1474
- const rehydratedDesc =
1475
- interpolate(
1476
- unformattedMetaMessage,
1477
- expectedSuggestion.data,
1478
- );
1479
-
1480
- assert.strictEqual(
1481
- actualSuggestion.desc,
1482
- rehydratedDesc,
1483
- `${suggestionPrefix} Hydrated test desc "${rehydratedDesc}" does not match received desc "${actualSuggestion.desc}".`,
1792
+ assert.fail(
1793
+ `${suggestionPrefix} Test must specify 'messageId' if 'data' is used.`,
1794
+ );
1795
+ } else {
1796
+ assert.fail(
1797
+ `${suggestionPrefix} Test must specify either 'messageId' or 'desc'.`,
1484
1798
  );
1485
1799
  }
1486
- } else if (
1487
- hasOwnProperty(
1488
- expectedSuggestion,
1489
- "data",
1490
- )
1491
- ) {
1492
- assert.fail(
1493
- `${suggestionPrefix} Test must specify 'messageId' if 'data' is used.`,
1800
+
1801
+ assert.ok(
1802
+ hasOwnProperty(
1803
+ expectedSuggestion,
1804
+ "output",
1805
+ ),
1806
+ `${suggestionPrefix} The "output" property is required.`,
1494
1807
  );
1495
- } else {
1496
- assert.fail(
1497
- `${suggestionPrefix} Test must specify either 'messageId' or 'desc'.`,
1808
+ const codeWithAppliedSuggestion =
1809
+ SourceCodeFixer.applyFixes(
1810
+ item.code,
1811
+ [actualSuggestion],
1812
+ ).output;
1813
+
1814
+ // Verify if suggestion fix makes a syntax error or not.
1815
+ const errorMessageInSuggestion =
1816
+ linter
1817
+ .verify(
1818
+ codeWithAppliedSuggestion,
1819
+ result.configs,
1820
+ result.filename,
1821
+ )
1822
+ .find(m => m.fatal);
1823
+
1824
+ assert(
1825
+ !errorMessageInSuggestion,
1826
+ [
1827
+ "A fatal parsing error occurred in suggestion fix.",
1828
+ `Error: ${errorMessageInSuggestion && errorMessageInSuggestion.message}`,
1829
+ "Suggestion output:",
1830
+ codeWithAppliedSuggestion,
1831
+ ].join("\n"),
1498
1832
  );
1499
- }
1500
1833
 
1501
- assert.ok(
1502
- hasOwnProperty(
1503
- expectedSuggestion,
1504
- "output",
1505
- ),
1506
- `${suggestionPrefix} The "output" property is required.`,
1507
- );
1508
- const codeWithAppliedSuggestion =
1509
- SourceCodeFixer.applyFixes(
1510
- item.code,
1511
- [actualSuggestion],
1512
- ).output;
1513
-
1514
- // Verify if suggestion fix makes a syntax error or not.
1515
- const errorMessageInSuggestion =
1516
- linter
1517
- .verify(
1518
- codeWithAppliedSuggestion,
1519
- result.configs,
1520
- result.filename,
1521
- )
1522
- .find(m => m.fatal);
1523
-
1524
- assert(
1525
- !errorMessageInSuggestion,
1526
- [
1527
- "A fatal parsing error occurred in suggestion fix.",
1528
- `Error: ${errorMessageInSuggestion && errorMessageInSuggestion.message}`,
1529
- "Suggestion output:",
1834
+ assert.strictEqual(
1530
1835
  codeWithAppliedSuggestion,
1531
- ].join("\n"),
1532
- );
1533
-
1534
- assert.strictEqual(
1535
- codeWithAppliedSuggestion,
1536
- expectedSuggestion.output,
1537
- `Expected the applied suggestion fix to match the test suggestion output for suggestion at index: ${index} on error with message: "${message.message}"`,
1538
- );
1539
- assert.notStrictEqual(
1540
- expectedSuggestion.output,
1541
- item.code,
1542
- `The output of a suggestion should differ from the original source code for suggestion at index: ${index} on error with message: "${message.message}"`,
1543
- );
1544
- },
1545
- );
1546
- } else {
1547
- assert.fail(
1548
- "Test error object property 'suggestions' should be an array or a number",
1549
- );
1836
+ expectedSuggestion.output,
1837
+ `Expected the applied suggestion fix to match the test suggestion output for suggestion at index: ${index} on error with message: "${message.message}"`,
1838
+ );
1839
+ assert.notStrictEqual(
1840
+ expectedSuggestion.output,
1841
+ item.code,
1842
+ `The output of a suggestion should differ from the original source code for suggestion at index: ${index} on error with message: "${message.message}"`,
1843
+ );
1844
+ },
1845
+ );
1846
+ } else {
1847
+ assert.fail(
1848
+ "Test error object property 'suggestions' should be an array or a number",
1849
+ );
1850
+ }
1550
1851
  }
1551
1852
  }
1552
1853
  }
1553
- } else {
1554
- // Message was an unexpected type
1555
- assert.fail(
1556
- `Error should be a string, object, or RegExp, but found (${util.inspect(message)})`,
1557
- );
1854
+ } catch (error) {
1855
+ if (error instanceof Error) {
1856
+ error.errorIndex = i;
1857
+ }
1858
+ throw error;
1558
1859
  }
1559
1860
  }
1560
1861
  }
@@ -1599,7 +1900,7 @@ class RuleTester {
1599
1900
  if (test.valid.length > 0) {
1600
1901
  this.constructor.describe("valid", () => {
1601
1902
  const seenTestCases = new Set();
1602
- test.valid.forEach(valid => {
1903
+ test.valid.forEach((valid, index) => {
1603
1904
  const item = normalizeTestCase(valid);
1604
1905
  this.constructor[valid.only ? "itOnly" : "it"](
1605
1906
  sanitize(item.name || item.code),
@@ -1608,6 +1909,21 @@ class RuleTester {
1608
1909
  runHook(item, "before");
1609
1910
  assertValidTestCase(item, seenTestCases);
1610
1911
  testValidTemplate(item);
1912
+ } catch (error) {
1913
+ if (error instanceof Error) {
1914
+ error.scenarioType = "valid";
1915
+ error.scenarioIndex = index;
1916
+ error.stack = error.stack.replace(
1917
+ /^ +at /mu,
1918
+ [
1919
+ ` roughly at RuleTester.run.valid[${index}] (${estimateTestLocation(`valid[${index}]`)})`,
1920
+ ` roughly at RuleTester.run.valid (${estimateTestLocation("valid")})`,
1921
+ ` at RuleTester.run (${estimateTestLocation("root")})`,
1922
+ " at ",
1923
+ ].join("\n"),
1924
+ );
1925
+ }
1926
+ throw error;
1611
1927
  } finally {
1612
1928
  runHook(item, "after");
1613
1929
  }
@@ -1620,7 +1936,7 @@ class RuleTester {
1620
1936
  if (test.invalid.length > 0) {
1621
1937
  this.constructor.describe("invalid", () => {
1622
1938
  const seenTestCases = new Set();
1623
- test.invalid.forEach(invalid => {
1939
+ test.invalid.forEach((invalid, index) => {
1624
1940
  const item = normalizeTestCase(invalid);
1625
1941
  this.constructor[item.only ? "itOnly" : "it"](
1626
1942
  sanitize(item.name || item.code),
@@ -1631,8 +1947,31 @@ class RuleTester {
1631
1947
  item,
1632
1948
  seenTestCases,
1633
1949
  ruleName,
1950
+ test.assertionOptions,
1634
1951
  );
1635
1952
  testInvalidTemplate(item);
1953
+ } catch (error) {
1954
+ if (error instanceof Error) {
1955
+ error.scenarioType = "invalid";
1956
+ error.scenarioIndex = index;
1957
+ const errorIndex = error.errorIndex;
1958
+ error.stack = error.stack.replace(
1959
+ /^ +at /mu,
1960
+ [
1961
+ ...(typeof errorIndex ===
1962
+ "number"
1963
+ ? [
1964
+ ` roughly at RuleTester.run.invalid[${index}].error[${errorIndex}] (${estimateTestLocation(`invalid[${index}].errors[${errorIndex}]`)})`,
1965
+ ]
1966
+ : []),
1967
+ ` roughly at RuleTester.run.invalid[${index}] (${estimateTestLocation(`invalid[${index}]`)})`,
1968
+ ` roughly at RuleTester.run.invalid (${estimateTestLocation("invalid")})`,
1969
+ ` at RuleTester.run (${estimateTestLocation("root")})`,
1970
+ " at ",
1971
+ ].join("\n"),
1972
+ );
1973
+ }
1974
+ throw error;
1636
1975
  } finally {
1637
1976
  runHook(item, "after");
1638
1977
  }