@checkdigit/eslint-plugin 6.5.0 → 6.6.0-PR.75-5b00
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.
- package/dist-cjs/index.cjs +244 -91
- package/dist-cjs/metafile.json +36 -6
- package/dist-mjs/index.mjs +8 -4
- package/dist-mjs/no-fixture.mjs +239 -0
- package/dist-types/index.d.ts +3 -0
- package/dist-types/no-fixture.d.ts +4 -0
- package/package.json +1 -85
- package/src/index.ts +4 -0
- package/src/no-fixture.ts +334 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// no-fixture.ts
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Copyright (c) 2021-2024 Check Digit, LLC
|
|
5
|
+
*
|
|
6
|
+
* This code is licensed under the MIT license (see LICENSE.txt for details).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* eslint-disable no-console */
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
AwaitExpression,
|
|
13
|
+
Expression,
|
|
14
|
+
MemberExpression,
|
|
15
|
+
Node,
|
|
16
|
+
ReturnStatement,
|
|
17
|
+
SimpleCallExpression,
|
|
18
|
+
} from 'estree';
|
|
19
|
+
import type { Rule, Scope, SourceCode } from 'eslint';
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import getDocumentationUrl from './get-documentation-url';
|
|
22
|
+
|
|
23
|
+
export const ruleId = 'no-fixture';
|
|
24
|
+
|
|
25
|
+
type NodeParent = Node | undefined | null;
|
|
26
|
+
|
|
27
|
+
interface NodeParentExtension {
|
|
28
|
+
parent: NodeParent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface FixtureCallInformation {
|
|
32
|
+
root: AwaitExpression | ReturnStatement;
|
|
33
|
+
requestBody?: Expression;
|
|
34
|
+
requestHeaders?: { name: Expression; value: Expression }[];
|
|
35
|
+
assertions?: Expression[][];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getParent(node: Node): Node | undefined | null {
|
|
39
|
+
return (node as unknown as NodeParentExtension).parent;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function analyze(call: SimpleCallExpression, results: FixtureCallInformation) {
|
|
43
|
+
const parent = getParent(call);
|
|
44
|
+
assert.ok(parent, 'parent should exist for fixture/supertest call node');
|
|
45
|
+
|
|
46
|
+
let nextCall;
|
|
47
|
+
if (parent.type === 'AwaitExpression' || parent.type === 'ReturnStatement') {
|
|
48
|
+
// no more assertions, return the await expression of the fixture call
|
|
49
|
+
results.root = parent;
|
|
50
|
+
} else if (parent.type === 'MemberExpression' && parent.property.type === 'Identifier') {
|
|
51
|
+
if (parent.property.name === 'expect') {
|
|
52
|
+
const assertionCall = getParent(parent);
|
|
53
|
+
assert.ok(assertionCall && assertionCall.type === 'CallExpression');
|
|
54
|
+
results.assertions = [...(results.assertions ?? []), assertionCall.arguments as Expression[]];
|
|
55
|
+
nextCall = assertionCall;
|
|
56
|
+
} else if (parent.property.name === 'send') {
|
|
57
|
+
const sendRequestBodyCall = getParent(parent);
|
|
58
|
+
assert.ok(sendRequestBodyCall && sendRequestBodyCall.type === 'CallExpression');
|
|
59
|
+
results.requestBody = sendRequestBodyCall.arguments[0] as Expression;
|
|
60
|
+
nextCall = sendRequestBodyCall;
|
|
61
|
+
} else if (parent.property.name === 'set') {
|
|
62
|
+
const setRequestHeaderCall = getParent(parent);
|
|
63
|
+
assert.ok(setRequestHeaderCall && setRequestHeaderCall.type === 'CallExpression');
|
|
64
|
+
const [name, value] = setRequestHeaderCall.arguments as [Expression, Expression];
|
|
65
|
+
results.requestHeaders = [...(results.requestHeaders ?? []), { name, value }];
|
|
66
|
+
nextCall = setRequestHeaderCall;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
throw new Error(`Unexpected expression in fixture/supertest call ${String(parent)}`);
|
|
70
|
+
}
|
|
71
|
+
if (nextCall) {
|
|
72
|
+
analyze(nextCall, results);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function replaceEndpointUrlPrefixWithBasePath(url: string) {
|
|
77
|
+
// eslint-disable-next-line no-template-curly-in-string
|
|
78
|
+
return url.replace(/`\/\w+(?<parts>-\w+)*\/v\d+\//u, '`${BASE_PATH}/');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isValidPropertyName(name: unknown) {
|
|
82
|
+
return typeof name === 'string' && /^[a-zA-Z_$][a-zA-Z_$0-9]*$/u.test(name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function appendAssertions(expects: Expression[][], sourceCode: SourceCode, variableName: string) {
|
|
86
|
+
const assertions: string[] = [];
|
|
87
|
+
for (const expectArguments of expects) {
|
|
88
|
+
if (expectArguments.length === 1) {
|
|
89
|
+
const [assertionArgument] = expectArguments;
|
|
90
|
+
assert.ok(assertionArgument);
|
|
91
|
+
if (
|
|
92
|
+
assertionArgument.type === 'MemberExpression' &&
|
|
93
|
+
assertionArgument.object.type === 'Identifier' &&
|
|
94
|
+
assertionArgument.object.name === 'StatusCodes'
|
|
95
|
+
) {
|
|
96
|
+
// status code assertion
|
|
97
|
+
assertions.push(`assert.equal(${variableName}.status, ${sourceCode.getText(assertionArgument)})`);
|
|
98
|
+
} else if (assertionArgument.type === 'ArrowFunctionExpression') {
|
|
99
|
+
// callback assertion
|
|
100
|
+
assertions.push(`assert.ok(${sourceCode.getText(assertionArgument)})`);
|
|
101
|
+
} else if (assertionArgument.type === 'Identifier') {
|
|
102
|
+
// callback assertion
|
|
103
|
+
assertions.push(`assert.ok(${sourceCode.getText(assertionArgument)}(${variableName}))`);
|
|
104
|
+
} else if (assertionArgument.type === 'ObjectExpression') {
|
|
105
|
+
// body deep equal assertion
|
|
106
|
+
assertions.push(`assert.deepEqual(${variableName}.body, ${sourceCode.getText(assertionArgument)})`);
|
|
107
|
+
} else {
|
|
108
|
+
throw new Error(`Unexpected assertion argument: ${sourceCode.getText(assertionArgument)}`);
|
|
109
|
+
}
|
|
110
|
+
} else if (expectArguments.length === 2) {
|
|
111
|
+
// header assertion
|
|
112
|
+
const [headerName, headerValue] = expectArguments;
|
|
113
|
+
assert.ok(headerName && headerValue);
|
|
114
|
+
if (headerValue.type === 'Literal' && headerValue.value instanceof RegExp) {
|
|
115
|
+
assertions.push(
|
|
116
|
+
`assert.ok(${variableName}.headers.get(${sourceCode.getText(headerName)}).match(${sourceCode.getText(headerValue)}))`,
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
assertions.push(
|
|
120
|
+
`assert.equal(${variableName}.headers.get(${sourceCode.getText(headerName)}), ${sourceCode.getText(headerValue)})`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return assertions;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getAncestor(node: Node, matchType: string, quitType: string) {
|
|
129
|
+
const parent = getParent(node);
|
|
130
|
+
if (!parent || parent.type === quitType) {
|
|
131
|
+
return undefined;
|
|
132
|
+
} else if (parent.type === matchType) {
|
|
133
|
+
return parent;
|
|
134
|
+
}
|
|
135
|
+
return getAncestor(parent, matchType, quitType);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function analyzeReferences(fixtureCallAwait: AwaitExpression | ReturnStatement, scopeManager: Scope.ScopeManager) {
|
|
139
|
+
const results: {
|
|
140
|
+
responseVariableName?: string;
|
|
141
|
+
responseBodyReferences: MemberExpression[];
|
|
142
|
+
responseHeadersReferences: MemberExpression[];
|
|
143
|
+
} = {
|
|
144
|
+
responseBodyReferences: [],
|
|
145
|
+
responseHeadersReferences: [],
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const variableDeclaration = getAncestor(fixtureCallAwait, 'VariableDeclaration', 'FunctionDeclaration');
|
|
149
|
+
if (variableDeclaration && variableDeclaration.type === 'VariableDeclaration') {
|
|
150
|
+
const [responseVariable] = scopeManager.getDeclaredVariables(variableDeclaration);
|
|
151
|
+
assert.ok(responseVariable);
|
|
152
|
+
|
|
153
|
+
results.responseVariableName = responseVariable.name;
|
|
154
|
+
results.responseBodyReferences = responseVariable.references
|
|
155
|
+
.map((responseBodyReference) => getParent(responseBodyReference.identifier))
|
|
156
|
+
.filter(
|
|
157
|
+
(node): node is MemberExpression =>
|
|
158
|
+
node !== null &&
|
|
159
|
+
node !== undefined &&
|
|
160
|
+
node.type === 'MemberExpression' &&
|
|
161
|
+
node.property.type === 'Identifier' &&
|
|
162
|
+
node.property.name === 'body',
|
|
163
|
+
);
|
|
164
|
+
results.responseHeadersReferences = responseVariable.references
|
|
165
|
+
.map((responseHeadersReference) => getParent(responseHeadersReference.identifier))
|
|
166
|
+
.filter(
|
|
167
|
+
(node): node is MemberExpression =>
|
|
168
|
+
node !== null &&
|
|
169
|
+
node !== undefined &&
|
|
170
|
+
node.type === 'MemberExpression' &&
|
|
171
|
+
node.property.type === 'Identifier' &&
|
|
172
|
+
(node.property.name === 'header' || node.property.name === 'headers'),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getIndentation(node: Node, sourceCode: SourceCode) {
|
|
179
|
+
assert.ok(node.loc);
|
|
180
|
+
const line = sourceCode.lines[node.loc.start.line - 1];
|
|
181
|
+
assert.ok(line);
|
|
182
|
+
const indentMatch = line.match(/^\s*/u);
|
|
183
|
+
return indentMatch ? indentMatch[0] : '';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rule: Rule.RuleModule = {
|
|
187
|
+
meta: {
|
|
188
|
+
type: 'suggestion',
|
|
189
|
+
docs: {
|
|
190
|
+
description: 'Prefer native fetch API over customized fixture API.',
|
|
191
|
+
url: getDocumentationUrl(ruleId),
|
|
192
|
+
},
|
|
193
|
+
messages: {
|
|
194
|
+
preferNativeFetch: 'Prefer native fetch API over customized fixture API.',
|
|
195
|
+
unknownError:
|
|
196
|
+
'Unknown error occurred: {{ error }}. Please manually convert the fixture API call to fetch API call.',
|
|
197
|
+
},
|
|
198
|
+
fixable: 'code',
|
|
199
|
+
schema: [],
|
|
200
|
+
},
|
|
201
|
+
create(context) {
|
|
202
|
+
const sourceCode = context.sourceCode;
|
|
203
|
+
const scopeManager = sourceCode.scopeManager;
|
|
204
|
+
let variableCounter = 0;
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
'CallExpression[callee.object.object.name="fixture"][callee.object.property.name="api"]': (fixtureCall: Node) => {
|
|
208
|
+
try {
|
|
209
|
+
assert.ok(fixtureCall.type === 'CallExpression');
|
|
210
|
+
const fixtureFunction = fixtureCall.callee; // node - fixture.api.get
|
|
211
|
+
assert.ok(fixtureFunction.type === 'MemberExpression');
|
|
212
|
+
const methodNode = fixtureFunction.property; // get/put/etc.
|
|
213
|
+
assert.ok(methodNode.type === 'Identifier');
|
|
214
|
+
const indentation = getIndentation(fixtureCall, sourceCode);
|
|
215
|
+
|
|
216
|
+
const [urlArgumentNode] = fixtureCall.arguments; // node - `/smartdata/v1/ping`
|
|
217
|
+
assert.ok(urlArgumentNode !== undefined);
|
|
218
|
+
|
|
219
|
+
const fixtureCallInformation = {} as FixtureCallInformation;
|
|
220
|
+
analyze(fixtureCall, fixtureCallInformation);
|
|
221
|
+
|
|
222
|
+
const { responseVariableName, responseBodyReferences, responseHeadersReferences } = analyzeReferences(
|
|
223
|
+
fixtureCallInformation.root,
|
|
224
|
+
scopeManager,
|
|
225
|
+
);
|
|
226
|
+
let variableNameToUse: string;
|
|
227
|
+
let isResponseVariableDeclared = false;
|
|
228
|
+
if (responseVariableName === undefined) {
|
|
229
|
+
variableNameToUse = `response${variableCounter === 0 ? '' : variableCounter.toString()}`;
|
|
230
|
+
variableCounter++;
|
|
231
|
+
} else {
|
|
232
|
+
isResponseVariableDeclared = true;
|
|
233
|
+
variableNameToUse = responseVariableName;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// convert fixture.api.get to fetch
|
|
237
|
+
const fixtureApiCallText = sourceCode.getText(fixtureCall); // e.g. "fixture.api.get(`/smartdata/v1/ping`)""
|
|
238
|
+
const fixtureMethodText = sourceCode.getText(fixtureFunction); // e.g. "fixture.api.get"
|
|
239
|
+
|
|
240
|
+
const fetchStatementStart =
|
|
241
|
+
fixtureCallInformation.root.type === 'ReturnStatement' && fixtureCallInformation.assertions === undefined
|
|
242
|
+
? 'return'
|
|
243
|
+
: 'await';
|
|
244
|
+
let replacedText = fixtureApiCallText.replace(fixtureMethodText, `${fetchStatementStart} fetch`);
|
|
245
|
+
|
|
246
|
+
// convert `/smartdata/v1/ping` to `${BASE_PATH}/ping`
|
|
247
|
+
const fixtureArgumentText = sourceCode.getText(urlArgumentNode); // text - e.g. `/smartdata/v1/ping`
|
|
248
|
+
let fetchArgumentText = replaceEndpointUrlPrefixWithBasePath(fixtureArgumentText); // test - e.g. `${BASE_PATH}/ping`
|
|
249
|
+
|
|
250
|
+
// add request argument if deeded
|
|
251
|
+
if (
|
|
252
|
+
methodNode.name !== 'get' ||
|
|
253
|
+
fixtureCallInformation.requestBody !== undefined ||
|
|
254
|
+
fixtureCallInformation.requestHeaders !== undefined
|
|
255
|
+
) {
|
|
256
|
+
fetchArgumentText += [
|
|
257
|
+
', {',
|
|
258
|
+
` method: '${methodNode.name.toUpperCase()}',`,
|
|
259
|
+
...(fixtureCallInformation.requestBody
|
|
260
|
+
? [` body: JSON.stringify(${sourceCode.getText(fixtureCallInformation.requestBody)}),`]
|
|
261
|
+
: []),
|
|
262
|
+
...(fixtureCallInformation.requestHeaders
|
|
263
|
+
? [
|
|
264
|
+
` headers: {`,
|
|
265
|
+
...fixtureCallInformation.requestHeaders.map(
|
|
266
|
+
({ name, value }) =>
|
|
267
|
+
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, no-nested-ternary, sonarjs/no-nested-template-literals
|
|
268
|
+
` ${name.type === 'Literal' ? (isValidPropertyName(name.value) ? name.value : `'${name.value}'`) : `[${sourceCode.getText(name)}]`}: ${sourceCode.getText(value)},`,
|
|
269
|
+
),
|
|
270
|
+
` },`,
|
|
271
|
+
]
|
|
272
|
+
: []),
|
|
273
|
+
'}',
|
|
274
|
+
].join(`\n${indentation}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
replacedText = replacedText.replace(fixtureArgumentText, fetchArgumentText);
|
|
278
|
+
|
|
279
|
+
if (fixtureCallInformation.assertions) {
|
|
280
|
+
// add variable declaration if needed
|
|
281
|
+
if (!isResponseVariableDeclared) {
|
|
282
|
+
replacedText = `const ${variableNameToUse} = ${replacedText}`;
|
|
283
|
+
}
|
|
284
|
+
// externalize response assertions
|
|
285
|
+
replacedText = [
|
|
286
|
+
replacedText,
|
|
287
|
+
...appendAssertions(fixtureCallInformation.assertions, sourceCode, variableNameToUse),
|
|
288
|
+
].join(`;\n${indentation}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
context.report({
|
|
292
|
+
node: fixtureCall,
|
|
293
|
+
messageId: 'preferNativeFetch',
|
|
294
|
+
*fix(fixer) {
|
|
295
|
+
yield fixer.replaceText(fixtureCallInformation.root, replacedText);
|
|
296
|
+
|
|
297
|
+
for (const responseBodyReference of responseBodyReferences) {
|
|
298
|
+
yield fixer.replaceText(responseBodyReference, `await ${variableNameToUse}.json()`);
|
|
299
|
+
}
|
|
300
|
+
for (const responseHeadersReference of responseHeadersReferences) {
|
|
301
|
+
const parent = getParent(responseHeadersReference);
|
|
302
|
+
assert.ok(parent?.type === 'MemberExpression');
|
|
303
|
+
const headerNameNode = parent.property;
|
|
304
|
+
const headerName =
|
|
305
|
+
// eslint-disable-next-line no-nested-ternary, @typescript-eslint/restrict-template-expressions
|
|
306
|
+
parent.computed ? sourceCode.getText(headerNameNode) : `'${sourceCode.getText(headerNameNode)}'`;
|
|
307
|
+
assert.ok(headerName);
|
|
308
|
+
yield fixer.replaceText(parent, `${variableNameToUse}.headers.get(${headerName})`);
|
|
309
|
+
}
|
|
310
|
+
if (
|
|
311
|
+
fixtureCallInformation.root.type === 'ReturnStatement' &&
|
|
312
|
+
fixtureCallInformation.assertions !== undefined
|
|
313
|
+
) {
|
|
314
|
+
yield fixer.insertTextAfter(
|
|
315
|
+
fixtureCallInformation.root,
|
|
316
|
+
`;\n${indentation}return ${variableNameToUse};`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
} catch (error) {
|
|
322
|
+
context.report({
|
|
323
|
+
node: fixtureCall,
|
|
324
|
+
messageId: 'unknownError',
|
|
325
|
+
data: {
|
|
326
|
+
error: String(error),
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
export default rule;
|