Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New internal testing helpers: waitFor, waitForAll, waitForPaint #26285

Merged
merged 2 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ module.exports = {
'packages/react-native-renderer/**/*.js',
'packages/eslint-plugin-react-hooks/**/*.js',
'packages/jest-react/**/*.js',
'packages/internal-test-utils/**/*.js',
'packages/**/__tests__/*.js',
'packages/**/npm/*.js',
],
Expand Down
182 changes: 182 additions & 0 deletions packages/internal-test-utils/ReactInternalTestUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* 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.
*/

// TODO: Move `internalAct` and other test helpers to this package, too

import * as SchedulerMock from 'scheduler/unstable_mock';
import {diff} from 'jest-diff';
import {equals} from '@jest/expect-utils';

function assertYieldsWereCleared(Scheduler) {
const actualYields = Scheduler.unstable_clearYields();
if (actualYields.length !== 0) {
const error = Error(
'The event log is not empty. Call assertLog(...) first.',
);
Error.captureStackTrace(error, assertYieldsWereCleared);
throw error;
}
}

export async function waitFor(expectedLog) {
assertYieldsWereCleared(SchedulerMock);

// Create the error object before doing any async work, to get a better
// stack trace.
const error = new Error();
Error.captureStackTrace(error, waitFor);

const actualLog = [];
do {
// Wait until end of current task/microtask.
await null;
if (SchedulerMock.unstable_hasPendingWork()) {
SchedulerMock.unstable_flushNumberOfYields(
expectedLog.length - actualLog.length,
);
actualLog.push(...SchedulerMock.unstable_clearYields());
if (expectedLog.length > actualLog.length) {
// Continue flushing until we've logged the expected number of items.
} else {
// Once we've reached the expected sequence, wait one more microtask to
// flush any remaining synchronous work.
await null;
actualLog.push(...SchedulerMock.unstable_clearYields());
break;
}
} else {
// There's no pending work, even after a microtask.
break;
}
} while (true);

if (equals(actualLog, expectedLog)) {
return;
}

error.message = `
Expected sequence of events did not occur.

${diff(expectedLog, actualLog)}
`;
throw error;
}

export async function waitForAll(expectedLog) {
assertYieldsWereCleared(SchedulerMock);

// Create the error object before doing any async work, to get a better
// stack trace.
const error = new Error();
Error.captureStackTrace(error, waitFor);

do {
// Wait until end of current task/microtask.
await null;
if (!SchedulerMock.unstable_hasPendingWork()) {
// There's no pending work, even after a microtask. Stop flushing.
break;
}
SchedulerMock.unstable_flushAllWithoutAsserting();
} while (true);

const actualLog = SchedulerMock.unstable_clearYields();
if (equals(actualLog, expectedLog)) {
return;
}

error.message = `
Expected sequence of events did not occur.

${diff(expectedLog, actualLog)}
`;
throw error;
}

export async function waitForThrow(expectedError: mixed) {
assertYieldsWereCleared(SchedulerMock);

// Create the error object before doing any async work, to get a better
// stack trace.
const error = new Error();
Error.captureStackTrace(error, waitFor);

do {
// Wait until end of current task/microtask.
await null;
if (!SchedulerMock.unstable_hasPendingWork()) {
// There's no pending work, even after a microtask. Stop flushing.
error.message = 'Expected something to throw, but nothing did.';
throw error;
}
try {
SchedulerMock.unstable_flushAllWithoutAsserting();
} catch (x) {
if (equals(x, expectedError)) {
return;
}
if (typeof x === 'object' && x !== null && x.message === expectedError) {
return;
}
error.message = `
Expected error was not thrown.

${diff(expectedError, x)}
`;
throw error;
}
} while (true);
}

// TODO: This name is a bit misleading currently because it will stop as soon as
// React yields for any reason, not just for a paint. I've left it this way for
// now because that's how untable_flushUntilNextPaint already worked, but maybe
// we should split these use cases into separate APIs.
export async function waitForPaint(expectedLog) {
assertYieldsWereCleared(SchedulerMock);

// Create the error object before doing any async work, to get a better
// stack trace.
const error = new Error();
Error.captureStackTrace(error, waitFor);

// Wait until end of current task/microtask.
await null;
if (SchedulerMock.unstable_hasPendingWork()) {
// Flush until React yields.
SchedulerMock.unstable_flushUntilNextPaint();
// Wait one more microtask to flush any remaining synchronous work.
await null;
}

const actualLog = SchedulerMock.unstable_clearYields();
if (equals(actualLog, expectedLog)) {
return;
}

error.message = `
Expected sequence of events did not occur.

${diff(expectedLog, actualLog)}
`;
throw error;
}

