Skip to content

Commit ce9cef4

Browse files
[9.2] [@kbn/eslint-plugin-eslint] Add ESLint rule that requires apiClient usage in Scout apiTest (#245172) (#245710)
# Backport This will backport the following commits from `main` to `9.2`: - [[@kbn/eslint-plugin-eslint] Add ESLint rule that requires `apiClient` usage in Scout `apiTest` (#245172)](#245172) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Stelios Mavro","email":"81311181+steliosmavro@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-12-09T17:39:31Z","message":"[@kbn/eslint-plugin-eslint] Add ESLint rule that requires `apiClient` usage in Scout `apiTest` (#245172)\n\n## Summary\n\nAdds a new ESLint rule `scout_require_api_client_in_api_test` that\nenforces the use of the `apiClient` fixture in `apiTest` functions to\nensure API endpoints are properly tested.\n\n## What it does\n\nWhen writing API tests with `apiTest`, developers must use the\n`apiClient` fixture to make HTTP calls. The rule reports an error if\n`apiClient` is destructured but never used.\n**Invalid:**\n```\napiTest('test', async ({ apiClient }) => {\n console.log('no api call'); // ❌ apiClient not used\n});\n```\n**Valid:**\n```\napiTest('test', async ({ apiClient }) => {\n await apiClient.get('/foo'); // ✅ apiClient used\n});\n```\n\n## Features\n\n- Detects direct usage, aliases, and variable assignments\n- Tracks usage through helper functions and closures\n- Supports `eslint-disable-next-line` overrides\n\n## Changes\n\n- Added rule implementation and comprehensive test suite (21 test cases)\n- Registered rule in plugin and enabled as error for Scout test files","sha":"c700c870bc5c738c5e6de452fe6f423b6ec19bab","branchLabelMapping":{"^v9.3.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:all-open","test:scout","v9.3.0"],"title":"[@kbn/eslint-plugin-eslint] Add ESLint rule that requires `apiClient` usage in Scout `apiTest`","number":245172,"url":"https://github.com/elastic/kibana/pull/245172","mergeCommit":{"message":"[@kbn/eslint-plugin-eslint] Add ESLint rule that requires `apiClient` usage in Scout `apiTest` (#245172)\n\n## Summary\n\nAdds a new ESLint rule `scout_require_api_client_in_api_test` that\nenforces the use of the `apiClient` fixture in `apiTest` functions to\nensure API endpoints are properly tested.\n\n## What it does\n\nWhen writing API tests with `apiTest`, developers must use the\n`apiClient` fixture to make HTTP calls. The rule reports an error if\n`apiClient` is destructured but never used.\n**Invalid:**\n```\napiTest('test', async ({ apiClient }) => {\n console.log('no api call'); // ❌ apiClient not used\n});\n```\n**Valid:**\n```\napiTest('test', async ({ apiClient }) => {\n await apiClient.get('/foo'); // ✅ apiClient used\n});\n```\n\n## Features\n\n- Detects direct usage, aliases, and variable assignments\n- Tracks usage through helper functions and closures\n- Supports `eslint-disable-next-line` overrides\n\n## Changes\n\n- Added rule implementation and comprehensive test suite (21 test cases)\n- Registered rule in plugin and enabled as error for Scout test files","sha":"c700c870bc5c738c5e6de452fe6f423b6ec19bab"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.3.0","branchLabelMappingKey":"^v9.3.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/245172","number":245172,"mergeCommit":{"message":"[@kbn/eslint-plugin-eslint] Add ESLint rule that requires `apiClient` usage in Scout `apiTest` (#245172)\n\n## Summary\n\nAdds a new ESLint rule `scout_require_api_client_in_api_test` that\nenforces the use of the `apiClient` fixture in `apiTest` functions to\nensure API endpoints are properly tested.\n\n## What it does\n\nWhen writing API tests with `apiTest`, developers must use the\n`apiClient` fixture to make HTTP calls. The rule reports an error if\n`apiClient` is destructured but never used.\n**Invalid:**\n```\napiTest('test', async ({ apiClient }) => {\n console.log('no api call'); // ❌ apiClient not used\n});\n```\n**Valid:**\n```\napiTest('test', async ({ apiClient }) => {\n await apiClient.get('/foo'); // ✅ apiClient used\n});\n```\n\n## Features\n\n- Detects direct usage, aliases, and variable assignments\n- Tracks usage through helper functions and closures\n- Supports `eslint-disable-next-line` overrides\n\n## Changes\n\n- Added rule implementation and comprehensive test suite (21 test cases)\n- Registered rule in plugin and enabled as error for Scout test files","sha":"c700c870bc5c738c5e6de452fe6f423b6ec19bab"}}]}] BACKPORT--> Co-authored-by: Stelios Mavro <81311181+steliosmavro@users.noreply.github.com>
1 parent 374c88b commit ce9cef4

4 files changed

Lines changed: 437 additions & 0 deletions

File tree

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2469,6 +2469,7 @@ module.exports = {
24692469
],
24702470
rules: {
24712471
'@kbn/eslint/scout_no_describe_configure': 'error',
2472+
'@kbn/eslint/scout_require_api_client_in_api_test': 'error',
24722473
'@kbn/eslint/require_include_in_check_a11y': 'warn',
24732474
},
24742475
},

