diff --git a/packages/trace-viewer/src/ui/networkFilters.css b/packages/trace-viewer/src/ui/networkFilters.css new file mode 100644 index 0000000000..836c7102c9 --- /dev/null +++ b/packages/trace-viewer/src/ui/networkFilters.css @@ -0,0 +1,46 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.network-filters { + display: flex; + gap: 16px; + background-color: var(--vscode-sideBar-background); + padding: 4px 8px; + min-height: 32px; +} + +.network-filters input[type="search"] { + padding: 0 5px; +} + +.network-filters-resource-types { + display: flex; + gap: 8px; + align-items: center; +} + +.network-filters-resource-type { + cursor: pointer; + border-radius: 2px; + padding: 3px 8px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; +} + +.network-filters-resource-type.selected { + background-color: var(--vscode-list-inactiveSelectionBackground); +} diff --git a/packages/trace-viewer/src/ui/networkFilters.tsx b/packages/trace-viewer/src/ui/networkFilters.tsx new file mode 100644 index 0000000000..de6c827e2b --- /dev/null +++ b/packages/trace-viewer/src/ui/networkFilters.tsx @@ -0,0 +1,57 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import './networkFilters.css'; + +const resourceTypes = ['All', 'Fetch', 'HTML', 'JS', 'CSS', 'Font', 'Image'] as const; +export type ResourceType = typeof resourceTypes[number]; + +export type FilterState = { + searchValue: string; + resourceType: ResourceType; +}; + +export const defaultFilterState: FilterState = { searchValue: '', resourceType: 'All' }; + +export const NetworkFilters: React.FunctionComponent<{ + filterState: FilterState, + onFilterStateChange: (filterState: FilterState) => void, +}> = ({ filterState, onFilterStateChange }) => { + return ( +
+ onFilterStateChange({ ...filterState, searchValue: e.target.value })} + /> + +
+ {resourceTypes.map(resourceType => ( +
onFilterStateChange({ ...filterState, resourceType })} + className={`network-filters-resource-type ${filterState.resourceType === resourceType ? 'selected' : ''}`} + > + {resourceType} +
+ ))} +
+
+ ); +}; diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 36bb54547e..207dd33547 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -25,6 +25,7 @@ import { context, type MultiTraceModel } from './modelUtil'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; import type { ContextEntry } from '../entries'; +import { NetworkFilters, defaultFilterState, type FilterState, type ResourceType } from './networkFilters'; type NetworkTabModel = { resources: Entry[], @@ -68,18 +69,24 @@ export const NetworkTab: React.FunctionComponent<{ }> = ({ boundaries, networkModel, onEntryHovered }) => { const [sorting, setSorting] = React.useState(undefined); const [selectedEntry, setSelectedEntry] = React.useState(undefined); + const [filterState, setFilterState] = React.useState(defaultFilterState); const { renderedEntries } = React.useMemo(() => { - const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.contextIdMap)); + const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.contextIdMap)).filter(filterEntry(filterState)); if (sorting) sort(renderedEntries, sorting); return { renderedEntries }; - }, [networkModel.resources, networkModel.contextIdMap, sorting, boundaries]); + }, [networkModel.resources, networkModel.contextIdMap, filterState, sorting, boundaries]); const [columnWidths, setColumnWidths] = React.useState>(() => { return new Map(allColumns().map(column => [column, columnWidth(column)])); }); + const onFilterStateChange = React.useCallback((newFilterState: FilterState) => { + setFilterState(newFilterState); + setSelectedEntry(undefined); + }, []); + if (!networkModel.resources.length) return ; @@ -100,6 +107,7 @@ export const NetworkTab: React.FunctionComponent<{ setSorting={setSorting} />; return <> + {!selectedEntry && grid} {selectedEntry && a.contextId.localeCompare(b.contextId); } + +const resourceTypePredicates: Record boolean> = { + 'All': () => true, + 'Fetch': contentType => contentType === 'application/json', + 'HTML': contentType => contentType === 'text/html', + 'CSS': contentType => contentType === 'text/css', + 'JS': contentType => contentType.includes('javascript'), + 'Font': contentType => contentType.includes('font'), + 'Image': contentType => contentType.includes('image'), +}; + +function filterEntry({ searchValue, resourceType }: FilterState) { + return (entry: RenderedEntry) => { + const typePredicate = resourceTypePredicates[resourceType]; + + return typePredicate(entry.contentType) && entry.name.url.toLowerCase().includes(searchValue.toLowerCase()); + }; +} diff --git a/tests/assets/network-tab/font.woff2 b/tests/assets/network-tab/font.woff2 new file mode 100644 index 0000000000..ceba03549a Binary files /dev/null and b/tests/assets/network-tab/font.woff2 differ diff --git a/tests/assets/network-tab/image.png b/tests/assets/network-tab/image.png new file mode 100644 index 0000000000..3942859ccc Binary files /dev/null and b/tests/assets/network-tab/image.png differ diff --git a/tests/assets/network-tab/network.html b/tests/assets/network-tab/network.html new file mode 100644 index 0000000000..d46ff846dc --- /dev/null +++ b/tests/assets/network-tab/network.html @@ -0,0 +1,21 @@ + + + + + + + + +

Network Tab Test

+ + + diff --git a/tests/assets/network-tab/script.js b/tests/assets/network-tab/script.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/assets/network-tab/style.css b/tests/assets/network-tab/style.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index d8386d1684..e8471c16d6 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -256,6 +256,67 @@ test('should have network requests', async ({ showTraceViewer }) => { await expect(traceViewer.networkRequests.filter({ hasText: '404' })).toHaveCSS('background-color', 'rgb(242, 222, 222)'); }); +test('should filter network requests by resource type', async ({ page, runAndTrace, server }) => { + const traceViewer = await runAndTrace(async () => { + server.setRoute('/api/endpoint', (_, res) => res.setHeader('Content-Type', 'application/json').end()); + await page.goto(`${server.PREFIX}/network-tab/network.html`); + }); + await traceViewer.selectAction('http://localhost'); + await traceViewer.showNetworkTab(); + + await traceViewer.page.getByText('JS', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('script.js')).toBeVisible(); + + await traceViewer.page.getByText('CSS', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('style.css')).toBeVisible(); + + await traceViewer.page.getByText('Image', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('image.png')).toBeVisible(); + + await traceViewer.page.getByText('Fetch', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('endpoint')).toBeVisible(); + + await traceViewer.page.getByText('HTML', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('network.html')).toBeVisible(); + + await traceViewer.page.getByText('Font', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('font.woff2')).toBeVisible(); +}); + +test('should filter network requests by url', async ({ page, runAndTrace, server }) => { + const traceViewer = await runAndTrace(async () => { + await page.goto(`${server.PREFIX}/network-tab/network.html`); + }); + await traceViewer.selectAction('http://localhost'); + await traceViewer.showNetworkTab(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('script.'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('script.js')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('png'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('image.png')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('api/'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('endpoint')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('End'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('endpoint')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('FON'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('font.woff2')).toBeVisible(); +}); + test('should have network request overrides', async ({ page, server, runAndTrace }) => { const traceViewer = await runAndTrace(async () => { await page.route('**/style.css', route => route.abort()); diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts new file mode 100644 index 0000000000..45d77aa528 --- /dev/null +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, test } from './ui-mode-fixtures'; + +test('should filter network requests by resource type', async ({ runUITest, server }) => { + server.setRoute('/api/endpoint', (_, res) => res.setHeader('Content-Type', 'application/json').end()); + + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + }); + `, + }); + + await page.getByText('network tab test').dblclick(); + await page.getByText('Network', { exact: true }).click(); + + const networkItems = page.getByTestId('network-list').getByRole('listitem'); + + await page.getByText('JS', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('script.js')).toBeVisible(); + + await page.getByText('CSS', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('style.css')).toBeVisible(); + + await page.getByText('Image', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('image.png')).toBeVisible(); + + await page.getByText('Fetch', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('endpoint')).toBeVisible(); + + await page.getByText('HTML', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('network.html')).toBeVisible(); + + await page.getByText('Font', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('font.woff2')).toBeVisible(); +}); + +test('should filter network requests by url', async ({ runUITest, server }) => { + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + }); + `, + }); + + await page.getByText('network tab test').dblclick(); + await page.getByText('Network', { exact: true }).click(); + + const networkItems = page.getByTestId('network-list').getByRole('listitem'); + + await page.getByPlaceholder('Filter network').fill('script.'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('script.js')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('png'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('image.png')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('api/'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('endpoint')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('End'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('endpoint')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('FON'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('font.woff2')).toBeVisible(); +});