Skip to content

Commit 5110248

Browse files
authored
feat(rule-tester): check for missing placeholder data in the message (typescript-eslint#9039)
Enhancement(rule-tester): check for missing placeholder data in the message
1 parent af47bc9 commit 5110248

File tree

5 files changed

+360
-12
lines changed

5 files changed

+360
-12
lines changed

packages/rule-tester/src/RuleTester.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { satisfiesAllDependencyConstraints } from './utils/dependencyConstraints
3838
import { freezeDeeply } from './utils/freezeDeeply';
3939
import { getRuleOptionsSchema } from './utils/getRuleOptionsSchema';
4040
import { hasOwnProperty } from './utils/hasOwnProperty';
41-
import { interpolate } from './utils/interpolate';
41+
import { getPlaceholderMatcher, interpolate } from './utils/interpolate';
4242
import { isReadonlyArray } from './utils/isReadonlyArray';
4343
import * as SourceCodeFixer from './utils/SourceCodeFixer';
4444
import {
@@ -73,6 +73,45 @@ let defaultConfig = deepMerge(
7373
testerDefaultConfig,
7474
) as TesterConfigWithDefaults;
7575

76+
/**
77+
* Extracts names of {{ placeholders }} from the reported message.
78+
* @param message Reported message
79+
* @returns Array of placeholder names
80+
*/
81+
function getMessagePlaceholders(message: string): string[] {
82+
const matcher = getPlaceholderMatcher();
83+
84+
return Array.from(message.matchAll(matcher), ([, name]) => name.trim());
85+
}
86+
87+
/**
88+
* Returns the placeholders in the reported messages but
89+
* only includes the placeholders available in the raw message and not in the provided data.
90+
* @param message The reported message
91+
* @param raw The raw message specified in the rule meta.messages
92+
* @param data The passed
93+
* @returns Missing placeholder names
94+
*/
95+
function getUnsubstitutedMessagePlaceholders(
96+
message: string,
97+
raw: string,
98+
data: Record<string, unknown> = {},
99+
): string[] {
100+
const unsubstituted = getMessagePlaceholders(message);
101+
102+
if (unsubstituted.length === 0) {
103+
return [];
104+
}
105+
106+
// Remove false positives by only counting placeholders in the raw message, which were not provided in the data matcher or added with a data property
107+
const known = getMessagePlaceholders(raw);
108+
const provided = Object.keys(data);
109+
110+
return unsubstituted.filter(
111+
name => known.includes(name) && !provided.includes(name),
112+
);
113+
}
114+
76115
export class RuleTester extends TestFramework {
77116
readonly #testerConfig: TesterConfigWithDefaults;
78117
readonly #rules: Record<string, AnyRuleCreateFunction | AnyRuleModule> = {};
@@ -809,6 +848,19 @@ export class RuleTester extends TestFramework {
809848
error.messageId,
810849
`messageId '${message.messageId}' does not match expected messageId '${error.messageId}'.`,
811850
);
851+
852+
const unsubstitutedPlaceholders =
853+
getUnsubstitutedMessagePlaceholders(
854+
message.message,
855+
rule.meta.messages[message.messageId],
856+
error.data,
857+
);
858+
859+
assert.ok(
860+
unsubstitutedPlaceholders.length === 0,
861+
`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.`,
862+
);
863+
812864
if (hasOwnProperty(error, 'data')) {
813865
/*
814866
* if data was provided, then directly compare the returned message to a synthetic
@@ -954,6 +1006,19 @@ export class RuleTester extends TestFramework {
9541006
expectedSuggestion.messageId,
9551007
`${suggestionPrefix} messageId should be '${expectedSuggestion.messageId}' but got '${actualSuggestion.messageId}' instead.`,
9561008
);
1009+
1010+
const unsubstitutedPlaceholders =
1011+
getUnsubstitutedMessagePlaceholders(
1012+
actualSuggestion.desc,
1013+
rule.meta.messages[expectedSuggestion.messageId],
1014+
expectedSuggestion.data,
1015+
);
1016+
1017+
assert.ok(
1018+
unsubstitutedPlaceholders.length === 0,
1019+
`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.`,
1020+
);
1021+
9571022
if (hasOwnProperty(expectedSuggestion, 'data')) {
9581023
const unformattedMetaMessage =
9591024
rule.meta.messages[expectedSuggestion.messageId];

packages/rule-tester/src/utils/interpolate.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
import type { ReportDescriptorMessageData } from '@typescript-eslint/utils/ts-eslint';
44

5+
/**
6+
* Returns a global expression matching placeholders in messages.
7+
*/
8+
export function getPlaceholderMatcher(): RegExp {
9+
return /\{\{([^{}]+?)\}\}/gu;
10+
}
11+
512
export function interpolate(
613
text: string,
714
data: ReportDescriptorMessageData | undefined,
@@ -10,18 +17,17 @@ export function interpolate(
1017
return text;
1118
}
1219

20+
const matcher = getPlaceholderMatcher();
21+
1322
// Substitution content for any {{ }} markers.
14-
return text.replace(
15-
/\{\{([^{}]+?)\}\}/gu,
16-
(fullMatch, termWithWhitespace: string) => {
17-
const term = termWithWhitespace.trim();
23+
return text.replace(matcher, (fullMatch, termWithWhitespace: string) => {
24+
const term = termWithWhitespace.trim();
1825

19-
if (term in data) {
20-
return String(data[term]);
21-
}
26+
if (term in data) {
27+
return String(data[term]);
28+
}
2229

23-
// Preserve old behavior: If parameter name not provided, don't replace it.
24-
return fullMatch;
25-
},
26-
);
30+
// Preserve old behavior: If parameter name not provided, don't replace it.
31+
return fullMatch;
32+
});
2733
}

packages/rule-tester/tests/eslint-base/eslint-base.test.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,63 @@ describe("RuleTester", () => {
16861686
}, "Hydrated message \"Avoid using variables named 'notFoo'.\" does not match \"Avoid using variables named 'foo'.\"");
16871687
});
16881688

1689+
it("should throw if the message has a single unsubstituted placeholder when data is not specified", () => {
1690+
assert.throws(() => {
1691+
ruleTester.run("foo", require("./fixtures/messageId").withMissingData, {
1692+
valid: [],
1693+
invalid: [{ code: "foo", errors: [{ messageId: "avoidFoo" }] }]
1694+
});
1695+
}, "The reported message has an unsubstituted placeholder 'name'. Please provide the missing value via the 'data' property in the context.report() call.");
1696+
});
1697+
1698+
it("should throw if the message has a single unsubstituted placeholders when data is specified", () => {
1699+
assert.throws(() => {
1700+
ruleTester.run("foo", require("./fixtures/messageId").withMissingData, {
1701+
valid: [],
1702+
invalid: [{ code: "foo", errors: [{ messageId: "avoidFoo", data: { name: "name" } }] }]
1703+
});
1704+
}, "Hydrated message \"Avoid using variables named 'name'.\" does not match \"Avoid using variables named '{{ name }}'.");
1705+
});
1706+
1707+
it("should throw if the message has multiple unsubstituted placeholders when data is not specified", () => {
1708+
assert.throws(() => {
1709+
ruleTester.run("foo", require("./fixtures/messageId").withMultipleMissingDataProperties, {
1710+
valid: [],
1711+
invalid: [{ code: "foo", errors: [{ messageId: "avoidFoo" }] }]
1712+
});
1713+
}, "The reported message has unsubstituted placeholders: 'type', 'name'. Please provide the missing values via the 'data' property in the context.report() call.");
1714+
});
1715+
1716+
it("should not throw if the data in the message contains placeholders not present in the raw message", () => {
1717+
ruleTester.run("foo", require("./fixtures/messageId").withPlaceholdersInData, {
1718+
valid: [],
1719+
invalid: [{ code: "foo", errors: [{ messageId: "avoidFoo" }] }]
1720+
});
1721+
});
1722+
1723+
it("should throw if the data in the message contains the same placeholder and data is not specified", () => {
1724+
assert.throws(() => {
1725+
ruleTester.run("foo", require("./fixtures/messageId").withSamePlaceholdersInData, {
1726+
valid: [],
1727+
invalid: [{ code: "foo", errors: [{ messageId: "avoidFoo" }] }]
1728+
});
1729+
}, "The reported message has an unsubstituted placeholder 'name'. Please provide the missing value via the 'data' property in the context.report() call.");
1730+
});
1731+
1732+
it("should not throw if the data in the message contains the same placeholder and data is specified", () => {
1733+
ruleTester.run("foo", require("./fixtures/messageId").withSamePlaceholdersInData, {
1734+
valid: [],
1735+
invalid: [{ code: "foo", errors: [{ messageId: "avoidFoo", data: { name: "{{ name }}" } }] }]
1736+
});
1737+
});
1738+
1739+
it("should not throw an error for specifying non-string data values", () => {
1740+
ruleTester.run("foo", require("./fixtures/messageId").withNonStringData, {
1741+
valid: [],
1742+
invalid: [{ code: "0", errors: [{ messageId: "avoid", data: { value: 0 } }] }]
1743+
});
1744+
});
1745+
16891746
// messageId/message misconfiguration cases
16901747
it("should throw if user tests for both message and messageId", () => {
16911748
assert.throws(() => {
@@ -1854,6 +1911,61 @@ describe("RuleTester", () => {
18541911
});
18551912
});
18561913

1914+
it("should fail with a single missing data placeholder when data is not specified", () => {
1915+
assert.throws(() => {
1916+
ruleTester.run("suggestions-messageIds", require("../../fixtures/testers/rule-tester/suggestions").withMissingPlaceholderData, {
1917+
valid: [],
1918+
invalid: [{
1919+
code: "var foo;",
1920+
errors: [{
1921+
messageId: "avoidFoo",
1922+
suggestions: [{
1923+
messageId: "renameFoo",
1924+
output: "var bar;"
1925+
}]
1926+
}]
1927+
}]
1928+
});
1929+
}, "The message of the suggestion has an unsubstituted placeholder 'newName'. Please provide the missing value via the 'data' property for the suggestion in the context.report() call.");
1930+
});
1931+
1932+
it("should fail with a single missing data placeholder when data is specified", () => {
1933+
assert.throws(() => {
1934+
ruleTester.run("suggestions-messageIds", require("../../fixtures/testers/rule-tester/suggestions").withMissingPlaceholderData, {
1935+
valid: [],
1936+
invalid: [{
1937+
code: "var foo;",
1938+
errors: [{
1939+
messageId: "avoidFoo",
1940+
suggestions: [{
1941+
messageId: "renameFoo",
1942+
data: { other: "name" },
1943+
output: "var bar;"
1944+
}]
1945+
}]
1946+
}]
1947+
});
1948+
}, "The message of the suggestion has an unsubstituted placeholder 'newName'. Please provide the missing value via the 'data' property for the suggestion in the context.report() call.");
1949+
});
1950+
1951+
it("should fail with multiple missing data placeholders when data is not specified", () => {
1952+
assert.throws(() => {
1953+
ruleTester.run("suggestions-messageIds", require("../../fixtures/testers/rule-tester/suggestions").withMultipleMissingPlaceholderDataProperties, {
1954+
valid: [],
1955+
invalid: [{
1956+
code: "var foo;",
1957+
errors: [{
1958+
messageId: "avoidFoo",
1959+
suggestions: [{
1960+
messageId: "rename",
1961+
output: "var bar;"
1962+
}]
1963+
}]
1964+
}]
1965+
});
1966+
}, "The message of the suggestion has unsubstituted placeholders: 'currentName', 'newName'. Please provide the missing values via the 'data' property for the suggestion in the context.report() call.");
1967+
});
1968+
18571969

18581970
it("should pass when tested using empty suggestion test objects if the array length is correct", () => {
18591971
ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, {

packages/rule-tester/tests/eslint-base/fixtures/messageId.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,110 @@ module.exports.withMessageOnly = {
3737
};
3838
}
3939
};
40+
41+
module.exports.withMissingData = {
42+
meta: {
43+
messages: {
44+
avoidFoo: "Avoid using variables named '{{ name }}'.",
45+
unused: "An unused key"
46+
}
47+
},
48+
create(context) {
49+
return {
50+
Identifier(node) {
51+
if (node.name === "foo") {
52+
context.report({
53+
node,
54+
messageId: "avoidFoo",
55+
});
56+
}
57+
}
58+
};
59+
}
60+
};
61+
62+
module.exports.withMultipleMissingDataProperties = {
63+
meta: {
64+
messages: {
65+
avoidFoo: "Avoid using {{ type }} named '{{ name }}'.",
66+
unused: "An unused key"
67+
}
68+
},
69+
create(context) {
70+
return {
71+
Identifier(node) {
72+
if (node.name === "foo") {
73+
context.report({
74+
node,
75+
messageId: "avoidFoo",
76+
});
77+
}
78+
}
79+
};
80+
}
81+
};
82+
83+
module.exports.withPlaceholdersInData = {
84+
meta: {
85+
messages: {
86+
avoidFoo: "Avoid using variables named '{{ name }}'.",
87+
unused: "An unused key"
88+
}
89+
},
90+
create(context) {
91+
return {
92+
Identifier(node) {
93+
if (node.name === "foo") {
94+
context.report({
95+
node,
96+
messageId: "avoidFoo",
97+
data: { name: '{{ placeholder }}' },
98+
});
99+
}
100+
}
101+
};
102+
}
103+
};
104+
105+
module.exports.withSamePlaceholdersInData = {
106+
meta: {
107+
messages: {
108+
avoidFoo: "Avoid using variables named '{{ name }}'.",
109+
unused: "An unused key"
110+
}
111+
},
112+
create(context) {
113+
return {
114+
Identifier(node) {
115+
if (node.name === "foo") {
116+
context.report({
117+
node,
118+
messageId: "avoidFoo",
119+
data: { name: '{{ name }}' },
120+
});
121+
}
122+
}
123+
};
124+
}
125+
};
126+
127+
module.exports.withNonStringData = {
128+
meta: {
129+
messages: {
130+
avoid: "Avoid using the value '{{ value }}'.",
131+
}
132+
},
133+
create(context) {
134+
return {
135+
Literal(node) {
136+
if (node.value === 0) {
137+
context.report({
138+
node,
139+
messageId: "avoid",
140+
data: { value: 0 },
141+
});
142+
}
143+
}
144+
};
145+
}
146+
};

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy