@checkdigit/eslint-plugin 6.5.0 → 6.6.0-PR.75-a2cc

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