From 268723da3245b57382062d12511a4040ab0c3c43 Mon Sep 17 00:00:00 2001 From: Stelios Mavro Date: Wed, 28 Jan 2026 18:50:12 +0200 Subject: [PATCH 1/2] Update Solutions API tests to use API `expect` --- .../matchers/api/generic_matchers.ts | 40 ++++++++++--------- .../matchers/api/response_matchers.ts | 9 +++-- .../src/playwright/matchers/api/utils.test.ts | 32 --------------- .../src/playwright/matchers/api/utils.ts | 35 ++++++++++------ .../packages/kbn-scout-oblt/api.ts | 8 ++++ .../test/scout/api/tests/has_no_setup.spec.ts | 16 ++++---- .../tests/has_setup_apm_not_installed.spec.ts | 16 ++++---- .../api/tests/has_setup_with_data.spec.ts | 16 ++++---- .../tests/has_setup_with_integrations.spec.ts | 30 +++++++------- .../search/packages/kbn-scout-search/api.ts | 8 ++++ .../packages/kbn-scout-security/api.ts | 8 ++++ 11 files changed, 114 insertions(+), 104 deletions(-) create mode 100644 x-pack/solutions/observability/packages/kbn-scout-oblt/api.ts create mode 100644 x-pack/solutions/search/packages/kbn-scout-search/api.ts create mode 100644 x-pack/solutions/security/packages/kbn-scout-security/api.ts diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/generic_matchers.ts b/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/generic_matchers.ts index ade04ed30d469..e87b915e6b56e 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/generic_matchers.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/generic_matchers.ts @@ -9,6 +9,7 @@ import { expect as baseExpect } from '@playwright/test'; import type { GenericMatchers } from './types'; +import { wrapMatcher } from './utils'; /** * Create generic matchers delegating to Playwright/Jest expect @@ -17,25 +18,28 @@ export function createGenericMatchers(actual: unknown): GenericMatchers { // eslint-disable-next-line playwright/valid-expect const base = baseExpect(actual); return { - toBe: (expected: unknown) => base.toBe(expected), - toBeDefined: () => base.toBeDefined(), - toBeUndefined: () => base.toBeUndefined(), - toContain: (expected: unknown) => base.toContain(expected), - toHaveLength: (expected: number) => base.toHaveLength(expected), - toStrictEqual: (expected: unknown) => base.toStrictEqual(expected), - toBeGreaterThan: (expected: number) => base.toBeGreaterThan(expected), - toBeLessThan: (expected: number) => base.toBeLessThan(expected), - toMatchObject: (expected: Record | unknown[]) => base.toMatchObject(expected), + toBe: wrapMatcher((expected: unknown) => base.toBe(expected)), + toBeDefined: wrapMatcher(() => base.toBeDefined()), + toBeUndefined: wrapMatcher(() => base.toBeUndefined()), + toContain: wrapMatcher((expected: unknown) => base.toContain(expected)), + toHaveLength: wrapMatcher((expected: number) => base.toHaveLength(expected)), + toStrictEqual: wrapMatcher((expected: unknown) => base.toStrictEqual(expected)), + toBeGreaterThan: wrapMatcher((expected: number) => base.toBeGreaterThan(expected)), + toBeLessThan: wrapMatcher((expected: number) => base.toBeLessThan(expected)), + toMatchObject: wrapMatcher((expected: Record | unknown[]) => + base.toMatchObject(expected) + ), not: { - toBe: (expected: unknown) => base.not.toBe(expected), - toBeUndefined: () => base.not.toBeUndefined(), - toContain: (expected: unknown) => base.not.toContain(expected), - toHaveLength: (expected: number) => base.not.toHaveLength(expected), - toStrictEqual: (expected: unknown) => base.not.toStrictEqual(expected), - toBeGreaterThan: (expected: number) => base.not.toBeGreaterThan(expected), - toBeLessThan: (expected: number) => base.not.toBeLessThan(expected), - toMatchObject: (expected: Record | unknown[]) => - base.not.toMatchObject(expected), + toBe: wrapMatcher((expected: unknown) => base.not.toBe(expected)), + toBeUndefined: wrapMatcher(() => base.not.toBeUndefined()), + toContain: wrapMatcher((expected: unknown) => base.not.toContain(expected)), + toHaveLength: wrapMatcher((expected: number) => base.not.toHaveLength(expected)), + toStrictEqual: wrapMatcher((expected: unknown) => base.not.toStrictEqual(expected)), + toBeGreaterThan: wrapMatcher((expected: number) => base.not.toBeGreaterThan(expected)), + toBeLessThan: wrapMatcher((expected: number) => base.not.toBeLessThan(expected)), + toMatchObject: wrapMatcher((expected: Record | unknown[]) => + base.not.toMatchObject(expected) + ), }, }; } diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/response_matchers.ts b/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/response_matchers.ts index 6e3ada2fc4df2..e3e7333584254 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/response_matchers.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/response_matchers.ts @@ -10,6 +10,7 @@ import { toHaveHeaders } from './to_have_headers'; import { toHaveStatusCode } from './to_have_status_code'; import { toHaveStatusText } from './to_have_status_text'; +import { wrapMatcher } from './utils'; import type { ToHaveStatusCodeOptions } from './to_have_status_code'; import type { ResponseMatchers } from './types'; @@ -19,8 +20,10 @@ import type { ResponseMatchers } from './types'; */ export function createResponseMatchers(obj: unknown): ResponseMatchers { return { - toHaveStatusCode: (code: number | ToHaveStatusCodeOptions) => toHaveStatusCode(obj, code), - toHaveStatusText: (text: string) => toHaveStatusText(obj, text), - toHaveHeaders: (headers: Record) => toHaveHeaders(obj, headers), + toHaveStatusCode: wrapMatcher((code: number | ToHaveStatusCodeOptions) => + toHaveStatusCode(obj, code) + ), + toHaveStatusText: wrapMatcher((text: string) => toHaveStatusText(obj, text)), + toHaveHeaders: wrapMatcher((headers: Record) => toHaveHeaders(obj, headers)), }; } diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/utils.test.ts b/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/utils.test.ts index 4590a86994501..f714ee12167ad 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/utils.test.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/utils.test.ts @@ -36,36 +36,4 @@ describe('createMatcherError', () => { expect(error.message).toContain('Expected: not'); }); - - it('skips stack lines when skipStackLines is provided', () => { - const errorWithoutSkip = createMatcherError({ - expected: 200, - matcherName: 'toHaveStatusCode', - received: 404, - skipStackLines: 0, - }); - const errorWithSkip = createMatcherError({ - expected: 200, - matcherName: 'toHaveStatusCode', - received: 404, - skipStackLines: 1, - }); - - const getStackLines = (err: Error) => - err.stack!.split('\n').filter((l) => l.trimStart().startsWith('at ')); - - const stackLinesWithoutSkip = getStackLines(errorWithoutSkip); - const stackLinesWithSkip = getStackLines(errorWithSkip); - - // After skipping 1 line, first stack line now points to current test file - expect(stackLinesWithSkip[0]).toContain(__filename); - - // One less stack line - expect(stackLinesWithSkip.length).toBe(stackLinesWithoutSkip.length - 1); - - // Message content is preserved - expect(errorWithSkip.stack).toContain('toHaveStatusCode'); - expect(errorWithSkip.stack).toContain('Expected:'); - expect(errorWithSkip.stack).toContain('Received:'); - }); }); diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/utils.ts b/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/utils.ts index ebfb0976cbb24..d7edb23aa25ec 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/utils.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/matchers/api/utils.ts @@ -12,14 +12,13 @@ export interface MatcherErrorOptions { matcherName: string; received: unknown; isNegated?: boolean; - skipStackLines?: number; } /** * Format error messages for API matchers like Playwright. */ export function createMatcherError(options: MatcherErrorOptions): Error { - const { expected, matcherName, received, isNegated = false, skipStackLines = 0 } = options; + const { expected, matcherName, received, isNegated = false } = options; const gray = '\x1b[90m'; const red = '\x1b[31m'; const green = '\x1b[32m'; @@ -31,19 +30,31 @@ export function createMatcherError(options: MatcherErrorOptions): Error { `${matcherName}` + `${gray}(${green}expected${gray})${reset}`; - const error = new Error( + return new Error( `${matcherCall}\n\n` + `Expected: ${isNegated ? 'not ' : ''}${green}${expected}${reset}\n` + `Received: ${red}${received}${reset}` ); +} - if (skipStackLines > 0 && error.stack) { - const lines = error.stack.split('\n'); - // First line is the error message, rest are stack frames - const messageLines = lines.filter((line) => !line.trimStart().startsWith('at ')); - const stackLines = lines.filter((line) => line.trimStart().startsWith('at ')); - error.stack = [...messageLines, ...stackLines.slice(skipStackLines)].join('\n'); - } - - return error; +/** + * Wraps a matcher function to fix the stack trace. + * When a matcher throws, the error points to internal files instead of the test. + * This wrapper catches the error and uses Error.captureStackTrace to exclude + * the wrapper function, making the error point to the actual test file. + */ +export function wrapMatcher( + fn: (...args: TArgs) => TReturn +): (...args: TArgs) => TReturn { + const wrapper = (...args: TArgs): TReturn => { + try { + return fn(...args); + } catch (error) { + if (error instanceof Error) { + Error.captureStackTrace(error, wrapper); + } + throw error; + } + }; + return wrapper; } diff --git a/x-pack/solutions/observability/packages/kbn-scout-oblt/api.ts b/x-pack/solutions/observability/packages/kbn-scout-oblt/api.ts new file mode 100644 index 0000000000000..7f22fd0537ae5 --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-scout-oblt/api.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { expect } from '@kbn/scout/api'; diff --git a/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_no_setup.spec.ts b/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_no_setup.spec.ts index b2af9f6000cbf..e630297737576 100644 --- a/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_no_setup.spec.ts +++ b/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_no_setup.spec.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { expect } from '@kbn/scout-oblt'; +import { expect } from '@kbn/scout-oblt/api'; import type { RoleApiCredentials } from '@kbn/scout-oblt'; import { apiTest } from '../../common/fixtures'; import { esResourcesEndpoint } from '../../common/fixtures/constants'; @@ -30,9 +30,9 @@ apiTest.describe('Profiling is not setup and no data is loaded', { tag: ['@ess'] }, }); const adminStatus = adminRes.body; - expect(adminStatus.has_setup).toBeFalsy(); - expect(adminStatus.has_data).toBeFalsy(); - expect(adminStatus.pre_8_9_1_data).toBeFalsy(); + expect(adminStatus.has_setup).toBe(false); + expect(adminStatus.has_data).toBe(false); + expect(adminStatus.pre_8_9_1_data).toBe(false); }); apiTest('Viewer users', async ({ apiClient }) => { @@ -44,9 +44,9 @@ apiTest.describe('Profiling is not setup and no data is loaded', { tag: ['@ess'] }, }); const readStatus = readRes.body; - expect(readStatus.has_setup).toBeFalsy(); - expect(readStatus.has_data).toBeFalsy(); - expect(readStatus.pre_8_9_1_data).toBeFalsy(); - expect(readStatus.has_required_role).toBeFalsy(); + expect(readStatus.has_setup).toBe(false); + expect(readStatus.has_data).toBe(false); + expect(readStatus.pre_8_9_1_data).toBe(false); + expect(readStatus.has_required_role).toBe(false); }); }); diff --git a/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_apm_not_installed.spec.ts b/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_apm_not_installed.spec.ts index c989aca7d702e..e30a569ff28b2 100644 --- a/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_apm_not_installed.spec.ts +++ b/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_apm_not_installed.spec.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { expect } from '@kbn/scout-oblt'; +import { expect } from '@kbn/scout-oblt/api'; import type { RoleApiCredentials } from '@kbn/scout-oblt'; import { apiTest } from '../../common/fixtures'; import { esResourcesEndpoint } from '../../common/fixtures/constants'; @@ -31,9 +31,9 @@ apiTest.describe('APM integration not installed but setup completed', { tag: ['@ }); const adminStatus = adminRes.body; - expect(adminStatus.has_setup).toBeTruthy(); - expect(adminStatus.has_data).toBeFalsy(); - expect(adminStatus.pre_8_9_1_data).toBeFalsy(); + expect(adminStatus.has_setup).toBe(true); + expect(adminStatus.has_data).toBe(false); + expect(adminStatus.pre_8_9_1_data).toBe(false); }); apiTest('Viewer user', async ({ apiClient }) => { @@ -46,9 +46,9 @@ apiTest.describe('APM integration not installed but setup completed', { tag: ['@ }); const readStatus = readRes.body; - expect(readStatus.has_setup).toBeTruthy(); - expect(readStatus.has_data).toBeFalsy(); - expect(readStatus.pre_8_9_1_data).toBeFalsy(); - expect(readStatus.has_required_role).toBeFalsy(); + expect(readStatus.has_setup).toBe(true); + expect(readStatus.has_data).toBe(false); + expect(readStatus.pre_8_9_1_data).toBe(false); + expect(readStatus.has_required_role).toBe(false); }); }); diff --git a/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_with_data.spec.ts b/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_with_data.spec.ts index a2144ba75e91f..e50f9c033e406 100644 --- a/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_with_data.spec.ts +++ b/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_with_data.spec.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { expect } from '@kbn/scout-oblt'; +import { expect } from '@kbn/scout-oblt/api'; import type { RoleApiCredentials } from '@kbn/scout-oblt'; import { apiTest } from '../../common/fixtures'; import { esArchiversPath, esResourcesEndpoint } from '../../common/fixtures/constants'; @@ -28,9 +28,9 @@ apiTest.describe('Profiling is setup and data is loaded', { tag: ['@ess'] }, () }, }); const adminStatus = adminRes.body; - expect(adminStatus.has_setup).toBeTruthy(); - expect(adminStatus.has_data).toBeTruthy(); - expect(adminStatus.pre_8_9_1_data).toBeFalsy(); + expect(adminStatus.has_setup).toBe(true); + expect(adminStatus.has_data).toBe(true); + expect(adminStatus.pre_8_9_1_data).toBe(false); }); apiTest('Viewer user', async ({ apiClient }) => { @@ -43,9 +43,9 @@ apiTest.describe('Profiling is setup and data is loaded', { tag: ['@ess'] }, () }); const readStatus = readRes.body; - expect(readStatus.has_setup).toBeTruthy(); - expect(readStatus.has_data).toBeTruthy(); - expect(readStatus.pre_8_9_1_data).toBeFalsy(); - expect(readStatus.has_required_role).toBeFalsy(); + expect(readStatus.has_setup).toBe(true); + expect(readStatus.has_data).toBe(true); + expect(readStatus.pre_8_9_1_data).toBe(false); + expect(readStatus.has_required_role).toBe(false); }); }); diff --git a/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_with_integrations.spec.ts b/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_with_integrations.spec.ts index 2226692434e96..f2e16eee42858 100644 --- a/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_with_integrations.spec.ts +++ b/x-pack/solutions/observability/plugins/profiling/test/scout/api/tests/has_setup_with_integrations.spec.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { expect } from '@kbn/scout-oblt'; +import { expect } from '@kbn/scout-oblt/api'; import type { RoleApiCredentials } from '@kbn/scout-oblt'; import { apiTest } from '../../common/fixtures'; import { esResourcesEndpoint } from '../../common/fixtures/constants'; @@ -30,9 +30,9 @@ apiTest.describe('Collector integration is not installed', { tag: ['@ess'] }, () const adminRes = await apiClient.get(esResourcesEndpoint); const adminStatus = adminRes.body; - expect(adminStatus.has_setup).toBeFalsy(); - expect(adminStatus.has_data).toBeFalsy(); - expect(adminStatus.pre_8_9_1_data).toBeFalsy(); + expect(adminStatus.has_setup).toBeUndefined(); + expect(adminStatus.has_data).toBeUndefined(); + expect(adminStatus.pre_8_9_1_data).toBeUndefined(); const readRes = await apiClient.get(esResourcesEndpoint, { headers: { @@ -42,10 +42,10 @@ apiTest.describe('Collector integration is not installed', { tag: ['@ess'] }, () }, }); const readStatus = readRes.body; - expect(readStatus.has_setup).toBeFalsy(); - expect(readStatus.has_data).toBeFalsy(); - expect(readStatus.pre_8_9_1_data).toBeFalsy(); - expect(readStatus.has_required_role).toBeFalsy(); + expect(readStatus.has_setup).toBe(false); + expect(readStatus.has_data).toBe(false); + expect(readStatus.pre_8_9_1_data).toBe(false); + expect(readStatus.has_required_role).toBe(false); }); apiTest( @@ -67,9 +67,9 @@ apiTest.describe('Collector integration is not installed', { tag: ['@ess'] }, () }, }); const adminStatus = adminRes.body; - expect(adminStatus.has_setup).toBeFalsy(); - expect(adminStatus.has_data).toBeFalsy(); - expect(adminStatus.pre_8_9_1_data).toBeFalsy(); + expect(adminStatus.has_setup).toBe(false); + expect(adminStatus.has_data).toBe(false); + expect(adminStatus.pre_8_9_1_data).toBe(false); const readRes = await apiClient.get(esResourcesEndpoint, { headers: { @@ -77,10 +77,10 @@ apiTest.describe('Collector integration is not installed', { tag: ['@ess'] }, () }, }); const readStatus = readRes.body; - expect(readStatus.has_setup).toBeFalsy(); - expect(readStatus.has_data).toBeFalsy(); - expect(readStatus.pre_8_9_1_data).toBeFalsy(); - expect(readStatus.has_required_role).toBeFalsy(); + expect(readStatus.has_setup).toBe(false); + expect(readStatus.has_data).toBe(false); + expect(readStatus.pre_8_9_1_data).toBe(false); + expect(readStatus.has_required_role).toBe(false); } ); }); diff --git a/x-pack/solutions/search/packages/kbn-scout-search/api.ts b/x-pack/solutions/search/packages/kbn-scout-search/api.ts new file mode 100644 index 0000000000000..7f22fd0537ae5 --- /dev/null +++ b/x-pack/solutions/search/packages/kbn-scout-search/api.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { expect } from '@kbn/scout/api'; diff --git a/x-pack/solutions/security/packages/kbn-scout-security/api.ts b/x-pack/solutions/security/packages/kbn-scout-security/api.ts new file mode 100644 index 0000000000000..7f22fd0537ae5 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-scout-security/api.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { expect } from '@kbn/scout/api'; From 970d30af87f051bec6fa63268a09d332bd6e7722 Mon Sep 17 00:00:00 2001 From: Stelios Mavro Date: Wed, 28 Jan 2026 20:39:23 +0200 Subject: [PATCH 2/2] Add rule to validate `expect` imports based on test type --- .eslintrc.js | 7 ++ packages/kbn-eslint-plugin-eslint/index.js | 1 + .../rules/scout_expect_import.js | 110 ++++++++++++++++++ .../rules/scout_expect_import.test.js | 96 +++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 packages/kbn-eslint-plugin-eslint/rules/scout_expect_import.js create mode 100644 packages/kbn-eslint-plugin-eslint/rules/scout_expect_import.test.js diff --git a/.eslintrc.js b/.eslintrc.js index 245146c004f5e..cb7b88f622775 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2721,6 +2721,13 @@ module.exports = { '@kbn/eslint/require_include_in_check_a11y': 'warn', }, }, + { + // Ensure correct expect import path in solutions scout API tests + files: ['x-pack/solutions/**/plugins/**/test/scout/api/**/*.ts'], + rules: { + '@kbn/eslint/scout_expect_import': 'error', + }, + }, { // Deployment-agnostic test files must use proper context and services files: [ diff --git a/packages/kbn-eslint-plugin-eslint/index.js b/packages/kbn-eslint-plugin-eslint/index.js index a4fba581d78db..9f09e5a3a48fd 100644 --- a/packages/kbn-eslint-plugin-eslint/index.js +++ b/packages/kbn-eslint-plugin-eslint/index.js @@ -29,6 +29,7 @@ module.exports = { scout_require_api_client_in_api_test: require('./rules/scout_require_api_client_in_api_test'), scout_require_global_setup_hook_in_parallel_tests: require('./rules/scout_require_global_setup_hook_in_parallel_tests'), scout_no_es_archiver_in_parallel_tests: require('./rules/scout_no_es_archiver_in_parallel_tests'), + scout_expect_import: require('./rules/scout_expect_import'), require_kbn_fs: require('./rules/require_kbn_fs'), require_include_in_check_a11y: require('./rules/require_include_in_check_a11y'), no_wrapped_error_in_logger: require('./rules/no_wrapped_error_in_logger'), diff --git a/packages/kbn-eslint-plugin-eslint/rules/scout_expect_import.js b/packages/kbn-eslint-plugin-eslint/rules/scout_expect_import.js new file mode 100644 index 0000000000000..6d3e89aa68959 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/scout_expect_import.js @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** @typedef {import("eslint").Rule.RuleModule} Rule */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.ImportDeclaration} ImportDeclaration */ + +const SOLUTION_PACKAGES = { + observability: '@kbn/scout-oblt', + search: '@kbn/scout-search', + security: '@kbn/scout-security', +}; + +/** + * Determines the test type (api/ui) based on file path. + * @param {string} filePath + * @returns {'api' | 'ui' | null} + */ +const getTestType = (filePath) => { + if (/\/test\/scout\/api\//.test(filePath)) { + return 'api'; + } + if (/\/test\/scout\/ui\//.test(filePath)) { + return 'ui'; + } + return null; +}; + +/** + * Gets the recommended package based on file path. + * @param {string} filePath + * @returns {string} + */ +const getRecommendedPackage = (filePath) => { + const solutionMatch = filePath.match(/x-pack\/solutions\/(\w+)\//); + if (solutionMatch) { + const solution = solutionMatch[1]; + return SOLUTION_PACKAGES[solution] || '@kbn/scout'; + } + return '@kbn/scout'; +}; + +/** @type {Rule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Ensure `expect` is imported from the correct scout subpath based on test type (api/ui).', + category: 'Best Practices', + }, + fixable: 'code', + schema: [], + messages: { + wrongImportPath: + 'Import `expect` from `{{recommendedImport}}` in {{testType}} tests. Found: `{{actualImport}}`.', + }, + }, + + create(context) { + const filePath = context.getFilename(); + const testType = getTestType(filePath); + + if (!testType) { + return {}; + } + + const recommendedPackage = getRecommendedPackage(filePath); + const recommendedImport = `${recommendedPackage}/${testType}`; + + return { + ImportDeclaration(node) { + const source = node.source.value; + + const hasExpectImport = node.specifiers.some( + (specifier) => + specifier.type === 'ImportSpecifier' && + specifier.imported && + specifier.imported.name === 'expect' + ); + + if (!hasExpectImport) { + return; + } + + if (source === recommendedImport) { + return; + } + + context.report({ + node: node.source, + messageId: 'wrongImportPath', + data: { + testType, + recommendedImport, + actualImport: source, + }, + fix(fixer) { + return fixer.replaceText(node.source, `'${recommendedImport}'`); + }, + }); + }, + }; + }, +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/scout_expect_import.test.js b/packages/kbn-eslint-plugin-eslint/rules/scout_expect_import.test.js new file mode 100644 index 0000000000000..03950c7331756 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/scout_expect_import.test.js @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +const { RuleTester } = require('eslint'); +const rule = require('./scout_expect_import'); + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + }, +}); + +// Solutions test files +const OBLT_API_FILE = + 'x-pack/solutions/observability/plugins/apm/test/scout/api/tests/example.spec.ts'; +const OBLT_UI_FILE = + 'x-pack/solutions/observability/plugins/apm/test/scout/ui/tests/example.spec.ts'; + +// Platform test files +const PLATFORM_API_FILE = 'src/platform/plugins/example/test/scout/api/tests/example.spec.ts'; + +ruleTester.run('@kbn/eslint/scout_expect_import', rule, { + valid: [ + // Solutions: correct imports + { + filename: OBLT_API_FILE, + code: `import { expect } from '@kbn/scout-oblt/api';`, + }, + { + filename: OBLT_UI_FILE, + code: `import { expect } from '@kbn/scout-oblt/ui';`, + }, + // Platform: correct import + { + filename: PLATFORM_API_FILE, + code: `import { expect } from '@kbn/scout/api';`, + }, + // Non-expect imports are ignored + { + filename: OBLT_API_FILE, + code: `import { test } from '@kbn/scout-oblt';`, + }, + // Files outside scout directories are ignored + { + filename: + 'x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.test.ts', + code: `import { expect } from 'expect';`, + }, + ], + + invalid: [ + // Solutions API: missing /api suffix → suggests @kbn/scout-oblt/api + { + filename: OBLT_API_FILE, + code: `import { expect } from '@kbn/scout-oblt';`, + output: `import { expect } from '@kbn/scout-oblt/api';`, + errors: [{ messageId: 'wrongImportPath' }], + }, + // Solutions API: wrong /ui suffix → suggests @kbn/scout-oblt/api + { + filename: OBLT_API_FILE, + code: `import { expect } from '@kbn/scout-oblt/ui';`, + output: `import { expect } from '@kbn/scout-oblt/api';`, + errors: [{ messageId: 'wrongImportPath' }], + }, + // Solutions UI: wrong /api suffix → suggests @kbn/scout-oblt/ui + { + filename: OBLT_UI_FILE, + code: `import { expect } from '@kbn/scout-oblt/api';`, + output: `import { expect } from '@kbn/scout-oblt/ui';`, + errors: [{ messageId: 'wrongImportPath' }], + }, + // Platform API: missing suffix → suggests @kbn/scout/api + { + filename: PLATFORM_API_FILE, + code: `import { expect } from '@kbn/scout';`, + output: `import { expect } from '@kbn/scout/api';`, + errors: [{ messageId: 'wrongImportPath' }], + }, + // Playwright import → suggests correct scout package + { + filename: OBLT_API_FILE, + code: `import { expect } from '@playwright/test';`, + output: `import { expect } from '@kbn/scout-oblt/api';`, + errors: [{ messageId: 'wrongImportPath' }], + }, + ], +});