diff --git a/packages/kbn-eslint-plugin-eslint/rules/scout_require_global_setup_hook_in_parallel_tests.js b/packages/kbn-eslint-plugin-eslint/rules/scout_require_global_setup_hook_in_parallel_tests.js new file mode 100644 index 0000000000000..153ca63519132 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/scout_require_global_setup_hook_in_parallel_tests.js @@ -0,0 +1,72 @@ +/* + * 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.CallExpression} CallExpression */ + +const path = require('path'); + +const ERROR_MSG_MISSING_HOOK = + '`global.setup.ts` must explicitly call `globalSetupHook`. Without it, ES security indexes are not pre-generated and tests become flaky.'; + +/** + * Checks if a file is a global.setup.ts file + * @param {string} filename + * @returns {boolean} + */ +const isGlobalSetupFile = (filename) => { + return path.basename(filename) === 'global.setup.ts'; +}; + +/** + * Checks if a node is a call to globalSetupHook + * @param {CallExpression} node + * @returns {boolean} + */ +const isGlobalSetupHookCall = (node) => { + return node.callee.type === 'Identifier' && node.callee.name === 'globalSetupHook'; +}; + +/** @type {Rule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Require globalSetupHook call in global.setup.ts files', + category: 'Best Practices', + }, + fixable: null, + schema: [], + }, + create: (context) => { + const filename = context.getFilename(); + + if (!isGlobalSetupFile(filename)) { + return {}; + } + + let hasGlobalSetupHook = false; + + return { + CallExpression(node) { + if (isGlobalSetupHookCall(node)) { + hasGlobalSetupHook = true; + } + }, + 'Program:exit'(node) { + if (!hasGlobalSetupHook) { + context.report({ + node, + message: ERROR_MSG_MISSING_HOOK, + }); + } + }, + }; + }, +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/scout_require_global_setup_hook_in_parallel_tests.test.js b/packages/kbn-eslint-plugin-eslint/rules/scout_require_global_setup_hook_in_parallel_tests.test.js new file mode 100644 index 0000000000000..7e1f750bc1864 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/scout_require_global_setup_hook_in_parallel_tests.test.js @@ -0,0 +1,61 @@ +/* + * 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_require_global_setup_hook_in_parallel_tests'); +const dedent = require('dedent'); + +const ERROR_MSG_MISSING_HOOK = + '`global.setup.ts` must explicitly call `globalSetupHook`. Without it, ES security indexes are not pre-generated and tests become flaky.'; + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2020, + }, +}); + +ruleTester.run('@kbn/eslint/scout_require_global_setup_hook_in_parallel_tests', rule, { + valid: [ + // global.setup.ts with globalSetupHook call + { + code: dedent` + import { globalSetupHook } from '@kbn/scout-security'; + + globalSetupHook('Ingest archives', async ({ esArchiver }) => {}); + `, + filename: '/path/to/plugin/test/scout/ui/parallel_tests/global.setup.ts', + }, + // Non global.setup.ts file (rule should not apply) + { + code: `export const setup = () => {};`, + filename: '/path/to/plugin/test/scout/ui/some_other_file.ts', + }, + ], + + invalid: [ + // globalSetupHook not present in global.setup.ts + { + code: `export const setup = async () => {};`, + filename: '/path/to/plugin/test/scout/ui/parallel_tests/global.setup.ts', + errors: [{ message: ERROR_MSG_MISSING_HOOK }], + }, + // globalSetupHook imported but not called + { + code: dedent` + import { globalSetupHook } from '@kbn/scout-security'; + + export const setup = async () => {}; + `, + filename: '/path/to/plugin/test/scout/ui/parallel_tests/global.setup.ts', + errors: [{ message: ERROR_MSG_MISSING_HOOK }], + }, + ], +}); diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/config/create_config.ts b/src/platform/packages/shared/kbn-scout/src/playwright/config/create_config.ts index 6502eb7bda009..93f5267c82dd9 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/config/create_config.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/config/create_config.ts @@ -88,6 +88,7 @@ export function createPlaywrightConfig(options: ScoutPlaywrightOptions): Playwri testIdAttribute: 'data-test-subj', serversConfigDir: SCOUT_SERVERS_ROOT, [VALID_CONFIG_MARKER]: true, + runGlobalSetup: options.runGlobalSetup, /* Base URL to use in actions like `await page.goto('/')`. */ // baseURL: 'http://127.0.0.1:3000', diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/runner/config_validator.test.ts b/src/platform/packages/shared/kbn-scout/src/playwright/runner/config_validator.test.ts index eb6d97ee8e576..cadd047886b16 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/runner/config_validator.test.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/runner/config_validator.test.ts @@ -96,4 +96,60 @@ describe('validatePlaywrightConfig', () => { `Path to a valid TypeScript config file is required: --config ` ); }); + + it('should throw an error if runGlobalSetup is true but global.setup.ts does not exist', async () => { + const configPath = '/path/to/plugin/playwright.config.ts'; + existsSyncMock + .mockReturnValueOnce(true) // config file exists + .mockReturnValueOnce(false); // global.setup.ts does not exist + loadConfigModuleMock.mockResolvedValue({ + default: { + use: { [VALID_CONFIG_MARKER]: true, runGlobalSetup: true }, + testDir: './tests', + }, + }); + + await expect(validatePlaywrightConfig(configPath)).rejects.toThrow( + `The config file at "${configPath}" has 'runGlobalSetup: true' but no global.setup.ts file found.` + ); + }); + + it('should pass validation if runGlobalSetup is true and global.setup.ts exists', async () => { + const configPath = '/path/to/plugin/playwright.config.ts'; + existsSyncMock.mockReturnValue(true); // both config and global.setup.ts exist + loadConfigModuleMock.mockResolvedValue({ + default: { + use: { [VALID_CONFIG_MARKER]: true, runGlobalSetup: true }, + testDir: './tests', + }, + }); + + await expect(validatePlaywrightConfig(configPath)).resolves.not.toThrow(); + }); + + it('should pass validation if runGlobalSetup is false', async () => { + const configPath = '/path/to/plugin/playwright.config.ts'; + existsSyncMock.mockReturnValue(true); + loadConfigModuleMock.mockResolvedValue({ + default: { + use: { [VALID_CONFIG_MARKER]: true, runGlobalSetup: false }, + testDir: './tests', + }, + }); + + await expect(validatePlaywrightConfig(configPath)).resolves.not.toThrow(); + }); + + it('should pass validation if runGlobalSetup is undefined', async () => { + const configPath = '/path/to/plugin/playwright.config.ts'; + existsSyncMock.mockReturnValue(true); + loadConfigModuleMock.mockResolvedValue({ + default: { + use: { [VALID_CONFIG_MARKER]: true }, + testDir: './tests', + }, + }); + + await expect(validatePlaywrightConfig(configPath)).resolves.not.toThrow(); + }); }); diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/runner/config_validator.ts b/src/platform/packages/shared/kbn-scout/src/playwright/runner/config_validator.ts index cbecb6309172f..08b5dc30da372 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/runner/config_validator.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/runner/config_validator.ts @@ -8,6 +8,7 @@ */ import Fs from 'fs'; +import path from 'path'; import type { PlaywrightTestConfig } from 'playwright/test'; import { createFlagError } from '@kbn/dev-cli-errors'; import type { ScoutTestOptions } from '../types'; @@ -45,4 +46,19 @@ export async function validatePlaywrightConfig(configPath: string) { `The config file at "${configPath}" must export a valid Playwright configuration with "testDir" property.` ); } + + if (config.use?.runGlobalSetup) { + const configDir = path.dirname(configPath); + const testDir = path.resolve(configDir, config.testDir); + const globalSetupPath = path.join(testDir, 'global.setup.ts'); + const hasGlobalSetupFile = Fs.existsSync(globalSetupPath); + + if (!hasGlobalSetupFile) { + throw createFlagError( + `The config file at "${configPath}" has 'runGlobalSetup: true' but no global.setup.ts file found.\n` + + `Expected file at: ${globalSetupPath}\n\n` + + `Either create the file or set 'runGlobalSetup: false' in your config.` + ); + } + } } diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/types/index.ts b/src/platform/packages/shared/kbn-scout/src/playwright/types/index.ts index e6145c34f39db..68251520d864d 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/types/index.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/types/index.ts @@ -21,6 +21,7 @@ export interface ScoutTestOptions extends PlaywrightTestOptions { serversConfigDir: string; configName: ScoutConfigName; [VALID_CONFIG_MARKER]: boolean; + runGlobalSetup?: boolean; } export interface ScoutPlaywrightOptions extends Pick {