[go: nahoru, domu]

Add clear button and filter to EventStream UI

This piggybacks on the same patterns as found in ResourceWebSocketFrameView.ts.

- Note that we are searching the id, type, and data fields because they
all can be user generated
- Changes test behavior to honor newlines in *.rawresponse files


Fixed: 1488863
Change-Id: Ib73fc13fb3c44696ac9805d63d432b45545dc020
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/4973389
Reviewed-by: Danil Somsikov <dsv@chromium.org>
Commit-Queue: Charles Vazac <cvazac@gmail.com>
diff --git a/AUTHORS b/AUTHORS
index a9e7412..1e115e1 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -21,6 +21,7 @@
 Biboswan Roy <biboswan98@gmail.com>
 Boris Verkhovskiy <boris.verk@gmail.com>
 Carl Espe <carl@cpespe.com>
+Charles Vazac <cvazac@gmail.com>
 Conner Turner <cturner@zyme.xyz>
 Daniel bellfield <dnlbellfield@gmail.com>
 Danny Eldridge <me@heardanieljames.com>
diff --git a/front_end/panels/network/EventSourceMessagesView.ts b/front_end/panels/network/EventSourceMessagesView.ts
index 579b387..3206f41 100644
--- a/front_end/panels/network/EventSourceMessagesView.ts
+++ b/front_end/panels/network/EventSourceMessagesView.ts
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import type * as Common from '../../core/common/common.js';
+import * as Common from '../../core/common/common.js';
 import * as Host from '../../core/host/host.js';
 import * as i18n from '../../core/i18n/i18n.js';
 import * as SDK from '../../core/sdk/sdk.js';
@@ -37,12 +37,27 @@
    *@description A context menu item in the Resource Web Socket Frame View of the Network panel
    */
   copyMessage: 'Copy message',
