diff --git a/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/index.js b/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/index.js new file mode 100644 index 0000000000000..34ad9d589ce44 --- /dev/null +++ b/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/index.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('@kbn/babel-register').install(); +module.exports = require('./reporter').ScoutJestReporter; diff --git a/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/options.ts b/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/options.ts new file mode 100644 index 0000000000000..63df4d41f14a6 --- /dev/null +++ b/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/options.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 { ScoutTestRunConfigCategory } from '@kbn/scout-info'; + +/** + * Configuration options for the Scout Jest reporter + */ +export interface ScoutJestReporterOptions { + name?: string; + runId?: string; + configCategory?: ScoutTestRunConfigCategory; + outputPath?: string; +} diff --git a/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/reporter.ts b/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/reporter.ts new file mode 100644 index 0000000000000..f85351a42cd11 --- /dev/null +++ b/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/reporter.ts @@ -0,0 +1,247 @@ +/* + * 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 { Config, AggregatedResult, TestContext, ReporterOnStartOptions } from '@jest/reporters'; +import { BaseReporter } from '@jest/reporters'; +import { TestResult } from '@jest/types'; +import { ToolingLog } from '@kbn/tooling-log'; +import { + type CodeOwnerArea, + CodeOwnersEntry, + findAreaForCodeOwner, + getCodeOwnersEntries, + getOwningTeamsForPath, +} from '@kbn/code-owners'; +import { SCOUT_REPORT_OUTPUT_ROOT } from '@kbn/scout-info'; +import path from 'node:path'; +import { REPO_ROOT } from '@kbn/repo-info'; +import stripAnsi from 'strip-ansi'; +import { ScoutJestReporterOptions } from './options'; +import { + datasources, + generateTestRunId, + getTestIDForTitle, + ScoutEventsReport, + ScoutFileInfo, + ScoutReportEventAction, + type ScoutTestRunInfo, + uploadScoutReportEvents, +} from '../../..'; + +/** + * Scout Jest reporter + */ +export class ScoutJestReporter extends BaseReporter { + name: string; + readonly scoutLog: ToolingLog; + readonly runId: string; + private report: ScoutEventsReport; + private baseTestRunInfo: ScoutTestRunInfo; + private readonly codeOwnersEntries: CodeOwnersEntry[]; + + constructor( + _jestGlobalConfig: Config.GlobalConfig, + private reporterOptions: ScoutJestReporterOptions + ) { + super(); + this.scoutLog = new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }); + + this.name = this.reporterOptions.name || 'unknown'; + this.runId = this.reporterOptions.runId || generateTestRunId(); + this.scoutLog.info(`Scout test run ID: ${this.runId}`); + + this.report = new ScoutEventsReport(this.scoutLog); + this.baseTestRunInfo = { + id: this.runId, + config: { + category: reporterOptions.configCategory, + }, + }; + + this.codeOwnersEntries = getCodeOwnersEntries(); + } + + private getFileOwners(filePath: string): string[] { + return getOwningTeamsForPath(filePath, this.codeOwnersEntries); + } + + private getOwnerAreas(owners: string[]): CodeOwnerArea[] { + return owners + .map((owner) => findAreaForCodeOwner(owner)) + .filter((area) => area !== undefined) as CodeOwnerArea[]; + } + + private getScoutFileInfoForPath(filePath: string): ScoutFileInfo { + const fileOwners = this.getFileOwners(filePath); + + return { + path: filePath, + owner: fileOwners, + area: this.getOwnerAreas(fileOwners), + }; + } + + /** + * Root path of this reporter's output + */ + public get reportRootPath(): string { + const outputPath = this.reporterOptions.outputPath || SCOUT_REPORT_OUTPUT_ROOT; + return path.join(outputPath, `scout-jest-${this.runId}`); + } + + /** + * Separate the error message from the stack trace in a Jest failure message + * + * If the message doesn't contain a stack trace, it'll return unmodified. + * + * @param message Jest failure message + */ + parseJestFailureMessage(message: string) { + const match = message.match(/(?^.+?)(\r\n|\r|\n)(?=.+?at)\s(?.+$)/s); + + return match === null + ? { message } + : (match.groups as { message: string; stack_trace?: string }); + } + + /** + * Log a Jest test result as a Scout reporter event + * + * @param test Jest test information + * @param test.result Jest test result + * @param test.filePath Jest test file path + * + */ + logTestResult(test: { result: TestResult.AssertionResult; filePath: string }): void { + const suiteTitle = test.result.ancestorTitles.join(' '); + const parsedErrorMessages: string[] = []; + const parsedStackTraces: string[] = []; + + test.result.failureMessages + .map((message) => this.parseJestFailureMessage(stripAnsi(message))) + .forEach((parsed) => { + if (parsed.message) { + parsedErrorMessages.push(parsed.message); + } + if (parsed.stack_trace) { + parsedStackTraces.push(parsed.stack_trace); + } + }); + + this.report.logEvent({ + ...datasources.environmentMetadata, + reporter: { + name: this.name, + type: 'jest', + }, + test_run: this.baseTestRunInfo, + suite: { + title: suiteTitle || 'unknown', + type: test.result.ancestorTitles.length <= 1 ? 'root' : 'suite', + }, + test: { + id: getTestIDForTitle(test.result.fullName), + title: test.result.title, + tags: [], + file: this.getScoutFileInfoForPath(path.relative(REPO_ROOT, test.filePath)), + status: test.result.status === 'pending' ? 'skipped' : test.result.status, + duration: test.result.duration || 0, + }, + event: { + action: ScoutReportEventAction.TEST_END, + error: { + message: + parsedErrorMessages.length > 0 + ? parsedErrorMessages.join('\n--- NEXT ERROR ---\n') + : undefined, + stack_trace: + parsedStackTraces.length > 0 + ? parsedStackTraces.join('\n--- NEXT STACK TRACE ---\n') + : undefined, + }, + }, + }); + } + + onRunStart(results: AggregatedResult, _options?: ReporterOnStartOptions): void { + /** + * Test execution started + */ + // Look for Jest config path in environment variables + // Must do it here rather than the constructor as the reporter object is created when the Jest config is evaluated + // and the JEST_CONFIG_PATH environment variable might not be set + this.baseTestRunInfo = { + ...this.baseTestRunInfo, + config: { + ...this.baseTestRunInfo.config, + file: + process.env.JEST_CONFIG_PATH !== undefined + ? this.getScoutFileInfoForPath(path.relative(REPO_ROOT, process.env.JEST_CONFIG_PATH)) + : undefined, + }, + }; + + // Log "run start" event + this.report.logEvent({ + ...datasources.environmentMetadata, + '@timestamp': new Date(results.startTime), + reporter: { + name: this.name, + type: 'jest', + }, + test_run: this.baseTestRunInfo, + event: { + action: ScoutReportEventAction.RUN_BEGIN, + }, + }); + } + + async onRunComplete(_testContexts: Set, results: AggregatedResult): Promise { + /** + * Test execution ended + */ + // Turn test results into events in bulk + results.testResults.forEach((suite) => { + suite.testResults.forEach((testResult) => { + this.logTestResult({ result: testResult, filePath: suite.testFilePath }); + }); + }); + + // Log "run end" event + this.report.logEvent({ + ...datasources.environmentMetadata, + reporter: { + name: this.name, + type: 'jest', + }, + test_run: { + ...this.baseTestRunInfo, + status: results.numFailedTests === 0 ? 'passed' : 'failed', + duration: Date.now() - results.startTime || 0, + }, + event: { + action: ScoutReportEventAction.RUN_END, + }, + }); + + // Save & conclude the report + try { + this.report.save(this.reportRootPath); + await uploadScoutReportEvents(this.report.eventLogPath, this.scoutLog); + } catch (e) { + // Log the error but don't propagate it + this.scoutLog.error(e); + } finally { + this.report.conclude(); + } + } +} diff --git a/src/platform/packages/shared/kbn-test/jest-preset.js b/src/platform/packages/shared/kbn-test/jest-preset.js index 0d2b51113122a..304772e525d0d 100644 --- a/src/platform/packages/shared/kbn-test/jest-preset.js +++ b/src/platform/packages/shared/kbn-test/jest-preset.js @@ -52,6 +52,14 @@ module.exports = { ], ] : []), + ...(['1', 'yes', 'true'].includes(process.env.SCOUT_REPORTER_ENABLED) + ? [ + [ + '/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest', + { name: 'Jest tests (unit)', configCategory: 'unit-test' }, + ], + ] + : []), ], // The paths to modules that run some code to configure or set up the testing environment before each test diff --git a/src/platform/packages/shared/kbn-test/jest_integration/jest-preset.js b/src/platform/packages/shared/kbn-test/jest_integration/jest-preset.js index 6cb08436449de..77912a9f28bb0 100644 --- a/src/platform/packages/shared/kbn-test/jest_integration/jest-preset.js +++ b/src/platform/packages/shared/kbn-test/jest_integration/jest-preset.js @@ -39,6 +39,14 @@ module.exports = { ], ] : []), + ...(['1', 'yes', 'true'].includes(process.env.SCOUT_REPORTER_ENABLED) + ? [ + [ + '/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest', + { name: 'Jest tests (integration)', configCategory: 'unit-integration-test' }, + ], + ] + : []), ], coverageReporters: !!process.env.CI ? [['json', { file: 'jest-integration.json' }]] diff --git a/src/platform/packages/shared/kbn-test/jest_integration_node/jest-preset.js b/src/platform/packages/shared/kbn-test/jest_integration_node/jest-preset.js index e7da3b216647a..17a279c209300 100644 --- a/src/platform/packages/shared/kbn-test/jest_integration_node/jest-preset.js +++ b/src/platform/packages/shared/kbn-test/jest_integration_node/jest-preset.js @@ -46,6 +46,14 @@ module.exports = { testGroupType: 'Jest Integration Tests', }, ], + ...(['1', 'yes', 'true'].includes(process.env.SCOUT_REPORTER_ENABLED) + ? [ + [ + '/src/platform/packages/private/kbn-scout-reporting/src/reporting/jest', + { name: 'Jest tests (integration, node)', configCategory: 'unit-integration-test' }, + ], + ] + : []), ], coverageReporters: !!process.env.CI ? [['json', { file: 'jest-integration.json' }]] diff --git a/src/platform/packages/shared/kbn-test/src/jest/run.ts b/src/platform/packages/shared/kbn-test/src/jest/run.ts index 1fbb3a80caacb..565d41160326f 100644 --- a/src/platform/packages/shared/kbn-test/src/jest/run.ts +++ b/src/platform/packages/shared/kbn-test/src/jest/run.ts @@ -27,6 +27,7 @@ import { createFailError } from '@kbn/dev-cli-errors'; import { REPO_ROOT } from '@kbn/repo-info'; import { map } from 'lodash'; import getopts from 'getopts'; +import { SCOUT_REPORTER_ENABLED } from '@kbn/scout-info'; import jestFlags from './jest_flags.json'; // yarn test:jest src/core/server/saved_objects @@ -144,12 +145,22 @@ export function runJest(configName = 'jest.config.js') { } log.info('yarn jest', process.argv.slice(2).join(' ')); + + if (SCOUT_REPORTER_ENABLED) { + // Expose Jest config file path via environment variables + process.env.JEST_CONFIG_PATH = configPath; + } } if (process.env.NODE_ENV == null) { process.env.NODE_ENV = 'test'; } + if (SCOUT_REPORTER_ENABLED && argv.config) { + // Expose Jest config file path via environment variables + process.env.JEST_CONFIG_PATH = argv.config; + } + run().then(() => { // Success means that tests finished, doesn't mean they passed. reportTime(runStartTime, 'total', {