Skip to content

Commit f9e54f4

Browse files
authored
feat!: estimate rule-tester failure location (#20420)
* feat(rule-tester): estimate failure location * fix: handle more cases * fix: handle code inline comments * fix: test * refactor: move estimator out of run method * chore: formatting * fix: handle invalid block before valid block * chore: handle oneline nested objects * chore: spelling
1 parent b0e4717 commit f9e54f4

2 files changed

Lines changed: 710 additions & 110 deletions

File tree

lib/rule-tester/rule-tester.js

Lines changed: 180 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//------------------------------------------------------------------------------
1212

1313
const assert = require("node:assert"),
14+
{ existsSync, readFileSync } = require("node:fs"),
1415
util = require("node:util"),
1516
path = require("node:path"),
1617
equal = require("fast-deep-equal"),
@@ -666,6 +667,177 @@ function getInvocationLocation(relative = getInvocationLocation) {
666667
return stack.split("\n")[1].replace(/.*?\(/u, "").replace(/\)$/u, "");
667668
}
668669

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+
669841
//------------------------------------------------------------------------------
670842
// Public Interface
671843
//------------------------------------------------------------------------------
@@ -866,7 +1038,7 @@ class RuleTester {
8661038
assertRule(rule, ruleName);
8671039
assertTest(test, ruleName);
8681040

869-
const invocationLocation = getInvocationLocation(this.run);
1041+
const estimateTestLocation = buildLazyTestLocationEstimator(this.run);
8701042

8711043
const baseConfig = [
8721044
{
@@ -1744,9 +1916,9 @@ class RuleTester {
17441916
error.stack = error.stack.replace(
17451917
/^ +at /mu,
17461918
[
1747-
` at RuleTester.run.valid[${index}]`,
1748-
` at RuleTester.run.valid`,
1749-
` at RuleTester.run (${invocationLocation})`,
1919+
` roughly at RuleTester.run.valid[${index}] (${estimateTestLocation(`valid[${index}]`)})`,
1920+
` roughly at RuleTester.run.valid (${estimateTestLocation("valid")})`,
1921+
` at RuleTester.run (${estimateTestLocation("root")})`,
17501922
" at ",
17511923
].join("\n"),
17521924
);
@@ -1789,12 +1961,12 @@ class RuleTester {
17891961
...(typeof errorIndex ===
17901962
"number"
17911963
? [
1792-
` at RuleTester.run.invalid[${index}].error[${errorIndex}]`,
1964+
` roughly at RuleTester.run.invalid[${index}].error[${errorIndex}] (${estimateTestLocation(`invalid[${index}].errors[${errorIndex}]`)})`,
17931965
]
17941966
: []),
1795-
` at RuleTester.run.invalid[${index}]`,
1796-
` at RuleTester.run.invalid`,
1797-
` at RuleTester.run (${invocationLocation})`,
1967+
` roughly at RuleTester.run.invalid[${index}] (${estimateTestLocation(`invalid[${index}]`)})`,
1968+
` roughly at RuleTester.run.invalid (${estimateTestLocation("invalid")})`,
1969+
` at RuleTester.run (${estimateTestLocation("root")})`,
17981970
" at ",
17991971
].join("\n"),
18001972
);

0 commit comments

Comments
 (0)