From b947a65c51b530b3dc10ec1867b6cc1fa00bf219 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Mon, 17 May 2021 03:15:46 -0700 Subject: [PATCH] Fix Network serialization Summary: Changelog: [Network] Fix import / export of binary data Introduced proper serialization of binary data when creating a Flipper export. Implements / solves https://github.com/facebook/flipper/issues/2308 Reviewed By: nikoant Differential Revision: D28441021 fbshipit-source-id: 90b524bf2a5d85e373073b50a3ccf2bb29628ee0 --- .../network/__tests__/customheaders.node.tsx | 4 +- .../network/__tests__/encoding.node.tsx | 121 ++++++++++++++++++ desktop/plugins/public/network/index.tsx | 82 ++++++++++-- desktop/plugins/public/network/types.tsx | 10 ++ 4 files changed, 201 insertions(+), 16 deletions(-) diff --git a/desktop/plugins/public/network/__tests__/customheaders.node.tsx b/desktop/plugins/public/network/__tests__/customheaders.node.tsx index 35163006c..9446b1fa3 100644 --- a/desktop/plugins/public/network/__tests__/customheaders.node.tsx +++ b/desktop/plugins/public/network/__tests__/customheaders.node.tsx @@ -11,7 +11,7 @@ import {TestUtils} from 'flipper-plugin'; import * as NetworkPlugin from '../index'; test('Can handle custom headers', async () => { - const {instance, sendEvent, act, renderer, exportState} = + const {instance, sendEvent, act, renderer, exportStateAsync} = TestUtils.renderPlugin(NetworkPlugin); act(() => { @@ -122,7 +122,7 @@ test('Can handle custom headers', async () => { // after import, columns should be visible and restored { - const snapshot = exportState(); + const snapshot = await exportStateAsync(); // Note: snapshot is set in the previous test const {instance: instance2, renderer: renderer2} = TestUtils.renderPlugin( NetworkPlugin, diff --git a/desktop/plugins/public/network/__tests__/encoding.node.tsx b/desktop/plugins/public/network/__tests__/encoding.node.tsx index df89ae5a9..f3cc40a60 100644 --- a/desktop/plugins/public/network/__tests__/encoding.node.tsx +++ b/desktop/plugins/public/network/__tests__/encoding.node.tsx @@ -13,6 +13,8 @@ import {decodeBody} from '../utils'; import {ResponseInfo} from '../types'; import {promisify} from 'util'; import {readFileSync} from 'fs'; +import {TestUtils} from 'flipper-plugin'; +import * as NetworkPlugin from '../index'; async function createMockResponse( input: string, @@ -136,3 +138,122 @@ describe('network data encoding', () => { expect(bodyAsBuffer(response)).toEqual(tinyLogoExpected); }); }); + +test('binary data gets serialized correctly', async () => { + const tinyLogoExpected = readFileSync( + path.join(__dirname, 'fixtures', 'tiny_logo.png'), + ); + const tinyLogoData = readFileSync( + path.join(__dirname, 'fixtures', 'tiny_logo.base64.txt'), + 'utf-8', + ); + const donatingExpected = readFileSync( + path.join(__dirname, 'fixtures', 'donating.md'), + 'utf-8', + ); + const donatingData = readFileSync( + path.join(__dirname, 'fixtures', 'donating.md.utf8.gzip.ios.txt'), + 'utf-8', + ); + const {instance, sendEvent, exportStateAsync} = + TestUtils.startPlugin(NetworkPlugin); + sendEvent('newRequest', { + id: '0', + timestamp: 0, + data: donatingData, + headers: [ + { + key: 'Content-Type', + value: 'text/plain', + }, + ], + method: 'post', + url: 'http://www.fbflipper.com', + }); + const response = await createMockResponse( + 'tiny_logo.android.txt', + 'image/png', + ); + sendEvent('newResponse', response); + + expect(instance.requests.getById('0')).toMatchObject({ + requestHeaders: [ + { + key: 'Content-Type', + value: 'text/plain', + }, + ], + requestData: donatingExpected, + responseHeaders: [ + { + key: 'Content-Type', + value: 'image/png', + }, + ], + responseData: new Uint8Array(tinyLogoExpected), + }); + + const snapshot = await exportStateAsync(); + expect(snapshot).toMatchObject({ + isMockResponseSupported: true, + selectedId: undefined, + requests2: [ + { + domain: 'www.fbflipper.com/', + duration: 0, + id: '0', + insights: undefined, + method: 'post', + reason: 'dunno', + requestHeaders: [ + { + key: 'Content-Type', + value: 'text/plain', + }, + ], + requestData: donatingExpected, // not encoded + responseData: [tinyLogoData.trim()], // wrapped represents base64 + responseHeaders: [ + { + key: 'Content-Type', + value: 'image/png', + }, + ], + responseIsMock: false, + responseLength: 24838, + status: 200, + url: 'http://www.fbflipper.com', + }, + ], + }); + + const {instance: instance2} = TestUtils.startPlugin(NetworkPlugin, { + initialState: snapshot, + }); + expect(instance2.requests.getById('0')).toMatchObject({ + domain: 'www.fbflipper.com/', + duration: 0, + id: '0', + insights: undefined, + method: 'post', + reason: 'dunno', + requestHeaders: [ + { + key: 'Content-Type', + value: 'text/plain', + }, + ], + requestData: donatingExpected, + responseData: new Uint8Array(tinyLogoExpected), + responseHeaders: [ + { + key: 'Content-Type', + value: 'image/png', + }, + ], + responseIsMock: false, + responseLength: 24838, + status: 200, + url: 'http://www.fbflipper.com', + }); +}); diff --git a/desktop/plugins/public/network/index.tsx b/desktop/plugins/public/network/index.tsx index 032b6b142..13992b1f5 100644 --- a/desktop/plugins/public/network/index.tsx +++ b/desktop/plugins/public/network/index.tsx @@ -42,6 +42,7 @@ import { ResponseFollowupChunk, AddProtobufEvent, PartialResponses, + SerializedRequest, } from './types'; import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository'; import { @@ -68,6 +69,7 @@ import { createNetworkManager, computeMockRoutes, } from './request-mocking/NetworkRouteManager'; +import {Base64} from 'js-base64'; const LOCALSTORAGE_MOCK_ROUTE_LIST_KEY = '__NETWORK_CACHED_MOCK_ROUTE_LIST'; const LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY = @@ -94,6 +96,13 @@ type CustomColumnConfig = { type: 'response' | 'request'; }; +type StateExport = { + requests2: SerializedRequest[]; + isMockResponseSupported: boolean; + selectedId: string | undefined; + customColumns: CustomColumnConfig[]; +}; + export function plugin(client: PluginClient) { const networkRouteManager = createState( nullNetworkRouteManager, @@ -101,30 +110,20 @@ export function plugin(client: PluginClient) { const routes = createState<{[id: string]: Route}>({}); const nextRouteId = createState(0); - const isMockResponseSupported = createState(false, { - persist: 'isMockResponseSupported', - }); + const isMockResponseSupported = createState(false); const showMockResponseDialog = createState(false); const detailBodyFormat = createState( localStorage.getItem(LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY) || 'parsed', ); const requests = createDataSource([], { key: 'id', - persist: 'requests2', - }); - const selectedId = createState(undefined, { - persist: 'selectedId', }); + const selectedId = createState(undefined); const tableManagerRef = createRef>(); - const partialResponses = createState( - {}, - {persist: 'partialResponses'}, - ); + const partialResponses = createState({}); - const customColumns = createState([], { - persist: 'customColumns', // Store in local storage as well: T69989583 - }); + const customColumns = createState([]); // Store in local storage as well: T69989583 const columns = createState[]>(baseColumns); // not persistable client.onDeepLink((payload: unknown) => { @@ -337,6 +336,61 @@ export function plugin(client: PluginClient) { customColumns.get().forEach(addDataTableColumnConfig); }); + client.onExport(async (idler, onStatusMessage) => { + const serializedRequests: SerializedRequest[] = []; + for (let i = 0; i < requests.size; i++) { + const request = requests.get(i); + serializedRequests.push({ + ...request, + requestTime: request.requestTime.getTime(), + responseTime: request.responseTime?.getTime(), + requestData: + request.requestData instanceof Uint8Array + ? [Base64.fromUint8Array(request.requestData)] + : request.requestData, + responseData: + request.responseData instanceof Uint8Array + ? [Base64.fromUint8Array(request.responseData)] + : request.responseData, + }); + if (idler.isCancelled()) { + return; + } + if (idler.shouldIdle()) { + onStatusMessage(`Serializing request ${i + 1}/${requests.size}`); + await idler.idle(); + } + } + return { + isMockResponseSupported: isMockResponseSupported.get(), + selectedId: selectedId.get(), + requests2: serializedRequests, + customColumns: customColumns.get(), + }; + }); + + client.onImport((data) => { + selectedId.set(data.selectedId); + isMockResponseSupported.set(data.isMockResponseSupported); + customColumns.set(data.customColumns); + data.requests2.forEach((request) => { + requests.append({ + ...request, + requestTime: new Date(request.requestTime), + responseTime: + request.responseTime != null + ? new Date(request.responseTime) + : undefined, + requestData: Array.isArray(request.requestData) + ? Base64.toUint8Array(request.requestData[0]) + : request.requestData, + responseData: Array.isArray(request.responseData) + ? Base64.toUint8Array(request.responseData[0]) + : request.responseData, + }); + }); + }); + return { columns, routes, diff --git a/desktop/plugins/public/network/types.tsx b/desktop/plugins/public/network/types.tsx index cda73f7b7..251e763c7 100644 --- a/desktop/plugins/public/network/types.tsx +++ b/desktop/plugins/public/network/types.tsx @@ -35,6 +35,16 @@ export interface Request { export type Requests = DataSource; +export type SerializedRequest = Omit< + Request, + 'requestTime' | 'responseTime' | 'requestData' | 'responseData' +> & { + requestTime: number; + requestData?: string | [string]; // wrapped in Array represents base64 encoded + responseTime?: number; + responseData?: string | [string]; // wrapped in Array represents base64 encoded +}; + export type RequestInfo = { id: RequestId; timestamp: number;