From 8204ac831463d90303e83b94f883c3d1b3915264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Wed, 13 Nov 2024 10:29:50 -0800 Subject: [PATCH] Initial implementation of Jest test runner for RN integration tests Differential Revision: D65661701 --- .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 | 182 ++++++++++++++++++ jest/integration/runtime/setup.js | 163 ++++++++++++++++ 7 files changed, 456 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 b26247390fb3d8..0c64f7e5c43bcb 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 00000000000000..1252b6d61962d2 --- /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 00000000000000..3080b3c8f6246f --- /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 00000000000000..e8095ec1d9966f --- /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 00000000000000..871fbff7ae3ee7 --- /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 00000000000000..3f26e5e8ac348a --- /dev/null +++ b/jest/integration/runner/index.js @@ -0,0 +1,182 @@ +/** + * 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 fs = require('fs'); +const {formatResultsErrors} = require('jest-message-util'); +const Metro = require('metro'); +const os = require('os'); +const path = require('path'); + +const BUILD_OUTPUT_PATH = path.resolve(__dirname, '..', 'build'); + +function parseRNTesterCommandResult(result) { + const stdout = result.stdout.toString('utf8'); + + 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(testResultJSON); + } catch (error) { + throw new Error( + [ + 'Failed to parse test results from RN tester binary result. Full output:', + 'buck2 ' + rnTesterCommandArgs.join(' '), + 'stdout:', + stdout, + 'stderr:', + result.stderr.toString('utf8'), + ].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()}`); + } +} + +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 entrypointPath = path.join( + BUILD_OUTPUT_PATH, + `${Date.now()}-${path.basename(testPath)}`, + ); + const testBundlePath = entrypointPath + '.bundle'; + const setupModulePath = path.resolve(__dirname, '../runtime/setup.js'); + + const entrypointContents = entrypointTemplate({ + testPath: `.${path.sep}${path.relative(path.dirname(entrypointPath), testPath)}`, + setupModulePath: `.${path.sep}${path.relative(path.dirname(entrypointPath), setupModulePath)}`, + }); + + 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 rnTesterCommandResult = spawnSync('buck2', [ + 'run', + getBuckModeForPlatform(), + '//xplat/ReactNative/react-native-cxx/samples/tester:tester', + '--', + `--bundlePath=${testBundlePath}`, + ]); + + if (rnTesterCommandResult.status !== 0) { + throw new Error( + `Failed to run test: ${rnTesterCommandResult.stderr.toString('utf8')}`, + ); + } + + const rnTesterParsedOutput = parseRNTesterCommandResult( + rnTesterCommandResult, + ); + + if (rnTesterParsedOutput.testResult.error) { + const error = new Error(rnTesterParsedOutput.testResult.error.message); + error.stack = rnTesterParsedOutput.testResult.error.stack; + throw error; + } + + const endTime = Date.now(); + + console.log(rnTesterParsedOutput.logs); + + const testResults = + 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 00000000000000..4f1d01b3751ea1 --- /dev/null +++ b/jest/integration/runtime/setup.js @@ -0,0 +1,163 @@ +/** + * 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 + */ + +const tests: Array<{ + title: string, + ancestorTitles: Array, + implementation: () => mixed, + result?: { + ancestorTitles: Array, + title: string, + fullName: string, + status: 'passed' | 'failed' | 'skipped', + duration: number, + failureMessages: Array, + numPassingAsserts: number, + // location: string, + }, +}> = []; + +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) { + console.log( + JSON.stringify({ + error: { + message: error.message, + stack: error.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, + }; + } + + console.log( + JSON.stringify({ + testResults: tests.map(test => test.result), + }), + ); +} + +export function registerTest(setUpTest: () => void) { + runWithGuard(() => { + setUpTest(); + executeTests(); + }); +}