mirror of
https://github.com/facebook/react-native.git
synced 2024-11-21 12:39:27 +00:00
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:
parent
fb32d93d17
commit
849c139a4c
3
.gitignore
vendored
3
.gitignore
vendored
@ -155,3 +155,6 @@ vendor/
|
||||
|
||||
# CircleCI
|
||||
.circleci/generated_config.yml
|
||||
|
||||
# Jest Integration
|
||||
/jest/integration/build/
|
||||
|
27
jest/integration/config/jest.config.js
Normal file
27
jest/integration/config/jest.config.js
Normal 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/'],
|
||||
};
|
13
jest/integration/config/metro-babel-transformer.js
Normal file
13
jest/integration/config/metro-babel-transformer.js
Normal 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');
|
40
jest/integration/config/metro.config.js
Normal file
40
jest/integration/config/metro.config.js
Normal 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);
|
28
jest/integration/runner/entrypoint-template.js
Normal file
28
jest/integration/runner/entrypoint-template.js
Normal 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}'));
|
||||
`;
|
||||
};
|
201
jest/integration/runner/index.js
Normal file
201
jest/integration/runner/index.js
Normal 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,
|
||||
};
|
||||
};
|
180
jest/integration/runtime/setup.js
Normal file
180
jest/integration/runtime/setup.js
Normal 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();
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user