packages/kbn-eslint-plugin-eslint/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module.exports = {
2424
no_deprecated_imports: require('./rules/no_deprecated_imports'),
2525
deployment_agnostic_test_context: require('./rules/deployment_agnostic_test_context'),
2626
scout_no_describe_configure: require('./rules/scout_no_describe_configure'),
27+
scout_require_api_client_in_api_test: require('./rules/scout_require_api_client_in_api_test'),
2728
require_kbn_fs: require('./rules/require_kbn_fs'),
2829
require_include_in_check_a11y: require('./rules/require_include_in_check_a11y'),
2930
},
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
/** @typedef {import("eslint").Rule.RuleModule} Rule */
11+
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.CallExpression} CallExpression */
12+
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.Identifier} Identifier */
13+
14+
const ERROR_MSG =
15+
'The `apiClient` fixture should be used in `apiTest` to call an endpoint and later verify response code and body.';
16+
17+
const traverse = require('eslint-traverse');
18+
19+
const isApiTestCall = (node) => node.callee.type === 'Identifier' && node.callee.name === 'apiTest';
20+
21+
/** Get local param name for `apiClient` (supports `{ apiClient: alias }`). */
22+
const getApiClientLocalName = (fnNode) => {
23+
const firstParam = fnNode.params[0];
24+
if (!firstParam || firstParam.type !== 'ObjectPattern') {
25+
return 'apiClient';
26+
}
27+
28+
const apiClientProp = firstParam.properties.find((prop) => {
29+
if (prop.type === 'RestElement') return false;
30+
return prop.key && prop.key.name === 'apiClient';
31+
});
32+
33+
return apiClientProp?.value?.name || 'apiClient';
34+
};
35+
36+
/** Helper: Check if an identifier is used anywhere in a function body (simple recursion). */
37+
const paramUsesIdentifierInBody = (node, paramName) => {
38+
if (!node || typeof node !== 'object') return false;
39+
40+
// Direct match
41+
if (node.type === 'Identifier' && node.name === paramName) return true;
42+
43+
// Recurse into object properties
44+
for (const key in node) {
45+
// Skip non-traversable properties
46+
if (
47+
key === 'parent' ||
48+
key === 'loc' ||
49+
key === 'range' ||
50+
key === 'leadingComments' ||
51+
key === 'trailingComments'
52+
) {
53+
continue;
54+
}
55+
const value = node[key];
56+
if (Array.isArray(value)) {
57+
for (const item of value) {
58+
if (paramUsesIdentifierInBody(item, paramName)) return true;
59+
}
60+
} else if (value && typeof value === 'object') {
61+
if (paramUsesIdentifierInBody(value, paramName)) return true;
62+
}
63+
}
64+
return false;
65+
};
66+
67+
/**
68+
* Determine whether `fnNode` uses `apiClient`.
69+
*
70+
* Strategy: Single-pass traversal that:
71+
* 1. Collects variable aliases pointing to apiClient (e.g., `const client = apiClient`)
72+
* 2. Collects local function definitions and their parameter usage
73+
* 3. Detects member access on apiClient or calls passing apiClient to functions that use it
74+
*
75+
* We avoid the ESLint scope manager to maintain consistency with other rules in this package.
76+
*/
77+
const functionUsesApiClient = (fnNode, context) => {
78+
const apiClientName = getApiClientLocalName(fnNode);
79+
const variableAliases = new Set([apiClientName]);
80+
const localFnUsesParam = new Map();
81+
let found = false;
82+
83+
traverse(context, fnNode.body, (path) => {
84+
if (found) return traverse.SKIP;
85+
86+
const node = path.node;
87+
88+
// Collect variable aliases and local function definitions in one pass
89+
if (node.type === 'VariableDeclarator' && node.init && node.id?.type === 'Identifier') {
90+
// Track variable aliases (e.g., `const client = apiClient;`)
91+
if (node.init.type === 'Identifier' && variableAliases.has(node.init.name)) {
92+
variableAliases.add(node.id.name);
93+
}
94+
95+
// Pre-compute parameter usage for local function expressions
96+
if (node.init.type === 'FunctionExpression' || node.init.type === 'ArrowFunctionExpression') {
97+
const fnName = node.id.name;
98+
const paramName = getApiClientLocalName(node.init);
99+
localFnUsesParam.set(fnName, paramUsesIdentifierInBody(node.init.body, paramName));
100+
// Skip traversing into nested function bodies to avoid false positives
101+
return traverse.SKIP;
102+
}
103+
}
104+
105+
// Collect function declarations and pre-compute parameter usage
106+
if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier') {
107+
const fnName = node.id.name;
108+
const paramName = getApiClientLocalName(node);
109+
localFnUsesParam.set(fnName, paramUsesIdentifierInBody(node.body, paramName));
110+
// Skip traversing into nested function bodies to avoid false positives
111+
return traverse.SKIP;
112+
}
113+
114+
// Detect member access on apiClient (e.g., `apiClient.get(...)`)
115+
if (
116+
node.type === 'MemberExpression' &&
117+
node.object?.type === 'Identifier' &&
118+
variableAliases.has(node.object.name)
119+
) {
120+
found = true;
121+
return traverse.SKIP;
122+
}
123+
124+
// Detect calls with apiClient passed as argument
125+
if (node.type === 'CallExpression' && node.arguments) {
126+
for (const arg of node.arguments) {
127+
if (arg.type === 'Identifier' && variableAliases.has(arg.name)) {
128+
const callee = node.callee;
129+
// For local functions, check if they use their parameter; for external, assume they do
130+
if (callee.type === 'Identifier' && localFnUsesParam.has(callee.name)) {
131+
if (localFnUsesParam.get(callee.name)) {
132+
found = true;
133+
return traverse.SKIP;
134+
}
135+
} else {
136+
// External call or member-based call; assume external helper uses it
137+
found = true;
138+
return traverse.SKIP;
139+
}
140+
}
141+
}
142+
143+
// Also check if a local function is called that uses apiClient from closure (no args needed)
144+
const callee = node.callee;
145+
if (callee.type === 'Identifier' && localFnUsesParam.has(callee.name)) {
146+
if (localFnUsesParam.get(callee.name)) {
147+
found = true;
148+
return traverse.SKIP;
149+
}
150+
}
151+
}
152+
});
153+
154+
return found;
155+
};
156+
157+
/** @type {Rule} */
158+
module.exports = {
159+
meta: {
160+
type: 'problem',
161+
docs: {
162+
description: 'Encourage `apiClient` fixture usage in API tests.',
163+
category: 'Best Practices',
164+
},
165+
fixable: null,
166+
schema: [],
167+
},
168+
169+
create(context) {
170+
return {
171+
CallExpression(node) {
172+
if (!isApiTestCall(node)) return;
173+
174+
const callbackArg = node.arguments[node.arguments.length - 1];
175+
if (
176+
!callbackArg ||
177+
(callbackArg.type !== 'ArrowFunctionExpression' &&
178+
callbackArg.type !== 'FunctionExpression')
179+
) {
180+
return;
181+
}
182+
183+
// Skip reporting if there is an eslint-disable comment for this rule
184+
const sourceCode = context.getSourceCode();
185+
const comments = sourceCode.getCommentsBefore(node);
186+
if (
187+
comments.some((c) =>
188+
/eslint-disable-next-line\s+@kbn\/eslint\/scout_require_api_client_in_api_test/.test(
189+
c.value
190+
)
191+
)
192+
) {
193+
return;
194+
}
195+
196+
if (!functionUsesApiClient(callbackArg, context)) {
197+
context.report({
198+
node,
199+
message: ERROR_MSG,
200+
});
201+
}
202+
},
203+
};
204+
},
205+
};

0 commit comments

Comments
 (0)