forked from facebook/react-native
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add tests for inspector-proxy's HTTP API
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
1 parent
bc0d8f9
commit c26eaf8
Showing
6 changed files
with
499 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
139
packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
211
packages/dev-middleware/src/__tests__/InspectorProxyHttpApi-test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.