Files
Rubén Norte 7dc84491e9 Fix reporting of errors without stack traces (#52601)
Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/52601

Changelog: [internal]

Fixes a bug in Fantom when throwing a value that's not an instance of `Error` in a test.

Reviewed By: javache

Differential Revision: D78332756

fbshipit-source-id: 350479dcb7bcea399070c6851aca76a1d1cc2629
2025-07-15 03:45:28 -07:00

448 lines
10 KiB
JavaScript

/**
* 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
*/
import type {SnapshotConfig, TestSnapshotResults} from './snapshotContext';
import expect from './expect';
import {createMockFunction} from './mocks';
import patchWeakRef from './patchWeakRef';
import {setupSnapshotConfig, snapshotContext} from './snapshotContext';
export type TestCaseResult = {
ancestorTitles: Array<string>,
title: string,
fullName: string,
status: 'passed' | 'failed' | 'pending',
duration: number,
failureMessages: Array<string>,
failureDetails: Array<FailureDetail>,
numPassingAsserts: number,
snapshotResults: TestSnapshotResults,
// location: string,
};
export type FailureDetail = {
message: string,
stack?: string,
cause?: FailureDetail,
};
export type TestSuiteResult =
| {
testResults: Array<TestCaseResult>,
}
| {
error: FailureDetail,
};
type FocusState = {
focused: boolean,
skipped: boolean,
};
type Spec = {
...FocusState,
title: string,
parentContext: Context,
implementation: () => mixed,
};
type Suite = Spec | Context;
type Hook = () => void;
type Context = {
...FocusState,
title?: string,
afterAllHooks: Hook[],
afterEachHooks: Hook[],
beforeAllHooks: Hook[],
beforeEachHooks: Hook[],
parentContext?: Context,
children: Array<Suite>,
};
const rootContext: Context = {
beforeAllHooks: [],
beforeEachHooks: [],
afterAllHooks: [],
afterEachHooks: [validateEmptyMessageQueue],
children: [],
focused: false,
skipped: false,
};
let currentContext: Context = rootContext;
const globalModifiers: Array<'focused' | 'skipped'> = [];
const globalDescribe = (global.describe = (
title: string,
implementation: () => mixed,
) => {
const parentContext = currentContext;
const {focused, skipped} = getFocusState();
const childContext: Context = {
title,
parentContext,
afterAllHooks: [],
afterEachHooks: [],
beforeAllHooks: [],
beforeEachHooks: [],
children: [],
focused,
skipped,
};
currentContext.children.push(childContext);
currentContext = childContext;
implementation();
currentContext = parentContext;
});
global.afterAll = (implementation: () => void) => {
currentContext.afterAllHooks.push(implementation);
};
global.afterEach = (implementation: () => void) => {
currentContext.afterEachHooks.push(implementation);
};
global.beforeAll = (implementation: () => void) => {
currentContext.beforeAllHooks.push(implementation);
};
global.beforeEach = (implementation: () => void) => {
currentContext.beforeEachHooks.push(implementation);
};
function getFocusState(): {focused: boolean, skipped: boolean} {
const focused =
globalModifiers.length > 0 &&
globalModifiers[globalModifiers.length - 1] === 'focused';
const skipped =
globalModifiers.length > 0 &&
globalModifiers[globalModifiers.length - 1] === 'skipped';
return {focused, skipped};
}
const globalIt =
(global.it =
global.test =
(title: string, implementation: () => mixed) => {
const {focused, skipped} = getFocusState();
currentContext.children.push({
title,
parentContext: currentContext,
implementation,
focused,
skipped,
});
});
// $FlowExpectedError[prop-missing]
global.fdescribe = global.describe.only = (
title: string,
implementation: () => mixed,
) => {
globalModifiers.push('focused');
globalDescribe(title, implementation);
globalModifiers.pop();
};
// $FlowExpectedError[prop-missing]
global.it.only =
global.fit =
// $FlowExpectedError[prop-missing]
global.test.only =
(title: string, implementation: () => mixed) => {
globalModifiers.push('focused');
globalIt(title, implementation);
globalModifiers.pop();
};
// $FlowExpectedError[prop-missing]
global.xdescribe = global.describe.skip = (
title: string,
implementation: () => mixed,
) => {
globalModifiers.push('skipped');
globalDescribe(title, implementation);
globalModifiers.pop();
};
// $FlowExpectedError[prop-missing]
global.it.skip =
global.xit =
// $FlowExpectedError[prop-missing]
global.test.skip =
global.xtest =
(title: string, implementation: () => mixed) => {
globalModifiers.push('skipped');
globalIt(title, implementation);
globalModifiers.pop();
};
global.jest = {
fn: createMockFunction,
};
global.expect = expect;
let testSetupError: ?Error;
function runWithGuard(fn: () => void) {
try {
fn();
} catch (error) {
testSetupError = error instanceof Error ? error : new Error(String(error));
}
}
const focusCache = new Map<Suite, boolean>();
function isFocusedSuite(suite: Suite): boolean {
const cached = focusCache.get(suite);
if (cached != null) {
return cached;
}
if (isSkipped(suite)) {
focusCache.set(suite, false);
return false;
}
if ('children' in suite) {
const hasFocused = suite.children.some(isFocusedSuite);
focusCache.set(suite, hasFocused);
return hasFocused;
}
focusCache.set(suite, suite.focused);
return suite.focused;
}
const skippedCache = new Map<Suite, boolean>();
function isSkipped(suite: Suite): boolean {
const cached = skippedCache.get(suite);
if (cached != null) {
return cached;
}
if (suite.skipped) {
skippedCache.set(suite, true);
return true;
}
if (suite.parentContext != null) {
const skipped = isSkipped(suite.parentContext);
skippedCache.set(suite, skipped);
return skipped;
}
skippedCache.set(suite, false);
return false;
}
function getContextTitle(context: Context): string[] {
if (context.parentContext == null) {
return [];
}
const titles = getContextTitle(context.parentContext);
if (context.title != null) {
titles.push(context.title);
}
return titles;
}
function invokeHooks(
context: Context,
hookType: 'beforeEachHooks' | 'afterEachHooks',
) {
const contextStack = [];
let current: ?Context = context;
while (current != null) {
if (hookType === 'beforeEachHooks') {
contextStack.unshift(current);
} else {
contextStack.push(current);
}
current = current.parentContext;
}
for (const c of contextStack) {
for (const hook of c[hookType]) {
hook();
}
}
}
function shouldRunSuite(suite: Suite): boolean {
if (isSkipped(suite)) {
return false;
}
if (isFocusedSuite(suite)) {
return true;
}
// there is a focused suite in the root at some point
// but not in this suite hence we should not run it
if (isFocusedSuite(rootContext)) {
return false;
}
return true;
}
function runSpec(spec: Spec): TestCaseResult {
const ancestorTitles = getContextTitle(spec.parentContext);
const result: TestCaseResult = {
title: spec.title,
ancestorTitles,
fullName: [...ancestorTitles, spec.title].join(' '),
status: 'pending',
duration: 0,
failureMessages: [],
failureDetails: [],
numPassingAsserts: 0,
snapshotResults: {},
};
if (!shouldRunSuite(spec)) {
return result;
}
let status: 'passed' | 'failed' | 'pending';
let error: mixed;
const start = Date.now();
snapshotContext.setTargetTest(result.fullName);
try {
invokeHooks(spec.parentContext, 'beforeEachHooks');
spec.implementation();
invokeHooks(spec.parentContext, 'afterEachHooks');
status = 'passed';
} catch (e: mixed) {
error = e;
status = 'failed';
}
result.status = status;
result.duration = Date.now() - start;
if (status === 'failed' && error != null) {
if (error instanceof Error) {
result.failureMessages = [error.stack ?? error.message ?? String(error)];
result.failureDetails = [serializeError(error)];
} else {
result.failureMessages = [`Non-error value thrown: ${String(error)}`];
result.failureDetails = [];
}
} else {
result.failureMessages = [];
result.failureDetails = [];
}
result.snapshotResults = snapshotContext.getSnapshotResults();
return result;
}
function runContext(context: Context): TestCaseResult[] {
const shouldRunHooks = shouldRunSuite(context);
if (shouldRunHooks) {
for (const beforeAllHook of context.beforeAllHooks) {
beforeAllHook();
}
}
const testResults: TestCaseResult[] = [];
for (const child of context.children) {
testResults.push(...runSuite(child));
}
if (shouldRunHooks) {
for (const afterAllHook of context.afterAllHooks) {
afterAllHook();
}
}
return testResults;
}
function runSuite(suite: Suite): TestCaseResult[] {
if ('children' in suite) {
return runContext(suite);
} else {
return [runSpec(suite)];
}
}
function reportTestSuiteResult(testSuiteResult: TestSuiteResult): void {
// Force the import of the native module to be lazy
const NativeFantom =
require('react-native/src/private/testing/fantom/specs/NativeFantom').default;
NativeFantom.reportTestSuiteResultsJSON(
JSON.stringify({
type: 'test-result',
...testSuiteResult,
}),
);
}
function validateEmptyMessageQueue(): void {
// Force the import of the native module to be lazy
const NativeFantom =
require('react-native/src/private/testing/fantom/specs/NativeFantom').default;
NativeFantom.validateEmptyMessageQueue();
}
function serializeError(error: Error): FailureDetail {
const result: FailureDetail = {
message: error.message,
stack: error.stack,
};
if (error.cause instanceof Error) {
result.cause = serializeError(error.cause);
}
return result;
}
global.$$RunTests$$ = () => {
if (testSetupError != null) {
reportTestSuiteResult({
error: {
message: testSetupError.message,
stack: testSetupError.stack,
},
});
} else {
reportTestSuiteResult({
testResults: runSuite(currentContext),
});
}
};
export function registerTest(
setUpTest: () => void,
snapshotConfig: SnapshotConfig,
) {
setupSnapshotConfig(snapshotConfig);
runWithGuard(() => {
setUpTest();
});
}
patchWeakRef();