diff --git a/jest/integration/runtime/expect.js b/jest/integration/runtime/expect.js new file mode 100644 index 00000000000000..98c28ba549ddbd --- /dev/null +++ b/jest/integration/runtime/expect.js @@ -0,0 +1,235 @@ +/** + * 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 {ensureMockFunction} from './mocks'; +import deepEqual from 'deep-equal'; +import {diff} from 'jest-diff'; + +class ErrorWithCustomBlame extends Error { + // Initially 5 to ignore all the frames from Babel helpers to instantiate this + // custom error class. + #ignoredFrameCount: number = 5; + #cachedProcessedStack: ?string; + #customStack: ?string; + + blameToPreviousFrame(): this { + this.#cachedProcessedStack = null; + this.#ignoredFrameCount++; + return this; + } + + // $FlowExpectedError[unsafe-getters-setters] + get stack(): string { + if (this.#cachedProcessedStack == null) { + const originalStack = this.#customStack ?? super.stack; + + const lines = originalStack.split('\n'); + const index = lines.findIndex(line => + /at (.*) \((.*):(\d+):(\d+)\)/.test(line), + ); + lines.splice(index > -1 ? index : 1, this.#ignoredFrameCount); + this.#cachedProcessedStack = lines.join('\n'); + } + + return this.#cachedProcessedStack; + } + + // $FlowExpectedError[unsafe-getters-setters] + set stack(value: string) { + this.#cachedProcessedStack = null; + this.#customStack = value; + } + + static fromError(error: Error): ErrorWithCustomBlame { + const errorWithCustomBlame = new ErrorWithCustomBlame(error.message); + // In this case we're inheriting the error and we don't know if the stack + // contains helpers that we need to ignore. + errorWithCustomBlame.#ignoredFrameCount = 0; + errorWithCustomBlame.stack = error.stack; + return errorWithCustomBlame; + } +} + +class Expect { + #received: mixed; + #isNot: boolean = false; + + constructor(received: mixed) { + this.#received = received; + } + + // $FlowExpectedError[unsafe-getters-setters] + get not(): this { + this.#isNot = !this.#isNot; + return this; + } + + toEqual(expected: mixed): void { + const pass = deepEqual(this.#received, expected, {strict: true}); + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `Expected${this.#maybeNotLabel()} to equal:\n${ + diff(expected, this.#received, { + contextLines: 1, + expand: false, + omitAnnotationLines: true, + }) ?? 'Failed to compare outputs' + }`, + ).blameToPreviousFrame(); + } + } + + toBe(expected: mixed): void { + const pass = this.#received === expected; + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `Expected${this.#maybeNotLabel()} ${String(expected)} but received ${String(this.#received)}.`, + ).blameToPreviousFrame(); + } + } + + toBeInstanceOf(expected: Class): void { + const pass = this.#received instanceof expected; + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `expected ${String(this.#received)}${this.#maybeNotLabel()} to be an instance of ${String(expected)}`, + ).blameToPreviousFrame(); + } + } + + toBeCloseTo(expected: number, precision: number = 2): void { + const pass = + Math.abs(expected - Number(this.#received)) < Math.pow(10, -precision); + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `Expected ${String(this.#received)}${this.#maybeNotLabel()} to be close to ${expected}`, + ).blameToPreviousFrame(); + } + } + + toBeNull(): void { + const pass = this.#received == null; + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `Expected ${String(this.#received)}${this.#maybeNotLabel()} to be null`, + ).blameToPreviousFrame(); + } + } + + toThrow(expected?: string): void { + if (expected != null && typeof expected !== 'string') { + throw new ErrorWithCustomBlame( + 'toThrow() implementation only accepts strings as arguments.', + ).blameToPreviousFrame(); + } + + let pass = false; + try { + // $FlowExpectedError[not-a-function] + this.#received(); + } catch (error) { + pass = expected != null ? error.message === expected : true; + } + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `Expected ${String(this.#received)}${this.#maybeNotLabel()} to throw`, + ).blameToPreviousFrame(); + } + } + + toHaveBeenCalled(): void { + const mock = this.#requireMock(); + const pass = mock.calls.length > 0; + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `Expected ${String(this.#received)}${this.#maybeNotLabel()} to have been called, but it was${this.#isNot ? '' : "n't"}`, + ).blameToPreviousFrame(); + } + } + + toHaveBeenCalledTimes(times: number): void { + const mock = this.#requireMock(); + const pass = mock.calls.length === times; + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `Expected ${String(this.#received)}${this.#maybeNotLabel()} to have been called ${times} times, but it was called ${mock.calls.length} times`, + ).blameToPreviousFrame(); + } + } + + toBeGreaterThanOrEqual(expected: number): void { + if (typeof this.#received !== 'number') { + throw new ErrorWithCustomBlame( + `Expected ${String(this.#received)} to be a number but it was a ${typeof this.#received}`, + ).blameToPreviousFrame(); + } + + if (typeof expected !== 'number') { + throw new ErrorWithCustomBlame( + `Expected ${String(expected)} to be a number but it was a ${typeof expected}`, + ).blameToPreviousFrame(); + } + + const pass = this.#received >= expected; + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `Expected ${String(this.#received)}${this.#maybeNotLabel()} to be greater than or equal to ${expected}`, + ).blameToPreviousFrame(); + } + } + + toBeLessThanOrEqual(expected: number): void { + if (typeof this.#received !== 'number') { + throw new ErrorWithCustomBlame( + `Expected ${String(this.#received)} to be a number but it was a ${typeof this.#received}`, + ).blameToPreviousFrame(); + } + + if (typeof expected !== 'number') { + throw new ErrorWithCustomBlame( + `Expected ${String(expected)} to be a number but it was a ${typeof expected}`, + ).blameToPreviousFrame(); + } + + const pass = this.#received <= expected; + if (!this.#isExpectedResult(pass)) { + throw new ErrorWithCustomBlame( + `Expected ${String(this.#received)}${this.#maybeNotLabel()} to be less than or equal to ${expected}`, + ).blameToPreviousFrame(); + } + } + + #isExpectedResult(pass: boolean): boolean { + return this.#isNot ? !pass : pass; + } + + #maybeNotLabel(): string { + return this.#isNot ? ' not' : ''; + } + + #requireMock(): JestMockFn, mixed>['mock'] { + try { + return ensureMockFunction(this.#received).mock; + } catch (error) { + const errorWithCustomBlame = ErrorWithCustomBlame.fromError(error); + errorWithCustomBlame.message = `Expected ${String(this.#received)} to be a mock function, but it wasn't`; + errorWithCustomBlame + .blameToPreviousFrame() // ignore `ensureMockFunction` + .blameToPreviousFrame() // ignore `requireMock` + .blameToPreviousFrame(); // ignore `expect().[method]` + throw errorWithCustomBlame; + } + } +} + +const expect: mixed => Expect = (received: mixed) => new Expect(received); + +export default expect; diff --git a/jest/integration/runtime/mocks.js b/jest/integration/runtime/mocks.js new file mode 100644 index 00000000000000..4d8e41a4cdd7f5 --- /dev/null +++ b/jest/integration/runtime/mocks.js @@ -0,0 +1,83 @@ +/** + * 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 + */ + +export const MOCK_FN_TAG: symbol = Symbol('mock function'); + +// The type is defined this way because if we get a mixed value, we return +// a generic mock function, and if we get a typed function, we get a typed mock. +export const ensureMockFunction: (, TReturn>( + fn: (...TArgs) => TReturn, +) => JestMockFn) & + ((fn: mixed) => JestMockFn, mixed>) = fn => { + // $FlowExpectedError[invalid-computed-prop] + // $FlowExpectedError[incompatible-use] + if (typeof fn !== 'function' || !fn[MOCK_FN_TAG]) { + throw new Error( + `Expected ${String(fn)} to be a mock function, but it wasn't`, + ); + } + + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[prop-missing] + return fn; +}; + +export function createMockFunction, TReturn>( + initialImplementation?: (...TArgs) => TReturn, +): JestMockFn { + let implementation: ?(...TArgs) => TReturn = initialImplementation; + + const mock: JestMockFn['mock'] = { + calls: [], + // $FlowExpectedError[incompatible-type] + lastCall: undefined, + instances: [], + contexts: [], + results: [], + }; + + const mockFunction = function (this: mixed, ...args: TArgs): TReturn { + let result: JestMockFn['mock']['results'][number] = { + isThrow: false, + // $FlowExpectedError[incompatible-type] + value: undefined, + }; + + if (implementation != null) { + try { + result.value = implementation.apply(this, args); + } catch (error) { + result.isThrow = true; + result.value = error; + } + } + + mock.calls.push(args); + mock.lastCall = args; + // $FlowExpectedError[incompatible-call] + mock.instances.push(new.target ? this : undefined); + mock.contexts.push(this); + mock.results.push(result); + + if (result.isThrow) { + throw result.value; + } + + return result.value; + }; + + mockFunction.mock = mock; + // $FlowExpectedError[invalid-computed-prop] + mockFunction[MOCK_FN_TAG] = true; + + // $FlowExpectedError[prop-missing] + return mockFunction; +} diff --git a/jest/integration/runtime/setup.js b/jest/integration/runtime/setup.js index ef0bdcaa6b3f91..fc8321532b46d4 100644 --- a/jest/integration/runtime/setup.js +++ b/jest/integration/runtime/setup.js @@ -9,8 +9,8 @@ * @oncall react_native */ -import deepEqual from 'deep-equal'; -import {diff} from 'jest-diff'; +import expect from './expect'; +import {createMockFunction} from './mocks'; import nullthrows from 'nullthrows'; export type TestCaseResult = { @@ -120,269 +120,7 @@ global.jest = { fn: createMockFunction, }; -const MOCK_FN_TAG = Symbol('mock function'); - -function createMockFunction, TReturn>( - initialImplementation?: (...TArgs) => TReturn, -): JestMockFn { - let implementation: ?(...TArgs) => TReturn = initialImplementation; - - const mock: JestMockFn['mock'] = { - calls: [], - // $FlowExpectedError[incompatible-type] - lastCall: undefined, - instances: [], - contexts: [], - results: [], - }; - - const mockFunction = function (this: mixed, ...args: TArgs): TReturn { - let result: JestMockFn['mock']['results'][number] = { - isThrow: false, - // $FlowExpectedError[incompatible-type] - value: undefined, - }; - - if (implementation != null) { - try { - result.value = implementation.apply(this, args); - } catch (error) { - result.isThrow = true; - result.value = error; - } - } - - mock.calls.push(args); - mock.lastCall = args; - // $FlowExpectedError[incompatible-call] - mock.instances.push(new.target ? this : undefined); - mock.contexts.push(this); - mock.results.push(result); - - if (result.isThrow) { - throw result.value; - } - - return result.value; - }; - - mockFunction.mock = mock; - // $FlowExpectedError[invalid-computed-prop] - mockFunction[MOCK_FN_TAG] = true; - - // $FlowExpectedError[prop-missing] - return mockFunction; -} - -// flowlint unsafe-getters-setters:off - -class ErrorWithCustomBlame extends Error { - // Initially 5 to ignore all the frames from Babel helpers to instantiate this - // custom error class. - #ignoredFrameCount: number = 5; - #cachedProcessedStack: ?string; - - blameToPreviousFrame(): this { - this.#ignoredFrameCount++; - return this; - } - - get stack(): string { - if (this.#cachedProcessedStack == null) { - const originalStack = super.stack; - - if (originalStack == null) { - this.#cachedProcessedStack = originalStack; - } else { - const lines = originalStack.split('\n'); - const index = lines.findIndex(line => - /at (.*) \((.*):(\d+):(\d+)\)/.test(line), - ); - lines.splice(index > -1 ? index : 1, this.#ignoredFrameCount); - this.#cachedProcessedStack = lines.join('\n'); - } - } - - return this.#cachedProcessedStack; - } - - set stack(value: string) { - // no-op - } -} - -class Expect { - #received: mixed; - #isNot: boolean = false; - - constructor(received: mixed) { - this.#received = received; - } - - get not(): this { - this.#isNot = !this.#isNot; - return this; - } - - toEqual(expected: mixed): void { - const pass = deepEqual(this.#received, expected, {strict: true}); - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `Expected${this.#maybeNotLabel()} to equal:\n${ - diff(expected, this.#received, { - contextLines: 1, - expand: false, - omitAnnotationLines: true, - }) ?? 'Failed to compare outputs' - }`, - ).blameToPreviousFrame(); - } - } - - toBe(expected: mixed): void { - const pass = this.#received === expected; - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `Expected${this.#maybeNotLabel()} ${String(expected)} but received ${String(this.#received)}.`, - ).blameToPreviousFrame(); - } - } - - toBeInstanceOf(expected: Class): void { - const pass = this.#received instanceof expected; - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `expected ${String(this.#received)}${this.#maybeNotLabel()} to be an instance of ${String(expected)}`, - ).blameToPreviousFrame(); - } - } - - toBeCloseTo(expected: number, precision: number = 2): void { - const pass = - Math.abs(expected - Number(this.#received)) < Math.pow(10, -precision); - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)}${this.#maybeNotLabel()} to be close to ${expected}`, - ).blameToPreviousFrame(); - } - } - - toBeNull(): void { - const pass = this.#received == null; - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)}${this.#maybeNotLabel()} to be null`, - ).blameToPreviousFrame(); - } - } - - toThrow(expected?: string): void { - if (expected != null && typeof expected !== 'string') { - throw new ErrorWithCustomBlame( - 'toThrow() implementation only accepts strings as arguments.', - ).blameToPreviousFrame(); - } - - let pass = false; - try { - // $FlowExpectedError[not-a-function] - this.#received(); - } catch (error) { - pass = expected != null ? error.message === expected : true; - } - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)}${this.#maybeNotLabel()} to throw`, - ).blameToPreviousFrame(); - } - } - - toHaveBeenCalled(): void { - const mock = this.#requireMock(); - const pass = mock.calls.length > 0; - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)}${this.#maybeNotLabel()} to have been called, but it was${this.#isNot ? '' : "n't"}`, - ).blameToPreviousFrame(); - } - } - - toHaveBeenCalledTimes(times: number): void { - const mock = this.#requireMock(); - const pass = mock.calls.length === times; - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)}${this.#maybeNotLabel()} to have been called ${times} times, but it was called ${mock.calls.length} times`, - ).blameToPreviousFrame(); - } - } - - toBeGreaterThanOrEqual(expected: number): void { - if (typeof this.#received !== 'number') { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)} to be a number but it was a ${typeof this.#received}`, - ).blameToPreviousFrame(); - } - - if (typeof expected !== 'number') { - throw new ErrorWithCustomBlame( - `Expected ${String(expected)} to be a number but it was a ${typeof expected}`, - ).blameToPreviousFrame(); - } - - const pass = this.#received >= expected; - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)}${this.#maybeNotLabel()} to be greater than or equal to ${expected}`, - ).blameToPreviousFrame(); - } - } - - toBeLessThanOrEqual(expected: number): void { - if (typeof this.#received !== 'number') { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)} to be a number but it was a ${typeof this.#received}`, - ).blameToPreviousFrame(); - } - - if (typeof expected !== 'number') { - throw new ErrorWithCustomBlame( - `Expected ${String(expected)} to be a number but it was a ${typeof expected}`, - ).blameToPreviousFrame(); - } - - const pass = this.#received <= expected; - if (!this.#isExpectedResult(pass)) { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)}${this.#maybeNotLabel()} to be less than or equal to ${expected}`, - ).blameToPreviousFrame(); - } - } - - #isExpectedResult(pass: boolean): boolean { - return this.#isNot ? !pass : pass; - } - - #maybeNotLabel(): string { - return this.#isNot ? ' not' : ''; - } - - #requireMock(): JestMockFn<$ReadOnlyArray, mixed>['mock'] { - // $FlowExpectedError[incompatible-use] - if (!this.#received?.[MOCK_FN_TAG]) { - throw new ErrorWithCustomBlame( - `Expected ${String(this.#received)} to be a mock function, but it wasn't`, - ) - .blameToPreviousFrame() - .blameToPreviousFrame(); - } - - // $FlowExpectedError[incompatible-use] - return this.#received.mock; - } -} - -global.expect = (received: mixed) => new Expect(received); +global.expect = expect; function runWithGuard(fn: () => void) { try { diff --git a/packages/react-native/src/private/__tests__/ReactNativeTester-itest.js b/packages/react-native/src/private/__tests__/ReactNativeTester-itest.js new file mode 100644 index 00000000000000..5c6d73148d3ccc --- /dev/null +++ b/packages/react-native/src/private/__tests__/ReactNativeTester-itest.js @@ -0,0 +1,72 @@ +/** + * 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 '../../../Libraries/Core/InitializeCore'; +import * as ReactNativeTester from './ReactNativeTester'; + +describe('ReactNativeTester', () => { + describe('runTask', () => { + it('should run a task synchronously', () => { + const task = jest.fn(); + + ReactNativeTester.runTask(task); + + expect(task).toHaveBeenCalledTimes(1); + }); + + // TODO: fix error handling and make this pass + it.skip('should re-throw errors from the task synchronously', () => { + expect(() => { + ReactNativeTester.runTask(() => { + throw new Error('test error'); + }); + }).toThrow('test error'); + }); + + it('should exhaust the microtask queue synchronously', () => { + const lastMicrotask = jest.fn(); + + ReactNativeTester.runTask(() => { + queueMicrotask(() => { + queueMicrotask(() => { + queueMicrotask(() => { + queueMicrotask(lastMicrotask); + }); + }); + }); + }); + + expect(lastMicrotask).toHaveBeenCalledTimes(1); + }); + + // TODO: fix error handling and make this pass + it.skip('should re-throw errors from microtasks synchronously', () => { + expect(() => { + ReactNativeTester.runTask(() => { + queueMicrotask(() => { + throw new Error('test error'); + }); + }); + }).toThrow('test error'); + }); + + it('should run async tasks synchronously', () => { + let completed = false; + + ReactNativeTester.runTask(async () => { + await Promise.resolve(6); + completed = true; + }); + + expect(completed).toBe(true); + }); + }); +}); diff --git a/packages/react-native/src/private/__tests__/ReactNativeTester.js b/packages/react-native/src/private/__tests__/ReactNativeTester.js index 990e71541e1d36..e8b6c864807102 100644 --- a/packages/react-native/src/private/__tests__/ReactNativeTester.js +++ b/packages/react-native/src/private/__tests__/ReactNativeTester.js @@ -65,7 +65,7 @@ class Root { * * React must run inside of event loop to ensure scheduling environment is closer to production. */ -export function runTask(task: () => void) { +export function runTask(task: () => void | Promise) { nativeRuntimeScheduler.unstable_scheduleCallback( schedulerPriorityImmediate, task,