@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.
@@ -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;