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
|
||||||
.circleci/generated_config.yml
|
.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