@checkdigit/eslint-plugin 6.6.0-PR.75-3e31 → 6.6.0-PR.75-a3df

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/src/no-fixture.ts CHANGED
@@ -6,19 +6,18 @@
6
6
  * This code is licensed under the MIT license (see LICENSE.txt for details).
7
7
  */
8
8
 
9
- /* eslint-disable no-console */
10
-
11
9
  import type {
12
10
  AwaitExpression,
13
11
  CallExpression,
14
12
  Expression,
15
13
  MemberExpression,
14
+ Node,
16
15
  ReturnStatement,
17
16
  SimpleCallExpression,
18
17
  VariableDeclaration,
19
18
  } from 'estree';
20
19
  import type { Rule, Scope, SourceCode } from 'eslint';
21
- import { getAncestor, getParent } from './ast/tree';
20
+ import { getEnclosingScopeNode, getEnclosingStatement, getParent } from './ast/tree';
22
21
  import { strict as assert } from 'node:assert';
23
22
  import getDocumentationUrl from './get-documentation-url';
24
23
  import { getIndentation } from './ast/format';
@@ -32,6 +31,8 @@ interface FixtureCallInformation {
32
31
  requestBody?: Expression;
33
32
  requestHeaders?: { name: Expression; value: Expression }[];
34
33
  assertions?: Expression[][];
34
+ inlineStatementNode?: Node;
35
+ inlineBodyReference?: MemberExpression;
35
36
  }
36
37
 
37
38
  // recursively analyze the fixture/supertest call chain to collect information of request/response
