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 <[email protected]>
Commit-Queue: Charles Vazac <[email protected]>
diff --git a/AUTHORS b/AUTHORS
index a9e7412..1e115e1 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -21,6 +21,7 @@
Biboswan Roy <[email protected]>
Boris Verkhovskiy <[email protected]>
Carl Espe <[email protected]>
+Charles Vazac <[email protected]>
Conner Turner <[email protected]>
Daniel bellfield <[email protected]>
Danny Eldridge <[email protected]>
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;
};