+  /**
+   *@description Text to clear everything
+   */
+  clearAll: 'Clear all',
+  /**
+   *@description Example for placeholder text
+   */
+  enterRegex: 'Enter regex, for example: https?',
 };
 const str_ = i18n.i18n.registerUIStrings('panels/network/EventSourceMessagesView.ts', UIStrings);
 const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
 export class EventSourceMessagesView extends UI.Widget.VBox {
   private readonly request: SDK.NetworkRequest.NetworkRequest;
   private dataGrid: DataGrid.SortableDataGrid.SortableDataGrid<EventSourceMessageNode>;
+  private readonly mainToolbar: UI.Toolbar.Toolbar;
+  private readonly clearAllButton: UI.Toolbar.ToolbarButton;
+  private readonly filterTextInput: UI.Toolbar.ToolbarInput;
+  private filterRegex: RegExp|null;
+
+  private messageFilterSetting: Common.Settings.Setting<string> =
+      Common.Settings.Settings.instance().createSetting('networkEventSourceMessageFilter', '');
 
   constructor(request: SDK.NetworkRequest.NetworkRequest) {
     super();
@@ -51,6 +66,25 @@
     this.element.setAttribute('jslog', `${VisualLogging.pane('event-stream')}`);
     this.request = request;
 
+    this.mainToolbar = new UI.Toolbar.Toolbar('');
+
+    this.clearAllButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.clearAll), 'clear');
+    this.clearAllButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, this.clearMessages, this);
+    this.mainToolbar.appendToolbarItem(this.clearAllButton);
+
+    const placeholder = i18nString(UIStrings.enterRegex);
+    this.filterTextInput = new UI.Toolbar.ToolbarInput(placeholder, '', 0.4);
+    this.filterTextInput.addEventListener(UI.Toolbar.ToolbarInput.Event.TextChanged, this.updateFilterSetting, this);
+    const filter = this.messageFilterSetting.get();
+    this.filterRegex = null;
+    this.setFilter(filter);
+    if (filter) {
+      this.filterTextInput.setValue(filter);
+    }
+    this.mainToolbar.appendToolbarItem(this.filterTextInput);
+
+    this.element.appendChild(this.mainToolbar.element);
+
     const columns = ([
       {id: 'id', title: i18nString(UIStrings.id), sortable: true, weight: 8},
       {id: 'type', title: i18nString(UIStrings.type), sortable: true, weight: 8},
@@ -77,13 +111,8 @@
   }
 
   override wasShown(): void {
-    this.dataGrid.rootNode().removeChildren();
+    this.refresh();
     this.registerCSSFiles([eventSourceMessagesViewStyles]);
-    const messages = this.request.eventSourceMessages();
-    for (let i = 0; i < messages.length; ++i) {
-      this.dataGrid.insertChild(new EventSourceMessageNode(messages[i]));
-    }
-
     this.request.addEventListener(SDK.NetworkRequest.Events.EventSourceMessageAdded, this.messageAdded, this);
   }
 
@@ -93,9 +122,41 @@
 
   private messageAdded(event: Common.EventTarget.EventTargetEvent<SDK.NetworkRequest.EventSourceMessage>): void {
     const message = event.data;
+    if (!this.messageFilter(message)) {
+      return;
+    }
     this.dataGrid.insertChild(new EventSourceMessageNode(message));
   }
 
+  private messageFilter(message: SDK.NetworkRequest.EventSourceMessage): boolean {
+    return !this.filterRegex || this.filterRegex.test(message.eventName) || this.filterRegex.test(message.eventId) ||
+        this.filterRegex.test(message.data);
+  }
+
+  private clearMessages(): void {
+    clearMessageOffsets.set(this.request, this.request.eventSourceMessages().length);
+    this.refresh();
+  }
+
+  private updateFilterSetting(): void {
+    const text = this.filterTextInput.value();
+    this.messageFilterSetting.set(text);
+    this.setFilter(text);
+    this.refresh();
+  }
+
+  private setFilter(text: string): void {
+    this.filterRegex = null;
+    if (text) {
+      try {
+        this.filterRegex = new RegExp(text, 'i');
+      } catch (e) {
+        // this regex will never match any input
+        this.filterRegex = new RegExp('(?!)', 'i');
+      }
+    }
+  }
+
   private sortItems(): void {
     const sortColumnId = this.dataGrid.sortColumnId();
     if (!sortColumnId) {
@@ -121,6 +182,16 @@
         Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText.bind(
             Host.InspectorFrontendHost.InspectorFrontendHostInstance, node.data.data));
   }
+
+  refresh(): void {
+    this.dataGrid.rootNode().removeChildren();
+
+    let messages = this.request.eventSourceMessages();
+    const offset = clearMessageOffsets.get(this.request) || 0;
+    messages = messages.slice(offset);
+    messages = messages.filter(this.messageFilter.bind(this));
+    messages.forEach(message => this.dataGrid.insertChild(new EventSourceMessageNode(message)));
+  }
 }
 
 export class EventSourceMessageNode extends DataGrid.SortableDataGrid.SortableDataGridNode<EventSourceMessageNode> {
@@ -155,3 +226,5 @@
   'type': EventSourceMessageNodeComparator.bind(null, message => message.eventName),
   'time': EventSourceMessageNodeComparator.bind(null, message => message.time),
 };