@@ -46,11 +47,18 @@ function analyzeFixtureCall(call: SimpleCallExpression, results: FixtureCallInfo
46
47
  results.rootNode = parent;
47
48
  } else if (parent.type === 'AwaitExpression') {
48
49
  results.fixtureNode = call;
49
- // [TODO:] should we consider variable declaration without await??
50
- const variableDeclaration = getAncestor(parent, 'VariableDeclaration', 'FunctionDeclaration');
51
- if (variableDeclaration?.type === 'VariableDeclaration') {
52
- results.variableDeclaration = variableDeclaration;
53
- results.rootNode = variableDeclaration;
50
+ const enclosingStatement = getEnclosingStatement(parent);
51
+ assert.ok(enclosingStatement);
52
+ const awaitParent = getParent(parent);
53
+ if (awaitParent?.type === 'MemberExpression') {
54
+ results.rootNode = parent;
55
+ results.inlineStatementNode = enclosingStatement;
56
+ if (awaitParent.property.type === 'Identifier' && awaitParent.property.name === 'body') {
57
+ results.inlineBodyReference = awaitParent;
58
+ }
59
+ } else if (enclosingStatement.type === 'VariableDeclaration') {
60
+ results.variableDeclaration = enclosingStatement;
61
+ results.rootNode = enclosingStatement;
54
62
  } else {
55
63
  results.rootNode = parent;
56
64
  }
@@ -90,8 +98,8 @@ function analyzeResponseReferences(fixtureInformation: FixtureCallInformation, s
90
98
  bodyReferences: MemberExpression[];
91
99
  headersReferences: MemberExpression[];
92
100
  statusReferences: MemberExpression[];
93
- spreadBodyVariable?: Scope.Variable;
94
- spreadHeadersVariable?: Scope.Variable;
101
+ destructuringBodyVariable?: Scope.Variable;
102
+ destructuringHeadersVariable?: Scope.Variable;
95
103
  } = {
96
104
  bodyReferences: [],
97
105
  headersReferences: [],
@@ -147,14 +155,14 @@ function analyzeResponseReferences(fixtureInformation: FixtureCallInformation, s
147
155
  identifierParent.key.type === 'Identifier' &&
148
156
  identifierParent.key.name === 'body'
149
157
  ) {
150
- results.spreadBodyVariable = responseVariable;
158
+ results.destructuringBodyVariable = responseVariable;
151
159
  } else if (
152
160
  // header reference through destruction/renaming, e.g. "const { headers } = ..."
153
161
  identifierParent.type === 'Property' &&
154
162
  identifierParent.key.type === 'Identifier' &&
155
163
  identifierParent.key.name === 'headers'
156
164
  ) {
157
- results.spreadHeadersVariable = responseVariable;
165
+ results.destructuringHeadersVariable = responseVariable;
158
166
  } else {
159
167
  throw new Error(`Unknown response variable reference: ${responseVariable.name}`);
160
168
  }
@@ -173,12 +181,12 @@ function isValidPropertyName(name: unknown) {
173
181
  return typeof name === 'string' && /^[a-zA-Z_$][a-zA-Z_$0-9]*$/u.test(name);
174
182
  }
175
183
 
184
+ // eslint-disable-next-line sonarjs/cognitive-complexity
176
185
  function createResponseAssertions(
177
186
  fixtureCallInformation: FixtureCallInformation,
178
187
  sourceCode: SourceCode,
179
- variableName: string,
188
+ responseVariableName: string,
180
189
  ) {
181
- // [TODO:] make sure status assertion is ordered as the first
182
190
  let statusAssertion: string | undefined;
183
191
  const nonStatusAssertions: string[] = [];
184
192
  for (const expectArguments of fixtureCallInformation.assertions ?? []) {
@@ -189,20 +197,32 @@ function createResponseAssertions(
189
197
  (assertionArgument.type === 'MemberExpression' &&
190
198
  assertionArgument.object.type === 'Identifier' &&
191
199
  assertionArgument.object.name === 'StatusCodes') ||
192
- assertionArgument.type === 'Literal'
200
+ assertionArgument.type === 'Literal' ||
201
+ sourceCode.getText(assertionArgument).includes('StatusCodes.')
193
202
  ) {
194
203
  // status code assertion
195
- statusAssertion = `assert.equal(${variableName}.status, ${sourceCode.getText(assertionArgument)})`;
204
+ statusAssertion = `assert.equal(${responseVariableName}.status, ${sourceCode.getText(assertionArgument)})`;
196
205
  } else if (assertionArgument.type === 'ArrowFunctionExpression') {
197
- // callback assertion
198
- nonStatusAssertions.push(`assert.ok(${sourceCode.getText(assertionArgument)})`);
206
+ // callback assertion using arrow function
207
+ let functionBody = sourceCode.getText(assertionArgument.body);
208
+
209
+ const [originalResponseArgument] = assertionArgument.params;
210
+ assert.ok(originalResponseArgument?.type === 'Identifier');
211
+ const originalResponseArgumentName = originalResponseArgument.name;
212
+ if (originalResponseArgumentName !== responseVariableName) {
213
+ functionBody = functionBody.replace(
214
+ new RegExp(`\\b${originalResponseArgumentName}\\b`, 'ug'),
215
+ responseVariableName,
216
+ );
217
+ }
218
+ nonStatusAssertions.push(`assert.ok(${functionBody})`);
199
219
  } else if (assertionArgument.type === 'Identifier') {
200
- // callback assertion
201
- nonStatusAssertions.push(`assert.ok(${sourceCode.getText(assertionArgument)}(${variableName}))`);
220
+ // callback assertion using function reference
221
+ nonStatusAssertions.push(`assert.ok(${sourceCode.getText(assertionArgument)}(${responseVariableName}))`);
202
222
  } else if (assertionArgument.type === 'ObjectExpression' || assertionArgument.type === 'CallExpression') {
203
223
  // body deep equal assertion
204
224
  nonStatusAssertions.push(
205
- `assert.deepEqual(await ${variableName}.json(), ${sourceCode.getText(assertionArgument)})`,
225
+ `assert.deepEqual(await ${responseVariableName}.json(), ${sourceCode.getText(assertionArgument)})`,
206
226
  );
207
227
  } else {
208
228
  throw new Error(`Unexpected Supertest assertion argument: ".expect(${sourceCode.getText(assertionArgument)})`);
@@ -213,11 +233,11 @@ function createResponseAssertions(
213
233
  assert.ok(headerName && headerValue);
214
234
  if (headerValue.type === 'Literal' && headerValue.value instanceof RegExp) {
215
235
  nonStatusAssertions.push(
216
- `assert.ok(${variableName}.headers.get(${sourceCode.getText(headerName)}).match(${sourceCode.getText(headerValue)}))`,
236
+ `assert.ok(${responseVariableName}.headers.get(${sourceCode.getText(headerName)}).match(${sourceCode.getText(headerValue)}))`,
217
237
  );
218
238
  } else {
219
239
  nonStatusAssertions.push(
220
- `assert.equal(${variableName}.headers.get(${sourceCode.getText(headerName)}), ${sourceCode.getText(headerValue)})`,
240
+ `assert.equal(${responseVariableName}.headers.get(${sourceCode.getText(headerName)}), ${sourceCode.getText(headerValue)})`,
221
241
  );
222
242
  }
223
243
  }
@@ -228,6 +248,52 @@ function createResponseAssertions(
228
248
  };
229
249
  }
230
250
 
251
+ function getResponseVariableNameToUse(
252
+ scopeManager: Scope.ScopeManager,
253
+ fixtureCallInformation: FixtureCallInformation,
254
+ scopeVariablesMap: Map<Scope.Scope, string[]>,
255
+ ) {
256
+ if (fixtureCallInformation.variableDeclaration) {
257
+ const firstDeclaration = fixtureCallInformation.variableDeclaration.declarations[0];
258
+ // [TODO:] double check if it works for destruction/rename declaration
259
+ if (firstDeclaration && firstDeclaration.id.type === 'Identifier') {
260
+ return firstDeclaration.id.name;
261
+ }
262
+ }
263
+
264
+ const enclosingScopeNode = getEnclosingScopeNode(fixtureCallInformation.rootNode);
265
+ scopeManager.getDeclaredVariables(fixtureCallInformation.rootNode);
266
+ assert.ok(enclosingScopeNode);
267
+ const scope = scopeManager.acquire(enclosingScopeNode);
268
+ assert.ok(scope !== null);
269
+ let scopeVariables = scopeVariablesMap.get(scope);
270
+ if (!scopeVariables) {
271
+ scopeVariables = [...scope.set.keys()];
272
+ scopeVariablesMap.set(scope, scopeVariables);
273
+ }
274
+
275
+ let responseVariableCounter = 0;
276
+ let responseVariableNameToUse;
277
+ while (responseVariableNameToUse === undefined) {
278
+ responseVariableCounter++;
279
+ responseVariableNameToUse = `response${responseVariableCounter === 1 ? '' : responseVariableCounter.toString()}`;
280
+ if (scopeVariables.includes(responseVariableNameToUse)) {
281
+ responseVariableNameToUse = undefined;
282
+ }
283
+ }
284
+ scopeVariables.push(responseVariableNameToUse);
285
+ return responseVariableNameToUse;
286
+ }
287
+
288
+ function isResponseBodyRedefinition(responseBodyReference: MemberExpression): boolean {
289
+ const parent = getParent(responseBodyReference);
290
+ return parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier';
291
+ }
292
+
293
+ function getResponseBodyRetrievalText(responseVariableName: string) {
294
+ return `await ${responseVariableName}.json()`;
295
+ }
296
+
231
297
  const rule: Rule.RuleModule = {
232
298
  meta: {
233
299
  type: 'suggestion',
@@ -238,15 +304,16 @@ const rule: Rule.RuleModule = {
238
304
  messages: {
239
305
  preferNativeFetch: 'Prefer native fetch API over customized fixture API.',
240
306
  unknownError:
241
- 'Unknown error occurred: {{ error }}. Please manually convert the fixture API call to fetch API call.',
307
+ 'Unknown error occurred in file "{{fileName}}": {{ error }}. Please manually convert the fixture API call to fetch API call.',
242
308
  },
243
309
  fixable: 'code',
244
310
  schema: [],
245
311
  },
312
+ // eslint-disable-next-line max-lines-per-function
246
313
  create(context) {
247
314
  const sourceCode = context.sourceCode;
248
315
  const scopeManager = sourceCode.scopeManager;
249
- let responseVariableCounter = 0;
316
+ const scopeVariablesMap = new Map<Scope.Scope, string[]>();
250
317
 
251
318
  return {
252
319
  'CallExpression[callee.object.object.name="fixture"][callee.object.property.name="api"]': (
@@ -269,8 +336,8 @@ const rule: Rule.RuleModule = {
269
336
  bodyReferences: responseBodyReferences,
270
337
  headersReferences: responseHeadersReferences,
271
338
  statusReferences: responseStatusReferences,
272
- spreadBodyVariable: spreadResponseBodyVariable,
273
- spreadHeadersVariable: spreadResponseHeadersVariable,
339
+ destructuringBodyVariable: destructuringResponseBodyVariable,
340
+ destructuringHeadersVariable: destructuringResponseHeadersVariable,
274
341
  } = analyzeResponseReferences(fixtureCallInformation, scopeManager);
275
342
 
276
343
  // convert url from `/sample-service/v1/ping` to `${BASE_PATH}/ping`
@@ -300,25 +367,36 @@ const rule: Rule.RuleModule = {
300
367
  '}',
301
368
  ].join(`\n${indentation}`);
302
369
 
303
- let responseVariableNameToUse: string;
304
- if (responseVariable === undefined) {
305
- responseVariableNameToUse = `response${responseVariableCounter === 0 ? '' : responseVariableCounter.toString()}`;
306
- responseVariableCounter++;
307
- } else {
308
- responseVariableNameToUse = responseVariable.name;
309
- }
370
+ const responseVariableNameToUse = getResponseVariableNameToUse(
371
+ scopeManager,
372
+ fixtureCallInformation,
373
+ scopeVariablesMap,
374
+ );
375
+
376
+ const isResponseBodyVariableRedefinitionNeeded =
377
+ destructuringResponseBodyVariable !== undefined ||
378
+ fixtureCallInformation.inlineBodyReference !== undefined ||
379
+ (responseBodyReferences.length > 0 && !responseBodyReferences.some(isResponseBodyRedefinition));
380
+ const redefineResponseBodyVariableName = `${responseVariableNameToUse}Body`;
310
381
 
311
- const needResponseVariableRedefine =
312
- spreadResponseBodyVariable !== undefined ||
313
- (responseVariable === undefined && fixtureCallInformation.assertions !== undefined);
382
+ const isResponseVariableRedefinitionNeeded =
383
+ (responseVariable === undefined && fixtureCallInformation.assertions !== undefined) ||
384
+ isResponseBodyVariableRedefinitionNeeded;
314
385
 
315
- const responseBodyHeadersVariableRedefineLines = needResponseVariableRedefine
386
+ const responseBodyHeadersVariableRedefineLines = isResponseVariableRedefinitionNeeded
316
387
  ? [
317
- ...(spreadResponseBodyVariable
318
- ? [`const ${spreadResponseBodyVariable.name} = await ${responseVariableNameToUse}.json()`]
319
- : []),
320
- ...(spreadResponseHeadersVariable
321
- ? [`const ${spreadResponseHeadersVariable.name} = ${responseVariableNameToUse}.headers`]
388
+ // eslint-disable-next-line no-nested-ternary
389
+ ...(destructuringResponseBodyVariable
390
+ ? [
391
+ `const ${destructuringResponseBodyVariable.name} = ${getResponseBodyRetrievalText(responseVariableNameToUse)}`,
392
+ ]
393
+ : isResponseBodyVariableRedefinitionNeeded
394
+ ? [
395
+ `const ${redefineResponseBodyVariableName} = ${getResponseBodyRetrievalText(responseVariableNameToUse)}`,
396
+ ]
397
+ : []),
398
+ ...(destructuringResponseHeadersVariable
399
+ ? [`const ${destructuringResponseHeadersVariable.name} = ${responseVariableNameToUse}.headers`]
322
400
  : []),
323
401
  ]
324
402
  : [];
@@ -331,11 +409,11 @@ const rule: Rule.RuleModule = {
331
409
 
332
410
  // add variable declaration if needed
333
411
  const fetchCallText = `fetch(${fetchUrlArgumentText}, ${fetchRequestArgumentLines})`;
334
- const fetchStatementText = !needResponseVariableRedefine
412
+ const fetchStatementText = !isResponseVariableRedefinitionNeeded
335
413
  ? fetchCallText
336
414
  : `const ${responseVariableNameToUse} = await ${fetchCallText}`;
337
415
 
338
- const nodeToReplace = needResponseVariableRedefine
416
+ const nodeToReplace = isResponseVariableRedefinitionNeeded
339
417
  ? fixtureCallInformation.rootNode
340
418
  : fixtureCallInformation.fixtureNode;
341
419
  const appendingAssignmentAndAssertionText = [
@@ -349,17 +427,33 @@ const rule: Rule.RuleModule = {
349
427
  node: fixtureCall,
350
428
  messageId: 'preferNativeFetch',
351
429
  *fix(fixer) {
352
- yield fixer.replaceText(nodeToReplace, fetchStatementText);
353
-
354
- const needEndingSemiColon = sourceCode.getText(nodeToReplace).endsWith(';');
355
- yield fixer.insertTextAfter(
356
- nodeToReplace,
357
- needEndingSemiColon ? `${appendingAssignmentAndAssertionText};` : appendingAssignmentAndAssertionText,
358
- );
430
+ if (fixtureCallInformation.inlineStatementNode) {
431
+ const preInlineDeclaration = [
432
+ fetchStatementText,
433
+ `${appendingAssignmentAndAssertionText};\n${indentation}`,
434
+ ].join(``);
435
+ yield fixer.insertTextBefore(fixtureCallInformation.inlineStatementNode, preInlineDeclaration);
436
+ } else {
437
+ yield fixer.replaceText(nodeToReplace, fetchStatementText);
438
+
439
+ const needEndingSemiColon = sourceCode.getText(nodeToReplace).endsWith(';');
440
+ yield fixer.insertTextAfter(
441
+ nodeToReplace,
442
+ needEndingSemiColon ? `${appendingAssignmentAndAssertionText};` : appendingAssignmentAndAssertionText,
443
+ );
444
+ }
359
445
 
360
446
  // handle response body references
361
447
  for (const responseBodyReference of responseBodyReferences) {
362
- yield fixer.replaceText(responseBodyReference, `await ${responseVariableNameToUse}.json()`);
448
+ yield fixer.replaceText(
449
+ responseBodyReference,
450
+ isResponseBodyVariableRedefinitionNeeded || !isResponseBodyRedefinition(responseBodyReference)
451
+ ? redefineResponseBodyVariableName
452
+ : getResponseBodyRetrievalText(responseVariableNameToUse),
453
+ );
454
+ }
455
+ if (fixtureCallInformation.inlineBodyReference) {
456
+ yield fixer.replaceText(fixtureCallInformation.inlineBodyReference, redefineResponseBodyVariableName);
363
457
  }
364
458
 
365
459
  // handle response headers references
@@ -390,7 +484,7 @@ const rule: Rule.RuleModule = {
390
484
  }
391
485
  }
392
486
 
393
- // handle direct return without await
487
+ // handle direct return statement without await, e.g. "return fixture.api.get(...);"
394
488
  if (
395
489
  fixtureCallInformation.rootNode.type === 'ReturnStatement' &&
396
490
  fixtureCallInformation.assertions !== undefined
@@ -403,12 +497,14 @@ const rule: Rule.RuleModule = {
403
497
  },
404
498
  });
405
499
  } catch (error) {
406
- console.error(`Failed to apply ${ruleId} rule. Error:`, error);
500
+ // eslint-disable-next-line no-console
501
+ console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error);
407
502
  context.report({
408
503
  node: fixtureCall,
409
504
  messageId: 'unknownError',
410
505
  data: {
411
- error: String(error),
506
+ fileName: context.filename,
507
+ error: error instanceof Error ? error.toString() : JSON.stringify(error),
412
508
  },
413
509
  });
414
510
  }