Skip to content

Commit

Permalink
Add tests for inspector-proxy's HTTP API (facebook#41314)
Browse files Browse the repository at this point in the history
Summary:

Changelog: [Internal]

Adds the beginning of a test suite for `inspector-proxy`. For maintainability, we only test functionality exposed from the `dev-middleware` boundary rather than instantiating `InspectorProxy` directly.

In this diff, the test coverage is far from complete, but this is a first stab at covering some basics. `InspectorProxyHttpApi-test` exercises the HTTP GET endpoints (`/json/list` and `/json/version`) as well as some device registration logic through the `/inspector/device` WebSocket. Some reusable helpers for server setup and device mocking are included in separate files.

As an overall strategy, I'm planning to add multiple test files that share helpers between them, not build out one massive test file with all the helpers inline. There will likely be some verbose tests when we start covering debugger-to-device communication, and I want to keep them as readable as possible.

Differential Revision: D50980467
  • Loading branch information
motiz88 authored and facebook-github-bot committed Nov 6, 2023
1 parent b04fdf3 commit 6a63254
Show file tree
Hide file tree
Showing 6 changed files with 499 additions and 0 deletions.
18 changes: 18 additions & 0 deletions packages/dev-middleware/src/__tests__/FetchUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* 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 async function fetchJson(url: string): Promise<mixed> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
return response.json();
}
139 changes: 139 additions & 0 deletions packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* 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 type {
ConnectRequest,
DisconnectRequest,
GetPagesRequest,
GetPagesResponse,
MessageFromDevice,
MessageToDevice,
WrappedEvent,
} from '../inspector-proxy/types';

import WebSocket from 'ws';

export class DeviceAgent {
_ws: ?WebSocket;
_readyPromise: Promise<void>;

constructor(url: string, signal?: AbortSignal) {
const ws = new WebSocket(url);
this._ws = ws;
ws.on('message', data => {
this.__handle(JSON.parse(data.toString()));
});
if (signal != null) {
signal.addEventListener('abort', () => {
this.close();
});
}
this._readyPromise = new Promise<void>((resolve, reject) => {
ws.once('open', () => {
resolve();
});
ws.once('error', error => {
reject(error);
});
});
}

__handle(message: MessageToDevice): void {}

send(message: MessageFromDevice) {
if (!this._ws) {
return;
}
this._ws.send(JSON.stringify(message));
}

ready(): Promise<void> {
return this._readyPromise;
}

close() {
if (!this._ws) {
return;
}
try {
this._ws.terminate();
} catch {}
this._ws = null;
}
}

export class DeviceMock extends DeviceAgent {
// Empty handlers
+connect: JestMockFn<[message: ConnectRequest], void> = jest.fn();
+disconnect: JestMockFn<[message: DisconnectRequest], void> = jest.fn();
+getPages: JestMockFn<
[message: GetPagesRequest],
| GetPagesResponse['payload']
| Promise<GetPagesResponse['payload'] | void>
| void,
> = jest.fn();
+wrappedEvent: JestMockFn<[message: WrappedEvent], void> = jest.fn();

__handle(message: MessageToDevice): void {
switch (message.event) {
case 'connect':
this.connect(message);
break;
case 'disconnect':
this.disconnect(message);
break;
case 'getPages':
const result = this.getPages(message);
this._sendPayloadIfNonNull('getPages', result);
break;
case 'wrappedEvent':
this.wrappedEvent(message);
break;
default:
(message: empty);
throw new Error(`Unhandled event ${message.event}`);
}
}

_sendPayloadIfNonNull<Event: MessageFromDevice['event']>(
event: Event,
maybePayload:
| MessageFromDevice['payload']
| Promise<MessageFromDevice['payload'] | void>
| void,
) {
if (maybePayload == null) {
return;
}
if (maybePayload instanceof Promise) {
// eslint-disable-next-line no-void
void maybePayload.then(payload => {
if (!payload) {
return;
}
// $FlowFixMe[incompatible-call] TODO(moti) Figure out the right way to type maybePayload generically
this.send({event, payload});
});
return;
}
// $FlowFixMe[incompatible-call] TODO(moti) Figure out the right way to type maybePayload generically
this.send({event, payload: maybePayload});
}
}

export async function createDeviceMock(
url: string,
signal: AbortSignal,
): Promise<DeviceMock> {
const device = new DeviceMock(url, signal);
await device.ready();
return device;
}
211 changes: 211 additions & 0 deletions packages/dev-middleware/src/__tests__/InspectorProxyHttpApi-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* 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 {createDeviceMock} from './InspectorDeviceUtils';
import {fetchJson} from './FetchUtils';
import {withAbortSignalForEachTest} from './ResourceUtils';
import {withServerForEachTest} from './ServerUtils';

