From c85b2da1e7b0da62b996e9b46d6a2029ff00dd52 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 8 Nov 2023 07:19:40 -0800 Subject: [PATCH] Add minimal test for "Improved Chrome Reloads" synthetic page (#41335) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/41335 `inspector-proxy` has special behaviour to allow a debugger connection to persist across app reloads. In the React Native runtime, a reload is modelled as the creation of an entirely new "page" with its own ID. To insulate the debugger from this detail, the proxy advertises a separate, synthetic page on each device, with ID `-1`, that always maps to the latest React Native page reported by that device. Here we test the message forwarding part of this functionality. The proxy also injects CDP messages (in both directions) as part of simulating a reload, but that will be tested in a separate diff. Changelog: [Internal] Reviewed By: blakef Differential Revision: D51002262 fbshipit-source-id: 296135177321a511ebbe7d9696e4e7a61275aa32 --- .../src/__tests__/InspectorDeviceUtils.js | 8 + .../InspectorProxyCdpTransport-test.js | 13 +- .../InspectorProxyReactNativeReloads-test.js | 252 ++++++++++++++++++ 3 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 packages/dev-middleware/src/__tests__/InspectorProxyReactNativeReloads-test.js 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(); + } + }); +});