From 849c139a4cfa7c914be5360a15ce70db8daacd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Thu, 14 Nov 2024 06:20:47 -0800 Subject: [PATCH] Initial implementation of Jest test runner for RN integration tests (#47558) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/47558 Changelog: [internal] Reviewed By: sammy-SC Differential Revision: D65661701 fbshipit-source-id: 0f0227debc769d0cebebc1989cbcfbbdd44dfc34 --- .gitignore | 3 + jest/integration/config/jest.config.js | 27 +++ .../config/metro-babel-transformer.js | 13 ++ jest/integration/config/metro.config.js | 40 ++++ .../integration/runner/entrypoint-template.js | 28 +++ jest/integration/runner/index.js | 201 ++++++++++++++++++ jest/integration/runtime/setup.js | 180 ++++++++++++++++ 7 files changed, 492 insertions(+) create mode 100644 jest/integration/config/jest.config.js create mode 100644 jest/integration/config/metro-babel-transformer.js create mode 100644 jest/integration/config/metro.config.js create mode 100644 jest/integration/runner/entrypoint-template.js create mode 100644 jest/integration/runner/index.js create mode 100644 jest/integration/runtime/setup.js diff --git a/.gitignore b/.gitignore index b26247390fb..0c64f7e5c43 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,6 @@ vendor/ # CircleCI .circleci/generated_config.yml + +# Jest Integration +/jest/integration/build/ diff --git a/jest/integration/config/jest.config.js b/jest/integration/config/jest.config.js new file mode 100644 index 00000000000..1252b6d6196 --- /dev/null +++ b/jest/integration/config/jest.config.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +'use strict'; + +const baseConfig = require('../../../jest.config'); +const path = require('path'); + +module.exports = { + rootDir: path.resolve(__dirname, '../../..'), + roots: [ + '/packages/react-native', + '/jest/integration/runtime', + ], + // This allows running Meta-internal tests with the `-test.fb.js` suffix. + testRegex: '/__tests__/.*-itest(\\.fb)?\\.js$', + testPathIgnorePatterns: baseConfig.testPathIgnorePatterns, + transformIgnorePatterns: ['.*'], + testRunner: './jest/integration/runner/index.js', + watchPathIgnorePatterns: ['/jest/integration/build/'], +}; diff --git a/jest/integration/config/metro-babel-transformer.js b/jest/integration/config/metro-babel-transformer.js new file mode 100644 index 00000000000..3080b3c8f62 --- /dev/null +++ b/jest/integration/config/metro-babel-transformer.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +'use strict'; + +require('../../../scripts/build/babel-register').registerForMonorepo(); +module.exports = require('@react-native/metro-babel-transformer'); diff --git a/jest/integration/config/metro.config.js b/jest/integration/config/metro.config.js new file mode 100644 index 00000000000..e8095ec1d99 --- /dev/null +++ b/jest/integration/config/metro.config.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +'use strict'; + +const {getDefaultConfig} = require('@react-native/metro-config'); +const {mergeConfig} = require('metro-config'); +const path = require('path'); + +const rnTesterConfig = getDefaultConfig( + path.resolve('../../../packages/rn-tester'), +); + +const config = { + projectRoot: path.resolve(__dirname, '../../..'), + reporter: { + update: () => {}, + }, + resolver: { + blockList: null, + sourceExts: [...rnTesterConfig.resolver.sourceExts, 'fb.js'], + nodeModulesPaths: process.env.JS_DIR + ? [path.join(process.env.JS_DIR, 'public', 'node_modules')] + : [], + }, + transformer: { + // We need to wrap the default transformer so we can run it from source + // using babel-register. + babelTransformerPath: path.resolve(__dirname, 'metro-babel-transformer.js'), + }, + watchFolders: process.env.JS_DIR ? [process.env.JS_DIR] : [], +}; + +module.exports = mergeConfig(rnTesterConfig, config); diff --git a/jest/integration/runner/entrypoint-template.js b/jest/integration/runner/entrypoint-template.js new file mode 100644 index 00000000000..871fbff7ae3 --- /dev/null +++ b/jest/integration/runner/entrypoint-template.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall react_native + */ + +module.exports = function entrypointTemplate({testPath, setupModulePath}) { + return `/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * ${'@'}generated + * @noformat + * @noflow + * @oncall react_native + */ + +import {registerTest} from '${setupModulePath}'; + +registerTest(() => require('${testPath}')); +`; +}; diff --git a/jest/integration/runner/index.js b/jest/integration/runner/index.js new file mode 100644 index 00000000000..2cc50d921a9 --- /dev/null +++ b/jest/integration/runner/index.js @@ -0,0 +1,201 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall react_native + */ + +'use strict'; + +const entrypointTemplate = require('./entrypoint-template'); +const {spawnSync} = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const {formatResultsErrors} = require('jest-message-util'); +const Metro = require('metro'); +const nullthrows = require('nullthrows'); +const os = require('os'); +const path = require('path'); + +const BUILD_OUTPUT_PATH = path.resolve(__dirname, '..', 'build'); + +function parseRNTesterCommandResult(commandArgs, result) { + const stdout = result.stdout.toString(); + + const outputArray = stdout.trim().split('\n'); + + // Remove AppRegistry logs at the end + while ( + outputArray.length > 0 && + outputArray[outputArray.length - 1].startsWith('Running "') + ) { + outputArray.pop(); + } + + // The last line should be the test output in JSON format + const testResultJSON = outputArray.pop(); + + let testResult; + try { + testResult = JSON.parse(nullthrows(testResultJSON)); + } catch (error) { + throw new Error( + [ + 'Failed to parse test results from RN tester binary result. Full output:', + 'buck2 ' + commandArgs.join(' '), + 'stdout:', + stdout, + 'stderr:', + result.stderr.toString(), + ].join('\n'), + ); + } + + return {logs: outputArray.join('\n'), testResult}; +} + +function getBuckModeForPlatform() { + switch (os.platform()) { + case 'linux': + return '@//arvr/mode/linux/dev'; + case 'darwin': + return os.arch() === 'arm64' + ? '@//arvr/mode/mac-arm/dev' + : '@//arvr/mode/mac/dev'; + case 'win32': + return '@//arvr/mode/win/dev'; + default: + throw new Error(`Unsupported platform: ${os.platform()}`); + } +} + +function getShortHash(contents) { + return crypto.createHash('md5').update(contents).digest('hex').slice(0, 8); +} + +module.exports = async function runTest( + globalConfig, + config, + environment, + runtime, + testPath, + sendMessageToJest, +) { + const startTime = Date.now(); + + const metroConfig = await Metro.loadConfig({ + config: require.resolve('../config/metro.config.js'), + }); + + const setupModulePath = path.resolve(__dirname, '../runtime/setup.js'); + + const entrypointContents = entrypointTemplate({ + testPath: `.${path.sep}${path.relative(BUILD_OUTPUT_PATH, testPath)}`, + setupModulePath: `.${path.sep}${path.relative(BUILD_OUTPUT_PATH, setupModulePath)}`, + }); + + const entrypointPath = path.join( + BUILD_OUTPUT_PATH, + `${getShortHash(entrypointContents)}-${path.basename(testPath)}`, + ); + const testBundlePath = entrypointPath + '.bundle'; + + fs.mkdirSync(path.dirname(entrypointPath), {recursive: true}); + fs.writeFileSync(entrypointPath, entrypointContents, 'utf8'); + + await Metro.runBuild(metroConfig, { + entry: entrypointPath, + out: testBundlePath, + platform: 'android', + minify: false, + dev: true, + }); + + const rnTesterCommandArgs = [ + 'run', + getBuckModeForPlatform(), + '//xplat/ReactNative/react-native-cxx/samples/tester:tester', + '--', + `--bundlePath=${testBundlePath}`, + ]; + const rnTesterCommandResult = spawnSync('buck2', rnTesterCommandArgs, { + encoding: 'utf8', + }); + + if (rnTesterCommandResult.status !== 0) { + throw new Error( + [ + 'Failed to run test in RN tester binary. Full output:', + 'buck2 ' + rnTesterCommandArgs.join(' '), + 'stdout:', + rnTesterCommandResult.stdout, + 'stderr:', + rnTesterCommandResult.stderr, + ].join('\n'), + ); + } + + const rnTesterParsedOutput = parseRNTesterCommandResult( + rnTesterCommandArgs, + rnTesterCommandResult, + ); + + const testResultError = rnTesterParsedOutput.testResult.error; + if (testResultError) { + const error = new Error(testResultError.message); + error.stack = testResultError.stack; + throw error; + } + + const endTime = Date.now(); + + console.log(rnTesterParsedOutput.logs); + + const testResults = + nullthrows(rnTesterParsedOutput.testResult.testResults).map(testResult => ({ + ancestorTitles: [], + failureDetails: [], + testFilePath: testPath, + ...testResult, + })) ?? []; + + return { + testFilePath: testPath, + failureMessage: formatResultsErrors( + testResults, + config, + globalConfig, + testPath, + ), + leaks: false, + openHandles: [], + perfStats: { + start: startTime, + end: endTime, + duration: endTime - startTime, + runtime: endTime - startTime, + slow: false, + }, + snapshot: { + added: 0, + fileDeleted: false, + matched: 0, + unchecked: 0, + uncheckedKeys: [], + unmatched: 0, + updated: 0, + }, + numTotalTests: testResults.length, + numPassingTests: testResults.filter(test => test.status === 'passed') + .length, + numFailingTests: testResults.filter(test => test.status === 'failed') + .length, + numPendingTests: 0, + numTodoTests: 0, + skipped: false, + testResults, + }; +}; diff --git a/jest/integration/runtime/setup.js b/jest/integration/runtime/setup.js new file mode 100644 index 00000000000..9d0c57bf7bb --- /dev/null +++ b/jest/integration/runtime/setup.js @@ -0,0 +1,180 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import nullthrows from 'nullthrows'; + +export type TestCaseResult = { + ancestorTitles: Array, + title: string, + fullName: string, + status: 'passed' | 'failed' | 'pending', + duration: number, + failureMessages: Array, + numPassingAsserts: number, + // location: string, +}; + +export type TestSuiteResult = + | { + testResults: Array, + } + | { + error: { + message: string, + stack: string, + }, + }; + +const tests: Array<{ + title: string, + ancestorTitles: Array, + implementation: () => mixed, + result?: TestCaseResult, +}> = []; + +const ancestorTitles: Array = []; + +global.describe = (title: string, implementation: () => mixed) => { + ancestorTitles.push(title); + implementation(); + ancestorTitles.pop(); +}; + +global.it = (title: string, implementation: () => mixed) => + tests.push({ + title, + implementation, + ancestorTitles: ancestorTitles.slice(), + }); + +// flowlint unsafe-getters-setters:off + +class Expect { + #received: mixed; + #isNot: boolean = false; + + constructor(received: mixed) { + this.#received = received; + } + + get not(): this { + this.#isNot = !this.#isNot; + return this; + } + + toBe(expected: mixed): void { + const pass = this.#received !== expected; + if (this.#isExpectedResult(pass)) { + throw new Error( + `Expected ${String(expected)} but received ${String(this.#received)}.`, + ); + } + } + + toBeInstanceOf(expected: Class): void { + const pass = this.#received instanceof expected; + if (!pass) { + throw new Error( + `expected ${String(this.#received)} to be an instance of ${String(expected)}`, + ); + } + } + + toBeCloseTo(expected: number, precision: number = 2): void { + const pass = + Math.abs(expected - Number(this.#received)) < Math.pow(10, -precision); + if (!pass) { + throw new Error( + `expected ${String(this.#received)} to be close to ${expected}`, + ); + } + } + + toThrow(error: mixed): void { + if (error != null) { + throw new Error('toThrow() implementation does not accept arguments.'); + } + + let pass = false; + try { + // $FlowExpectedError[not-a-function] + this.#received(); + } catch { + pass = true; + } + if (!pass) { + throw new Error(`expected ${String(this.#received)} to throw`); + } + } + + #isExpectedResult(pass: boolean): boolean { + return this.#isNot ? !pass : pass; + } +} + +global.expect = (received: mixed) => new Expect(received); + +function runWithGuard(fn: () => void) { + try { + fn(); + } catch (error) { + let reportedError = + error instanceof Error ? error : new Error(String(error)); + reportTestSuiteResult({ + error: { + message: reportedError.message, + stack: reportedError.stack, + }, + }); + } +} + +function executeTests() { + for (const test of tests) { + let status; + let error; + + const start = Date.now(); + + try { + test.implementation(); + status = 'passed'; + } catch (e) { + error = e; + status = 'failed'; + } + + test.result = { + title: test.title, + fullName: [...test.ancestorTitles, test.title].join(' '), + ancestorTitles: test.ancestorTitles, + status, + duration: Date.now() - start, + failureMessages: status === 'failed' && error ? [error.message] : [], + numPassingAsserts: 0, + }; + } + + reportTestSuiteResult({ + testResults: tests.map(test => nullthrows(test.result)), + }); +} + +function reportTestSuiteResult(testSuiteResult: TestSuiteResult): void { + console.log(JSON.stringify(testSuiteResult)); +} + +export function registerTest(setUpTest: () => void) { + runWithGuard(() => { + setUpTest(); + executeTests(); + }); +}