// Must be greater than or equal to PAGES_POLLING_INTERVAL in `InspectorProxy.js`.
const PAGES_POLLING_DELAY = 1000;

jest.useFakeTimers();

describe('inspector proxy HTTP API', () => {
const serverRef = withServerForEachTest({
logger: undefined,
projectRoot: '',
});
const autoCleanup = withAbortSignalForEachTest();
afterEach(() => {
jest.clearAllMocks();
});

describe('/json/version endpoint', () => {
test('returns version', async () => {
const json = await fetchJson(`${serverRef.serverBaseUrl}/json/version`);
expect(json).toMatchSnapshot();
});
});

describe.each(['/json', '/json/list'])('%s endpoint', endpoint => {
test('empty on start', async () => {
const json = await fetchJson(`${serverRef.serverBaseUrl}${endpoint}`);
expect(json).toEqual([]);
});

test('updates page details through polling', async () => {
const device1 = await createDeviceMock(
`${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`,
autoCleanup.signal,
);
try {
device1.getPages.mockImplementation(() => [
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
]);

jest.advanceTimersByTime(PAGES_POLLING_DELAY);

const jsonBefore = await fetchJson(
`${serverRef.serverBaseUrl}${endpoint}`,
);

device1.getPages.mockImplementation(() => [
{
app: 'bar-app',
id: 'page1-updated',
title: 'bar-title-updated',
vm: 'bar-vm-updated',
},
]);

jest.advanceTimersByTime(PAGES_POLLING_DELAY);

const jsonAfter = await fetchJson(
`${serverRef.serverBaseUrl}${endpoint}`,
);

expect(jsonBefore).toEqual([
expect.objectContaining({
id: 'device1-page1',
title: 'bar-title',
vm: 'bar-vm',
}),
]);

expect(jsonAfter).toEqual([
expect.objectContaining({
id: 'device1-page1-updated',
title: 'bar-title-updated',
vm: 'bar-vm-updated',
}),
]);
} finally {
device1.close();
}
});

test('returns to empty on device disconnect', async () => {
const device1 = await createDeviceMock(
`${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`,
autoCleanup.signal,
);
try {
device1.getPages.mockImplementation(() => [
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
]);

jest.advanceTimersByTime(PAGES_POLLING_DELAY);

const jsonBefore = await fetchJson(
`${serverRef.serverBaseUrl}${endpoint}`,
);

device1.close();

const jsonAfter = await fetchJson(
`${serverRef.serverBaseUrl}${endpoint}`,
);

expect(jsonBefore).toEqual([
expect.objectContaining({
id: 'device1-page1',
title: 'bar-title',
vm: 'bar-vm',
}),
]);

expect(jsonAfter).toEqual([]);
} finally {
device1.close();
}
});

test('reports pages from two connected devices', async () => {
const device1 = await createDeviceMock(
`${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`,
autoCleanup.signal,
);

device1.getPages.mockImplementation(() => [
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
]);

const device2 = await createDeviceMock(
`${serverRef.serverBaseWsUrl}/inspector/device?device=device2&name=foo&app=bar`,
autoCleanup.signal,
);
device2.getPages.mockImplementation(() => [
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
]);

// Ensure polling has happened a few times
jest.advanceTimersByTime(10 * PAGES_POLLING_DELAY);

try {
const json = await fetchJson(`${serverRef.serverBaseUrl}${endpoint}`);
expect(json).toEqual([
{
description: 'bar-app',
deviceName: 'foo',
devtoolsFrontendUrl: expect.any(String),
faviconUrl: 'https://reactjs.org/favicon.ico',
id: 'device1-page1',
reactNative: {
logicalDeviceId: 'device1',
},
title: 'bar-title',
type: 'node',
vm: 'bar-vm',
webSocketDebuggerUrl: expect.any(String),
},
{
description: 'bar-app',
deviceName: 'foo',
devtoolsFrontendUrl: expect.any(String),
faviconUrl: 'https://reactjs.org/favicon.ico',
id: 'device2-page1',
reactNative: {
logicalDeviceId: 'device2',
},
title: 'bar-title',
type: 'node',
vm: 'bar-vm',
webSocketDebuggerUrl: expect.any(String),
},
]);
} finally {
device1.close();
device2.close();
}
});
});
});
Loading

0 comments on commit 6a63254

Please sign in to comment.