export function assertLog(expectedLog) {
const actualLog = SchedulerMock.unstable_clearYields();
if (equals(actualLog, expectedLog)) {
return;
}

const error = new Error(`
Expected sequence of events did not occur.

${diff(expectedLog, actualLog)}
`);
Error.captureStackTrace(error, assertLog);
throw error;
}
1 change: 1 addition & 0 deletions packages/internal-test-utils/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ReactInternalTestUtils';
5 changes: 5 additions & 0 deletions packages/internal-test-utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"name": "internal-test-utils",
"version": "0.0.0"
}
4 changes: 3 additions & 1 deletion packages/jest-react/src/JestReact.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ function assertYieldsWereCleared(root) {
const Scheduler = root._Scheduler;
const actualYields = Scheduler.unstable_clearYields();
if (actualYields.length !== 0) {
throw new Error(
const error = Error(
'Log of yielded values is not empty. ' +
'Call expect(ReactTestRenderer).unstable_toHaveYielded(...) first.',
);
Error.captureStackTrace(error, assertYieldsWereCleared);
throw error;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ let Fragment;
let ReactNoop;
let Scheduler;
let act;
let waitFor;
let waitForAll;
let assertLog;
let waitForPaint;
let Suspense;
let getCacheForType;

Expand All @@ -19,6 +23,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
Scheduler = require('scheduler');
act = require('jest-react').act;
Suspense = React.Suspense;
const InternalTestUtils = require('internal-test-utils');
waitFor = InternalTestUtils.waitFor;
waitForAll = InternalTestUtils.waitForAll;
waitForPaint = InternalTestUtils.waitForPaint;
assertLog = InternalTestUtils.assertLog;

getCacheForType = React.unstable_getCacheForType;

Expand Down Expand Up @@ -208,7 +217,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
React.startTransition(() => {
ReactNoop.render(<Foo />);
});
expect(Scheduler).toFlushAndYieldThrough([
await waitFor([
'Foo',
'Bar',
// A suspends
Expand All @@ -226,7 +235,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {

// Even though the promise has resolved, we should now flush
// and commit the in progress render instead of restarting.
expect(Scheduler).toFlushAndYield(['D']);
await waitForPaint(['D']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Loading..." />
Expand All @@ -235,11 +244,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
</>,
);

// Await one micro task to attach the retry listeners.
await null;

// Next, we'll flush the complete content.
expect(Scheduler).toFlushAndYield(['Bar', 'A', 'B']);
await waitForAll(['Bar', 'A', 'B']);

expect(ReactNoop).toMatchRenderedOutput(
<>
Expand Down Expand Up @@ -544,7 +550,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
ReactNoop.flushSync(() => {
ReactNoop.render(<App highPri="B" lowPri="1" />);
});
expect(Scheduler).toHaveYielded(['B', '1']);
assertLog(['B', '1']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="B" />
Expand Down
6 changes: 6 additions & 0 deletions scripts/jest/config.build.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ const NODE_MODULES_DIR =
// Find all folders in packages/* with package.json
const packagesRoot = join(__dirname, '..', '..', 'packages');
const packages = readdirSync(packagesRoot).filter(dir => {
if (dir === 'internal-test-utils') {
// This is an internal package used only for testing. It's OK to read
// from source.
// TODO: Maybe let's have some convention for this?
return false;
}
if (dir.charAt(0) === '.') {
return false;
}
Expand Down
5 changes: 4 additions & 1 deletion scripts/jest/matchers/schedulerTestMatchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ function captureAssertion(fn) {

function assertYieldsWereCleared(Scheduler) {
const actualYields = Scheduler.unstable_clearYields();

if (actualYields.length !== 0) {
throw new Error(
const error = Error(
'Log of yielded values is not empty. ' +
'Call expect(Scheduler).toHaveYielded(...) first.'
);
Error.captureStackTrace(error, assertYieldsWereCleared);
throw error;
}
}

Expand Down