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
This commit is contained in:
Rubén Norte 2024-11-14 06:20:47 -08:00 committed by Facebook GitHub Bot
parent fb32d93d17
commit 849c139a4c
7 changed files with 492 additions and 0 deletions

3
.gitignore vendored
View File

@ -155,3 +155,6 @@ vendor/
# CircleCI # CircleCI
.circleci/generated_config.yml .circleci/generated_config.yml
# Jest Integration
/jest/integration/build/

View File

@ -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: [
'<rootDir>/packages/react-native',
'<rootDir>/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: ['<rootDir>/jest/integration/build/'],
};

View File

@ -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');

View File

@ -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);

View File

@ -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}'));
`;
};

View File

@ -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,
};
};

View File

@ -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<string>,
title: string,
fullName: string,
status: 'passed' | 'failed' | 'pending',
duration: number,
failureMessages: Array<string>,
numPassingAsserts: number,
// location: string,
};
export type TestSuiteResult =
| {
testResults: Array<TestCaseResult>,
}
| {
error: {
message: string,
stack: string,
},
};
const tests: Array<{
title: string,
ancestorTitles: Array<string>,
implementation: () => mixed,
result?: TestCaseResult,
}> = [];
const ancestorTitles: Array<string> = [];
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<mixed>): 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();
});
}