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
This commit is contained in:
Moti Zilberman
2023-11-08 07:19:40 -08:00
committed by Facebook GitHub Bot
parent 38a878194b
commit c85b2da1e7
3 changed files with 265 additions and 8 deletions
@@ -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);
@@ -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,
},
});
@@ -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();
}
});
});