From 0ecd561db26b624a56a34a98ab70a0542bd557a7 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 25 Aug 2023 12:10:28 -0700 Subject: [PATCH] chore: improve network panel rendering (#26708) --- .../src/server/har/harRecorder.ts | 3 +- .../src/server/har/harTracer.ts | 7 +- .../src/server/trace/recorder/tracing.ts | 4 +- .../server/trace/test/inMemorySnapshotter.ts | 6 +- .../src/server/trace/viewer/traceViewer.ts | 4 +- packages/trace-viewer/src/sw.ts | 14 +- packages/trace-viewer/src/traceModel.ts | 1 + .../trace-viewer/src/traceModelBackends.ts | 2 +- .../src/ui/networkResourceDetails.css | 26 +--- .../src/ui/networkResourceDetails.tsx | 147 +++++++++++------- packages/trace-viewer/src/ui/networkTab.css | 4 + packages/trace-viewer/src/ui/networkTab.tsx | 9 +- .../web/src/components/codeMirrorModule.tsx | 2 + .../web/src/components/codeMirrorWrapper.tsx | 12 +- 14 files changed, 141 insertions(+), 100 deletions(-) diff --git a/packages/playwright-core/src/server/har/harRecorder.ts b/packages/playwright-core/src/server/har/harRecorder.ts index c654a9dba7..4165a8ec87 100644 --- a/packages/playwright-core/src/server/har/harRecorder.ts +++ b/packages/playwright-core/src/server/har/harRecorder.ts @@ -47,11 +47,10 @@ export class HarRecorder { includeTraceInfo: false, recordRequestOverrides: true, waitForContentOnStop: true, - skipScripts: false, urlFilter: urlFilterRe ?? options.urlGlob, }); this._zipFile = content === 'attach' || expectsZip ? new yazl.ZipFile() : null; - this._tracer.start(); + this._tracer.start({ omitScripts: false }); } onEntryStarted(entry: har.Entry) { diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 4a322b4aa4..4d26b1a924 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -43,7 +43,6 @@ export interface HarTracerDelegate { type HarTracerOptions = { content: 'omit' | 'attach' | 'embed'; - skipScripts: boolean; includeTraceInfo: boolean; recordRequestOverrides: boolean; waitForContentOnStop: boolean; @@ -55,6 +54,7 @@ type HarTracerOptions = { omitServerIP?: boolean; omitPages?: boolean; omitSizes?: boolean; + omitScripts?: boolean; }; export class HarTracer { @@ -86,9 +86,10 @@ export class HarTracer { this._baseURL = context instanceof APIRequestContext ? context._defaultOptions().baseURL : context._options.baseURL; } - start() { + start(options: { omitScripts: boolean }) { if (this._started) return; + this._options.omitScripts = options.omitScripts; this._started = true; const apiRequest = this._context instanceof APIRequestContext ? this._context : this._context.fetchRequest; this._eventListeners = [ @@ -338,7 +339,7 @@ export class HarTracer { this._addBarrier(page || request.serviceWorker(), compressionCalculationBarrier.barrier); const promise = response.body().then(buffer => { - if (this._options.skipScripts && request.resourceType() === 'script') { + if (this._options.omitScripts && request.resourceType() === 'script') { compressionCalculationBarrier?.setDecodedBodySize(0); return; } diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index efbf34f481..59bbba4e61 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -92,7 +92,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps includeTraceInfo: true, recordRequestOverrides: false, waitForContentOnStop: false, - skipScripts: true, }); const testIdAttributeName = ('selectors' in context) ? context.selectors().testIdAttributeName() : undefined; this._contextCreatedEvent = { @@ -151,8 +150,9 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps }; this._fs.mkdir(this._state.resourcesDir); this._fs.writeFile(this._state.networkFile, ''); + // Tracing is 10x bigger if we include scripts in every trace. if (options.snapshots) - this._harTracer.start(); + this._harTracer.start({ omitScripts: !options.live }); } async startChunk(options: { name?: string, title?: string } = {}): Promise<{ traceName: string }> { diff --git a/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts b/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts index 335199e22b..af07910159 100644 --- a/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts +++ b/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts @@ -37,20 +37,20 @@ export class InMemorySnapshotter implements SnapshotterDelegate, HarTracerDelega constructor(context: BrowserContext) { this._snapshotter = new Snapshotter(context, this); - this._harTracer = new HarTracer(context, null, this, { content: 'attach', includeTraceInfo: true, recordRequestOverrides: false, waitForContentOnStop: false, skipScripts: true }); + this._harTracer = new HarTracer(context, null, this, { content: 'attach', includeTraceInfo: true, recordRequestOverrides: false, waitForContentOnStop: false }); this._storage = new SnapshotStorage(); } async initialize(): Promise { await this._snapshotter.start(); - this._harTracer.start(); + this._harTracer.start({ omitScripts: true }); } async reset() { await this._snapshotter.reset(); await this._harTracer.flush(); this._harTracer.stop(); - this._harTracer.start(); + this._harTracer.start({ omitScripts: true }); } async dispose() { diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index b2d1dcb3ee..8641cdd168 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -78,8 +78,10 @@ async function startTraceViewerServer(traceUrls: string[], options?: OpenTraceVi return true; } } catch (e) { - return false; } + response.statusCode = 404; + response.end(); + return true; } const absolutePath = path.join(__dirname, '..', '..', '..', 'vite', 'traceViewer', ...relativePath.split('/')); return server.serveFile(request, response, absolutePath); diff --git a/packages/trace-viewer/src/sw.ts b/packages/trace-viewer/src/sw.ts index 468f99605f..4200e3b4c0 100644 --- a/packages/trace-viewer/src/sw.ts +++ b/packages/trace-viewer/src/sw.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { MultiMap } from './multimap'; import { splitProgress } from './progress'; import { unwrapPopoutUrl } from './snapshotRenderer'; import { SnapshotServer } from './snapshotServer'; @@ -36,10 +35,17 @@ const scopePath = new URL(self.registration.scope).pathname; const loadedTraces = new Map(); -const clientIdToTraceUrls = new MultiMap(); +const clientIdToTraceUrls = new Map>(); async function loadTrace(traceUrl: string, traceFileName: string | null, clientId: string, progress: (done: number, total: number) => void): Promise { - clientIdToTraceUrls.set(clientId, traceUrl); + await gc(); + let set = clientIdToTraceUrls.get(clientId); + if (!set) { + set = new Set(); + clientIdToTraceUrls.set(clientId, set); + } + set.add(traceUrl); + const traceModel = new TraceModel(); try { // Allow 10% to hop from sw to page. @@ -162,7 +168,7 @@ async function gc() { for (const [clientId, traceUrls] of clientIdToTraceUrls) { // @ts-ignore if (!clients.find(c => c.id === clientId)) - clientIdToTraceUrls.deleteAll(clientId); + clientIdToTraceUrls.delete(clientId); else traceUrls.forEach(url => usedTraces.add(url)); } diff --git a/packages/trace-viewer/src/traceModel.ts b/packages/trace-viewer/src/traceModel.ts index 2726bc6ece..e95b706166 100644 --- a/packages/trace-viewer/src/traceModel.ts +++ b/packages/trace-viewer/src/traceModel.ts @@ -29,6 +29,7 @@ export interface TraceModelBackend { isLive(): boolean; traceURL(): string; } + export class TraceModel { contextEntries: ContextEntry[] = []; pageEntries = new Map(); diff --git a/packages/trace-viewer/src/traceModelBackends.ts b/packages/trace-viewer/src/traceModelBackends.ts index 9405072c42..7c11b4268e 100644 --- a/packages/trace-viewer/src/traceModelBackends.ts +++ b/packages/trace-viewer/src/traceModelBackends.ts @@ -120,7 +120,7 @@ export class FetchTraceModelBackend implements TraceModelBackend { async readBlob(entryName: string): Promise { const response = await this._readEntry(entryName); - return response?.blob(); + return response?.status === 200 ? await response?.blob() : undefined; } private async _readEntry(entryName: string): Promise { diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index 76d88872b8..aa3941601c 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -61,40 +61,26 @@ } .network-request-title-content-type { - margin-left: 6px; + margin: 0 6px; } .network-request-details { width: 100%; user-select: text; line-height: 24px; + margin-left: 10px; } .network-request-details-url { white-space: normal; word-wrap: break-word; + margin-left: 10px; } .network-request-headers { white-space: pre; overflow: hidden; -} - -.network-request-body { - white-space: pre; - overflow: scroll; - background-color: var(--vscode-sideBar-background); - border: black 1px solid; - max-height: 500px; -} - -.network-request-response-body { - white-space: pre; - overflow: scroll; - background-color: var(--vscode-sideBar-background); - border: black 1px solid; - max-height: 500px; - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; + margin-left: 10px; } .network-request-details-header { @@ -105,3 +91,7 @@ .network-request-details-time { float: right; } + +.network-request .cm-wrapper { + max-height: 300px; +} diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 2e56314b49..79affa56ee 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -20,58 +20,23 @@ import * as React from 'react'; import './networkResourceDetails.css'; import type { Entry } from '@trace/har'; import { msToString } from '@web/uiUtils'; +import { TabbedPane } from '@web/components/tabbedPane'; +import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; +import type { Language } from '@web/components/codeMirrorWrapper'; -export const NetworkResourceDetails: React.FunctionComponent<{ - resource: ResourceSnapshot, +export const NetworkResource: React.FunctionComponent<{ + resource: Entry, }> = ({ resource }) => { const [expanded, setExpanded] = React.useState(false); - const [requestBody, setRequestBody] = React.useState(null); - const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string } | null>(null); - React.useEffect(() => { - setExpanded(false); - }, [resource]); - - React.useEffect(() => { - const readResources = async () => { - if (resource.request.postData) { - if (resource.request.postData._sha1) { - const response = await fetch(`sha1/${resource.request.postData._sha1}`); - const requestResource = await response.text(); - setRequestBody(requestResource); - } else { - setRequestBody(resource.request.postData.text); - } - } - - if (resource.response.content._sha1) { - const useBase64 = resource.response.content.mimeType.includes('image'); - const response = await fetch(`sha1/${resource.response.content._sha1}`); - if (useBase64) { - const blob = await response.blob(); - const reader = new FileReader(); - const eventPromise = new Promise(f => reader.onload = f); - reader.readAsDataURL(blob); - setResponseBody({ dataUrl: (await eventPromise).target.result }); - } else { - setResponseBody({ text: await response.text() }); - } - } - }; - - readResources(); - }, [expanded, resource]); - - const { routeStatus, requestContentType, resourceName, contentType } = React.useMemo(() => { + const { routeStatus, resourceName, contentType } = React.useMemo(() => { const routeStatus = formatRouteStatus(resource); - const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type'); - const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; const resourceName = resource.request.url.substring(resource.request.url.lastIndexOf('/')); let contentType = resource.response.content.mimeType; const charset = contentType.match(/^(.*);\s*charset=.*$/); if (charset) contentType = charset[1]; - return { routeStatus, requestContentType, resourceName, contentType }; + return { routeStatus, resourceName, contentType }; }, [resource]); const renderTitle = React.useCallback(() => { @@ -85,26 +50,87 @@ export const NetworkResourceDetails: React.FunctionComponent<{ ; }, [contentType, resource, resourceName, routeStatus]); - return
- -
-
{msToString(resource.time)}
+ return
+ + {expanded && } + +
; +}; + +const NetworkResourceDetails: React.FunctionComponent<{ + resource: ResourceSnapshot, +}> = ({ resource }) => { + const [requestBody, setRequestBody] = React.useState<{ text: string, language?: Language } | null>(null); + const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, language?: Language } | null>(null); + const [selectedTab, setSelectedTab] = React.useState('request'); + + React.useEffect(() => { + const readResources = async () => { + if (resource.request.postData) { + const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type'); + const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; + const language = mimeTypeToHighlighter(requestContentType); + if (resource.request.postData._sha1) { + const response = await fetch(`sha1/${resource.request.postData._sha1}`); + setRequestBody({ text: formatBody(await response.text(), requestContentType), language }); + } else { + setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), language }); + } + } + + + if (resource.response.content._sha1) { + const useBase64 = resource.response.content.mimeType.includes('image'); + const response = await fetch(`sha1/${resource.response.content._sha1}`); + if (useBase64) { + const blob = await response.blob(); + const reader = new FileReader(); + const eventPromise = new Promise(f => reader.onload = f); + reader.readAsDataURL(blob); + setResponseBody({ dataUrl: (await eventPromise).target.result }); + } else { + const formattedBody = formatBody(await response.text(), resource.response.content.mimeType); + const language = mimeTypeToHighlighter(resource.response.content.mimeType); + setResponseBody({ text: formattedBody, language }); + } + } + }; + + readResources(); + }, [resource]); + + return
URL
{resource.request.url}
Request Headers
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
+ {requestBody &&
Request Body
} + {requestBody && } +
, + }, + { + id: 'response', + title: 'Response', + render: () =>
+
{msToString(resource.time)}
Response Headers
{resource.response.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
- {resource.request.postData ?
Request Body
: ''} - {resource.request.postData ?
{formatBody(requestBody, requestContentType)}
: ''} -
Response Body
- {!resource.response.content._sha1 ?
Response body is not available for this request.
: ''} - {responseBody !== null && responseBody.dataUrl ? : ''} - {responseBody !== null && responseBody.text ?
{formatBody(responseBody.text, resource.response.content.mimeType)}
: ''} -
- -
; +
, + }, + { + id: 'body', + title: 'Body', + render: () =>
+ {!resource.response.content._sha1 &&
Response body is not available for this request.
} + {responseBody && responseBody.dataUrl && } + {responseBody && responseBody.text && } +
, + }, + ]} selectedTab={selectedTab} setSelectedTab={setSelectedTab}/>; }; function formatStatus(status: number): string { @@ -148,3 +174,12 @@ function formatRouteStatus(request: Entry): string { return 'api'; return ''; } + +function mimeTypeToHighlighter(mimeType: string): Language | undefined { + if (mimeType.includes('javascript') || mimeType.includes('json')) + return 'javascript'; + if (mimeType.includes('html')) + return 'html'; + if (mimeType.includes('css')) + return 'css'; +} diff --git a/packages/trace-viewer/src/ui/networkTab.css b/packages/trace-viewer/src/ui/networkTab.css index 0379f70566..c0b3a05bf8 100644 --- a/packages/trace-viewer/src/ui/networkTab.css +++ b/packages/trace-viewer/src/ui/networkTab.css @@ -24,3 +24,7 @@ .network-tab:focus { outline: none; } + +.network-request .expandable { + width: 100%; +} \ No newline at end of file diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index d5e3acfaed..e5e4e56a00 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -16,7 +16,7 @@ import * as React from 'react'; import type * as modelUtil from './modelUtil'; -import { NetworkResourceDetails } from './networkResourceDetails'; +import { NetworkResource } from './networkResourceDetails'; import './networkTab.css'; import type { Boundaries } from '../geometry'; @@ -33,11 +33,6 @@ export const NetworkTab: React.FunctionComponent<{ }); }, [model, selectedTime]); return
{ - resources.map((resource, index) => { - return ; - }) + resources.map((resource, index) => ) }
; }; diff --git a/packages/web/src/components/codeMirrorModule.tsx b/packages/web/src/components/codeMirrorModule.tsx index 0c169e7eaf..9009eface0 100644 --- a/packages/web/src/components/codeMirrorModule.tsx +++ b/packages/web/src/components/codeMirrorModule.tsx @@ -16,6 +16,8 @@ import codemirror from 'codemirror'; import 'codemirror/lib/codemirror.css'; +import 'codemirror/mode/css/css'; +import 'codemirror/mode/htmlmixed/htmlmixed'; import 'codemirror/mode/javascript/javascript'; import 'codemirror/mode/python/python'; import 'codemirror/mode/clike/clike'; diff --git a/packages/web/src/components/codeMirrorWrapper.tsx b/packages/web/src/components/codeMirrorWrapper.tsx index ae42dd7ce8..1f8e0c0ac0 100644 --- a/packages/web/src/components/codeMirrorWrapper.tsx +++ b/packages/web/src/components/codeMirrorWrapper.tsx @@ -26,11 +26,11 @@ export type SourceHighlight = { message?: string; }; -export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; +export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css'; export interface SourceProps { text: string; - language: Language; + language?: Language; readOnly?: boolean; // 1-based highlight?: SourceHighlight[]; @@ -68,13 +68,19 @@ export const CodeMirrorWrapper: React.FC = ({ if (!element) return; - let mode = 'javascript'; + let mode = ''; + if (language === 'javascript') + mode = 'javascript'; if (language === 'python') mode = 'python'; if (language === 'java') mode = 'text/x-java'; if (language === 'csharp') mode = 'text/x-csharp'; + if (language === 'html') + mode = 'htmlmixed'; + if (language === 'css') + mode = 'css'; if (codemirrorRef.current && mode === codemirrorRef.current.cm.getOption('mode')