+
+const clearMessageOffsets = new WeakMap<SDK.NetworkRequest.NetworkRequest, number>();
diff --git a/scripts/hosted_mode/server.js b/scripts/hosted_mode/server.js
index 0de3d54..ede8053 100644
--- a/scripts/hosted_mode/server.js
+++ b/scripts/hosted_mode/server.js
@@ -215,7 +215,8 @@
   }
 
   function parseRawResponse(rawResponse) {
-    const lines = rawResponse.split('\n');
+    const newline = '\n';
+    const lines = rawResponse.split(newline);
 
     let isHeader = true;
     let line = lines.shift();
@@ -226,6 +227,11 @@
 
     while ((line = lines.shift()) !== undefined) {
       if (line.trim() === '') {
+        if (!isHeader) {
+          // The first empty line should be omitted as it indicates the transition from headers to body.
+          // All those that follow should be included in the response body.
+          data += line + newline;
+        }
         isHeader = false;
         if (request.headers['if-none-match'] && response.getHeader('ETag') === request.headers['if-none-match']) {
           return {statusCode: 304};
@@ -239,7 +245,7 @@
         headerValue = headerValue.replace('$host_port', `${server.address().port}`);
         headers.set(line.substring(0, firstColon), headerValue);
       } else {
-        data += line;
+        data += line + newline;
       }
     }
 
diff --git a/test/e2e/network/can-pretty-print-network_test.ts b/test/e2e/network/can-pretty-print-network_test.ts
index eb1127c..eae885b 100644
--- a/test/e2e/network/can-pretty-print-network_test.ts
+++ b/test/e2e/network/can-pretty-print-network_test.ts
@@ -75,7 +75,7 @@
     await step('can un-pretty-print a json subtype', async () => {
       const actualNotPrettyText = await retrieveCodeMirrorEditorContent();
       const expectedNotPrettyText =
-          '{"Keys": [{"Key1": "Value1","Key2": "Value2","Key3": true},{"Key1": "Value1","Key2": "Value2","Key3": false}]}';
+          '{"Keys": [{"Key1": "Value1","Key2": "Value2","Key3": true},{"Key1": "Value1","Key2": "Value2","Key3": false}]},';
 
       assert.strictEqual(expectedNotPrettyText, actualNotPrettyText.toString());
     });
diff --git a/test/e2e/network/network-datagrid_test.ts b/test/e2e/network/network-datagrid_test.ts
index 6127c32..fbe843a 100644
--- a/test/e2e/network/network-datagrid_test.ts
+++ b/test/e2e/network/network-datagrid_test.ts
@@ -161,7 +161,7 @@
     // Open the raw response HTML
     await click('[aria-label="Response"]');
     // Disable pretty printing
-    await waitFor('[aria-label="Pretty print"][aria-pressed="true"]');
+    await waitFor('[aria-label="Pretty print"]');
     await Promise.all([
       click('[aria-label="Pretty print"]'),
       waitFor('[aria-label="Pretty print"][aria-pressed="true"]'),
@@ -173,7 +173,7 @@
 
     assert.strictEqual(
         htmlRawResponse,
-        '<html><body>The following word is written using cyrillic letters and should look like "SUCCESS": SU\u0421\u0421\u0415SS.</body></html>');
+        '<html>    <body>The following word is written using cyrillic letters and should look like "SUCCESS": SU\u0421\u0421\u0415SS.</body></html>');
   });
 
   it('the correct MIME type when resources came from HTTP cache', async () => {
diff --git a/test/e2e/network/network-request-view_test.ts b/test/e2e/network/network-request-view_test.ts
index 70bad74..32c458f 100644
--- a/test/e2e/network/network-request-view_test.ts
+++ b/test/e2e/network/network-request-view_test.ts
@@ -3,22 +3,24 @@
 // found in the LICENSE file.
 
 import {assert} from 'chai';
+import type * as puppeteer from 'puppeteer-core';
 
 import {expectError} from '../../conductor/events.js';
 import {
   $,
   $$,
+  assertNotNullOrUndefined,
   click,
+  getBrowserAndPages,
+  getResourcesPath,
+  getTextContent,
+  pasteText,
   step,
   typeText,
   waitFor,
   waitForAria,
   waitForElementWithTextContent,
   waitForFunction,
-  getBrowserAndPages,
-  getResourcesPath,
-  assertNotNullOrUndefined,
-  pasteText,
 } from '../../shared/helper.js';
 import {describe, it} from '../../shared/mocha-extensions.js';
 import {CONSOLE_TAB_SELECTOR, focusConsolePrompt} from '../helpers/console-helpers.js';
@@ -207,6 +209,115 @@
     assert.deepEqual(color, 'rgb(255, 0, 0)');
   });
 
+  const navigateToEventStreamMessages = async () => {
+    await navigateToNetworkTab('eventstream.html');
+    await waitForSomeRequestsToAppear(2);
+
+    await selectRequestByName('event-stream.rawresponse');
+
+    const networkView = await waitFor('.network-item-view');
+    await click('[aria-label=EventStream][role=tab]', {
+      root: networkView,
+    });
+    await waitFor(
+        '[aria-label=EventStream][role=tab][aria-selected=true]',
+        networkView,
+    );
+    return waitFor('.event-source-messages-view');
+  };
+
+  interface EventSourceMessageRaw {
+    id: string|undefined;
+    type: string|undefined;
+    data: string|undefined;
+    time?: string;
+  }
+
+  const waitForMessages =
+      async(messagesView: puppeteer.ElementHandle<Element>, count: number): Promise<EventSourceMessageRaw[]> => {
+    return waitForFunction(async () => {
+      const messages = await $$('.data-grid-data-grid-node', messagesView);
+      if (messages.length !== count) {
+        return undefined;
+      }
+
+      return Promise.all(messages.map(message => {
+        return new Promise<EventSourceMessageRaw>(async resolve => {
+          const [id, type, data] = await Promise.all([
+            getTextContent('.id-column', message),
+            getTextContent('.type-column', message),
+            getTextContent('.data-column', message),
+          ]);
+          resolve({
+            id,
+            type,
+            data,
+          });
+        });
+      }));
+    });
+  };
+
+  const knownMessages: EventSourceMessageRaw[] = [
+    {id: '1', type: 'custom-one', data: '{"one": "value-one"}'},
+    {id: '2', type: 'message', data: '{"two": "value-two"}'},
+    {id: '3', type: 'message', data: '{"three": "value-three"}'},
+  ];
+  const assertMessage = (actualMessage: EventSourceMessageRaw, expectedMessage: EventSourceMessageRaw) => {
+    assert.deepEqual(actualMessage.id, expectedMessage.id);
+    assert.deepEqual(actualMessage.type, expectedMessage.type);
+    assert.deepEqual(actualMessage.data, expectedMessage.data);
+  };
+  const assertBaseState = async(messagesView: puppeteer.ElementHandle<Element>): Promise<void> => {
+    const messages = await waitForMessages(messagesView, 3);
+    assertMessage(messages[0], knownMessages[0]);
+    assertMessage(messages[1], knownMessages[1]);
+    assertMessage(messages[2], knownMessages[2]);
+  };
+
+  it('stores EventSource filter', async () => {
+    const messagesView = await navigateToEventStreamMessages();
+    let messages = await waitForMessages(messagesView, 3);
+    await assertBaseState(messagesView);
+
+    const inputSelector = '[aria-placeholder="Enter regex, for example: https?';
+
+    const filterInput = await waitFor(inputSelector, messagesView);
+
+    // "one"
+    await filterInput.focus();
+    await typeText('one');
+    messages = await waitForMessages(messagesView, 1);
+    assertMessage(messages[0], knownMessages[0]);
+
+    // clear
+    await click('[title="Clear input"]', {
+      root: messagesView,
+    });
+    await assertBaseState(messagesView);
+
+    // "two"
+    await filterInput.focus();
+    await typeText('two');
+    messages = await waitForMessages(messagesView, 1);
+    assertMessage(messages[0], knownMessages[1]);
+
+    // invalid regex
+    await filterInput.focus();
+    await typeText('invalid(');
+    messages = await waitForMessages(messagesView, 0);
+  });
+
+  it('handles EventSource clear', async () => {
+    const messagesView = await navigateToEventStreamMessages();
+    await assertBaseState(messagesView);
+
+    await click('[aria-label="Clear all"]', {
+      root: messagesView,
+    });
+    await waitForMessages(messagesView, 0);
+  });
+
   it('stores websocket filter', async () => {
     const navigateToWebsocketMessages = async () => {
       await navigateToNetworkTab('websocket.html');
diff --git a/test/e2e/resources/network/BUILD.gn b/test/e2e/resources/network/BUILD.gn
index 41c6c36..7d2be2f 100644
--- a/test/e2e/resources/network/BUILD.gn
+++ b/test/e2e/resources/network/BUILD.gn
@@ -13,6 +13,8 @@
     "coffees.json",
     "csp-report-only.rawresponse",
     "embedded_requests.html",
+    "event-stream.rawresponse",
+    "eventstream.html",
     "fetch-json.html",
     "fetch.html",
     "headers-and-payload.html",
diff --git a/test/e2e/resources/network/event-stream.rawresponse b/test/e2e/resources/network/event-stream.rawresponse
new file mode 100644
index 0000000..0378503
--- /dev/null
+++ b/test/e2e/resources/network/event-stream.rawresponse
@@ -0,0 +1,19 @@
+200
+Date: Wed, 19 Feb 2020 08:04:35 GMT
+Server: Apache/2.2.8 (Ubuntu) mod_ssl/2.2.8 OpenSSL/0.9.8g
+Access-Control-Allow-Origin: *
+Connection: keep-alive
+Cache-Control: no-cache
+Content-Type: text/event-stream
+
+event: custom-one
+id:1
+data: {"one": "value-one"}
+
+id:2
+data: {"two": "value-two"}
+
+
+id:3
+data: {"three": "value-three"}
+
diff --git a/test/e2e/resources/network/eventstream.html b/test/e2e/resources/network/eventstream.html
new file mode 100644
index 0000000..166a4b2
--- /dev/null
+++ b/test/e2e/resources/network/eventstream.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<body>
+  <script>
+    const url = 'event-stream.rawresponse'
+    const evtSource = new EventSource(url);
+    window.addEventListener('beforeunload', () => {
+      evtSource.close()
+    });
+  </script>
+</body>
diff --git a/test/e2e/resources/network/utf-8.rawresponse b/test/e2e/resources/network/utf-8.rawresponse
index a145d04..95ebc33 100644
--- a/test/e2e/resources/network/utf-8.rawresponse
+++ b/test/e2e/resources/network/utf-8.rawresponse
@@ -4,7 +4,7 @@
 Last-Modified: Sun, 26 Sep 2010 22:04:35 GMT
 ETag: "45b6-834-49130cc1182c0"
 Accept-Ranges: bytes
-Content-Length: 122
+Content-Length: 126
 Connection: close
 Content-Type: text/html; charset=utf-8
 
diff --git a/test/e2e/sources/can-open-linear-memory-inspector_test.ts b/test/e2e/sources/can-open-linear-memory-inspector_test.ts
index e4627ae..a6cd2cf 100644
--- a/test/e2e/sources/can-open-linear-memory-inspector_test.ts
+++ b/test/e2e/sources/can-open-linear-memory-inspector_test.ts
@@ -133,7 +133,7 @@
       // Wait until we pause in the other worker.
       await waitFor(PAUSE_INDICATOR_SELECTOR);
       const scriptLocation = await retrieveTopCallFrameWithoutResuming();
-      assert.deepEqual(scriptLocation, 'memory-worker1.rawresponse:1');
+      assert.deepEqual(scriptLocation, 'memory-worker1.rawresponse:10');
     });
 
     await step('open other buffer in other worker', async () => {
diff --git a/test/e2e/sources/can-pretty-print-sourcecode_test.ts b/test/e2e/sources/can-pretty-print-sourcecode_test.ts
index 33c23b9..4845726 100644
--- a/test/e2e/sources/can-pretty-print-sourcecode_test.ts
+++ b/test/e2e/sources/can-pretty-print-sourcecode_test.ts
@@ -120,7 +120,7 @@
     await step('can un-pretty-print a json subtype file', async () => {
       await click(PRETTY_PRINT_BUTTON);
       const expectedNotPrettyLines =
-          '{"Keys": [{"Key1": "Value1","Key2": "Value2","Key3": true},{"Key1": "Value1","Key2": "Value2","Key3": false}]}';
+          '{"Keys": [{"Key1": "Value1","Key2": "Value2","Key3": true},{"Key1": "Value1","Key2": "Value2","Key3": false}]},';
       const actualNotPrettyText = await retrieveCodeMirrorEditorContent();
       assert.strictEqual(expectedNotPrettyLines, actualNotPrettyText.toString());
     });
diff --git a/test/shared/helper.ts b/test/shared/helper.ts
index 58f60e3..2901065 100644
--- a/test/shared/helper.ts
+++ b/test/shared/helper.ts
@@ -242,8 +242,9 @@
 
 export const timeout = (duration: number) => new Promise(resolve => setTimeout(resolve, duration));
 
-export const getTextContent = async<ElementType extends Element = Element>(selector: string) => {
-  const text = await (await $<ElementType>(selector))?.evaluate(node => node.textContent);
+export const getTextContent =
+    async<ElementType extends Element = Element>(selector: string, root?: puppeteer.JSHandle) => {
+  const text = await (await $<ElementType>(selector, root))?.evaluate(node => node.textContent);
   return text ?? undefined;
 };