diff --git a/desktop/plugins/public/network/__tests__/customheaders.node.tsx b/desktop/plugins/public/network/__tests__/customheaders.node.tsx new file mode 100644 index 000000000..1c1001cf7 --- /dev/null +++ b/desktop/plugins/public/network/__tests__/customheaders.node.tsx @@ -0,0 +1,160 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {TestUtils} from 'flipper-plugin'; +import * as NetworkPlugin from '../index'; + +test('Can handle custom headers', async () => { + const { + instance, + sendEvent, + act, + renderer, + exportState, + } = TestUtils.renderPlugin(NetworkPlugin); + + act(() => { + sendEvent('newRequest', { + id: '1', + timestamp: 123, + data: 'hello', + headers: [ + { + key: 'test-header', + value: 'fluffie', + }, + ], + method: 'post', + url: 'http://www.fbflipper.com', + }); + }); + + // record visible + expect(await renderer.findByText('www.fbflipper.com/')).not.toBeNull(); + // header not found + expect(renderer.queryByText('fluffie')).toBeNull(); + + // add column + act(() => { + instance.addCustomColumn({ + type: 'request', + header: 'test-header', + }); + }); + + // applied to backlog, so header found + expect(await renderer.findByText('fluffie')).not.toBeNull(); + + // add response column + act(() => { + instance.addCustomColumn({ + type: 'response', + header: 'second-test-header', + }); + }); + + // newly arriving data should respect custom columns + sendEvent('newResponse', { + id: '1', + headers: [ + { + key: 'second-test-header', + value: 'dolphins', + }, + ], + timestamp: 124, + data: '', + status: 200, + reason: '', + isMock: false, + insights: undefined, + }); + + expect(await renderer.findByText('dolphins')).not.toBeNull(); + + // verify internal storage + expect(instance.columns.get().slice(-2)).toMatchInlineSnapshot(` + Array [ + Object { + "key": "request_header_test-header", + "title": "test-header (request)", + "width": 200, + }, + Object { + "key": "response_header_second-test-header", + "title": "second-test-header (response)", + "width": 200, + }, + ] + `); + expect(instance.requests.records()).toMatchObject([ + { + domain: 'www.fbflipper.com/', + duration: 1, + id: '1', + insights: undefined, + method: 'post', + reason: '', + requestHeaders: [ + { + key: 'test-header', + value: 'fluffie', + }, + ], + 'request_header_test-header': 'fluffie', + responseData: undefined, + responseHeaders: [ + { + key: 'second-test-header', + value: 'dolphins', + }, + ], + responseIsMock: false, + responseLength: 0, + 'response_header_second-test-header': 'dolphins', + status: 200, + url: 'http://www.fbflipper.com', + }, + ]); + + renderer.unmount(); + + // after import, columns should be visible and restored + { + const snapshot = exportState(); + // Note: snapshot is set in the previous test + const {instance: instance2, renderer: renderer2} = TestUtils.renderPlugin( + NetworkPlugin, + { + initialState: snapshot, + }, + ); + + // record visible + expect(await renderer2.findByText('www.fbflipper.com/')).not.toBeNull(); + expect(await renderer2.findByText('fluffie')).not.toBeNull(); + expect(await renderer2.findByText('dolphins')).not.toBeNull(); + + // verify internal storage + expect(instance2.columns.get().slice(-2)).toMatchInlineSnapshot(` + Array [ + Object { + "key": "request_header_test-header", + "title": "test-header (request)", + "width": 200, + }, + Object { + "key": "response_header_second-test-header", + "title": "second-test-header (response)", + "width": 200, + }, + ] + `); + } +}); diff --git a/desktop/plugins/public/network/index.tsx b/desktop/plugins/public/network/index.tsx index 9ac27aa0f..c89fde117 100644 --- a/desktop/plugins/public/network/index.tsx +++ b/desktop/plugins/public/network/index.tsx @@ -8,7 +8,16 @@ */ import React, {createRef} from 'react'; -import {Button, Menu, message, Modal, Typography} from 'antd'; +import { + Button, + Form, + Input, + Menu, + message, + Modal, + Radio, + Typography, +} from 'antd'; import { Layout, @@ -23,6 +32,8 @@ import { DataTableColumn, DataTableManager, theme, + renderReactRoot, + batch, } from 'flipper-plugin'; import { Request, @@ -78,6 +89,11 @@ type Methods = { mockResponses(params: {routes: MockRoute[]}): Promise; }; +type CustomColumnConfig = { + header: string; + type: 'response' | 'request'; +}; + export function plugin(client: PluginClient) { const networkRouteManager = createState( nullNetworkRouteManager, @@ -106,6 +122,11 @@ export function plugin(client: PluginClient) { {persist: 'partialResponses'}, ); + const customColumns = createState([], { + persist: 'customColumns', // Store in local storage as well: T69989583 + }); + const columns = createState[]>(baseColumns); // not persistable + client.onDeepLink((payload: unknown) => { const searchTermDelim = 'searchTerm='; if (typeof payload !== 'string') { @@ -133,7 +154,7 @@ export function plugin(client: PluginClient) { client.onMessage('newRequest', (data) => { // TODO: This should be append, but there is currently a bug where requests are send multiple times from the // device! (Wilde on emulator) - requests.upsert(createRequestFromRequestInfo(data)); + requests.upsert(createRequestFromRequestInfo(data, customColumns.get())); }); function storeResponse(response: ResponseInfo) { @@ -142,7 +163,9 @@ export function plugin(client: PluginClient) { return; // request table might have been cleared } - requests.upsert(updateRequestWithResponseInfo(request, response)); + requests.upsert( + updateRequestWithResponseInfo(request, response, customColumns.get()), + ); } client.onMessage('newResponse', (data) => { @@ -213,10 +236,12 @@ export function plugin(client: PluginClient) { localStorage.getItem(LOCALSTORAGE_MOCK_ROUTE_LIST_KEY + client.appId) || '{}', ); - routes.set(newRoutes); - isMockResponseSupported.set(result); - showMockResponseDialog.set(false); - nextRouteId.set(Object.keys(routes.get()).length); + batch(() => { + routes.set(newRoutes); + isMockResponseSupported.set(result); + showMockResponseDialog.set(false); + nextRouteId.set(Object.keys(routes.get()).length); + }); informClientMockChange(routes.get()); }); @@ -267,7 +292,53 @@ export function plugin(client: PluginClient) { } } + function addCustomColumn(column: CustomColumnConfig) { + // prevent doubles + if ( + customColumns + .get() + .find((c) => c.header === column.header && c.type === column.type) + ) { + return; + } + // add custom column config + customColumns.update((d) => { + d.push(column); + }); + // generate DataTable column config + addDataTableColumnConfig(column); + // update existing entries + for (let i = 0; i < requests.size; i++) { + const request = requests.get(i); + requests.update(i, { + ...request, + [`${column.type}_header_${column.header}`]: getHeaderValue( + column.type === 'request' + ? request.requestHeaders + : request.responseHeaders, + column.header, + ), + }); + } + } + + function addDataTableColumnConfig(column: CustomColumnConfig) { + columns.update((d) => { + d.push({ + key: `${column.type}_header_${column.header}` as any, + width: 200, + title: `${column.header} (${column.type})`, + }); + }); + } + + client.onReady(() => { + // after restoring a snapshot, let's make sure we update the columns + customColumns.get().forEach(addDataTableColumnConfig); + }); + return { + columns, routes, nextRouteId, isMockResponseSupported, @@ -295,27 +366,85 @@ export function plugin(client: PluginClient) { tableManagerRef, onContextMenu(request: Request | undefined) { return ( - { - if (!request) { - return; - } - const command = convertRequestToCurlCommand(request); - client.writeTextToClipboard(command); - }}> - Copy cURL command - + <> + { + if (!request) { + return; + } + const command = convertRequestToCurlCommand(request); + client.writeTextToClipboard(command); + }}> + Copy cURL command + + { + showCustomColumnDialog(addCustomColumn); + }}> + Add header column{'\u2026'} + + ); }, onCopyText(text: string) { client.writeTextToClipboard(text); message.success('Text copied to clipboard'); }, + addCustomColumn, }; } -function createRequestFromRequestInfo(data: RequestInfo): Request { +function showCustomColumnDialog( + addCustomColumn: (column: CustomColumnConfig) => void, +) { + function CustomColumnDialog({unmount}: {unmount(): void}) { + const [form] = Form.useForm(); + return ( + { + const header = form.getFieldValue('header'); + const type = form.getFieldValue('type'); + if (header && type) { + addCustomColumn({ + header, + type, + }); + unmount(); + } + }} + onCancel={unmount}> +
+ + + + + + Request + Response + + +
+
+ ); + } + + renderReactRoot((unmount) => ); +} + +function createRequestFromRequestInfo( + data: RequestInfo, + customColumns: CustomColumnConfig[], +): Request { let url: URL | undefined = undefined; try { url = data.url ? new URL(data.url) : undefined; @@ -326,7 +455,7 @@ function createRequestFromRequestInfo(data: RequestInfo): Request { getHeaderValue(data.headers, 'X-FB-Friendly-Name') || (url ? (url.pathname ? url.host + url.pathname : url.host) : ''); - return { + const res = { id: data.id, // request requestTime: new Date(data.timestamp), @@ -336,13 +465,23 @@ function createRequestFromRequestInfo(data: RequestInfo): Request { requestHeaders: data.headers, requestData: decodeBody(data.headers, data.data), }; + customColumns + .filter((c) => c.type === 'request') + .forEach(({header}) => { + (res as any)['request_header_' + header] = getHeaderValue( + data.headers, + header, + ); + }); + return res; } function updateRequestWithResponseInfo( request: Request, response: ResponseInfo, + customColumns: CustomColumnConfig[], ): Request { - return { + const res = { ...request, responseTime: new Date(response.timestamp), status: response.status, @@ -354,6 +493,15 @@ function updateRequestWithResponseInfo( duration: response.timestamp - request.requestTime.getTime(), insights: response.insights ?? undefined, }; + customColumns + .filter((c) => c.type === 'response') + .forEach(({header}) => { + (res as any)['response_header_' + header] = getHeaderValue( + response.headers, + header, + ); + }); + return res; } export function Component() { @@ -362,10 +510,15 @@ export function Component() { const isMockResponseSupported = useValue(instance.isMockResponseSupported); const showMockResponseDialog = useValue(instance.showMockResponseDialog); const networkRouteManager = useValue(instance.networkRouteManager); + const columns = useValue(instance.columns); return ( - + [] = [ +const baseColumns: DataTableColumn[] = [ { key: 'requestTime', title: 'Request Time',