Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-eslint-plugin-eslint/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
110 changes: 110 additions & 0 deletions packages/kbn-eslint-plugin-eslint/rules/scout_expect_import.js
Original file line number Diff line number Diff line change
@@ -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}'`);
},
});
},
};
},
};
Original file line number Diff line number Diff line change
@@ -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' }],
},
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | unknown[]) =>
base.not.toMatchObject(expected)
),
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<string, string>) => toHaveHeaders(obj, headers),
toHaveStatusCode: wrapMatcher((code: number | ToHaveStatusCodeOptions) =>
toHaveStatusCode(obj, code)
),
toHaveStatusText: wrapMatcher((text: string) => toHaveStatusText(obj, text)),
toHaveHeaders: wrapMatcher((headers: Record<string, string>) => toHaveHeaders(obj, headers)),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:');
});
});
Loading
Loading