diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 18c0d17dad25b..47bbcd64afaab 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -466,6 +466,7 @@ packages/kbn-rrule @elastic/response-ops packages/kbn-rule-data-utils @elastic/security-detections-response @elastic/response-ops @elastic/obs-ux-management-team packages/kbn-safer-lodash-set @elastic/kibana-security packages/kbn-saved-objects-settings @elastic/appex-sharedux +packages/kbn-scout @elastic/appex-qa packages/kbn-screenshotting-server @elastic/appex-sharedux packages/kbn-search-api-keys-components @elastic/search-kibana packages/kbn-search-api-keys-server @elastic/search-kibana @@ -1552,6 +1553,7 @@ packages/kbn-monaco/src/esql @elastic/kibana-esql /.eslintignore @elastic/kibana-operations # QA - Appex QA +/x-pack/plugins/discover_enhanced/ui_tests/ @elastic/appex-qa # temporarily /x-pack/test/functional/fixtures/package_registry_config.yml @elastic/appex-qa # No usages found /x-pack/test/functional/fixtures/kbn_archiver/packaging.json @elastic/appex-qa # No usages found /x-pack/test/functional/es_archives/filebeat @elastic/appex-qa diff --git a/.gitignore b/.gitignore index 34ba130ee2981..be8d495f95f1d 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,8 @@ x-pack/test/security_api_integration/plugins/audit_log/audit.log .ftr role_users.json +# ignore Scout temp directory +.scout .devcontainer/.env diff --git a/package.json b/package.json index 414fa3a3b9b14..5c530d4f019cf 100644 --- a/package.json +++ b/package.json @@ -1484,6 +1484,7 @@ "@kbn/repo-path": "link:packages/kbn-repo-path", "@kbn/repo-source-classifier": "link:packages/kbn-repo-source-classifier", "@kbn/repo-source-classifier-cli": "link:packages/kbn-repo-source-classifier-cli", + "@kbn/scout": "link:packages/kbn-scout", "@kbn/security-api-integration-helpers": "link:x-pack/test/security_api_integration/packages/helpers", "@kbn/serverless-storybook-config": "link:packages/serverless/storybook/config", "@kbn/some-dev-log": "link:packages/kbn-some-dev-log", diff --git a/packages/kbn-repo-source-classifier/src/config.ts b/packages/kbn-repo-source-classifier/src/config.ts index e6f8465a54ad5..08240db981694 100644 --- a/packages/kbn-repo-source-classifier/src/config.ts +++ b/packages/kbn-repo-source-classifier/src/config.ts @@ -58,5 +58,6 @@ export const TEST_DIR = new Set([ 'storybook', '.storybook', 'integration_tests', + 'ui_tests', ...RANDOM_TEST_FILE_NAMES, ]); diff --git a/packages/kbn-scout/README.md b/packages/kbn-scout/README.md new file mode 100644 index 0000000000000..4449bdf966200 --- /dev/null +++ b/packages/kbn-scout/README.md @@ -0,0 +1,9 @@ +# @kbn/scout + +The package is designed to streamline the setup and execution of Playwright tests for Kibana. It consolidates server management and testing capabilities by wrapping both the Kibana/Elasticsearch server launcher and the Playwright test runner. It includes: + + - core test and worker-scoped fixtures for reliable setup across test suites + - page objects combined into the fixture for for core Kibana apps UI interactions + - configurations for seamless test execution in both local and CI environments + +This package aims to simplify test setup and enhance modularity, making it easier to create, run, and maintain deployment-agnostic tests, that are located in the plugin they actually test. diff --git a/packages/kbn-scout/index.ts b/packages/kbn-scout/index.ts new file mode 100644 index 0000000000000..2cbf98d96a8e0 --- /dev/null +++ b/packages/kbn-scout/index.ts @@ -0,0 +1,19 @@ +/* + * 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". + */ + +export { startServersCli, runTestsCli } from './src/cli'; +export { expect, test, createPlaywrightConfig, createLazyPageObject } from './src/playwright'; +export type { + ScoutPage, + ScoutPlaywrightOptions, + ScoutTestOptions, + PageObjects, + ScoutTestFixtures, + ScoutWorkerFixtures, +} from './src/playwright'; diff --git a/packages/kbn-scout/jest.config.js b/packages/kbn-scout/jest.config.js new file mode 100644 index 0000000000000..0e1493f115c12 --- /dev/null +++ b/packages/kbn-scout/jest.config.js @@ -0,0 +1,14 @@ +/* + * 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". + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-scout'], +}; diff --git a/packages/kbn-scout/kibana.jsonc b/packages/kbn-scout/kibana.jsonc new file mode 100644 index 0000000000000..c35c71e9793d8 --- /dev/null +++ b/packages/kbn-scout/kibana.jsonc @@ -0,0 +1,6 @@ +{ + "type": "test-helper", + "id": "@kbn/scout", + "owner": "@elastic/appex-qa", + "devOnly": true +} diff --git a/packages/kbn-scout/package.json b/packages/kbn-scout/package.json new file mode 100644 index 0000000000000..fb362e66af2e9 --- /dev/null +++ b/packages/kbn-scout/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/scout", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/kbn-scout/src/cli/index.ts b/packages/kbn-scout/src/cli/index.ts new file mode 100644 index 0000000000000..f30b384f351d9 --- /dev/null +++ b/packages/kbn-scout/src/cli/index.ts @@ -0,0 +1,11 @@ +/* + * 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". + */ + +export { runTestsCli } from './run_tests_cli'; +export { startServersCli } from './start_servers_cli'; diff --git a/packages/kbn-scout/src/cli/run_tests_cli.ts b/packages/kbn-scout/src/cli/run_tests_cli.ts new file mode 100644 index 0000000000000..913f09a310a63 --- /dev/null +++ b/packages/kbn-scout/src/cli/run_tests_cli.ts @@ -0,0 +1,39 @@ +/* + * 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". + */ + +import { run } from '@kbn/dev-cli-runner'; +import { initLogsDir } from '@kbn/test'; +import { TEST_FLAG_OPTIONS, parseTestFlags, runTests } from '../playwright/runner'; + +/** + * Start servers and run the tests + */ +export function runTestsCli() { + run( + async ({ flagsReader, log }) => { + const options = await parseTestFlags(flagsReader); + + if (options.logsDir) { + initLogsDir(log, options.logsDir); + } + + await runTests(log, options); + }, + { + description: `Run Scout UI Tests`, + usage: ` + Usage: + node scripts/scout_test --help + node scripts/scout_test --stateful --config + node scripts/scout_test --serverless=es --headed --config + `, + flags: TEST_FLAG_OPTIONS, + } + ); +} diff --git a/packages/kbn-scout/src/cli/start_servers_cli.ts b/packages/kbn-scout/src/cli/start_servers_cli.ts new file mode 100644 index 0000000000000..3006f87f5ba57 --- /dev/null +++ b/packages/kbn-scout/src/cli/start_servers_cli.ts @@ -0,0 +1,34 @@ +/* + * 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". + */ + +import { run } from '@kbn/dev-cli-runner'; + +import { initLogsDir } from '@kbn/test'; + +import { startServers, parseServerFlags, SERVER_FLAG_OPTIONS } from '../servers'; + +/** + * Start servers + */ +export function startServersCli() { + run( + async ({ flagsReader: flags, log }) => { + const options = parseServerFlags(flags); + + if (options.logsDir) { + initLogsDir(log, options.logsDir); + } + + await startServers(log, options); + }, + { + flags: SERVER_FLAG_OPTIONS, + } + ); +} diff --git a/packages/kbn-scout/src/common/constants.ts b/packages/kbn-scout/src/common/constants.ts new file mode 100644 index 0000000000000..bf5c6fb181cd7 --- /dev/null +++ b/packages/kbn-scout/src/common/constants.ts @@ -0,0 +1,16 @@ +/* + * 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". + */ + +import { Role } from '@kbn/test/src/auth/types'; + +export const PROJECT_DEFAULT_ROLES = new Map([ + ['es', 'developer'], + ['security', 'editor'], + ['oblt', 'editor'], +]); diff --git a/packages/kbn-scout/src/common/index.ts b/packages/kbn-scout/src/common/index.ts new file mode 100644 index 0000000000000..7ff3c1ea52358 --- /dev/null +++ b/packages/kbn-scout/src/common/index.ts @@ -0,0 +1,12 @@ +/* + * 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". + */ + +export * from './services'; +export * from './constants'; +export * from './utils'; diff --git a/packages/kbn-scout/src/common/services/clients.ts b/packages/kbn-scout/src/common/services/clients.ts new file mode 100644 index 0000000000000..3a0dcf8bfe320 --- /dev/null +++ b/packages/kbn-scout/src/common/services/clients.ts @@ -0,0 +1,58 @@ +/* + * 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". + */ + +import { KbnClient, createEsClientForTesting } from '@kbn/test'; +import type { ToolingLog } from '@kbn/tooling-log'; +import { ScoutServerConfig } from '../../types'; +import { serviceLoadedMsg } from '../../playwright/utils'; + +interface ClientOptions { + serviceName: string; + url: string; + username: string; + password: string; + log: ToolingLog; +} + +function createClientUrlWithAuth({ serviceName, url, username, password, log }: ClientOptions) { + const clientUrl = new URL(url); + clientUrl.username = username; + clientUrl.password = password; + + log.debug(serviceLoadedMsg(`${serviceName}client`)); + return clientUrl.toString(); +} + +export function createEsClient(config: ScoutServerConfig, log: ToolingLog) { + const { username, password } = config.auth; + const elasticsearchUrl = createClientUrlWithAuth({ + serviceName: 'Es', + url: config.hosts.elasticsearch, + username, + password, + log, + }); + + return createEsClientForTesting({ + esUrl: elasticsearchUrl, + authOverride: { username, password }, + }); +} + +export function createKbnClient(config: ScoutServerConfig, log: ToolingLog) { + const kibanaUrl = createClientUrlWithAuth({ + serviceName: 'Kbn', + url: config.hosts.kibana, + username: config.auth.username, + password: config.auth.password, + log, + }); + + return new KbnClient({ log, url: kibanaUrl }); +} diff --git a/packages/kbn-scout/src/common/services/config.ts b/packages/kbn-scout/src/common/services/config.ts new file mode 100644 index 0000000000000..fe8e932194d91 --- /dev/null +++ b/packages/kbn-scout/src/common/services/config.ts @@ -0,0 +1,29 @@ +/* + * 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". + */ + +import path from 'path'; +import fs from 'fs'; +import { ToolingLog } from '@kbn/tooling-log'; +import { ScoutServerConfig } from '../../types'; +import { serviceLoadedMsg } from '../../playwright/utils'; + +export function createScoutConfig(configDir: string, configName: string, log: ToolingLog) { + if (!configDir || !fs.existsSync(configDir)) { + throw new Error(`Directory with servers configuration is missing`); + } + + const configPath = path.join(configDir, `${configName}.json`); + log.info(`Reading test servers confiuration from file: ${configPath}`); + + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as ScoutServerConfig; + + log.debug(serviceLoadedMsg('config')); + + return config; +} diff --git a/packages/kbn-scout/src/common/services/es_archiver.ts b/packages/kbn-scout/src/common/services/es_archiver.ts new file mode 100644 index 0000000000000..38b86d800459f --- /dev/null +++ b/packages/kbn-scout/src/common/services/es_archiver.ts @@ -0,0 +1,28 @@ +/* + * 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". + */ + +import { Client } from '@elastic/elasticsearch'; +import { EsArchiver } from '@kbn/es-archiver'; +import { REPO_ROOT } from '@kbn/repo-info'; +import type { KbnClient } from '@kbn/test'; +import type { ToolingLog } from '@kbn/tooling-log'; +import { serviceLoadedMsg } from '../../playwright/utils'; + +export function createEsArchiver(esClient: Client, kbnClient: KbnClient, log: ToolingLog) { + const esArchiver = new EsArchiver({ + log, + client: esClient, + kbnClient, + baseDir: REPO_ROOT, + }); + + log.debug(serviceLoadedMsg('esArchiver')); + + return esArchiver; +} diff --git a/packages/kbn-scout/src/common/services/index.ts b/packages/kbn-scout/src/common/services/index.ts new file mode 100644 index 0000000000000..6368e613c0284 --- /dev/null +++ b/packages/kbn-scout/src/common/services/index.ts @@ -0,0 +1,17 @@ +/* + * 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". + */ + +export { createEsClient, createKbnClient } from './clients'; +export { createScoutConfig } from './config'; +export { createEsArchiver } from './es_archiver'; +export { createKbnUrl } from './kibana_url'; +export { createSamlSessionManager } from './saml_auth'; +export { createLogger } from './logger'; + +export type { KibanaUrl } from './kibana_url'; diff --git a/packages/kbn-scout/src/common/services/kibana_url.ts b/packages/kbn-scout/src/common/services/kibana_url.ts new file mode 100644 index 0000000000000..cbfab5dc90796 --- /dev/null +++ b/packages/kbn-scout/src/common/services/kibana_url.ts @@ -0,0 +1,73 @@ +/* + * 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". + */ + +import type { ToolingLog } from '@kbn/tooling-log'; +import { ScoutServerConfig } from '../../types'; +import { serviceLoadedMsg } from '../../playwright/utils'; + +export interface PathOptions { + /** + * Query string parameters + */ + params?: Record; + /** + * The hash value of the URL + */ + hash?: string; +} + +export class KibanaUrl { + #baseUrl: URL; + + constructor(baseUrl: URL) { + this.#baseUrl = baseUrl; + } + + /** + * Get an absolute URL based on Kibana's URL + * @param rel relative url, resolved relative to Kibana's url + * @param options optional modifications to apply to the URL + */ + get(rel?: string, options?: PathOptions) { + const url = new URL(rel ?? '/', this.#baseUrl); + + if (options?.params) { + for (const [key, value] of Object.entries(options.params)) { + url.searchParams.set(key, value); + } + } + + if (options?.hash !== undefined) { + url.hash = options.hash; + } + + return url.href; + } + + /** + * Get the URL for an app + * @param appName name of the app to get the URL for + * @param options optional modifications to apply to the URL + */ + app(appName: string, options?: PathOptions) { + return this.get(`/app/${appName}`, options); + } + + toString() { + return this.#baseUrl.href; + } +} + +export function createKbnUrl(scoutConfig: ScoutServerConfig, log: ToolingLog) { + const kbnUrl = new KibanaUrl(new URL(scoutConfig.hosts.kibana)); + + log.debug(serviceLoadedMsg('kbnUrl')); + + return kbnUrl; +} diff --git a/packages/kbn-scout/src/common/services/logger.ts b/packages/kbn-scout/src/common/services/logger.ts new file mode 100644 index 0000000000000..4ab39ba7dec68 --- /dev/null +++ b/packages/kbn-scout/src/common/services/logger.ts @@ -0,0 +1,19 @@ +/* + * 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". + */ + +import { ToolingLog } from '@kbn/tooling-log'; +import { serviceLoadedMsg } from '../../playwright/utils'; + +export function createLogger() { + const log = new ToolingLog({ level: 'verbose', writeTo: process.stdout }); + + log.debug(serviceLoadedMsg('logger')); + + return log; +} diff --git a/packages/kbn-scout/src/common/services/saml_auth.ts b/packages/kbn-scout/src/common/services/saml_auth.ts new file mode 100644 index 0000000000000..e3dbd47fc8c90 --- /dev/null +++ b/packages/kbn-scout/src/common/services/saml_auth.ts @@ -0,0 +1,71 @@ +/* + * 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". + */ + +import path from 'path'; +import { URL } from 'url'; +import { + SERVERLESS_ROLES_ROOT_PATH, + STATEFUL_ROLES_ROOT_PATH, + readRolesDescriptorsFromResource, +} from '@kbn/es'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { HostOptions, SamlSessionManager } from '@kbn/test'; +import { ToolingLog } from '@kbn/tooling-log'; +import { ScoutServerConfig } from '../../types'; +import { Protocol } from '../../playwright/types'; +import { serviceLoadedMsg } from '../../playwright/utils'; + +const getResourceDirPath = (config: ScoutServerConfig) => { + return config.serverless + ? path.resolve(SERVERLESS_ROLES_ROOT_PATH, config.projectType!) + : path.resolve(REPO_ROOT, STATEFUL_ROLES_ROOT_PATH); +}; + +const createKibanaHostOptions = (config: ScoutServerConfig): HostOptions => { + const kibanaUrl = new URL(config.hosts.kibana); + kibanaUrl.username = config.auth.username; + kibanaUrl.password = config.auth.password; + + return { + protocol: kibanaUrl.protocol.replace(':', '') as Protocol, + hostname: kibanaUrl.hostname, + port: Number(kibanaUrl.port), + username: kibanaUrl.username, + password: kibanaUrl.password, + }; +}; + +export const createSamlSessionManager = ( + config: ScoutServerConfig, + log: ToolingLog +): SamlSessionManager => { + const resourceDirPath = getResourceDirPath(config); + const rolesDefinitionPath = path.resolve(resourceDirPath, 'roles.yml'); + + const supportedRoleDescriptors = readRolesDescriptorsFromResource(rolesDefinitionPath) as Record< + string, + unknown + >; + const supportedRoles = Object.keys(supportedRoleDescriptors); + + const sessionManager = new SamlSessionManager({ + hostOptions: createKibanaHostOptions(config), + log, + isCloud: config.isCloud, + supportedRoles: { + roles: supportedRoles, + sourcePath: rolesDefinitionPath, + }, + cloudUsersFilePath: config.cloudUsersFilePath, + }); + + log.debug(serviceLoadedMsg('samlAuth')); + + return sessionManager; +}; diff --git a/packages/kbn-scout/src/common/utils/index.ts b/packages/kbn-scout/src/common/utils/index.ts new file mode 100644 index 0000000000000..0ab702b0cdfde --- /dev/null +++ b/packages/kbn-scout/src/common/utils/index.ts @@ -0,0 +1,20 @@ +/* + * 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". + */ + +import { ToolingLog } from '@kbn/tooling-log'; +import * as Rx from 'rxjs'; + +export async function silence(log: ToolingLog, milliseconds: number) { + await Rx.firstValueFrom( + log.getWritten$().pipe( + Rx.startWith(null), + Rx.switchMap(() => Rx.timer(milliseconds)) + ) + ); +} diff --git a/packages/kbn-scout/src/config/config.ts b/packages/kbn-scout/src/config/config.ts new file mode 100644 index 0000000000000..a316aac61d69e --- /dev/null +++ b/packages/kbn-scout/src/config/config.ts @@ -0,0 +1,138 @@ +/* + * 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". + */ + +import { Schema } from 'joi'; +import * as Url from 'url'; +import Path from 'path'; +import { cloneDeepWith, get, has, toPath } from 'lodash'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { schema } from './schema'; +import { ScoutServerConfig } from '../types'; +import { formatCurrentDate, getProjectType } from './utils'; + +const $values = Symbol('values'); + +export class Config { + private [$values]: Record; + + constructor(data: Record) { + const { error, value } = schema.validate(data, { + abortEarly: false, + }); + + if (error) { + throw error; + } + + this[$values] = value; + } + + public has(key: string | string[]) { + function recursiveHasCheck( + remainingPath: string[], + values: Record, + childSchema: any + ): boolean { + if (!childSchema.$_terms.keys && !childSchema.$_terms.patterns) { + return false; + } + + // normalize child and pattern checks so we can iterate the checks in a single loop + const checks: Array<{ test: (k: string) => boolean; schema: Schema }> = [ + // match children first, they have priority + ...(childSchema.$_terms.keys || []).map((child: { key: string; schema: Schema }) => ({ + test: (k: string) => child.key === k, + schema: child.schema, + })), + + // match patterns on any key that doesn't match an explicit child + ...(childSchema.$_terms.patterns || []).map((pattern: { regex: RegExp; rule: Schema }) => ({ + test: (k: string) => pattern.regex.test(k) && has(values, k), + schema: pattern.rule, + })), + ]; + + for (const check of checks) { + if (!check.test(remainingPath[0])) { + continue; + } + + if (remainingPath.length > 1) { + return recursiveHasCheck( + remainingPath.slice(1), + get(values, remainingPath[0]), + check.schema + ); + } + + return true; + } + + return false; + } + + const path = toPath(key); + if (!path.length) { + return true; + } + return recursiveHasCheck(path, this[$values], schema); + } + + public get(key: string | string[], defaultValue?: any) { + if (!this.has(key)) { + throw new Error(`Unknown config key "${key}"`); + } + + return cloneDeepWith(get(this[$values], key, defaultValue), (v) => { + if (typeof v === 'function') { + return v; + } + }); + } + + public getAll() { + return cloneDeepWith(this[$values], (v) => { + if (typeof v === 'function') { + return v; + } + }); + } + + public getTestServersConfig(): ScoutServerConfig { + return { + serverless: this.get('serverless'), + projectType: this.get('serverless') + ? getProjectType(this.get('kbnTestServer.serverArgs')) + : undefined, + isCloud: false, + cloudUsersFilePath: Path.resolve(REPO_ROOT, '.ftr', 'role_users.json'), + hosts: { + kibana: Url.format({ + protocol: this.get('servers.kibana.protocol'), + hostname: this.get('servers.kibana.hostname'), + port: this.get('servers.kibana.port'), + }), + elasticsearch: Url.format({ + protocol: this.get('servers.elasticsearch.protocol'), + hostname: this.get('servers.elasticsearch.hostname'), + port: this.get('servers.elasticsearch.port'), + }), + }, + auth: { + username: this.get('servers.kibana.username'), + password: this.get('servers.kibana.password'), + }, + + metadata: { + generatedOn: formatCurrentDate(), + config: this.getAll(), + }, + }; + } +} diff --git a/packages/kbn-scout/src/config/constants.ts b/packages/kbn-scout/src/config/constants.ts new file mode 100644 index 0000000000000..c1593f23b35ee --- /dev/null +++ b/packages/kbn-scout/src/config/constants.ts @@ -0,0 +1,22 @@ +/* + * 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". + */ + +import { resolve } from 'path'; +import { REPO_ROOT } from '@kbn/repo-info'; + +const SECURITY_TEST_PATH = resolve(REPO_ROOT, 'x-pack/test/security_api_integration'); + +export const SAML_IDP_PLUGIN_PATH = resolve(SECURITY_TEST_PATH, 'plugins/saml_provider'); + +export const STATEFUL_IDP_METADATA_PATH = resolve( + SECURITY_TEST_PATH, + 'packages/helpers/saml/idp_metadata_mock_idp.xml' +); +export const SERVERLESS_IDP_METADATA_PATH = resolve(SAML_IDP_PLUGIN_PATH, 'metadata.xml'); +export const JWKS_PATH = resolve(SECURITY_TEST_PATH, 'packages/helpers/oidc/jwks.json'); diff --git a/packages/kbn-scout/src/config/get_config_file.ts b/packages/kbn-scout/src/config/get_config_file.ts new file mode 100644 index 0000000000000..5976db1265797 --- /dev/null +++ b/packages/kbn-scout/src/config/get_config_file.ts @@ -0,0 +1,26 @@ +/* + * 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". + */ + +import path from 'path'; +import { CliSupportedServerModes } from '../types'; + +export const getConfigFilePath = (config: CliSupportedServerModes): string => { + if (config === 'stateful') { + return path.join(__dirname, 'stateful', 'stateful.config.ts'); + } + + const [mode, type] = config.split('='); + if (mode !== 'serverless' || !type) { + throw new Error( + `Invalid config format: ${config}. Expected "stateful" or "serverless=".` + ); + } + + return path.join(__dirname, 'serverless', `${type}.serverless.config.ts`); +}; diff --git a/packages/kbn-scout/src/config/index.ts b/packages/kbn-scout/src/config/index.ts new file mode 100644 index 0000000000000..969edbe8e4483 --- /dev/null +++ b/packages/kbn-scout/src/config/index.ts @@ -0,0 +1,13 @@ +/* + * 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". + */ + +export { loadConfig } from './loader/config_load'; +export { getConfigFilePath } from './get_config_file'; +export { loadServersConfig } from './utils'; +export type { Config } from './config'; diff --git a/packages/kbn-scout/src/config/loader/config_load.ts b/packages/kbn-scout/src/config/loader/config_load.ts new file mode 100644 index 0000000000000..5ef4b88b4cf1a --- /dev/null +++ b/packages/kbn-scout/src/config/loader/config_load.ts @@ -0,0 +1,27 @@ +/* + * 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". + */ + +import path from 'path'; +import { ToolingLog } from '@kbn/tooling-log'; +import { Config } from '../config'; + +export const loadConfig = async (configPath: string, log: ToolingLog): Promise => { + try { + const absolutePath = path.resolve(configPath); + const configModule = await import(absolutePath); + + if (configModule.servers) { + return new Config(configModule.servers); + } else { + throw new Error(`No 'servers' found in the config file at path: ${absolutePath}`); + } + } catch (error) { + throw new Error(`Failed to load config from ${configPath}: ${error.message}`); + } +}; diff --git a/packages/kbn-scout/src/config/schema/index.ts b/packages/kbn-scout/src/config/schema/index.ts new file mode 100644 index 0000000000000..7fa3cbc65f29f --- /dev/null +++ b/packages/kbn-scout/src/config/schema/index.ts @@ -0,0 +1,10 @@ +/* + * 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". + */ + +export { schema } from './schema'; diff --git a/packages/kbn-scout/src/config/schema/schema.ts b/packages/kbn-scout/src/config/schema/schema.ts new file mode 100644 index 0000000000000..86add154cc661 --- /dev/null +++ b/packages/kbn-scout/src/config/schema/schema.ts @@ -0,0 +1,139 @@ +/* + * 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". + */ + +import Joi from 'joi'; + +const maybeRequireKeys = (keys: string[], schemas: Record) => { + if (!keys.length) { + return schemas; + } + + const withRequires: Record = {}; + for (const [key, schema] of Object.entries(schemas)) { + withRequires[key] = keys.includes(key) ? schema.required() : schema; + } + return withRequires; +}; + +const urlPartsSchema = ({ requiredKeys }: { requiredKeys?: string[] } = {}) => + Joi.object() + .keys( + maybeRequireKeys(requiredKeys ?? [], { + protocol: Joi.string().valid('http', 'https').default('http'), + hostname: Joi.string().hostname().default('localhost'), + port: Joi.number(), + auth: Joi.string().regex(/^[^:]+:.+$/, 'username and password separated by a colon'), + username: Joi.string(), + password: Joi.string(), + pathname: Joi.string().regex(/^\//, 'start with a /'), + hash: Joi.string().regex(/^\//, 'start with a /'), + certificateAuthorities: Joi.array().items(Joi.binary()).optional(), + }) + ) + .default(); + +const requiredWhenEnabled = (schema: Joi.Schema) => { + return Joi.when('enabled', { + is: true, + then: schema.required(), + otherwise: schema.optional(), + }); +}; + +const dockerServerSchema = () => + Joi.object() + .keys({ + enabled: Joi.boolean().required(), + image: requiredWhenEnabled(Joi.string()), + port: requiredWhenEnabled(Joi.number()), + portInContainer: requiredWhenEnabled(Joi.number()), + waitForLogLine: Joi.alternatives(Joi.object().instance(RegExp), Joi.string()).optional(), + waitForLogLineTimeoutMs: Joi.number().integer().optional(), + waitFor: Joi.func().optional(), + args: Joi.array().items(Joi.string()).optional(), + }) + .default(); + +export const schema = Joi.object() + .keys({ + serverless: Joi.boolean().default(false), + servers: Joi.object() + .keys({ + kibana: urlPartsSchema(), + elasticsearch: urlPartsSchema({ + requiredKeys: ['port'], + }), + fleetserver: urlPartsSchema(), + }) + .default(), + + esTestCluster: Joi.object() + .keys({ + license: Joi.valid('basic', 'trial', 'gold').default('basic'), + from: Joi.string().default('snapshot'), + serverArgs: Joi.array().items(Joi.string()).default([]), + esJavaOpts: Joi.string(), + dataArchive: Joi.string(), + ssl: Joi.boolean().default(false), + ccs: Joi.object().keys({ + remoteClusterUrl: Joi.string().uri({ + scheme: /https?/, + }), + }), + files: Joi.array().items(Joi.string()), + }) + .default(), + + esServerlessOptions: Joi.object() + .keys({ + host: Joi.string().ip(), + resources: Joi.array().items(Joi.string()).default([]), + }) + .default(), + + kbnTestServer: Joi.object() + .keys({ + buildArgs: Joi.array(), + sourceArgs: Joi.array(), + serverArgs: Joi.array(), + installDir: Joi.string(), + useDedicatedTaskRunner: Joi.boolean().default(false), + /** Options for how FTR should execute and interact with Kibana */ + runOptions: Joi.object() + .keys({ + /** + * Log message to wait for before initiating tests, defaults to waiting for Kibana status to be `available`. + * Note that this log message must not be filtered out by the current logging config, for example by the + * log level. If needed, you can adjust the logging level via `kbnTestServer.serverArgs`. + */ + wait: Joi.object() + .regex() + .default(/Kibana is now available/), + + /** + * Does this test config only work when run against source? + */ + alwaysUseSource: Joi.boolean().default(false), + }) + .default(), + env: Joi.object().unknown().default(), + delayShutdown: Joi.number(), + }) + .default(), + + // settings for the kibanaServer.uiSettings module + uiSettings: Joi.object() + .keys({ + defaults: Joi.object().unknown(true), + }) + .default(), + + dockerServers: Joi.object().pattern(Joi.string(), dockerServerSchema()).default(), + }) + .default(); diff --git a/packages/kbn-scout/src/config/serverless/es.serverless.config.ts b/packages/kbn-scout/src/config/serverless/es.serverless.config.ts new file mode 100644 index 0000000000000..89e27b4e877e0 --- /dev/null +++ b/packages/kbn-scout/src/config/serverless/es.serverless.config.ts @@ -0,0 +1,26 @@ +/* + * 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". + */ + +import { ScoutLoaderConfig } from '../../types'; +import { defaultConfig } from './serverless.base.config'; + +export const servers: ScoutLoaderConfig = { + ...defaultConfig, + esTestCluster: { + ...defaultConfig.esTestCluster, + serverArgs: [...defaultConfig.esTestCluster.serverArgs], + }, + kbnTestServer: { + serverArgs: [ + ...defaultConfig.kbnTestServer.serverArgs, + '--serverless=es', + '--coreApp.allowDynamicConfigOverrides=true', + ], + }, +}; diff --git a/packages/kbn-scout/src/config/serverless/oblt.serverless.config.ts b/packages/kbn-scout/src/config/serverless/oblt.serverless.config.ts new file mode 100644 index 0000000000000..3f283f140479e --- /dev/null +++ b/packages/kbn-scout/src/config/serverless/oblt.serverless.config.ts @@ -0,0 +1,32 @@ +/* + * 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". + */ + +import { defaultConfig } from './serverless.base.config'; +import { ScoutLoaderConfig } from '../../types'; + +export const servers: ScoutLoaderConfig = { + ...defaultConfig, + esTestCluster: { + ...defaultConfig.esTestCluster, + serverArgs: [ + ...defaultConfig.esTestCluster.serverArgs, + 'xpack.apm_data.enabled=true', + // for ML, data frame analytics are not part of this project type + 'xpack.ml.dfa.enabled=false', + ], + }, + kbnTestServer: { + serverArgs: [ + ...defaultConfig.kbnTestServer.serverArgs, + '--serverless=oblt', + '--coreApp.allowDynamicConfigOverrides=true', + '--xpack.uptime.service.manifestUrl=mockDevUrl', + ], + }, +}; diff --git a/packages/kbn-scout/src/config/serverless/resources/package_registry_config.yml b/packages/kbn-scout/src/config/serverless/resources/package_registry_config.yml new file mode 100644 index 0000000000000..1885fa5c2ebe5 --- /dev/null +++ b/packages/kbn-scout/src/config/serverless/resources/package_registry_config.yml @@ -0,0 +1,2 @@ +package_paths: + - /packages/package-storage diff --git a/packages/kbn-scout/src/config/serverless/security.serverless.config.ts b/packages/kbn-scout/src/config/serverless/security.serverless.config.ts new file mode 100644 index 0000000000000..f1fa4f53f8988 --- /dev/null +++ b/packages/kbn-scout/src/config/serverless/security.serverless.config.ts @@ -0,0 +1,30 @@ +/* + * 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". + */ + +import { ScoutLoaderConfig } from '../../types'; +import { defaultConfig } from './serverless.base.config'; + +export const servers: ScoutLoaderConfig = { + ...defaultConfig, + esTestCluster: { + ...defaultConfig.esTestCluster, + serverArgs: [ + ...defaultConfig.esTestCluster.serverArgs, + 'xpack.security.authc.api_key.cache.max_keys=70000', + ], + }, + kbnTestServer: { + serverArgs: [ + ...defaultConfig.kbnTestServer.serverArgs, + '--serverless=security', + '--coreApp.allowDynamicConfigOverrides=true', + `--xpack.task_manager.unsafe.exclude_task_types=${JSON.stringify(['Fleet-Metrics-Task'])}`, + ], + }, +}; diff --git a/packages/kbn-scout/src/config/serverless/serverless.base.config.ts b/packages/kbn-scout/src/config/serverless/serverless.base.config.ts new file mode 100644 index 0000000000000..8b4852f9c9e62 --- /dev/null +++ b/packages/kbn-scout/src/config/serverless/serverless.base.config.ts @@ -0,0 +1,157 @@ +/* + * 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". + */ + +import { resolve, join } from 'path'; +import { format as formatUrl } from 'url'; +import Fs from 'fs'; + +import { CA_CERT_PATH, kibanaDevServiceAccount } from '@kbn/dev-utils'; +import { defineDockerServersConfig, getDockerFileMountPath } from '@kbn/test'; +import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils'; + +import { dockerImage } from '@kbn/test-suites-xpack/fleet_api_integration/config.base'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { ScoutLoaderConfig } from '../../types'; +import { SAML_IDP_PLUGIN_PATH, SERVERLESS_IDP_METADATA_PATH, JWKS_PATH } from '../constants'; + +const packageRegistryConfig = join(__dirname, './package_registry_config.yml'); +const dockerArgs: string[] = ['-v', `${packageRegistryConfig}:/package-registry/config.yml`]; + +/** + * This is used by CI to set the docker registry port + * you can also define this environment variable locally when running tests which + * will spin up a local docker package registry locally for you + * if this is defined it takes precedence over the `packageRegistryOverride` variable + */ +const dockerRegistryPort: string | undefined = process.env.FLEET_PACKAGE_REGISTRY_PORT; + +const servers = { + elasticsearch: { + protocol: 'https', + hostname: 'localhost', + port: 9220, + username: 'elastic_serverless', + password: 'changeme', + certificateAuthorities: [Fs.readFileSync(CA_CERT_PATH)], + }, + kibana: { + protocol: 'http', + hostname: 'localhost', + port: 5620, + username: 'elastic_serverless', + password: 'changeme', + }, +}; + +export const defaultConfig: ScoutLoaderConfig = { + serverless: true, + servers, + dockerServers: defineDockerServersConfig({ + registry: { + enabled: !!dockerRegistryPort, + image: dockerImage, + portInContainer: 8080, + port: dockerRegistryPort, + args: dockerArgs, + waitForLogLine: 'package manifests loaded', + waitForLogLineTimeoutMs: 60 * 2 * 1000, // 2 minutes + }, + }), + esTestCluster: { + from: 'serverless', + files: [SERVERLESS_IDP_METADATA_PATH, JWKS_PATH], + serverArgs: [ + 'xpack.security.authc.realms.file.file1.order=-100', + `xpack.security.authc.realms.native.native1.enabled=false`, + `xpack.security.authc.realms.native.native1.order=-97`, + + 'xpack.security.authc.realms.jwt.jwt1.allowed_audiences=elasticsearch', + `xpack.security.authc.realms.jwt.jwt1.allowed_issuer=https://kibana.elastic.co/jwt/`, + `xpack.security.authc.realms.jwt.jwt1.allowed_signature_algorithms=[RS256]`, + `xpack.security.authc.realms.jwt.jwt1.allowed_subjects=elastic-agent`, + `xpack.security.authc.realms.jwt.jwt1.claims.principal=sub`, + 'xpack.security.authc.realms.jwt.jwt1.client_authentication.type=shared_secret', + 'xpack.security.authc.realms.jwt.jwt1.order=-98', + `xpack.security.authc.realms.jwt.jwt1.pkc_jwkset_path=${getDockerFileMountPath(JWKS_PATH)}`, + `xpack.security.authc.realms.jwt.jwt1.token_type=access_token`, + ], + ssl: true, // SSL is required for SAML realm + }, + kbnTestServer: { + buildArgs: [], + env: { + KBN_PATH_CONF: resolve(REPO_ROOT, 'config'), + }, + sourceArgs: ['--no-base-path', '--env.name=development'], + serverArgs: [ + `--server.restrictInternalApis=true`, + `--server.port=${servers.kibana.port}`, + '--status.allowAnonymous=true', + `--migrations.zdt.runOnRoles=${JSON.stringify(['ui'])}`, + // We shouldn't embed credentials into the URL since Kibana requests to Elasticsearch should + // either include `kibanaServerTestUser` credentials, or credentials provided by the test + // user, or none at all in case anonymous access is used. + `--elasticsearch.hosts=${formatUrl( + Object.fromEntries( + Object.entries(servers.elasticsearch).filter(([key]) => key.toLowerCase() !== 'auth') + ) + )}`, + `--elasticsearch.serviceAccountToken=${kibanaDevServiceAccount.token}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + '--telemetry.sendUsageTo=staging', + `--logging.appenders.deprecation=${JSON.stringify({ + type: 'console', + layout: { + type: 'json', + }, + })}`, + `--logging.loggers=${JSON.stringify([ + { + name: 'elasticsearch.deprecation', + level: 'all', + appenders: ['deprecation'], + }, + ])}`, + // Add meta info to the logs so FTR logs are more actionable + `--logging.appenders.default=${JSON.stringify({ + type: 'console', + layout: { + type: 'pattern', + pattern: '[%date][%level][%logger] %message %meta', + }, + })}`, + `--logging.appenders.console=${JSON.stringify({ + type: 'console', + layout: { + type: 'pattern', + pattern: '[%date][%level][%logger] %message %meta', + }, + })}`, + // This ensures that we register the Security SAML API endpoints. + // In the real world the SAML config is injected by control plane. + `--plugin-path=${SAML_IDP_PLUGIN_PATH}`, + '--xpack.cloud.id=ftr_fake_cloud_id', + // Ensure that SAML is used as the default authentication method whenever a user navigates to Kibana. In other + // words, Kibana should attempt to authenticate the user using the provider with the lowest order if the Login + // Selector is disabled (which is how Serverless Kibana is configured). By declaring `cloud-basic` with a higher + // order, we indicate that basic authentication can still be used, but only if explicitly requested when the + // user navigates to `/login` page directly and enters username and password in the login form. + '--xpack.security.authc.selector.enabled=false', + `--xpack.security.authc.providers=${JSON.stringify({ + saml: { 'cloud-saml-kibana': { order: 0, realm: MOCK_IDP_REALM_NAME } }, + basic: { 'cloud-basic': { order: 1 } }, + })}`, + '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', + `--server.publicBaseUrl=${servers.kibana.protocol}://${servers.kibana.hostname}:${servers.kibana.port}`, + // configure security reponse header report-to settings to mimic MKI configuration + `--csp.report_to=${JSON.stringify(['violations-endpoint'])}`, + `--permissionsPolicy.report_to=${JSON.stringify(['violations-endpoint'])}`, + ], + }, +}; diff --git a/packages/kbn-scout/src/config/stateful/base.config.ts b/packages/kbn-scout/src/config/stateful/base.config.ts new file mode 100644 index 0000000000000..a2d6f1e0fa6eb --- /dev/null +++ b/packages/kbn-scout/src/config/stateful/base.config.ts @@ -0,0 +1,204 @@ +/* + * 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". + */ + +import { join } from 'path'; +import { format as formatUrl } from 'url'; + +import { + MOCK_IDP_ENTITY_ID, + MOCK_IDP_ATTRIBUTE_PRINCIPAL, + MOCK_IDP_ATTRIBUTE_ROLES, + MOCK_IDP_ATTRIBUTE_EMAIL, + MOCK_IDP_ATTRIBUTE_NAME, +} from '@kbn/mock-idp-utils'; +import { defineDockerServersConfig } from '@kbn/test'; +import path from 'path'; + +import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils'; + +import { dockerImage } from '@kbn/test-suites-xpack/fleet_api_integration/config.base'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { STATEFUL_ROLES_ROOT_PATH } from '@kbn/es'; +import type { ScoutLoaderConfig } from '../../types'; +import { SAML_IDP_PLUGIN_PATH, STATEFUL_IDP_METADATA_PATH } from '../constants'; + +const packageRegistryConfig = join(__dirname, './package_registry_config.yml'); +const dockerArgs: string[] = ['-v', `${packageRegistryConfig}:/package-registry/config.yml`]; + +/** + * This is used by CI to set the docker registry port + * you can also define this environment variable locally when running tests which + * will spin up a local docker package registry locally for you + * if this is defined it takes precedence over the `packageRegistryOverride` variable + */ +const dockerRegistryPort: string | undefined = process.env.FLEET_PACKAGE_REGISTRY_PORT; + +// if config is executed on CI or locally +const isRunOnCI = process.env.CI; + +const servers = { + elasticsearch: { + protocol: 'http', + hostname: 'localhost', + port: 9220, + username: 'kibana_system', + password: 'changeme', + }, + kibana: { + protocol: 'http', + hostname: 'localhost', + port: 5620, + username: 'elastic', + password: 'changeme', + }, +}; + +const kbnUrl = `${servers.kibana.protocol}://${servers.kibana.hostname}:${servers.kibana.port}`; + +export const defaultConfig: ScoutLoaderConfig = { + servers, + dockerServers: defineDockerServersConfig({ + registry: { + enabled: !!dockerRegistryPort, + image: dockerImage, + portInContainer: 8080, + port: dockerRegistryPort, + args: dockerArgs, + waitForLogLine: 'package manifests loaded', + waitForLogLineTimeoutMs: 60 * 2 * 1000, // 2 minutes + }, + }), + esTestCluster: { + from: 'snapshot', + license: 'trial', + files: [ + // Passing the roles that are equivalent to the ones we have in serverless + path.resolve(REPO_ROOT, STATEFUL_ROLES_ROOT_PATH, 'roles.yml'), + ], + serverArgs: [ + 'path.repo=/tmp/', + 'path.repo=/tmp/repo,/tmp/repo_1,/tmp/repo_2,/tmp/cloud-snapshots/', + 'node.attr.name=apiIntegrationTestNode', + 'xpack.security.authc.api_key.enabled=true', + 'xpack.security.authc.token.enabled=true', + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.order=0`, + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.idp.metadata.path=${STATEFUL_IDP_METADATA_PATH}`, + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.idp.entity_id=${MOCK_IDP_ENTITY_ID}`, + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.sp.entity_id=${kbnUrl}`, + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.sp.acs=${kbnUrl}/api/security/saml/callback`, + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.sp.logout=${kbnUrl}/logout`, + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.principal=${MOCK_IDP_ATTRIBUTE_PRINCIPAL}`, + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.groups=${MOCK_IDP_ATTRIBUTE_ROLES}`, + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.name=${MOCK_IDP_ATTRIBUTE_NAME}`, + `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.mail=${MOCK_IDP_ATTRIBUTE_EMAIL}`, + ], + ssl: false, + }, + kbnTestServer: { + buildArgs: [], + env: {}, + sourceArgs: ['--no-base-path', '--env.name=development'], + serverArgs: [ + `--server.port=${servers.kibana.port}`, + '--status.allowAnonymous=true', + // We shouldn't embed credentials into the URL since Kibana requests to Elasticsearch should + // either include `kibanaServerTestUser` credentials, or credentials provided by the test + // user, or none at all in case anonymous access is used. + `--elasticsearch.hosts=${formatUrl( + Object.fromEntries( + Object.entries(servers.elasticsearch).filter(([key]) => key.toLowerCase() !== 'auth') + ) + )}`, + `--elasticsearch.username=${servers.elasticsearch.username}`, + `--elasticsearch.password=${servers.elasticsearch.password}`, + // Needed for async search functional tests to introduce a delay + `--data.search.aggs.shardDelay.enabled=true`, + `--data.query.timefilter.minRefreshInterval=1000`, + `--security.showInsecureClusterWarning=false`, + '--telemetry.banner=false', + '--telemetry.optIn=false', + // These are *very* important to have them pointing to staging + '--telemetry.sendUsageTo=staging', + `--server.maxPayload=1679958`, + // newsfeed mock service + `--plugin-path=${path.join(REPO_ROOT, 'test', 'common', 'plugins', 'newsfeed')}`, + // otel mock service + `--plugin-path=${path.join(REPO_ROOT, 'test', 'common', 'plugins', 'otel_metrics')}`, + `--newsfeed.service.urlRoot=${kbnUrl}`, + `--newsfeed.service.pathTemplate=/api/_newsfeed-FTS-external-service-simulators/kibana/v{VERSION}.json`, + `--logging.appenders.deprecation=${JSON.stringify({ + type: 'console', + layout: { + type: 'json', + }, + })}`, + `--logging.loggers=${JSON.stringify([ + { + name: 'elasticsearch.deprecation', + level: 'all', + appenders: ['deprecation'], + }, + ])}`, + // Add meta info to the logs so FTR logs are more actionable + `--logging.appenders.default=${JSON.stringify({ + type: 'console', + layout: { + type: 'pattern', + pattern: '[%date][%level][%logger] %message %meta', + }, + })}`, + `--logging.appenders.console=${JSON.stringify({ + type: 'console', + layout: { + type: 'pattern', + pattern: '[%date][%level][%logger] %message %meta', + }, + })}`, + // x-pack/test/functional/config.base.js + '--status.allowAnonymous=true', + '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', + '--xpack.maps.showMapsInspectorAdapter=true', + '--xpack.maps.preserveDrawingBuffer=true', + '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions + '--xpack.encryptedSavedObjects.encryptionKey="DkdXazszSCYexXqz4YktBGHCRkV6hyNK"', + '--xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled=true', + '--savedObjects.maxImportPayloadBytes=10485760', // for OSS test management/_import_objects, + '--savedObjects.allowHttpApiAccess=false', // override default to not allow hiddenFromHttpApis saved objects access to the http APIs see https://github.com/elastic/dev/issues/2200 + // explicitly disable internal API restriction. See https://github.com/elastic/kibana/issues/163654 + '--server.restrictInternalApis=false', + // disable fleet task that writes to metrics.fleet_server.* data streams, impacting functional tests + `--xpack.task_manager.unsafe.exclude_task_types=${JSON.stringify(['Fleet-Metrics-Task'])}`, + // x-pack/test/api_integration/config.ts + '--xpack.security.session.idleTimeout=3600000', // 1 hour + '--telemetry.optIn=true', + '--xpack.fleet.agents.pollingRequestTimeout=5000', // 5 seconds + '--xpack.ruleRegistry.write.enabled=true', + '--xpack.ruleRegistry.write.enabled=true', + '--xpack.ruleRegistry.write.cache.enabled=false', + '--monitoring_collection.opentelemetry.metrics.prometheus.enabled=true', + // SAML configuration + ...(isRunOnCI ? [] : ['--mock_idp_plugin.enabled=true']), + // This ensures that we register the Security SAML API endpoints. + // In the real world the SAML config is injected by control plane. + `--plugin-path=${SAML_IDP_PLUGIN_PATH}`, + '--xpack.cloud.id=ftr_fake_cloud_id', + // Ensure that SAML is used as the default authentication method whenever a user navigates to Kibana. In other + // words, Kibana should attempt to authenticate the user using the provider with the lowest order if the Login + // Selector is disabled (replicating Serverless configuration). By declaring `cloud-basic` with a higher + // order, we indicate that basic authentication can still be used, but only if explicitly requested when the + // user navigates to `/login` page directly and enters username and password in the login form. + '--xpack.security.authc.selector.enabled=false', + `--xpack.security.authc.providers=${JSON.stringify({ + saml: { 'cloud-saml-kibana': { order: 0, realm: MOCK_IDP_REALM_NAME } }, + basic: { 'cloud-basic': { order: 1 } }, + })}`, + `--server.publicBaseUrl=${kbnUrl}`, + ], + }, +}; diff --git a/packages/kbn-scout/src/config/stateful/stateful.config.ts b/packages/kbn-scout/src/config/stateful/stateful.config.ts new file mode 100644 index 0000000000000..e67419c21fb37 --- /dev/null +++ b/packages/kbn-scout/src/config/stateful/stateful.config.ts @@ -0,0 +1,13 @@ +/* + * 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". + */ + +import { ScoutLoaderConfig } from '../../types'; +import { defaultConfig } from './base.config'; + +export const servers: ScoutLoaderConfig = defaultConfig; diff --git a/packages/kbn-scout/src/config/utils.ts b/packages/kbn-scout/src/config/utils.ts new file mode 100644 index 0000000000000..61bdc1b7b81ac --- /dev/null +++ b/packages/kbn-scout/src/config/utils.ts @@ -0,0 +1,68 @@ +/* + * 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". + */ + +import * as Fs from 'fs'; +import getopts from 'getopts'; +import path from 'path'; +import { ToolingLog } from '@kbn/tooling-log'; +import { ServerlessProjectType } from '@kbn/es'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { CliSupportedServerModes, ScoutServerConfig } from '../types'; +import { getConfigFilePath } from './get_config_file'; +import { loadConfig } from './loader/config_load'; + +export const formatCurrentDate = () => { + const now = new Date(); + + const format = (num: number, length: number) => String(num).padStart(length, '0'); + + return ( + `${format(now.getDate(), 2)}/${format(now.getMonth() + 1, 2)}/${now.getFullYear()} ` + + `${format(now.getHours(), 2)}:${format(now.getMinutes(), 2)}:${format(now.getSeconds(), 2)}.` + + `${format(now.getMilliseconds(), 3)}` + ); +}; + +const saveTestServersConfigOnDisk = (testServersConfig: ScoutServerConfig, log: ToolingLog) => { + const configDirPath = path.resolve(REPO_ROOT, '.scout', 'servers'); + const configFilePath = path.join(configDirPath, `local.json`); + + try { + const jsonData = JSON.stringify(testServersConfig, null, 2); + + if (!Fs.existsSync(configDirPath)) { + log.debug(`scout: creating configuration directory: ${configDirPath}`); + Fs.mkdirSync(configDirPath, { recursive: true }); + } + + Fs.writeFileSync(configFilePath, jsonData, 'utf-8'); + log.info(`scout: Test server configuration saved at ${configFilePath}`); + } catch (error) { + log.error(`scout: Failed to save test server configuration - ${error.message}`); + throw new Error(`Failed to save test server configuration at ${configFilePath}`); + } +}; + +export async function loadServersConfig(mode: CliSupportedServerModes, log: ToolingLog) { + // get path to one of the predefined config files + const configPath = getConfigFilePath(mode); + // load config that is compatible with kbn-test input format + const config = await loadConfig(configPath, log); + // construct config for Playwright Test + const scoutServerConfig = config.getTestServersConfig(); + // save test config to the file + saveTestServersConfigOnDisk(scoutServerConfig, log); + + return config; +} + +export const getProjectType = (kbnServerArgs: string[]) => { + const options = getopts(kbnServerArgs); + return options.serverless as ServerlessProjectType; +}; diff --git a/packages/kbn-scout/src/playwright/config/index.ts b/packages/kbn-scout/src/playwright/config/index.ts new file mode 100644 index 0000000000000..62f5261c08e25 --- /dev/null +++ b/packages/kbn-scout/src/playwright/config/index.ts @@ -0,0 +1,75 @@ +/* + * 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". + */ + +import { defineConfig, PlaywrightTestConfig, devices } from '@playwright/test'; +import * as Path from 'path'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { ScoutPlaywrightOptions, ScoutTestOptions, VALID_CONFIG_MARKER } from '../types'; + +export function createPlaywrightConfig(options: ScoutPlaywrightOptions): PlaywrightTestConfig { + return defineConfig({ + testDir: options.testDir, + /* Run tests in files in parallel */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: options.workers ?? 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['html', { outputFolder: './output/reports', open: 'never' }], // HTML report configuration + ['json', { outputFile: './output/reports/test-results.json' }], // JSON report + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + serversConfigDir: Path.resolve(REPO_ROOT, '.scout', 'servers'), + [VALID_CONFIG_MARKER]: true, + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + screenshot: 'only-on-failure', + // video: 'retain-on-failure', + // storageState: './output/reports/state.json', // Store session state (like cookies) + }, + + // Timeout for each test, includes test, hooks and fixtures + timeout: 60000, + + // Timeout for each assertion + expect: { + timeout: 10000, + }, + + outputDir: './output/test-artifacts', // For other test artifacts (screenshots, videos, traces) + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, + }); +} diff --git a/packages/kbn-scout/src/playwright/expect.ts b/packages/kbn-scout/src/playwright/expect.ts new file mode 100644 index 0000000000000..a75e30adf2631 --- /dev/null +++ b/packages/kbn-scout/src/playwright/expect.ts @@ -0,0 +1,13 @@ +/* + * 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". + */ + +import { test } from '@playwright/test'; + +// Export `expect` to avoid importing from Playwright directly +export const expect = test.expect; diff --git a/packages/kbn-scout/src/playwright/fixtures/index.ts b/packages/kbn-scout/src/playwright/fixtures/index.ts new file mode 100644 index 0000000000000..348b581005994 --- /dev/null +++ b/packages/kbn-scout/src/playwright/fixtures/index.ts @@ -0,0 +1,25 @@ +/* + * 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". + */ + +import { mergeTests } from '@playwright/test'; + +import { scoutWorkerFixtures } from './worker'; +import { scoutTestFixtures } from './test'; + +export const scoutCoreFixtures = mergeTests(scoutWorkerFixtures, scoutTestFixtures); + +export type { + ScoutTestFixtures, + ScoutWorkerFixtures, + ScoutPage, + Client, + KbnClient, + KibanaUrl, + ToolingLog, +} from './types'; diff --git a/packages/kbn-scout/src/playwright/fixtures/test/browser_auth.ts b/packages/kbn-scout/src/playwright/fixtures/test/browser_auth.ts new file mode 100644 index 0000000000000..5faa1b5392d96 --- /dev/null +++ b/packages/kbn-scout/src/playwright/fixtures/test/browser_auth.ts @@ -0,0 +1,48 @@ +/* + * 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". + */ + +import { test as base } from '@playwright/test'; +import { PROJECT_DEFAULT_ROLES } from '../../../common'; +import { LoginFixture, ScoutWorkerFixtures } from '../types'; +import { serviceLoadedMsg } from '../../utils'; + +type LoginFunction = (role: string) => Promise; + +export const browserAuthFixture = base.extend<{ browserAuth: LoginFixture }, ScoutWorkerFixtures>({ + browserAuth: async ({ log, context, samlAuth, config }, use) => { + const setSessionCookie = async (cookieValue: string) => { + await context.clearCookies(); + await context.addCookies([ + { + name: 'sid', + value: cookieValue, + path: '/', + domain: 'localhost', + }, + ]); + }; + + const loginAs: LoginFunction = async (role) => { + const cookie = await samlAuth.getInteractiveUserSessionCookieWithRoleScope(role); + await setSessionCookie(cookie); + }; + + const loginAsAdmin = () => loginAs('admin'); + const loginAsViewer = () => loginAs('viewer'); + const loginAsPrivilegedUser = () => { + const roleName = config.serverless + ? PROJECT_DEFAULT_ROLES.get(config.projectType!)! + : 'editor'; + return loginAs(roleName); + }; + + log.debug(serviceLoadedMsg('browserAuth')); + await use({ loginAsAdmin, loginAsViewer, loginAsPrivilegedUser }); + }, +}); diff --git a/packages/kbn-scout/src/playwright/fixtures/test/index.ts b/packages/kbn-scout/src/playwright/fixtures/test/index.ts new file mode 100644 index 0000000000000..41bfedcf39dc7 --- /dev/null +++ b/packages/kbn-scout/src/playwright/fixtures/test/index.ts @@ -0,0 +1,19 @@ +/* + * 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". + */ + +import { mergeTests } from '@playwright/test'; +import { browserAuthFixture } from './browser_auth'; +import { scoutPageFixture } from './page'; +import { pageObjectsFixture } from './page_objects'; + +export const scoutTestFixtures = mergeTests( + browserAuthFixture, + scoutPageFixture, + pageObjectsFixture +); diff --git a/packages/kbn-scout/src/playwright/fixtures/test/page.ts b/packages/kbn-scout/src/playwright/fixtures/test/page.ts new file mode 100644 index 0000000000000..ffc309d37cbad --- /dev/null +++ b/packages/kbn-scout/src/playwright/fixtures/test/page.ts @@ -0,0 +1,83 @@ +/* + * 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". + */ + +import { Page, test as base } from '@playwright/test'; +import { subj } from '@kbn/test-subj-selector'; +import { ScoutPage, KibanaUrl } from '../types'; + +/** + * Instead of defining each method individually, we use a list of method names and loop through them, creating methods dynamically. + * All methods must have 'selector: string' as the first argument + */ +function extendPageWithTestSubject(page: Page) { + const methods: Array = [ + 'check', + 'click', + 'dblclick', + 'fill', + 'focus', + 'getAttribute', + 'hover', + 'isEnabled', + 'innerText', + 'isChecked', + 'isHidden', + 'locator', + ]; + + const extendedMethods: Partial> = {}; + + for (const method of methods) { + extendedMethods[method] = (...args: any[]) => { + const selector = args[0]; + const testSubjSelector = subj(selector); + return (page[method] as Function)(testSubjSelector, ...args.slice(1)); + }; + } + + return extendedMethods as Record; +} + +/** + * Extends the 'page' fixture with Kibana-specific functionality + * + * 1. Allow calling methods with simplified 'data-test-subj' selectors. + * Instead of manually constructing 'data-test-subj' selectors, this extension provides a `testSubj` object on the page + * Supported methods include `click`, `check`, `fill`, and others that interact with `data-test-subj`. + * + * Example Usage: + * + * ```typescript + * // Without `testSubj` extension: + * await page.locator('[data-test-subj="foo"][data-test-subj="bar"]').click(); + * + * // With `testSubj` extension: + * await page.testSubj.click('foo & bar'); + * ``` + * + * 2. Navigate to Kibana apps by using 'kbnUrl' fixture + * + * Example Usage: + * + * ```typescript + * // Navigate to '/app/discover' + * await page.gotoApp('discover); + * ``` + */ +export const scoutPageFixture = base.extend<{ page: ScoutPage; kbnUrl: KibanaUrl }>({ + page: async ({ page, kbnUrl }, use) => { + // Extend page with '@kbn/test-subj-selector' support + page.testSubj = extendPageWithTestSubject(page); + + // Method to navigate to specific Kibana apps + page.gotoApp = (appName: string) => page.goto(kbnUrl.app(appName)); + + await use(page); + }, +}); diff --git a/packages/kbn-scout/src/playwright/fixtures/test/page_objects.ts b/packages/kbn-scout/src/playwright/fixtures/test/page_objects.ts new file mode 100644 index 0000000000000..ed142b48b3f9a --- /dev/null +++ b/packages/kbn-scout/src/playwright/fixtures/test/page_objects.ts @@ -0,0 +1,20 @@ +/* + * 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". + */ + +import { test as base } from '@playwright/test'; +import { ScoutTestFixtures, ScoutWorkerFixtures } from '../types'; +import { createCorePageObjects } from '../../page_objects'; + +export const pageObjectsFixture = base.extend({ + pageObjects: async ({ page }, use) => { + const corePageObjects = createCorePageObjects(page); + + await use(corePageObjects); + }, +}); diff --git a/packages/kbn-scout/src/playwright/fixtures/types/index.ts b/packages/kbn-scout/src/playwright/fixtures/types/index.ts new file mode 100644 index 0000000000000..4a23d4c5ce936 --- /dev/null +++ b/packages/kbn-scout/src/playwright/fixtures/types/index.ts @@ -0,0 +1,11 @@ +/* + * 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". + */ + +export * from './test_scope'; +export * from './worker_scope'; diff --git a/packages/kbn-scout/src/playwright/fixtures/types/test_scope.ts b/packages/kbn-scout/src/playwright/fixtures/types/test_scope.ts new file mode 100644 index 0000000000000..2808381f0f6be --- /dev/null +++ b/packages/kbn-scout/src/playwright/fixtures/types/test_scope.ts @@ -0,0 +1,67 @@ +/* + * 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". + */ + +import { Page } from 'playwright/test'; +import { PageObjects } from '../../page_objects'; + +export interface ScoutTestFixtures { + browserAuth: LoginFixture; + page: ScoutPage; + pageObjects: PageObjects; +} + +export interface LoginFixture { + loginAsViewer: () => Promise; + loginAsAdmin: () => Promise; + loginAsPrivilegedUser: () => Promise; +} + +export type ScoutPage = Page & { + gotoApp: (appName: string, options?: Parameters[1]) => ReturnType; + testSubj: { + check: (selector: string, options?: Parameters[1]) => ReturnType; + click: (selector: string, options?: Parameters[1]) => ReturnType; + dblclick: ( + selector: string, + options?: Parameters[1] + ) => ReturnType; + fill: ( + selector: string, + value: string, + options?: Parameters[2] + ) => ReturnType; + focus: (selector: string, options?: Parameters[1]) => ReturnType; + getAttribute: ( + selector: string, + name: string, + options?: Parameters[2] + ) => ReturnType; + hover: (selector: string, options?: Parameters[1]) => ReturnType; + innerText: ( + selector: string, + options?: Parameters[1] + ) => ReturnType; + isEnabled: ( + selector: string, + options?: Parameters[1] + ) => ReturnType; + isChecked: ( + selector: string, + options?: Parameters[1] + ) => ReturnType; + isHidden: ( + selector: string, + options?: Parameters[1] + ) => ReturnType; + locator: ( + selector: string, + options?: Parameters[1] + ) => ReturnType; + }; +}; diff --git a/packages/kbn-scout/src/playwright/fixtures/types/worker_scope.ts b/packages/kbn-scout/src/playwright/fixtures/types/worker_scope.ts new file mode 100644 index 0000000000000..c9424dc0f5970 --- /dev/null +++ b/packages/kbn-scout/src/playwright/fixtures/types/worker_scope.ts @@ -0,0 +1,40 @@ +/* + * 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". + */ + +import type { KbnClient, SamlSessionManager } from '@kbn/test'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { Client } from '@elastic/elasticsearch'; +import { LoadActionPerfOptions } from '@kbn/es-archiver'; +import { IndexStats } from '@kbn/es-archiver/src/lib/stats'; + +import { ScoutServerConfig } from '../../../types'; +import { KibanaUrl } from '../../../common/services/kibana_url'; + +interface EsArchiverFixture { + loadIfNeeded: ( + name: string, + performance?: LoadActionPerfOptions | undefined + ) => Promise>; +} + +export interface ScoutWorkerFixtures { + log: ToolingLog; + config: ScoutServerConfig; + kbnUrl: KibanaUrl; + esClient: Client; + kbnClient: KbnClient; + esArchiver: EsArchiverFixture; + samlAuth: SamlSessionManager; +} + +// re-export to import types from '@kbn-scout' +export type { KbnClient, SamlSessionManager } from '@kbn/test'; +export type { ToolingLog } from '@kbn/tooling-log'; +export type { Client } from '@elastic/elasticsearch'; +export type { KibanaUrl } from '../../../common/services/kibana_url'; diff --git a/packages/kbn-scout/src/playwright/fixtures/worker/index.ts b/packages/kbn-scout/src/playwright/fixtures/worker/index.ts new file mode 100644 index 0000000000000..c61d9755c44db --- /dev/null +++ b/packages/kbn-scout/src/playwright/fixtures/worker/index.ts @@ -0,0 +1,84 @@ +/* + * 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". + */ + +import { test as base } from '@playwright/test'; + +import { LoadActionPerfOptions } from '@kbn/es-archiver'; +import { + createKbnUrl, + createEsArchiver, + createEsClient, + createKbnClient, + createLogger, + createSamlSessionManager, + createScoutConfig, +} from '../../../common/services'; +import { ScoutWorkerFixtures } from '../types/worker_scope'; +import { ScoutTestOptions } from '../../types'; + +export const scoutWorkerFixtures = base.extend<{}, ScoutWorkerFixtures>({ + log: [ + ({}, use) => { + use(createLogger()); + }, + { scope: 'worker' }, + ], + + config: [ + ({ log }, use, testInfo) => { + const configName = 'local'; + const projectUse = testInfo.project.use as ScoutTestOptions; + const serversConfigDir = projectUse.serversConfigDir; + const configInstance = createScoutConfig(serversConfigDir, configName, log); + + use(configInstance); + }, + { scope: 'worker' }, + ], + + kbnUrl: [ + ({ config, log }, use) => { + use(createKbnUrl(config, log)); + }, + { scope: 'worker' }, + ], + + esClient: [ + ({ config, log }, use) => { + use(createEsClient(config, log)); + }, + { scope: 'worker' }, + ], + + kbnClient: [ + ({ log, config }, use) => { + use(createKbnClient(config, log)); + }, + { scope: 'worker' }, + ], + + esArchiver: [ + ({ log, esClient, kbnClient }, use) => { + const esArchiverInstance = createEsArchiver(esClient, kbnClient, log); + // to speedup test execution we only allow to ingest the data indexes and only if index doesn't exist + const loadIfNeeded = async (name: string, performance?: LoadActionPerfOptions | undefined) => + esArchiverInstance!.loadIfNeeded(name, performance); + + use({ loadIfNeeded }); + }, + { scope: 'worker' }, + ], + + samlAuth: [ + ({ log, config }, use) => { + use(createSamlSessionManager(config, log)); + }, + { scope: 'worker' }, + ], +}); diff --git a/packages/kbn-scout/src/playwright/index.ts b/packages/kbn-scout/src/playwright/index.ts new file mode 100644 index 0000000000000..66c80f0068f06 --- /dev/null +++ b/packages/kbn-scout/src/playwright/index.ts @@ -0,0 +1,22 @@ +/* + * 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". + */ + +import { mergeTests } from 'playwright/test'; +import { scoutCoreFixtures } from './fixtures'; + +// Scout core fixtures: worker & test scope +export const test = mergeTests(scoutCoreFixtures); + +export { createPlaywrightConfig } from './config'; +export { createLazyPageObject } from './page_objects/utils'; +export { expect } from './expect'; + +export type { ScoutPlaywrightOptions, ScoutTestOptions } from './types'; +export type { PageObjects } from './page_objects'; +export type { ScoutTestFixtures, ScoutWorkerFixtures, ScoutPage } from './fixtures'; diff --git a/packages/kbn-scout/src/playwright/page_objects/date_picker.ts b/packages/kbn-scout/src/playwright/page_objects/date_picker.ts new file mode 100644 index 0000000000000..08b724a956a3d --- /dev/null +++ b/packages/kbn-scout/src/playwright/page_objects/date_picker.ts @@ -0,0 +1,41 @@ +/* + * 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". + */ + +import { ScoutPage } from '../fixtures/types'; +import { expect } from '..'; + +export class DatePicker { + constructor(private readonly page: ScoutPage) {} + + async setAbsoluteRange({ from, to }: { from: string; to: string }) { + await this.page.testSubj.click('superDatePickerShowDatesButton'); + // we start with end date + await this.page.testSubj.click('superDatePickerendDatePopoverButton'); + await this.page.testSubj.click('superDatePickerAbsoluteTab'); + const inputFrom = this.page.testSubj.locator('superDatePickerAbsoluteDateInput'); + await inputFrom.clear(); + await inputFrom.fill(to); + await this.page.testSubj.click('parseAbsoluteDateFormat'); + await this.page.testSubj.click('superDatePickerendDatePopoverButton'); + // and later change start date + await this.page.testSubj.click('superDatePickerstartDatePopoverButton'); + await this.page.testSubj.click('superDatePickerAbsoluteTab'); + const inputTo = this.page.testSubj.locator('superDatePickerAbsoluteDateInput'); + await inputTo.clear(); + await inputTo.fill(from); + await this.page.testSubj.click('parseAbsoluteDateFormat'); + await this.page.keyboard.press('Escape'); + + await expect(this.page.testSubj.locator('superDatePickerstartDatePopoverButton')).toHaveText( + from + ); + await expect(this.page.testSubj.locator('superDatePickerendDatePopoverButton')).toHaveText(to); + await this.page.testSubj.click('querySubmitButton'); + } +} diff --git a/packages/kbn-scout/src/playwright/page_objects/discover_app.ts b/packages/kbn-scout/src/playwright/page_objects/discover_app.ts new file mode 100644 index 0000000000000..e4abbf252ae31 --- /dev/null +++ b/packages/kbn-scout/src/playwright/page_objects/discover_app.ts @@ -0,0 +1,18 @@ +/* + * 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". + */ + +import { ScoutPage } from '../fixtures/types'; + +export class DiscoverApp { + constructor(private readonly page: ScoutPage) {} + + async goto() { + await this.page.gotoApp('discover'); + } +} diff --git a/packages/kbn-scout/src/playwright/page_objects/index.ts b/packages/kbn-scout/src/playwright/page_objects/index.ts new file mode 100644 index 0000000000000..fb90dfea38ff8 --- /dev/null +++ b/packages/kbn-scout/src/playwright/page_objects/index.ts @@ -0,0 +1,32 @@ +/* + * 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". + */ + +import { ScoutPage } from '../fixtures/types'; +import { DatePicker } from './date_picker'; +import { DiscoverApp } from './discover_app'; +import { createLazyPageObject } from './utils'; + +export interface PageObjects { + datePicker: DatePicker; + discover: DiscoverApp; +} + +/** + * Creates a set of core page objects, each lazily instantiated on first access. + * + * @param page - `ScoutPage` instance used for initializing page objects. + * @returns An object containing lazy-loaded core page objects. + */ +export function createCorePageObjects(page: ScoutPage): PageObjects { + return { + datePicker: createLazyPageObject(DatePicker, page), + discover: createLazyPageObject(DiscoverApp, page), + // Add new page objects here + }; +} diff --git a/packages/kbn-scout/src/playwright/page_objects/utils/index.ts b/packages/kbn-scout/src/playwright/page_objects/utils/index.ts new file mode 100644 index 0000000000000..5593a324a274f --- /dev/null +++ b/packages/kbn-scout/src/playwright/page_objects/utils/index.ts @@ -0,0 +1,39 @@ +/* + * 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". + */ + +import { ScoutPage } from '../../fixtures/types'; + +/** + * Creates a lazily instantiated proxy for a Page Object class, deferring the creation of the instance until + * a property or method is accessed. It helps avoiding instantiation of page objects that may not be used + * in certain test scenarios. + * + * @param PageObjectClass - The page object class to be instantiated lazily. + * @param scoutPage - ScoutPage instance, that extendes the Playwright `page` fixture and passed to the page object class constructor. + * @param constructorArgs - Additional arguments to be passed to the page object class constructor. + * @returns A proxy object that behaves like an instance of the page object class, instantiating it on demand. + */ +export function createLazyPageObject( + PageObjectClass: new (page: ScoutPage, ...args: any[]) => T, + scoutPage: ScoutPage, + ...constructorArgs: any[] +): T { + let instance: T | null = null; + return new Proxy({} as T, { + get(_, prop: string | symbol) { + if (!instance) { + instance = new PageObjectClass(scoutPage, ...constructorArgs); + } + if (typeof prop === 'symbol' || !(prop in instance)) { + return undefined; + } + return instance[prop as keyof T]; + }, + }); +} diff --git a/packages/kbn-scout/src/playwright/runner/config_validator.ts b/packages/kbn-scout/src/playwright/runner/config_validator.ts new file mode 100644 index 0000000000000..a066a6dfba30c --- /dev/null +++ b/packages/kbn-scout/src/playwright/runner/config_validator.ts @@ -0,0 +1,46 @@ +/* + * 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". + */ + +import * as Fs from 'fs'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { PlaywrightTestConfig } from 'playwright/test'; +import path from 'path'; +import { createFlagError } from '@kbn/dev-cli-errors'; +import { ScoutTestOptions, VALID_CONFIG_MARKER } from '../types'; + +export async function validatePlaywrightConfig(configPath: string) { + const fullPath = path.resolve(REPO_ROOT, configPath); + + // Check if the path exists and has a .ts extension + if (!configPath || !Fs.existsSync(fullPath) || !configPath.endsWith('.ts')) { + throw createFlagError( + `Path to a valid TypeScript config file is required: --config ` + ); + } + + // Dynamically import the file to check for a default export + const configModule = await import(fullPath); + const config = configModule.default as PlaywrightTestConfig; + + // Check if the config's 'use' property has the valid marker + if (!config?.use?.[VALID_CONFIG_MARKER]) { + throw createFlagError( + `The config file at "${configPath}" must be created with "createPlaywrightConfig" from '@kbn/scout' package:\n +export default createPlaywrightConfig({ + testDir: './tests', +});` + ); + } + + if (!config.testDir) { + throw createFlagError( + `The config file at "${configPath}" must export a valid Playwright configuration with "testDir" property.` + ); + } +} diff --git a/packages/kbn-scout/src/playwright/runner/flags.ts b/packages/kbn-scout/src/playwright/runner/flags.ts new file mode 100644 index 0000000000000..7d39d821705c1 --- /dev/null +++ b/packages/kbn-scout/src/playwright/runner/flags.ts @@ -0,0 +1,52 @@ +/* + * 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". + */ + +import { FlagOptions, FlagsReader } from '@kbn/dev-cli-runner'; +import { createFlagError } from '@kbn/dev-cli-errors'; +import { SERVER_FLAG_OPTIONS, parseServerFlags } from '../../servers'; +import { CliSupportedServerModes } from '../../types'; +import { validatePlaywrightConfig } from './config_validator'; + +export interface RunTestsOptions { + configPath: string; + headed: boolean; + mode: CliSupportedServerModes; + esFrom: 'serverless' | 'source' | 'snapshot' | undefined; + installDir: string | undefined; + logsDir: string | undefined; +} + +export const TEST_FLAG_OPTIONS: FlagOptions = { + ...SERVER_FLAG_OPTIONS, + boolean: [...(SERVER_FLAG_OPTIONS.boolean || []), 'headed'], + string: [...(SERVER_FLAG_OPTIONS.string || []), 'config'], + default: { headed: false }, + help: `${SERVER_FLAG_OPTIONS.help} + --config Playwright config file path + --headed Run Playwright with browser head + `, +}; + +export async function parseTestFlags(flags: FlagsReader) { + const options = parseServerFlags(flags); + const configPath = flags.string('config'); + const headed = flags.boolean('headed'); + + if (!configPath) { + throw createFlagError(`Path to playwright config is required: --config `); + } + + await validatePlaywrightConfig(configPath); + + return { + ...options, + configPath, + headed, + }; +} diff --git a/packages/kbn-scout/src/playwright/runner/index.ts b/packages/kbn-scout/src/playwright/runner/index.ts new file mode 100644 index 0000000000000..2e24f2d2d3039 --- /dev/null +++ b/packages/kbn-scout/src/playwright/runner/index.ts @@ -0,0 +1,12 @@ +/* + * 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". + */ + +export { runTests } from './run_tests'; +export { parseTestFlags, TEST_FLAG_OPTIONS } from './flags'; +export type { RunTestsOptions } from './flags'; diff --git a/packages/kbn-scout/src/playwright/runner/run_tests.ts b/packages/kbn-scout/src/playwright/runner/run_tests.ts new file mode 100644 index 0000000000000..a5d8aa137dbfd --- /dev/null +++ b/packages/kbn-scout/src/playwright/runner/run_tests.ts @@ -0,0 +1,84 @@ +/* + * 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". + */ + +import { resolve } from 'path'; + +import { ToolingLog } from '@kbn/tooling-log'; +import { withProcRunner } from '@kbn/dev-proc-runner'; +import { getTimeReporter } from '@kbn/ci-stats-reporter'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { runElasticsearch, runKibanaServer } from '../../servers'; +import { loadServersConfig } from '../../config'; +import { silence } from '../../common'; +import { RunTestsOptions } from './flags'; +import { getExtraKbnOpts } from '../../servers/run_kibana_server'; + +export async function runTests(log: ToolingLog, options: RunTestsOptions) { + const runStartTime = Date.now(); + const reportTime = getTimeReporter(log, 'scripts/scout_test'); + + const config = await loadServersConfig(options.mode, log); + const playwrightConfigPath = options.configPath; + + await withProcRunner(log, async (procs) => { + const abortCtrl = new AbortController(); + + const onEarlyExit = (msg: string) => { + log.error(msg); + abortCtrl.abort(); + }; + + let shutdownEs; + + try { + shutdownEs = await runElasticsearch({ + onEarlyExit, + config, + log, + esFrom: options.esFrom, + logsDir: options.logsDir, + }); + + await runKibanaServer({ + procs, + onEarlyExit, + config, + installDir: options.installDir, + extraKbnOpts: getExtraKbnOpts(options.installDir, config.get('serverless')), + }); + + // wait for 5 seconds + await silence(log, 5000); + + // Running 'npx playwright test --config=${playwrightConfigPath}' + await procs.run(`playwright`, { + cmd: resolve(REPO_ROOT, './node_modules/.bin/playwright'), + args: ['test', `--config=${playwrightConfigPath}`, ...(options.headed ? ['--headed'] : [])], + cwd: resolve(REPO_ROOT), + env: { + ...process.env, + }, + wait: true, + }); + } finally { + try { + await procs.stop('kibana'); + } finally { + if (shutdownEs) { + await shutdownEs(); + } + } + } + + reportTime(runStartTime, 'ready', { + success: true, + ...options, + }); + }); +} diff --git a/packages/kbn-scout/src/playwright/types/index.ts b/packages/kbn-scout/src/playwright/types/index.ts new file mode 100644 index 0000000000000..c8d0087d62438 --- /dev/null +++ b/packages/kbn-scout/src/playwright/types/index.ts @@ -0,0 +1,24 @@ +/* + * 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". + */ + +import { PlaywrightTestConfig, PlaywrightTestOptions } from 'playwright/test'; + +export type Protocol = 'http' | 'https'; + +export const VALID_CONFIG_MARKER = Symbol('validConfig'); + +export interface ScoutTestOptions extends PlaywrightTestOptions { + serversConfigDir: string; + [VALID_CONFIG_MARKER]: boolean; +} + +export interface ScoutPlaywrightOptions extends Pick { + testDir: string; + workers?: 1 | 2; +} diff --git a/packages/kbn-scout/src/playwright/utils/index.ts b/packages/kbn-scout/src/playwright/utils/index.ts new file mode 100644 index 0000000000000..6100cffc2f2c8 --- /dev/null +++ b/packages/kbn-scout/src/playwright/utils/index.ts @@ -0,0 +1,10 @@ +/* + * 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". + */ + +export const serviceLoadedMsg = (name: string) => `scout service loaded: ${name}`; diff --git a/packages/kbn-scout/src/servers/flags.ts b/packages/kbn-scout/src/servers/flags.ts new file mode 100644 index 0000000000000..7f372d72e2d7c --- /dev/null +++ b/packages/kbn-scout/src/servers/flags.ts @@ -0,0 +1,55 @@ +/* + * 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". + */ + +import { v4 as uuidV4 } from 'uuid'; +import { resolve } from 'path'; +import { FlagsReader, FlagOptions } from '@kbn/dev-cli-runner'; +import { createFlagError } from '@kbn/dev-cli-errors'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { CliSupportedServerModes } from '../types'; + +export type StartServerOptions = ReturnType; + +export const SERVER_FLAG_OPTIONS: FlagOptions = { + string: ['serverless', 'esFrom', 'kibana-install-dir'], + boolean: ['stateful', 'logToFile'], + help: ` + --stateful Start Elasticsearch and Kibana with default ESS configuration + --serverless Start Elasticsearch and Kibana with serverless project configuration: es | oblt | security + --esFrom Build Elasticsearch from source or run snapshot or serverless. Default: $TEST_ES_FROM or "snapshot" + --kibana-install-dir Run Kibana from existing install directory instead of from source + --logToFile Write the log output from Kibana/ES to files instead of to stdout + `, +}; + +export function parseServerFlags(flags: FlagsReader) { + const serverlessType = flags.enum('serverless', ['es', 'oblt', 'security']); + const isStateful = flags.boolean('stateful'); + + if (!(serverlessType || isStateful) || (serverlessType && isStateful)) { + throw createFlagError(`Expected exactly one of --serverless= or --stateful flag`); + } + + const mode: CliSupportedServerModes = serverlessType + ? `serverless=${serverlessType}` + : 'stateful'; + + const esFrom = flags.enum('esFrom', ['source', 'snapshot', 'serverless']); + const installDir = flags.string('kibana-install-dir'); + const logsDir = flags.boolean('logToFile') + ? resolve(REPO_ROOT, 'data/ftr_servers_logs', uuidV4()) + : undefined; + + return { + mode, + esFrom, + installDir, + logsDir, + }; +} diff --git a/packages/kbn-scout/src/servers/index.ts b/packages/kbn-scout/src/servers/index.ts new file mode 100644 index 0000000000000..9d19f18f2974e --- /dev/null +++ b/packages/kbn-scout/src/servers/index.ts @@ -0,0 +1,15 @@ +/* + * 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". + */ + +export { parseServerFlags, SERVER_FLAG_OPTIONS } from './flags'; +export { startServers } from './start_servers'; +export { runKibanaServer } from './run_kibana_server'; +export { runElasticsearch } from './run_elasticsearch'; + +export type { StartServerOptions } from './flags'; diff --git a/packages/kbn-scout/src/servers/run_elasticsearch.ts b/packages/kbn-scout/src/servers/run_elasticsearch.ts new file mode 100644 index 0000000000000..5406f755f5d72 --- /dev/null +++ b/packages/kbn-scout/src/servers/run_elasticsearch.ts @@ -0,0 +1,194 @@ +/* + * 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". + */ + +import Url from 'url'; +import { resolve } from 'path'; +import type { ToolingLog } from '@kbn/tooling-log'; +import { REPO_ROOT } from '@kbn/repo-info'; +import type { ArtifactLicense, ServerlessProjectType } from '@kbn/es'; +import { isServerlessProjectType, extractAndArchiveLogs } from '@kbn/es/src/utils'; +import { createTestEsCluster, esTestConfig } from '@kbn/test'; +import { Config } from '../config'; + +interface RunElasticsearchOptions { + log: ToolingLog; + esFrom?: string; + esServerlessImage?: string; + config: Config; + onEarlyExit?: (msg: string) => void; + logsDir?: string; + name?: string; +} + +type EsConfig = ReturnType; + +function getEsConfig({ + config, + esFrom = config.get('esTestCluster.from'), + esServerlessImage, +}: RunElasticsearchOptions) { + const ssl = !!config.get('esTestCluster.ssl'); + const license: ArtifactLicense = config.get('esTestCluster.license'); + const esArgs: string[] = config.get('esTestCluster.serverArgs'); + const esJavaOpts: string | undefined = config.get('esTestCluster.esJavaOpts'); + const isSecurityEnabled = esArgs.includes('xpack.security.enabled=true'); + + const port: number | undefined = config.get('servers.elasticsearch.port'); + + const password: string | undefined = isSecurityEnabled + ? 'changeme' + : config.get('servers.elasticsearch.password'); + + const dataArchive: string | undefined = config.get('esTestCluster.dataArchive'); + const serverless: boolean = config.get('serverless'); + const files: string[] | undefined = config.get('esTestCluster.files'); + + const esServerlessOptions = serverless + ? getESServerlessOptions(esServerlessImage, config) + : undefined; + + return { + ssl, + license, + esArgs, + esJavaOpts, + isSecurityEnabled, + esFrom, + esServerlessOptions, + port, + password, + dataArchive, + serverless, + files, + }; +} + +export async function runElasticsearch( + options: RunElasticsearchOptions +): Promise<() => Promise> { + const { log, logsDir, name } = options; + const config = getEsConfig(options); + + const node = await startEsNode({ + log, + name: name ?? 'scout', + logsDir, + config, + }); + return async () => { + await node.cleanup(); + await extractAndArchiveLogs({ outputFolder: logsDir, log }); + }; +} + +async function startEsNode({ + log, + name, + config, + onEarlyExit, + logsDir, +}: { + log: ToolingLog; + name: string; + config: EsConfig & { transportPort?: number }; + onEarlyExit?: (msg: string) => void; + logsDir?: string; +}) { + const cluster = createTestEsCluster({ + clusterName: `cluster-${name}`, + esArgs: config.esArgs, + esFrom: config.esFrom, + esServerlessOptions: config.esServerlessOptions, + esJavaOpts: config.esJavaOpts, + license: config.license, + password: config.password, + port: config.port, + ssl: config.ssl, + log, + writeLogsToPath: logsDir ? resolve(logsDir, `es-cluster-${name}.log`) : undefined, + basePath: resolve(REPO_ROOT, '.es'), + nodes: [ + { + name, + dataArchive: config.dataArchive, + }, + ], + transportPort: config.transportPort, + onEarlyExit, + serverless: config.serverless, + files: config.files, + }); + + await cluster.start(); + + return cluster; +} + +interface EsServerlessOptions { + projectType: ServerlessProjectType; + host?: string; + resources: string[]; + kibanaUrl: string; + tag?: string; + image?: string; +} + +function getESServerlessOptions( + esServerlessImageFromArg: string | undefined, + config: Config +): EsServerlessOptions { + const esServerlessImageUrlOrTag = + esServerlessImageFromArg || + esTestConfig.getESServerlessImage() || + (config.has('esTestCluster.esServerlessImage') && + config.get('esTestCluster.esServerlessImage')); + const serverlessResources: string[] = + (config.has('esServerlessOptions.resources') && config.get('esServerlessOptions.resources')) || + []; + const serverlessHost: string | undefined = + config.has('esServerlessOptions.host') && config.get('esServerlessOptions.host'); + + const kbnServerArgs = + (config.has('kbnTestServer.serverArgs') && + (config.get('kbnTestServer.serverArgs') as string[])) || + []; + + const projectType = kbnServerArgs + .filter((arg) => arg.startsWith('--serverless')) + .reduce((acc, arg) => { + const match = arg.match(/--serverless[=\s](\w+)/); + return acc + (match ? match[1] : ''); + }, '') as ServerlessProjectType; + + if (!isServerlessProjectType(projectType)) { + throw new Error(`Unsupported serverless projectType: ${projectType}`); + } + + const commonOptions = { + projectType, + host: serverlessHost, + resources: serverlessResources, + kibanaUrl: Url.format({ + protocol: config.get('servers.kibana.protocol'), + hostname: config.get('servers.kibana.hostname'), + port: config.get('servers.kibana.port'), + }), + }; + + if (esServerlessImageUrlOrTag) { + return { + ...commonOptions, + ...(esServerlessImageUrlOrTag.includes(':') + ? { image: esServerlessImageUrlOrTag } + : { tag: esServerlessImageUrlOrTag }), + }; + } + + return commonOptions; +} diff --git a/packages/kbn-scout/src/servers/run_kibana_server.ts b/packages/kbn-scout/src/servers/run_kibana_server.ts new file mode 100644 index 0000000000000..1363b8daaa906 --- /dev/null +++ b/packages/kbn-scout/src/servers/run_kibana_server.ts @@ -0,0 +1,135 @@ +/* + * 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". + */ + +import Path from 'path'; +import Os from 'os'; +import { v4 as uuidv4 } from 'uuid'; +import type { ProcRunner } from '@kbn/dev-proc-runner'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { parseRawFlags, getArgValue, remapPluginPaths, DedicatedTaskRunner } from '@kbn/test'; +import { Config } from '../config'; + +export async function runKibanaServer(options: { + procs: ProcRunner; + config: Config; + installDir?: string; + extraKbnOpts?: string[]; + logsDir?: string; + onEarlyExit?: (msg: string) => void; +}) { + const { config, procs } = options; + const runOptions = options.config.get('kbnTestServer.runOptions'); + const installDir = runOptions.alwaysUseSource ? undefined : options.installDir; + const devMode = !installDir; + const useTaskRunner = options.config.get('kbnTestServer.useDedicatedTaskRunner'); + + const procRunnerOpts = { + cwd: installDir || REPO_ROOT, + cmd: installDir + ? process.platform.startsWith('win') + ? Path.resolve(installDir, 'bin/kibana.bat') + : Path.resolve(installDir, 'bin/kibana') + : process.execPath, + env: { + FORCE_COLOR: 1, + ...process.env, + ...options.config.get('kbnTestServer.env'), + }, + wait: runOptions.wait, + onEarlyExit: options.onEarlyExit, + }; + + const prefixArgs = devMode + ? [Path.relative(procRunnerOpts.cwd, Path.resolve(REPO_ROOT, 'scripts/kibana'))] + : []; + + const buildArgs: string[] = config.get('kbnTestServer.buildArgs') || []; + const sourceArgs: string[] = config.get('kbnTestServer.sourceArgs') || []; + const serverArgs: string[] = config.get('kbnTestServer.serverArgs') || []; + + let kbnFlags = parseRawFlags([ + // When installDir is passed, we run from a built version of Kibana which uses different command line + // arguments. If installDir is not passed, we run from source code. + ...(installDir ? [...buildArgs, ...serverArgs] : [...sourceArgs, ...serverArgs]), + + // We also allow passing in extra Kibana server options, tack those on here so they always take precedence + ...(options.extraKbnOpts ?? []), + ]); + + if (installDir) { + kbnFlags = remapPluginPaths(kbnFlags, installDir); + } + + const mainName = useTaskRunner ? 'kbn-ui' : 'kibana'; + const promises = [ + // main process + procs.run(mainName, { + ...procRunnerOpts, + writeLogsToPath: options.logsDir + ? Path.resolve(options.logsDir, `${mainName}.log`) + : undefined, + args: [ + ...prefixArgs, + ...parseRawFlags([ + ...kbnFlags, + ...(!useTaskRunner + ? [] + : [ + '--node.roles=["ui"]', + `--path.data=${Path.resolve(Os.tmpdir(), `scout-ui-${uuidv4()}`)}`, + ]), + ]), + ], + }), + ]; + + if (useTaskRunner) { + const mainUuid = getArgValue(kbnFlags, 'server.uuid'); + + // dedicated task runner + promises.push( + procs.run('kbn-tasks', { + ...procRunnerOpts, + writeLogsToPath: options.logsDir + ? Path.resolve(options.logsDir, 'kbn-tasks.log') + : undefined, + args: [ + ...prefixArgs, + ...parseRawFlags([ + ...kbnFlags, + `--server.port=${DedicatedTaskRunner.getPort(config.get('servers.kibana.port'))}`, + '--node.roles=["background_tasks"]', + `--path.data=${Path.resolve(Os.tmpdir(), `ftr-task-runner-${uuidv4()}`)}`, + ...(typeof mainUuid === 'string' && mainUuid + ? [`--server.uuid=${DedicatedTaskRunner.getUuid(mainUuid)}`] + : []), + ...(devMode ? ['--no-optimizer'] : []), + ]), + ], + }) + ); + } + + await Promise.all(promises); +} + +export function getExtraKbnOpts(installDir: string | undefined, isServerless: boolean) { + if (installDir) { + return []; + } + + return [ + '--dev', + '--no-dev-config', + '--no-dev-credentials', + isServerless + ? '--server.versioned.versionResolution=newest' + : '--server.versioned.versionResolution=oldest', + ]; +} diff --git a/packages/kbn-scout/src/servers/start_servers.ts b/packages/kbn-scout/src/servers/start_servers.ts new file mode 100644 index 0000000000000..32eb2030c978d --- /dev/null +++ b/packages/kbn-scout/src/servers/start_servers.ts @@ -0,0 +1,63 @@ +/* + * 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". + */ + +import dedent from 'dedent'; + +import { ToolingLog } from '@kbn/tooling-log'; +import { withProcRunner } from '@kbn/dev-proc-runner'; +import { getTimeReporter } from '@kbn/ci-stats-reporter'; +import { runElasticsearch } from './run_elasticsearch'; +import { getExtraKbnOpts, runKibanaServer } from './run_kibana_server'; +import { StartServerOptions } from './flags'; +import { loadServersConfig } from '../config'; +import { silence } from '../common'; + +export async function startServers(log: ToolingLog, options: StartServerOptions) { + const runStartTime = Date.now(); + const reportTime = getTimeReporter(log, 'scripts/scout_start_servers'); + + await withProcRunner(log, async (procs) => { + const config = await loadServersConfig(options.mode, log); + + const shutdownEs = await runElasticsearch({ + config, + log, + esFrom: options.esFrom, + logsDir: options.logsDir, + }); + + await runKibanaServer({ + procs, + config, + installDir: options.installDir, + extraKbnOpts: getExtraKbnOpts(options.installDir, config.get('serverless')), + }); + + reportTime(runStartTime, 'ready', { + success: true, + ...options, + }); + + // wait for 5 seconds of silence before logging the + // success message so that it doesn't get buried + await silence(log, 5000); + + log.success( + '\n\n' + + dedent` + Elasticsearch and Kibana are ready for functional testing. + Use 'npx playwright test --config ' to run tests' + ` + + '\n\n' + ); + + await procs.waitForAllToStop(); + await shutdownEs(); + }); +} diff --git a/packages/kbn-scout/src/types/cli.d.ts b/packages/kbn-scout/src/types/cli.d.ts new file mode 100644 index 0000000000000..9f0d5a2653652 --- /dev/null +++ b/packages/kbn-scout/src/types/cli.d.ts @@ -0,0 +1,14 @@ +/* + * 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". + */ + +export type CliSupportedServerModes = + | 'stateful' + | 'serverless=es' + | 'serverless=oblt' + | 'serverless=security'; diff --git a/packages/kbn-scout/src/types/config.d.ts b/packages/kbn-scout/src/types/config.d.ts new file mode 100644 index 0000000000000..14cd27b47fde2 --- /dev/null +++ b/packages/kbn-scout/src/types/config.d.ts @@ -0,0 +1,34 @@ +/* + * 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". + */ + +import { UrlParts } from '@kbn/test'; + +export interface ScoutLoaderConfig { + serverless?: boolean; + servers: { + kibana: UrlParts; + elasticsearch: UrlParts; + fleet?: UrlParts; + }; + dockerServers: any; + esTestCluster: { + from: string; + license?: string; + files: string[]; + serverArgs: string[]; + ssl: boolean; + }; + kbnTestServer: { + env?: any; + buildArgs?: string[]; + sourceArgs?: string[]; + serverArgs: string[]; + useDedicatedTastRunner?: boolean; + }; +} diff --git a/packages/kbn-scout/src/types/index.ts b/packages/kbn-scout/src/types/index.ts new file mode 100644 index 0000000000000..811b63fb1aac3 --- /dev/null +++ b/packages/kbn-scout/src/types/index.ts @@ -0,0 +1,12 @@ +/* + * 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". + */ + +export * from './config'; +export * from './cli'; +export * from './servers'; diff --git a/packages/kbn-scout/src/types/servers.d.ts b/packages/kbn-scout/src/types/servers.d.ts new file mode 100644 index 0000000000000..587e1d213b9ba --- /dev/null +++ b/packages/kbn-scout/src/types/servers.d.ts @@ -0,0 +1,26 @@ +/* + * 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". + */ + +import { ServerlessProjectType } from '@kbn/es'; + +export interface ScoutServerConfig { + serverless: boolean; + projectType?: ServerlessProjectType; + isCloud: boolean; + cloudUsersFilePath: string; + hosts: { + kibana: string; + elasticsearch: string; + }; + auth: { + username: string; + password: string; + }; + metadata?: any; +} diff --git a/packages/kbn-scout/tsconfig.json b/packages/kbn-scout/tsconfig.json new file mode 100644 index 0000000000000..35d74c6437618 --- /dev/null +++ b/packages/kbn-scout/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/tooling-log", + "@kbn/dev-cli-runner", + "@kbn/dev-cli-errors", + "@kbn/ci-stats-reporter", + "@kbn/repo-info", + "@kbn/es", + "@kbn/dev-proc-runner", + "@kbn/test", + "@kbn/es-archiver", + "@kbn/dev-utils", + "@kbn/mock-idp-utils", + "@kbn/test-suites-xpack", + "@kbn/test-subj-selector", + ] +} diff --git a/packages/kbn-test/index.ts b/packages/kbn-test/index.ts index 57d9c767827df..bd196e27c8cb0 100644 --- a/packages/kbn-test/index.ts +++ b/packages/kbn-test/index.ts @@ -14,14 +14,23 @@ export { startServersCli, startServers } from './src/functional_tests/start_serv // @internal export { runTestsCli, runTests } from './src/functional_tests/run_tests'; +export { + runElasticsearch, + runKibanaServer, + parseRawFlags, + getArgValue, + remapPluginPaths, + getKibanaCliArg, + getKibanaCliLoggers, +} from './src/functional_tests/lib'; + +export { initLogsDir } from './src/functional_tests/lib'; export { SamlSessionManager, type SamlSessionManagerOptions, type HostOptions, type GetCookieOptions, } from './src/auth'; -export { runElasticsearch, runKibanaServer } from './src/functional_tests/lib'; -export { getKibanaCliArg, getKibanaCliLoggers } from './src/functional_tests/lib/kibana_cli_args'; export type { CreateTestEsClusterOptions, @@ -38,6 +47,7 @@ export { } from './src/es'; export { kbnTestConfig } from './kbn_test_config'; +export type { UrlParts } from './kbn_test_config'; export { kibanaServerTestUser, diff --git a/packages/kbn-test/src/functional_test_runner/index.ts b/packages/kbn-test/src/functional_test_runner/index.ts index 6e781df0a0ea3..6c5641cbe8aab 100644 --- a/packages/kbn-test/src/functional_test_runner/index.ts +++ b/packages/kbn-test/src/functional_test_runner/index.ts @@ -16,6 +16,7 @@ export { Lifecycle, LifecyclePhase, runCheckFtrConfigsCli, + DedicatedTaskRunner, } from './lib'; export { runFtrCli } from './cli'; export * from './lib/docker_servers'; diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/run_check_ftr_configs_cli.ts b/packages/kbn-test/src/functional_test_runner/lib/config/run_check_ftr_configs_cli.ts index f737e380267db..5808c88901b11 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/run_check_ftr_configs_cli.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/run_check_ftr_configs_cli.ts @@ -50,6 +50,11 @@ export async function runCheckFtrConfigsCli() { return false; } + // playwright config files + if (file.match(/\/ui_tests\/*playwright*.config.ts$/)) { + return false; + } + if (!file.match(/(test|e2e).*config[^\/]*\.(t|j)s$/)) { return false; } diff --git a/packages/kbn-test/src/functional_tests/lib/index.ts b/packages/kbn-test/src/functional_tests/lib/index.ts index 003a675d8421d..23c6ec8331602 100644 --- a/packages/kbn-test/src/functional_tests/lib/index.ts +++ b/packages/kbn-test/src/functional_tests/lib/index.ts @@ -10,3 +10,11 @@ export { runKibanaServer } from './run_kibana_server'; export { runElasticsearch } from './run_elasticsearch'; export * from './run_ftr'; +export { + parseRawFlags, + getArgValue, + remapPluginPaths, + getKibanaCliArg, + getKibanaCliLoggers, +} from './kibana_cli_args'; +export { initLogsDir } from './logs_dir'; diff --git a/scripts/scout_start_servers.js b/scripts/scout_start_servers.js new file mode 100644 index 0000000000000..b93ec0e456454 --- /dev/null +++ b/scripts/scout_start_servers.js @@ -0,0 +1,11 @@ +/* + * 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". + */ + +require('../src/setup_node_env'); +require('@kbn/scout').startServersCli(); diff --git a/scripts/scout_test.js b/scripts/scout_test.js new file mode 100644 index 0000000000000..8b14ebd33da19 --- /dev/null +++ b/scripts/scout_test.js @@ -0,0 +1,11 @@ +/* + * 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". + */ + +require('../src/setup_node_env'); +require('@kbn/scout').runTestsCli(); diff --git a/tsconfig.base.json b/tsconfig.base.json index d8426dfdef123..9dd73f15f8d1b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1534,6 +1534,8 @@ "@kbn/saved-objects-tagging-plugin/*": ["x-pack/plugins/saved_objects_tagging/*"], "@kbn/saved-search-plugin": ["src/plugins/saved_search"], "@kbn/saved-search-plugin/*": ["src/plugins/saved_search/*"], + "@kbn/scout": ["packages/kbn-scout"], + "@kbn/scout/*": ["packages/kbn-scout/*"], "@kbn/screenshot-mode-example-plugin": ["examples/screenshot_mode_example"], "@kbn/screenshot-mode-example-plugin/*": ["examples/screenshot_mode_example/*"], "@kbn/screenshot-mode-plugin": ["src/plugins/screenshot_mode"], diff --git a/x-pack/.gitignore b/x-pack/.gitignore index 0e0e9aba84467..97efbef318c90 100644 --- a/x-pack/.gitignore +++ b/x-pack/.gitignore @@ -12,3 +12,4 @@ /.env /.kibana-plugin-helpers.dev.* .cache +**/ui_tests/output diff --git a/x-pack/plugins/discover_enhanced/tsconfig.json b/x-pack/plugins/discover_enhanced/tsconfig.json index ada69e95f32a1..6839f4c2c18e1 100644 --- a/x-pack/plugins/discover_enhanced/tsconfig.json +++ b/x-pack/plugins/discover_enhanced/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "target/types", }, - "include": ["*.ts", "common/**/*", "public/**/*", "server/**/*"], + "include": ["*.ts", "common/**/*", "public/**/*", "server/**/*", "ui_tests/**/*"], "kbn_references": [ "@kbn/core", "@kbn/data-plugin", @@ -21,6 +21,7 @@ "@kbn/presentation-publishing", "@kbn/data-views-plugin", "@kbn/unified-search-plugin", + "@kbn/scout", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/discover_enhanced/ui_tests/README.md b/x-pack/plugins/discover_enhanced/ui_tests/README.md new file mode 100644 index 0000000000000..8320e9464d9ca --- /dev/null +++ b/x-pack/plugins/discover_enhanced/ui_tests/README.md @@ -0,0 +1,17 @@ +## How to run tests +First start the servers with + +```bash +// ESS +node scripts/scout_start_servers.js --stateful +// Serverless +node scripts/scout_start_servers.js --serverless=es +``` + +Then you can run the tests multiple times in another terminal with: + +```bash +npx playwright test --config x-pack/plugins/discover_enhanced/ui_tests/playwright.config.ts +``` + +Test results are available in `x-pack/plugins/discover_enhanced/ui_tests/output` diff --git a/x-pack/plugins/discover_enhanced/ui_tests/fixtures/index.ts b/x-pack/plugins/discover_enhanced/ui_tests/fixtures/index.ts new file mode 100644 index 0000000000000..38d4905f82e6f --- /dev/null +++ b/x-pack/plugins/discover_enhanced/ui_tests/fixtures/index.ts @@ -0,0 +1,32 @@ +/* + * 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. + */ + +import { + test as base, + PageObjects, + createLazyPageObject, + ScoutTestFixtures, + ScoutWorkerFixtures, +} from '@kbn/scout'; +import { DemoPage } from './page_objects'; + +interface ExtendedScoutTestFixtures extends ScoutTestFixtures { + pageObjects: PageObjects & { + demo: DemoPage; + }; +} + +export const test = base.extend({ + pageObjects: async ({ pageObjects, page }, use) => { + const extendedPageObjects = { + ...pageObjects, + demo: createLazyPageObject(DemoPage, page), + }; + + await use(extendedPageObjects); + }, +}); diff --git a/x-pack/plugins/discover_enhanced/ui_tests/fixtures/page_objects/demo.ts b/x-pack/plugins/discover_enhanced/ui_tests/fixtures/page_objects/demo.ts new file mode 100644 index 0000000000000..4c65384b9c816 --- /dev/null +++ b/x-pack/plugins/discover_enhanced/ui_tests/fixtures/page_objects/demo.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ + +import { ScoutPage } from '@kbn/scout'; + +export class DemoPage { + constructor(private readonly page: ScoutPage) {} + + async goto() { + this.page.gotoApp('not_implemented'); + } +} diff --git a/x-pack/plugins/discover_enhanced/ui_tests/fixtures/page_objects/index.ts b/x-pack/plugins/discover_enhanced/ui_tests/fixtures/page_objects/index.ts new file mode 100644 index 0000000000000..47afc9c11fe7b --- /dev/null +++ b/x-pack/plugins/discover_enhanced/ui_tests/fixtures/page_objects/index.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 { DemoPage } from './demo'; diff --git a/x-pack/plugins/discover_enhanced/ui_tests/playwright.config.ts b/x-pack/plugins/discover_enhanced/ui_tests/playwright.config.ts new file mode 100644 index 0000000000000..34b370396b67e --- /dev/null +++ b/x-pack/plugins/discover_enhanced/ui_tests/playwright.config.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +import { createPlaywrightConfig } from '@kbn/scout'; + +// eslint-disable-next-line import/no-default-export +export default createPlaywrightConfig({ + testDir: './tests', +}); diff --git a/x-pack/plugins/discover_enhanced/ui_tests/tests/value_suggestions.spec.ts b/x-pack/plugins/discover_enhanced/ui_tests/tests/value_suggestions.spec.ts new file mode 100644 index 0000000000000..ff1389e85924e --- /dev/null +++ b/x-pack/plugins/discover_enhanced/ui_tests/tests/value_suggestions.spec.ts @@ -0,0 +1,56 @@ +/* + * 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. + */ + +import { expect } from '@kbn/scout'; +import { test } from '../fixtures'; + +test.describe('Discover app - value suggestions', () => { + test.beforeAll(async ({ esArchiver, kbnClient }) => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); + await kbnClient.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/dashboard_drilldowns/drilldowns' + ); + await kbnClient.uiSettings.update({ + defaultIndex: 'logstash-*', // TODO: investigate why it is required for `node scripts/playwright_test.js` run + 'doc_table:legacy': false, + }); + }); + + test.afterAll(async ({ kbnClient }) => { + await kbnClient.uiSettings.unset('doc_table:legacy'); + await kbnClient.uiSettings.unset('defaultIndex'); + await kbnClient.savedObjects.cleanStandardList(); + }); + + test.beforeEach(async ({ browserAuth, pageObjects }) => { + await browserAuth.loginAsPrivilegedUser(); + await pageObjects.discover.goto(); + }); + + test('dont show up if outside of range', async ({ page, pageObjects }) => { + await pageObjects.datePicker.setAbsoluteRange({ + from: 'Mar 1, 2020 @ 00:00:00.000', + to: 'Nov 1, 2020 @ 00:00:00.000', + }); + + await page.testSubj.fill('queryInput', 'extension.raw : '); + await expect(page.testSubj.locator('autoCompleteSuggestionText')).toHaveCount(0); + }); + + test('show up if in range', async ({ page, pageObjects }) => { + await pageObjects.datePicker.setAbsoluteRange({ + from: 'Sep 19, 2015 @ 06:31:44.000', + to: 'Sep 23, 2015 @ 18:31:44.000', + }); + await page.testSubj.fill('queryInput', 'extension.raw : '); + await expect(page.testSubj.locator('autoCompleteSuggestionText')).toHaveCount(5); + const actualSuggestions = await page.testSubj + .locator('autoCompleteSuggestionText') + .allTextContents(); + expect(actualSuggestions.join(',')).toContain('jpg'); + }); +}); diff --git a/x-pack/plugins/discover_enhanced/ui_tests/tests/value_suggestions_non_time_based.spec.ts b/x-pack/plugins/discover_enhanced/ui_tests/tests/value_suggestions_non_time_based.spec.ts new file mode 100644 index 0000000000000..4ba9450869313 --- /dev/null +++ b/x-pack/plugins/discover_enhanced/ui_tests/tests/value_suggestions_non_time_based.spec.ts @@ -0,0 +1,44 @@ +/* + * 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. + */ + +import { expect } from '@kbn/scout'; +import { test } from '../fixtures'; + +test.describe('Discover app - value suggestions non-time based', () => { + test.beforeAll(async ({ esArchiver, kbnClient }) => { + await esArchiver.loadIfNeeded( + 'test/functional/fixtures/es_archiver/index_pattern_without_timefield' + ); + await kbnClient.importExport.load( + 'test/functional/fixtures/kbn_archiver/index_pattern_without_timefield' + ); + await kbnClient.uiSettings.update({ + defaultIndex: 'without-timefield', + 'doc_table:legacy': false, + }); + }); + + test.afterAll(async ({ kbnClient }) => { + await kbnClient.uiSettings.unset('doc_table:legacy'); + await kbnClient.uiSettings.unset('defaultIndex'); + await kbnClient.savedObjects.cleanStandardList(); + }); + + test.beforeEach(async ({ browserAuth, pageObjects }) => { + await browserAuth.loginAsPrivilegedUser(); + await pageObjects.discover.goto(); + }); + + test('shows all auto-suggest options for a filter in discover context app', async ({ page }) => { + await page.testSubj.fill('queryInput', 'type.keyword : '); + await expect(page.testSubj.locator('autoCompleteSuggestionText')).toHaveCount(1); + const actualSuggestions = await page.testSubj + .locator('autoCompleteSuggestionText') + .allTextContents(); + expect(actualSuggestions.join(',')).toContain('"apache"'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 2ed2785b60973..a24a9d81e476d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6876,6 +6876,10 @@ version "0.0.0" uid "" +"@kbn/scout@link:packages/kbn-scout": + version "0.0.0" + uid "" + "@kbn/screenshot-mode-example-plugin@link:examples/screenshot_mode_example": version "0.0.0" uid ""