diff --git a/packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js b/packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js index 8b5ba73d742..ee5e8e91311 100644 --- a/packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js +++ b/packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js @@ -92,6 +92,10 @@ export class DeviceMock extends DeviceAgent { | void, > = jest.fn(); +wrappedEvent: JestMockFn<[message: WrappedEvent], void> = jest.fn(); + +wrappedEventParsed: JestMockFn< + [payload: {...WrappedEvent['payload'], wrappedEvent: JSONSerializable}], + void, + > = jest.fn(); __handle(message: MessageToDevice): void { switch (message.event) { @@ -107,6 +111,10 @@ export class DeviceMock extends DeviceAgent { break; case 'wrappedEvent': this.wrappedEvent(message); + this.wrappedEventParsed({ + ...message.payload, + wrappedEvent: JSON.parse(message.payload.wrappedEvent), + }); break; default: (message: empty); diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyCdpTransport-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyCdpTransport-test.js index d57e5639f8c..f354cf90272 100644 --- a/packages/dev-middleware/src/__tests__/InspectorProxyCdpTransport-test.js +++ b/packages/dev-middleware/src/__tests__/InspectorProxyCdpTransport-test.js @@ -73,14 +73,11 @@ describe('inspector proxy CDP transport', () => { await until(() => expect(device1.wrappedEvent).toBeCalled()); - expect(device1.wrappedEvent).toBeCalledWith({ - event: 'wrappedEvent', - payload: { - pageId: 'page1', - wrappedEvent: JSON.stringify({ - method: 'Runtime.enable', - id: 0, - }), + expect(device1.wrappedEventParsed).toBeCalledWith({ + pageId: 'page1', + wrappedEvent: { + method: 'Runtime.enable', + id: 0, }, }); diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyReactNativeReloads-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyReactNativeReloads-test.js new file mode 100644 index 00000000000..dd25f52ad93 --- /dev/null +++ b/packages/dev-middleware/src/__tests__/InspectorProxyReactNativeReloads-test.js @@ -0,0 +1,252 @@ +/** + * 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 {fetchJson} from './FetchUtils'; +import {createDebuggerMock} from './InspectorDebuggerUtils'; +import {createDeviceMock} from './InspectorDeviceUtils'; +import {withAbortSignalForEachTest} from './ResourceUtils'; +import {withServerForEachTest} from './ServerUtils'; +import invariant from 'invariant'; +import until from 'wait-for-expect'; + +// WebSocket is unreliable when using fake timers. +jest.useRealTimers(); + +jest.setTimeout(10000); + +describe('inspector proxy React Native reloads', () => { + const serverRef = withServerForEachTest({ + logger: undefined, + projectRoot: '', + }); + const autoCleanup = withAbortSignalForEachTest(); + afterEach(() => { + jest.clearAllMocks(); + }); + + test('routing messages from the debugger to the latest React Native page', async () => { + let device1, debugger_; + try { + /*** + * Connect a device with one React Native page. + */ + device1 = await createDeviceMock( + `${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`, + autoCleanup.signal, + ); + device1.getPages.mockImplementation(() => [ + { + app: 'bar', + id: 'originalPage-initial', + // NOTE: 'React' is a magic string used to detect React Native pages. + title: 'React Native (mock)', + vm: 'vm', + }, + ]); + let pageList; + await until(async () => { + pageList = (await fetchJson( + `${serverRef.serverBaseUrl}/json`, + // $FlowIgnore[unclear-type] + ): any); + expect(pageList.length).toBeGreaterThan(0); + }); + invariant(pageList != null, ''); + + /*** + * The proxy reports *two* pages. + */ + const syntheticPage = pageList.find( + ({title}) => + // NOTE: Magic string used for the synthetic page that has a stable ID + title === 'React Native Experimental (Improved Chrome Reloads)', + ); + const originalPage = pageList.find( + ({title}) => title === 'React Native (mock)', + ); + expect(syntheticPage).not.toBeUndefined(); + expect(originalPage).not.toBeUndefined(); + expect(originalPage.id).toContain('originalPage-initial'); + expect(syntheticPage.id).not.toEqual(originalPage.id); + + // Connect to the synthetic page + debugger_ = await createDebuggerMock( + syntheticPage.webSocketDebuggerUrl, + autoCleanup.signal, + ); + + debugger_.send({ + method: 'Console.enable', + id: 0, + }); + + await until(() => + expect(device1.wrappedEventParsed).toBeCalledWith({ + pageId: 'originalPage-initial', + wrappedEvent: { + method: 'Console.enable', + id: 0, + }, + }), + ); + + /** + * Replace our original page with a new one. + */ + device1.getPages.mockImplementation(() => [ + { + app: 'bar', + id: 'originalPage-updated', + // NOTE: 'React' is a magic string used to detect React Native pages. + title: 'React Native (mock)', + vm: 'vm', + }, + ]); + await until(async () => { + pageList = (await fetchJson( + `${serverRef.serverBaseUrl}/json`, + // $FlowIgnore[unclear-type] + ): any); + expect(pageList).toContainEqual( + expect.objectContaining({ + id: expect.stringContaining('originalPage-updated'), + }), + ); + }); + + /** + * We can reuse our existing debugger connection to the synthetic page. + * Messages will be routed to the updated page. + */ + debugger_.send({ + method: 'Console.disable', + id: 1, + }); + + await until(() => + expect(device1.wrappedEventParsed).toBeCalledWith({ + pageId: 'originalPage-updated', + wrappedEvent: { + method: 'Console.disable', + id: 1, + }, + }), + ); + } finally { + device1?.close(); + debugger_?.close(); + } + }); + + test('routing messages from the latest React Native page to the debugger', async () => { + let device1, debugger_; + try { + /*** + * Connect a device with one React Native page. + */ + device1 = await createDeviceMock( + `${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`, + autoCleanup.signal, + ); + device1.getPages.mockImplementation(() => [ + { + app: 'bar', + id: 'originalPage-initial', + // NOTE: 'React' is a magic string used to detect React Native pages. + title: 'React Native (mock)', + vm: 'vm', + }, + ]); + let pageList; + await until(async () => { + pageList = (await fetchJson( + `${serverRef.serverBaseUrl}/json`, + // $FlowIgnore[unclear-type] + ): any); + expect(pageList.length).toBeGreaterThan(0); + }); + invariant(pageList != null, ''); + + /*** + * The proxy reports *two* pages. + */ + const syntheticPage = pageList.find( + ({title}) => + // NOTE: Magic string used for the synthetic page that has a stable ID + title === 'React Native Experimental (Improved Chrome Reloads)', + ); + const originalPage = pageList.find( + ({title}) => title === 'React Native (mock)', + ); + expect(syntheticPage).not.toBeUndefined(); + expect(originalPage).not.toBeUndefined(); + expect(originalPage.id).toContain('originalPage-initial'); + expect(syntheticPage.id).not.toEqual(originalPage.id); + + // Connect to the synthetic page + debugger_ = await createDebuggerMock( + syntheticPage.webSocketDebuggerUrl, + autoCleanup.signal, + ); + + device1.sendWrappedEvent('originalPage-initial', { + error: 'Mock error', + }); + + await until(() => + expect(debugger_.handle).toBeCalledWith({ + error: 'Mock error', + }), + ); + + /** + * Replace our original page with a new one. + */ + device1.getPages.mockImplementation(() => [ + { + app: 'bar', + id: 'originalPage-updated', + // NOTE: 'React' is a magic string used to detect React Native pages. + title: 'React Native (mock)', + vm: 'vm', + }, + ]); + await until(async () => { + pageList = (await fetchJson( + `${serverRef.serverBaseUrl}/json`, + // $FlowIgnore[unclear-type] + ): any); + expect(pageList).toContainEqual( + expect.objectContaining({ + id: expect.stringContaining('originalPage-updated'), + }), + ); + }); + + /** + * We can reuse our existing debugger connection to the synthetic page. + * Messages from the updated page will be routed to the debugger. + */ + device1.sendWrappedEvent('originalPage-initial', { + error: 'Another mock error', + }); + + await until(() => + expect(debugger_.handle).toBeCalledWith({ + error: 'Another mock error', + }), + ); + } finally { + device1?.close(); + debugger_?.close(); + } + }); +});