react/scripts/jest/setupTests.js
Sebastian Markbåge acee65d6d0
[Flight] Track Awaits on I/O as Debug Info (#33388)
This lets us track what data each Server Component depended on. This
will be used by Performance Track and React DevTools.

We use Node.js `async_hooks`. This has a number of downside. It is
Node.js specific so this feature is not available in other runtimes
until something equivalent becomes available. It's [discouraged by
Node.js docs](https://nodejs.org/api/async_hooks.html#async-hooks). It's
also slow which makes this approach only really viable in development
mode. At least with stack traces. However, it's really the only solution
that gives us the data that we need.

The [Diagnostic
Channel](https://nodejs.org/api/diagnostics_channel.html) API is not
sufficient. Not only is many Node.js built-in APIs missing but all
libraries like databases are also missing. Were as `async_hooks` covers
pretty much anything async in the Node.js ecosystem.

However, even if coverage was wider it's not actually showing the
information we want. It's not enough to show the low level I/O that is
happening because that doesn't provide the context. We need the stack
trace in user space code where it was initiated and where it was
awaited. It's also not each low level socket operation that we want to
surface but some higher level concept which can span a sequence of I/O
operations but as far as user space is concerned.

Therefore this solution is anchored on stack traces and ignore listing
to determine what the interesting span is. It is somewhat
Promise-centric (and in particular async/await) because it allows us to
model an abstract span instead of just random I/O. Async/await points
are also especially useful because this allows Async Stacks to show the
full sequence which is not supported by random callbacks. However, if no
Promises are involved we still to our best to show the stack causing
plain I/O callbacks.

Additionally, we don't want to track all possible I/O. For example,
side-effects like logging that doesn't affect the rendering performance
doesn't need to be included. We only want to include things that
actually block the rendering output. We also need to track which data
blocks each component so that we can track which data caused a
particular subtree to suspend.

We can do this using `async_hooks` because we can track the graph of
what resolved what and then spawned what.

To track what suspended what, something has to resolve. Therefore it
needs to run to completion before we can show what it was suspended on.
So something that never resolves, won't be tracked for example.

We use the `async_hooks` in `ReactFlightServerConfigDebugNode` to build
up an `ReactFlightAsyncSequence` graph that collects the stack traces
for basically all I/O and Promises allocated in the whole app. This is
pretty heavy, especially the stack traces, but it's because we don't
know which ones we'll need until they resolve. We don't materialize the
stacks until we need them though.

Once they end up pinging the Flight runtime, we collect which current
executing task that pinged the runtime and then log the sequence that
led up until that runtime into the RSC protocol. Currently we only
include things that weren't already resolved before we started rendering
this task/component, so that we don't log the entire history each time.

Each operation is split into two parts. First a `ReactIOInfo` which
represents an I/O operation and its start/end time. Basically the start
point where it was start. This is basically represents where you called
`new Promise()` or when entering an `async function` which has an
implied Promise. It can be started in a different component than where
it's awaited and it can be awaited in multiple places. Therefore this is
global information and not associated with a specific Component.

The second part is `ReactAsyncInfo`. This represents where this I/O was
`await`:ed or `.then()` called. This is associated with a point in the
tree (usually the Promise that's a direct child of a Component). Since
you can have multiple different I/O awaited in a sequence technically it
forms a dependency graph but to simplify the model these awaits as
flattened into the `ReactDebugInfo` list. Basically it contains each
await in a sequence that affected this part from unblocking.

This means that the same `ReactAsyncInfo` can appear in mutliple
components if they all await the same `ReactIOInfo` but the same Promise
only appears once.

Promises that are only resolved by other Promises or immediately are not
considered here. Only if they're resolved by an I/O operation. We pick
the Promise basically on the border between user space code and ignored
listed code (`node_modules`) to pick the most specific span but abstract
enough to not give too much detail irrelevant to the current audience.
Similarly, the deepest `await` in user space is marked as the relevant
`await` point.

This feature is only available in the `node` builds of React. Not if you
use the `edge` builds inside of Node.js.

---------

Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>
2025-06-03 14:14:40 -04:00

311 lines
10 KiB
JavaScript

'use strict';
const {getTestFlags} = require('./TestFlags');
const {
assertConsoleLogsCleared,
resetAllUnexpectedConsoleCalls,
patchConsoleMethods,
} = require('internal-test-utils/consoleMock');
if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
// Inside the class equivalence tester, we have a custom environment, let's
// require that instead.
require('./spec-equivalence-reporter/setupTests.js');
} else {
const errorMap = require('../error-codes/codes.json');
// By default, jest.spyOn also calls the spied method.
const spyOn = jest.spyOn;
const noop = jest.fn;
// Spying on console methods in production builds can mask errors.
// This is why we added an explicit spyOnDev() helper.
// It's too easy to accidentally use the more familiar spyOn() helper though,
// So we disable it entirely.
// Spying on both dev and prod will require using both spyOnDev() and spyOnProd().
global.spyOn = function () {
throw new Error(
'Do not use spyOn(). ' +
'It can accidentally hide unexpected errors in production builds. ' +
'Use spyOnDev(), spyOnProd(), or spyOnDevAndProd() instead.'
);
};
if (process.env.NODE_ENV === 'production') {
global.spyOnDev = noop;
global.spyOnProd = spyOn;
global.spyOnDevAndProd = spyOn;
} else {
global.spyOnDev = spyOn;
global.spyOnProd = noop;
global.spyOnDevAndProd = spyOn;
}
expect.extend({
...require('./matchers/reactTestMatchers'),
...require('./matchers/toThrow'),
});
// We have a Babel transform that inserts guards against infinite loops.
// If a loop runs for too many iterations, we throw an error and set this
// global variable. The global lets us detect an infinite loop even if
// the actual error object ends up being caught and ignored. An infinite
// loop must always fail the test!
beforeEach(() => {
global.infiniteLoopError = null;
});
afterEach(() => {
const error = global.infiniteLoopError;
global.infiniteLoopError = null;
if (error) {
throw error;
}
});
// Patch the console to assert that all console error/warn/log calls assert.
patchConsoleMethods({includeLog: !!process.env.CI});
beforeEach(resetAllUnexpectedConsoleCalls);
afterEach(assertConsoleLogsCleared);
// TODO: enable this check so we don't forget to reset spyOnX mocks.
// afterEach(() => {
// if (
// console[methodName] !== mockMethod &&
// !jest.isMockFunction(console[methodName])
// ) {
// throw new Error(
// `Test did not tear down console.${methodName} mock properly.`
// );
// }
// });
if (process.env.NODE_ENV === 'production') {
// In production, we strip error messages and turn them into codes.
// This decodes them back so that the test assertions on them work.
// 1. `ErrorProxy` decodes error messages at Error construction time and
// also proxies error instances with `proxyErrorInstance`.
// 2. `proxyErrorInstance` decodes error messages when the `message`
// property is changed.
const decodeErrorMessage = function (message) {
if (!message) {
return message;
}
const re = /react.dev\/errors\/(\d+)?\??([^\s]*)/;
let matches = message.match(re);
if (!matches || matches.length !== 3) {
// Some tests use React 17, when the URL was different.
const re17 = /error-decoder.html\?invariant=(\d+)([^\s]*)/;
matches = message.match(re17);
if (!matches || matches.length !== 3) {
return message;
}
}
const code = parseInt(matches[1], 10);
const args = matches[2]
.split('&')
.filter(s => s.startsWith('args[]='))
.map(s => s.slice('args[]='.length))
.map(decodeURIComponent);
const format = errorMap[code];
let argIndex = 0;
return format.replace(/%s/g, () => args[argIndex++]);
};
const OriginalError = global.Error;
// V8's Error.captureStackTrace (used in Jest) fails if the error object is
// a Proxy, so we need to pass it the unproxied instance.
const originalErrorInstances = new WeakMap();
const captureStackTrace = function (error, ...args) {
return OriginalError.captureStackTrace.call(
this,
originalErrorInstances.get(error) ||
// Sometimes this wrapper receives an already-unproxied instance.
error,
...args
);
};
const proxyErrorInstance = error => {
const proxy = new Proxy(error, {
set(target, key, value, receiver) {
if (key === 'message') {
return Reflect.set(
target,
key,
decodeErrorMessage(value),
receiver
);
}
return Reflect.set(target, key, value, receiver);
},
});
originalErrorInstances.set(proxy, error);
return proxy;
};
const ErrorProxy = new Proxy(OriginalError, {
apply(target, thisArg, argumentsList) {
const error = Reflect.apply(target, thisArg, argumentsList);
error.message = decodeErrorMessage(error.message);
return proxyErrorInstance(error);
},
construct(target, argumentsList, newTarget) {
const error = Reflect.construct(target, argumentsList, newTarget);
error.message = decodeErrorMessage(error.message);
return proxyErrorInstance(error);
},
get(target, key, receiver) {
if (key === 'captureStackTrace') {
return captureStackTrace;
}
return Reflect.get(target, key, receiver);
},
});
ErrorProxy.OriginalError = OriginalError;
global.Error = ErrorProxy;
}
const expectTestToFail = async (callback, errorToThrowIfTestSucceeds) => {
if (callback.length > 0) {
throw Error(
'Gated test helpers do not support the `done` callback. Return a ' +
'promise instead.'
);
}
// Install a global error event handler. We treat global error events as
// test failures, same as Jest's default behavior.
//
// Becaused we installed our own error event handler, Jest will not report a
// test failure. Conceptually it's as if we wrapped the entire test event in
// a try-catch.
let didError = false;
const errorEventHandler = () => {
didError = true;
};
// eslint-disable-next-line no-restricted-globals
if (typeof addEventListener === 'function') {
// eslint-disable-next-line no-restricted-globals
addEventListener('error', errorEventHandler);
}
try {
const maybePromise = callback();
if (
maybePromise !== undefined &&
maybePromise !== null &&
typeof maybePromise.then === 'function'
) {
await maybePromise;
}
// Flush unexpected console calls inside the test itself, instead of in
// `afterEach` like we normally do. `afterEach` is too late because if it
// throws, we won't have captured it.
assertConsoleLogsCleared();
} catch (testError) {
didError = true;
}
resetAllUnexpectedConsoleCalls();
// eslint-disable-next-line no-restricted-globals
if (typeof removeEventListener === 'function') {
// eslint-disable-next-line no-restricted-globals
removeEventListener('error', errorEventHandler);
}
if (!didError) {
// The test did not error like we expected it to. Report this to Jest as
// a failure.
throw errorToThrowIfTestSucceeds;
}
};
const coerceGateConditionToFunction = gateFnOrString => {
return typeof gateFnOrString === 'string'
? // `gate('foo')` is treated as equivalent to `gate(flags => flags.foo)`
flags => flags[gateFnOrString]
: // Assume this is already a function
gateFnOrString;
};
const gatedErrorMessage = 'Gated test was expected to fail, but it passed.';
global._test_gate = (gateFnOrString, testName, callback, timeoutMS) => {
const gateFn = coerceGateConditionToFunction(gateFnOrString);
let shouldPass;
try {
const flags = getTestFlags();
shouldPass = gateFn(flags);
} catch (e) {
test(
testName,
() => {
throw e;
},
timeoutMS
);
return;
}
if (shouldPass) {
test(testName, callback, timeoutMS);
} else {
const error = new Error(gatedErrorMessage);
Error.captureStackTrace(error, global._test_gate);
test(`[GATED, SHOULD FAIL] ${testName}`, () =>
expectTestToFail(callback, error, timeoutMS));
}
};
global._test_gate_focus = (gateFnOrString, testName, callback, timeoutMS) => {
const gateFn = coerceGateConditionToFunction(gateFnOrString);
let shouldPass;
try {
const flags = getTestFlags();
shouldPass = gateFn(flags);
} catch (e) {
test.only(
testName,
() => {
throw e;
},
timeoutMS
);
return;
}
if (shouldPass) {
test.only(testName, callback, timeoutMS);
} else {
const error = new Error(gatedErrorMessage);
Error.captureStackTrace(error, global._test_gate_focus);
test.only(
`[GATED, SHOULD FAIL] ${testName}`,
() => expectTestToFail(callback, error),
timeoutMS
);
}
};
// Dynamic version of @gate pragma
global.gate = gateFnOrString => {
const gateFn = coerceGateConditionToFunction(gateFnOrString);
const flags = getTestFlags();
return gateFn(flags);
};
// We augment JSDOM to produce a document that has a loading readyState by default
// and can be changed. We mock it here globally so we don't have to import our special
// mock in every file.
jest.mock('jsdom', () => {
return require('internal-test-utils/ReactJSDOM.js');
});
}
// We mock createHook so that we can automatically clean it up.
let installedHook = null;
jest.mock('async_hooks', () => {
const actual = jest.requireActual('async_hooks');
return {
...actual,
createHook(config) {
if (installedHook) {
installedHook.disable();
}
return (installedHook = actual.createHook(config));
},
};
});