From a2c6a3cd4ba38bdb914f9ece9d04158a359ad57e Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 1 Dec 2025 09:48:19 +0000 Subject: [PATCH 1/2] chore: move some trace utilities from sw to isomorphic --- .../server/trace/test/inMemorySnapshotter.ts | 4 +- .../src/utils/isomorphic}/lruCache.ts | 0 .../src/utils/isomorphic/trace/DEPS.list | 3 ++ .../src/utils/isomorphic/trace}/entries.ts | 2 +- .../isomorphic/trace}/snapshotRenderer.ts | 48 ++++++++++--------- .../utils/isomorphic/trace}/snapshotServer.ts | 0 .../isomorphic/trace}/snapshotStorage.ts | 4 +- .../src/utils/isomorphic/trace}/traceModel.ts | 2 +- .../isomorphic/trace}/traceModernizer.ts | 3 +- .../isomorphic/trace}/versions/traceV3.ts | 0 .../isomorphic/trace}/versions/traceV4.ts | 0 .../isomorphic/trace}/versions/traceV5.ts | 0 .../isomorphic/trace}/versions/traceV6.ts | 0 .../isomorphic/trace}/versions/traceV7.ts | 0 .../isomorphic/trace}/versions/traceV8.ts | 0 packages/trace-viewer/src/sw/main.ts | 7 +-- .../trace-viewer/src/sw/traceModelBackends.ts | 2 +- packages/trace-viewer/src/ui/filmStrip.tsx | 2 +- .../src/ui/liveWorkbenchLoader.tsx | 2 +- packages/trace-viewer/src/ui/modelUtil.ts | 2 +- packages/trace-viewer/src/ui/networkTab.tsx | 2 +- .../trace-viewer/src/ui/uiModeTraceView.tsx | 2 +- packages/trace/src/trace.ts | 2 +- tests/config/utils.ts | 4 +- 24 files changed, 49 insertions(+), 42 deletions(-) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic}/lruCache.ts (100%) create mode 100644 packages/playwright-core/src/utils/isomorphic/trace/DEPS.list rename packages/{trace-viewer/src/types => playwright-core/src/utils/isomorphic/trace}/entries.ts (94%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/snapshotRenderer.ts (94%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/snapshotServer.ts (100%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/snapshotStorage.ts (97%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/traceModel.ts (98%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/traceModernizer.ts (99%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/versions/traceV3.ts (100%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/versions/traceV4.ts (100%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/versions/traceV5.ts (100%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/versions/traceV6.ts (100%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/versions/traceV7.ts (100%) rename packages/{trace-viewer/src/sw => playwright-core/src/utils/isomorphic/trace}/versions/traceV8.ts (100%) diff --git a/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts b/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts index 36d4cf655eccf..7da822af1d4a7 100644 --- a/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts +++ b/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { SnapshotStorage } from '../../../../../trace-viewer/src/sw/snapshotStorage'; +import { SnapshotStorage } from '../../../utils/isomorphic/trace/snapshotStorage'; import { ManualPromise } from '../../../utils'; import { HarTracer } from '../../har/harTracer'; import { Snapshotter } from '../recorder/snapshotter'; -import type { SnapshotRenderer } from '../../../../../trace-viewer/src/sw/snapshotRenderer'; +import type { SnapshotRenderer } from '../../../utils/isomorphic/trace/snapshotRenderer'; import type { BrowserContext } from '../../browserContext'; import type { HarTracerDelegate } from '../../har/harTracer'; import type { Page } from '../../page'; diff --git a/packages/trace-viewer/src/sw/lruCache.ts b/packages/playwright-core/src/utils/isomorphic/lruCache.ts similarity index 100% rename from packages/trace-viewer/src/sw/lruCache.ts rename to packages/playwright-core/src/utils/isomorphic/lruCache.ts diff --git a/packages/playwright-core/src/utils/isomorphic/trace/DEPS.list b/packages/playwright-core/src/utils/isomorphic/trace/DEPS.list new file mode 100644 index 0000000000000..4192108ed5425 --- /dev/null +++ b/packages/playwright-core/src/utils/isomorphic/trace/DEPS.list @@ -0,0 +1,3 @@ +[*] +./** +../** diff --git a/packages/trace-viewer/src/types/entries.ts b/packages/playwright-core/src/utils/isomorphic/trace/entries.ts similarity index 94% rename from packages/trace-viewer/src/types/entries.ts rename to packages/playwright-core/src/utils/isomorphic/trace/entries.ts index c97a3e37c598b..b203acb9a19dd 100644 --- a/packages/trace-viewer/src/types/entries.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/entries.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Language } from '../../../playwright-core/src/utils/isomorphic/locatorGenerators'; +import type { Language } from '../locatorGenerators'; import type { ResourceSnapshot } from '@trace/snapshot'; import type * as trace from '@trace/trace'; diff --git a/packages/trace-viewer/src/sw/snapshotRenderer.ts b/packages/playwright-core/src/utils/isomorphic/trace/snapshotRenderer.ts similarity index 94% rename from packages/trace-viewer/src/sw/snapshotRenderer.ts rename to packages/playwright-core/src/utils/isomorphic/trace/snapshotRenderer.ts index 6473688e6e475..64efaebb36281 100644 --- a/packages/trace-viewer/src/sw/snapshotRenderer.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/snapshotRenderer.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { escapeHTMLAttribute, escapeHTML } from '@isomorphic/stringUtils'; +import { escapeHTMLAttribute, escapeHTML } from '../stringUtils'; import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot'; -import type { PageEntry } from '../types/entries'; -import type { LRUCache } from './lruCache'; +import type { PageEntry } from './entries'; +import type { LRUCache } from '../lruCache'; function findClosest(items: T[], metric: (v: T) => number, target: number) { return items.find((item, index) => { @@ -254,7 +254,9 @@ declare global { function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefined)[]) { function applyPlaywrightAttributes(viewport: ViewportSize, ...targetIds: (string | undefined)[]) { - const searchParams = new URLSearchParams(location.search); + // eslint-disable-next-line no-restricted-globals + const win = window; + const searchParams = new URLSearchParams(win.location.search); const shouldPopulateCanvasFromScreenshot = searchParams.has('shouldPopulateCanvasFromScreenshot'); const isUnderTest = searchParams.has('isUnderTest'); @@ -268,7 +270,7 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine viewport, frames: new WeakMap(), }; - window['__playwright_frame_bounding_rects__'] = frameBoundingRectsInfo; + win['__playwright_frame_bounding_rects__'] = frameBoundingRectsInfo; const kPointerWarningTitle = 'Recorded click position in absolute coordinates did not' + ' match the center of the clicked element. This is likely due to a difference between' + @@ -279,7 +281,7 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine const targetElements: Element[] = []; const canvasElements: HTMLCanvasElement[] = []; - let topSnapshotWindow: Window = window; + let topSnapshotWindow: Window = win; while (topSnapshotWindow !== topSnapshotWindow.parent && !topSnapshotWindow.location.pathname.match(/\/page@[a-z0-9]+$/)) topSnapshotWindow = topSnapshotWindow.parent; @@ -342,7 +344,7 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine iframe.setAttribute('src', 'data:text/html,'); } else { // Retain query parameters to inherit name=, time=, pointX=, pointY= and other values from parent. - const url = new URL(window.location.href); + const url = new URL(win.location.href); // We can be loading iframe from within iframe, reset base to be absolute. const index = url.pathname.lastIndexOf('/snapshot/'); if (index !== -1) @@ -354,10 +356,10 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine { const body = root.querySelector(`body[__playwright_custom_elements__]`); - if (body && window.customElements) { + if (body && win.customElements) { const customElements = (body.getAttribute('__playwright_custom_elements__') || '').split(','); for (const elementName of customElements) - window.customElements.define(elementName, class extends HTMLElement {}); + win.customElements.define(elementName, class extends HTMLElement {}); } } @@ -387,7 +389,7 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine }; const onLoad = () => { - window.removeEventListener('load', onLoad); + win.removeEventListener('load', onLoad); for (const element of scrollTops) { element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!; element.removeAttribute('__playwright_scroll_top_'); @@ -401,19 +403,19 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine frameBoundingRectsInfo.frames.get(element)!.scrollLeft = element.scrollTop; } - document.styleSheets[0].disabled = true; + win.document.styleSheets[0].disabled = true; - const search = new URL(window.location.href).searchParams; - const isTopFrame = window === topSnapshotWindow; + const search = new URL(win.location.href).searchParams; + const isTopFrame = win === topSnapshotWindow; if (search.get('pointX') && search.get('pointY')) { const pointX = +search.get('pointX')!; const pointY = +search.get('pointY')!; const hasInputTarget = search.has('hasInputTarget'); const hasTargetElements = targetElements.length > 0; - const roots = document.documentElement ? [document.documentElement] : []; + const roots = win.document.documentElement ? [win.document.documentElement] : []; for (const target of (hasTargetElements ? targetElements : roots)) { - const pointElement = document.createElement('x-pw-pointer'); + const pointElement = win.document.createElement('x-pw-pointer'); pointElement.style.position = 'fixed'; pointElement.style.backgroundColor = '#f44336'; pointElement.style.width = '20px'; @@ -436,7 +438,7 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine // "Warning symbol" indicates that action point is not 100% correct. // Note that action point is relative to the top frame, so we can only compare in the top frame. if (isTopFrame && (Math.abs(centerX - pointX) >= 10 || Math.abs(centerY - pointY) >= 10)) { - const warningElement = document.createElement('x-pw-pointer-warning'); + const warningElement = win.document.createElement('x-pw-pointer-warning'); warningElement.textContent = '⚠'; warningElement.style.fontSize = '19px'; warningElement.style.color = 'white'; @@ -445,13 +447,13 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine pointElement.appendChild(warningElement); pointElement.setAttribute('title', kPointerWarningTitle); } - document.documentElement.appendChild(pointElement); + win.document.documentElement.appendChild(pointElement); } else if (isTopFrame && !hasInputTarget) { // For actions without a target element, e.g. page.mouse.move(), // show the point at the recorded location, which is relative to the top frame. pointElement.style.left = pointX + 'px'; pointElement.style.top = pointY + 'px'; - document.documentElement.appendChild(pointElement); + win.document.documentElement.appendChild(pointElement); } } } @@ -459,7 +461,7 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine if (canvasElements.length > 0) { function drawCheckerboard(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement) { function createCheckerboardPattern() { - const pattern = document.createElement('canvas'); + const pattern = win.document.createElement('canvas'); pattern.width = pattern.width / Math.floor(pattern.width / 24); pattern.height = pattern.height / Math.floor(pattern.height / 24); const context = pattern.getContext('2d')!; @@ -492,7 +494,7 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine continue; } - let currWindow: Window = window; + let currWindow: Window = win; while (currWindow !== topSnapshotWindow) { const iframe = currWindow.frameElement!; currWindow = currWindow.parent; @@ -553,10 +555,10 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine } }; - const onDOMContentLoaded = () => visit(document); + const onDOMContentLoaded = () => visit(win.document); - window.addEventListener('load', onLoad); - window.addEventListener('DOMContentLoaded', onDOMContentLoaded); + win.addEventListener('load', onLoad); + win.addEventListener('DOMContentLoaded', onDOMContentLoaded); } return `\n(${applyPlaywrightAttributes.toString()})(${JSON.stringify(viewport)}${targetIds.map(id => `, "${id}"`).join('')})`; diff --git a/packages/trace-viewer/src/sw/snapshotServer.ts b/packages/playwright-core/src/utils/isomorphic/trace/snapshotServer.ts similarity index 100% rename from packages/trace-viewer/src/sw/snapshotServer.ts rename to packages/playwright-core/src/utils/isomorphic/trace/snapshotServer.ts diff --git a/packages/trace-viewer/src/sw/snapshotStorage.ts b/packages/playwright-core/src/utils/isomorphic/trace/snapshotStorage.ts similarity index 97% rename from packages/trace-viewer/src/sw/snapshotStorage.ts rename to packages/playwright-core/src/utils/isomorphic/trace/snapshotStorage.ts index d08fba647001a..4b0206cfacb04 100644 --- a/packages/trace-viewer/src/sw/snapshotStorage.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/snapshotStorage.ts @@ -15,10 +15,10 @@ */ import { rewriteURLForCustomProtocol, SnapshotRenderer } from './snapshotRenderer'; -import { LRUCache } from './lruCache'; +import { LRUCache } from '../lruCache'; import type { FrameSnapshot, ResourceSnapshot } from '@trace/snapshot'; -import type { PageEntry } from '../types/entries'; +import type { PageEntry } from './entries'; export class SnapshotStorage { diff --git a/packages/trace-viewer/src/sw/traceModel.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts similarity index 98% rename from packages/trace-viewer/src/sw/traceModel.ts rename to packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts index e86129c77856d..55064d69b4ab3 100644 --- a/packages/trace-viewer/src/sw/traceModel.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts @@ -19,7 +19,7 @@ import { parseClientSideCallMetadata } from '@isomorphic/traceUtils'; import { SnapshotStorage } from './snapshotStorage'; import { TraceModernizer } from './traceModernizer'; -import type { ContextEntry } from '../types/entries'; +import type { ContextEntry } from './entries'; export interface TraceModelBackend { entryNames(): Promise; diff --git a/packages/trace-viewer/src/sw/traceModernizer.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceModernizer.ts similarity index 99% rename from packages/trace-viewer/src/sw/traceModernizer.ts rename to packages/playwright-core/src/utils/isomorphic/trace/traceModernizer.ts index a2354c366ff56..f2b4f159deef0 100644 --- a/packages/trace-viewer/src/sw/traceModernizer.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceModernizer.ts @@ -21,7 +21,7 @@ import type * as traceV5 from './versions/traceV5'; import type * as traceV6 from './versions/traceV6'; import type * as traceV7 from './versions/traceV7'; import type * as traceV8 from './versions/traceV8'; -import type { ActionEntry, ContextEntry, PageEntry } from '../types/entries'; +import type { ActionEntry, ContextEntry, PageEntry } from './entries'; import type { SnapshotStorage } from './snapshotStorage'; export class TraceVersionError extends Error { @@ -299,6 +299,7 @@ export class TraceModernizer { class: metadata.type, method: metadata.method, params: metadata.params, + // eslint-disable-next-line no-restricted-globals wallTime: metadata.wallTime || Date.now(), log: metadata.log, beforeSnapshot: metadata.snapshots.find(s => s.title === 'before')?.snapshotName, diff --git a/packages/trace-viewer/src/sw/versions/traceV3.ts b/packages/playwright-core/src/utils/isomorphic/trace/versions/traceV3.ts similarity index 100% rename from packages/trace-viewer/src/sw/versions/traceV3.ts rename to packages/playwright-core/src/utils/isomorphic/trace/versions/traceV3.ts diff --git a/packages/trace-viewer/src/sw/versions/traceV4.ts b/packages/playwright-core/src/utils/isomorphic/trace/versions/traceV4.ts similarity index 100% rename from packages/trace-viewer/src/sw/versions/traceV4.ts rename to packages/playwright-core/src/utils/isomorphic/trace/versions/traceV4.ts diff --git a/packages/trace-viewer/src/sw/versions/traceV5.ts b/packages/playwright-core/src/utils/isomorphic/trace/versions/traceV5.ts similarity index 100% rename from packages/trace-viewer/src/sw/versions/traceV5.ts rename to packages/playwright-core/src/utils/isomorphic/trace/versions/traceV5.ts diff --git a/packages/trace-viewer/src/sw/versions/traceV6.ts b/packages/playwright-core/src/utils/isomorphic/trace/versions/traceV6.ts similarity index 100% rename from packages/trace-viewer/src/sw/versions/traceV6.ts rename to packages/playwright-core/src/utils/isomorphic/trace/versions/traceV6.ts diff --git a/packages/trace-viewer/src/sw/versions/traceV7.ts b/packages/playwright-core/src/utils/isomorphic/trace/versions/traceV7.ts similarity index 100% rename from packages/trace-viewer/src/sw/versions/traceV7.ts rename to packages/playwright-core/src/utils/isomorphic/trace/versions/traceV7.ts diff --git a/packages/trace-viewer/src/sw/versions/traceV8.ts b/packages/playwright-core/src/utils/isomorphic/trace/versions/traceV8.ts similarity index 100% rename from packages/trace-viewer/src/sw/versions/traceV8.ts rename to packages/playwright-core/src/utils/isomorphic/trace/versions/traceV8.ts diff --git a/packages/trace-viewer/src/sw/main.ts b/packages/trace-viewer/src/sw/main.ts index 4252972d072af..0b9371e520f01 100644 --- a/packages/trace-viewer/src/sw/main.ts +++ b/packages/trace-viewer/src/sw/main.ts @@ -14,11 +14,12 @@ * limitations under the License. */ +import { SnapshotServer } from '@isomorphic/trace/snapshotServer'; +import { TraceModel } from '@isomorphic/trace/traceModel'; +import { TraceVersionError } from '@isomorphic/trace/traceModernizer'; + import { Progress, splitProgress } from './progress'; -import { SnapshotServer } from './snapshotServer'; -import { TraceModel } from './traceModel'; import { FetchTraceModelBackend, traceFileURL, ZipTraceModelBackend } from './traceModelBackends'; -import { TraceVersionError } from './traceModernizer'; type Client = { id: string; diff --git a/packages/trace-viewer/src/sw/traceModelBackends.ts b/packages/trace-viewer/src/sw/traceModelBackends.ts index da60e9c1e9811..d08a69088ae83 100644 --- a/packages/trace-viewer/src/sw/traceModelBackends.ts +++ b/packages/trace-viewer/src/sw/traceModelBackends.ts @@ -17,7 +17,7 @@ import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js'; import type * as zip from '@zip.js/zip.js'; -import type { TraceModelBackend } from './traceModel'; +import type { TraceModelBackend } from '@isomorphic/trace/traceModel'; const zipjs = zipImport as typeof zip; diff --git a/packages/trace-viewer/src/ui/filmStrip.tsx b/packages/trace-viewer/src/ui/filmStrip.tsx index 935a205433a25..1262936db09e8 100644 --- a/packages/trace-viewer/src/ui/filmStrip.tsx +++ b/packages/trace-viewer/src/ui/filmStrip.tsx @@ -18,7 +18,7 @@ import './filmStrip.css'; import type { Boundaries, Size } from './geometry'; import * as React from 'react'; import { useMeasure, upperBound } from '@web/uiUtils'; -import type { PageEntry } from '../types/entries'; +import type { PageEntry } from '@isomorphic/trace/entries'; import type { ActionTraceEventInContext } from './modelUtil'; import { renderAction } from './actionList'; import type { Language } from '@isomorphic/locatorGenerators'; diff --git a/packages/trace-viewer/src/ui/liveWorkbenchLoader.tsx b/packages/trace-viewer/src/ui/liveWorkbenchLoader.tsx index d01b64e3c0110..ea8fbb81b20c0 100644 --- a/packages/trace-viewer/src/ui/liveWorkbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/liveWorkbenchLoader.tsx @@ -19,7 +19,7 @@ import { MultiTraceModel } from './modelUtil'; import './workbenchLoader.css'; import { Workbench } from './workbench'; -import type { ContextEntry } from '../types/entries'; +import type { ContextEntry } from '@isomorphic/trace/entries'; export const LiveWorkbenchLoader: React.FC<{ traceJson: string }> = ({ traceJson }) => { const [model, setModel] = React.useState(undefined); diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 6203992a99b25..0b5cc9e193779 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -20,7 +20,7 @@ import type { Language } from '@isomorphic/locatorGenerators'; import type { ResourceSnapshot } from '@trace/snapshot'; import type * as trace from '@trace/trace'; import type { ActionTraceEvent } from '@trace/trace'; -import type { ActionEntry, ContextEntry, PageEntry } from '../types/entries'; +import type { ActionEntry, ContextEntry, PageEntry } from '@isomorphic/trace/entries'; import type { StackFrame } from '@protocol/channels'; import type { ActionGroup } from '@isomorphic/protocolFormatter'; diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 24fe2034d99d8..f67600f626610 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -24,7 +24,7 @@ import { PlaceholderPanel } from './placeholderPanel'; import { context, type MultiTraceModel } from './modelUtil'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; -import type { ContextEntry } from '../types/entries'; +import type { ContextEntry } from '@isomorphic/trace/entries'; import { NetworkFilters, defaultFilterState, type FilterState, type ResourceType } from './networkFilters'; import type { Language } from '@isomorphic/locatorGenerators'; diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx index 211639103bd4c..90b9be2eb0ce0 100644 --- a/packages/trace-viewer/src/ui/uiModeTraceView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -20,7 +20,7 @@ import '@web/common.css'; import '@web/third_party/vscode/codicon.css'; import type * as reporterTypes from 'playwright/types/testReporter'; import React from 'react'; -import type { ContextEntry } from '../types/entries'; +import type { ContextEntry } from '@isomorphic/trace/entries'; import type { SourceLocation } from './modelUtil'; import { MultiTraceModel } from './modelUtil'; import { Workbench } from './workbench'; diff --git a/packages/trace/src/trace.ts b/packages/trace/src/trace.ts index d26a51717834b..3ffa6c4586874 100644 --- a/packages/trace/src/trace.ts +++ b/packages/trace/src/trace.ts @@ -15,7 +15,7 @@ */ import type { FrameSnapshot, ResourceSnapshot } from './snapshot'; -import type { Language } from '../../playwright-core/src/utils/isomorphic/locatorGenerators'; +import type { Language } from '@isomorphic/locatorGenerators'; import type { Point, SerializedError, StackFrame } from '@protocol/channels'; export type Size = { width: number, height: number }; diff --git a/tests/config/utils.ts b/tests/config/utils.ts index 20c7ef39fc148..087115e07bc6d 100644 --- a/tests/config/utils.ts +++ b/tests/config/utils.ts @@ -16,10 +16,10 @@ import type { Frame, Page } from 'playwright-core'; import { ZipFile } from '../../packages/playwright-core/lib/server/utils/zipFile'; -import type { TraceModelBackend } from '../../packages/trace-viewer/src/sw/traceModel'; +import type { TraceModelBackend } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceModel'; import type { StackFrame } from '../../packages/protocol/src/channels'; import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils'; -import { TraceModel } from '../../packages/trace-viewer/src/sw/traceModel'; +import { TraceModel } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceModel'; import type { ActionTreeItem } from '../../packages/trace-viewer/src/ui/modelUtil'; import { buildActionTree, MultiTraceModel } from '../../packages/trace-viewer/src/ui/modelUtil'; import type { ActionTraceEvent, ConsoleMessageTraceEvent, EventTraceEvent, TraceEvent } from '@trace/trace'; From 0fe077d6452d763d755a6f83e78b00a5fa85ca68 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 1 Dec 2025 11:01:36 +0000 Subject: [PATCH 2/2] chore: move modelUtil.ts into isomorphic/trace --- .../utils/isomorphic/trace/snapshotServer.ts | 8 - .../src/utils/isomorphic/trace/traceLoader.ts | 158 ++++++ .../src/utils/isomorphic/trace/traceModel.ts | 483 +++++++++++++----- packages/trace-viewer/src/sw/main.ts | 22 +- ...odelBackends.ts => traceLoaderBackends.ts} | 6 +- packages/trace-viewer/src/ui/actionList.tsx | 8 +- .../trace-viewer/src/ui/attachmentsTab.tsx | 6 +- packages/trace-viewer/src/ui/callTab.tsx | 2 +- packages/trace-viewer/src/ui/consoleTab.tsx | 4 +- packages/trace-viewer/src/ui/errorsTab.tsx | 14 +- packages/trace-viewer/src/ui/filmStrip.tsx | 2 +- .../src/ui/liveWorkbenchLoader.tsx | 10 +- packages/trace-viewer/src/ui/logTab.tsx | 2 +- packages/trace-viewer/src/ui/metadataView.tsx | 4 +- packages/trace-viewer/src/ui/modelUtil.ts | 413 --------------- packages/trace-viewer/src/ui/networkTab.tsx | 7 +- packages/trace-viewer/src/ui/snapshotTab.tsx | 5 +- packages/trace-viewer/src/ui/sourceTab.tsx | 2 +- packages/trace-viewer/src/ui/timeline.tsx | 4 +- .../trace-viewer/src/ui/traceModelContext.tsx | 4 +- .../src/ui/uiModeTestListView.tsx | 2 +- .../trace-viewer/src/ui/uiModeTraceView.tsx | 12 +- packages/trace-viewer/src/ui/uiModeView.tsx | 2 +- packages/trace-viewer/src/ui/workbench.tsx | 16 +- .../trace-viewer/src/ui/workbenchLoader.tsx | 8 +- tests/config/utils.ts | 20 +- .../playwright-test/playwright.reuse.spec.ts | 8 +- .../playwright-test/playwright.trace.spec.ts | 2 +- 28 files changed, 614 insertions(+), 620 deletions(-) create mode 100644 packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts rename packages/trace-viewer/src/sw/{traceModelBackends.ts => traceLoaderBackends.ts} (95%) delete mode 100644 packages/trace-viewer/src/ui/modelUtil.ts diff --git a/packages/playwright-core/src/utils/isomorphic/trace/snapshotServer.ts b/packages/playwright-core/src/utils/isomorphic/trace/snapshotServer.ts index 64a86935402f7..843667af33606 100644 --- a/packages/playwright-core/src/utils/isomorphic/trace/snapshotServer.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/snapshotServer.ts @@ -19,8 +19,6 @@ import type { SnapshotRenderer } from './snapshotRenderer'; import type { SnapshotStorage } from './snapshotStorage'; import type { ResourceSnapshot } from '@trace/snapshot'; -type Point = { x: number, y: number }; - export class SnapshotServer { private _snapshotStorage: SnapshotStorage; private _resourceLoader: (sha1: string) => Promise; @@ -117,12 +115,6 @@ export class SnapshotServer { } } -declare global { - interface Window { - showSnapshot: (url: string, point?: Point) => Promise; - } -} - function removeHash(url: string) { try { const u = new URL(url); diff --git a/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts new file mode 100644 index 0000000000000..3f0b3ffdab875 --- /dev/null +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts @@ -0,0 +1,158 @@ +/** + * 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 { parseClientSideCallMetadata } from '@isomorphic/traceUtils'; + +import { SnapshotStorage } from './snapshotStorage'; +import { TraceModernizer } from './traceModernizer'; + +import type { ContextEntry } from './entries'; + +export interface TraceLoaderBackend { + entryNames(): Promise; + hasEntry(entryName: string): Promise; + readText(entryName: string): Promise; + readBlob(entryName: string): Promise; + isLive(): boolean; + traceURL(): string; +} + +export class TraceLoader { + contextEntries: ContextEntry[] = []; + private _snapshotStorage: SnapshotStorage | undefined; + private _backend!: TraceLoaderBackend; + private _resourceToContentType = new Map(); + + constructor() { + } + + async load(backend: TraceLoaderBackend, unzipProgress: (done: number, total: number) => void) { + this._backend = backend; + + const ordinals: string[] = []; + let hasSource = false; + for (const entryName of await this._backend.entryNames()) { + const match = entryName.match(/(.+)\.trace$/); + if (match) + ordinals.push(match[1] || ''); + if (entryName.includes('src@')) + hasSource = true; + } + if (!ordinals.length) + throw new Error('Cannot find .trace file'); + + this._snapshotStorage = new SnapshotStorage(); + + // 3 * ordinals progress increments below. + const total = ordinals.length * 3; + let done = 0; + for (const ordinal of ordinals) { + const contextEntry = createEmptyContext(); + contextEntry.hasSource = hasSource; + const modernizer = new TraceModernizer(contextEntry, this._snapshotStorage); + + const trace = await this._backend.readText(ordinal + '.trace') || ''; + modernizer.appendTrace(trace); + unzipProgress(++done, total); + + const network = await this._backend.readText(ordinal + '.network') || ''; + modernizer.appendTrace(network); + unzipProgress(++done, total); + + contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime); + + if (!backend.isLive()) { + // Terminate actions w/o after event gracefully. + // This would close after hooks event that has not been closed because + // the trace is usually saved before after hooks complete. + for (const action of contextEntry.actions.slice().reverse()) { + if (!action.endTime && !action.error) { + for (const a of contextEntry.actions) { + if (a.parentId === action.callId && action.endTime < a.endTime) + action.endTime = a.endTime; + } + } + } + } + + const stacks = await this._backend.readText(ordinal + '.stacks'); + if (stacks) { + const callMetadata = parseClientSideCallMetadata(JSON.parse(stacks)); + for (const action of contextEntry.actions) + action.stack = action.stack || callMetadata.get(action.callId); + } + unzipProgress(++done, total); + + for (const resource of contextEntry.resources) { + if (resource.request.postData?._sha1) + this._resourceToContentType.set(resource.request.postData._sha1, stripEncodingFromContentType(resource.request.postData.mimeType)); + if (resource.response.content?._sha1) + this._resourceToContentType.set(resource.response.content._sha1, stripEncodingFromContentType(resource.response.content.mimeType)); + } + + this.contextEntries.push(contextEntry); + } + + this._snapshotStorage.finalize(); + } + + async hasEntry(filename: string): Promise { + return this._backend.hasEntry(filename); + } + + async resourceForSha1(sha1: string): Promise { + const blob = await this._backend.readBlob('resources/' + sha1); + const contentType = this._resourceToContentType.get(sha1); + // "x-unknown" in the har means "no content type". + if (!blob || contentType === undefined || contentType === 'x-unknown') + return blob; + return new Blob([blob], { type: contentType }); + } + + storage(): SnapshotStorage { + return this._snapshotStorage!; + } +} + +function stripEncodingFromContentType(contentType: string) { + const charset = contentType.match(/^(.*);\s*charset=.*$/); + if (charset) + return charset[1]; + return contentType; +} + +function createEmptyContext(): ContextEntry { + return { + origin: 'testRunner', + startTime: Number.MAX_SAFE_INTEGER, + wallTime: Number.MAX_SAFE_INTEGER, + endTime: 0, + browserName: '', + options: { + deviceScaleFactor: 1, + isMobile: false, + viewport: { width: 1280, height: 800 }, + }, + pages: [], + resources: [], + actions: [], + events: [], + errors: [], + stdio: [], + hasSource: false, + contextId: '', + }; +} diff --git a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts index 55064d69b4ab3..4483815a3b1fd 100644 --- a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts @@ -14,145 +14,400 @@ * limitations under the License. */ -import { parseClientSideCallMetadata } from '@isomorphic/traceUtils'; +import { getActionGroup } from '@isomorphic/protocolFormatter'; -import { SnapshotStorage } from './snapshotStorage'; -import { TraceModernizer } from './traceModernizer'; +import type { Language } from '@isomorphic/locatorGenerators'; +import type { ResourceSnapshot } from '@trace/snapshot'; +import type * as trace from '@trace/trace'; +import type { ActionTraceEvent } from '@trace/trace'; +import type { ActionEntry, ContextEntry, PageEntry } from '@isomorphic/trace/entries'; +import type { StackFrame } from '@protocol/channels'; +import type { ActionGroup } from '@isomorphic/protocolFormatter'; -import type { ContextEntry } from './entries'; +const contextSymbol = Symbol('context'); +const nextInContextSymbol = Symbol('nextInContext'); +const prevByEndTimeSymbol = Symbol('prevByEndTime'); +const nextByStartTimeSymbol = Symbol('nextByStartTime'); +const eventsSymbol = Symbol('events'); -export interface TraceModelBackend { - entryNames(): Promise; - hasEntry(entryName: string): Promise; - readText(entryName: string): Promise; - readBlob(entryName: string): Promise; - isLive(): boolean; - traceURL(): string; -} +export type SourceLocation = { + file: string; + line: number; + column: number; + source?: SourceModel; +}; + +export type SourceModel = { + errors: { line: number, message: string }[]; + content: string | undefined; +}; + +export type ActionTraceEventInContext = ActionEntry & { + context: ContextEntry; +}; + +export type ActionTreeItem = { + id: string; + children: ActionTreeItem[]; + parent: ActionTreeItem | undefined; + action?: ActionTraceEventInContext; +}; + +export type ErrorDescription = { + action?: ActionTraceEventInContext; + stack?: StackFrame[]; + message: string; +}; + +export type Attachment = trace.AfterActionTraceEventAttachment & { callId: string }; export class TraceModel { - contextEntries: ContextEntry[] = []; - private _snapshotStorage: SnapshotStorage | undefined; - private _backend!: TraceModelBackend; - private _resourceToContentType = new Map(); + readonly startTime: number; + readonly endTime: number; + readonly browserName: string; + readonly channel?: string; + readonly platform?: string; + readonly playwrightVersion?: string; + readonly wallTime?: number; + readonly title?: string; + readonly options: trace.BrowserContextEventOptions; + readonly pages: PageEntry[]; + readonly actions: ActionTraceEventInContext[]; + readonly attachments: Attachment[]; + readonly visibleAttachments: Attachment[]; + readonly events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]; + readonly stdio: trace.StdioTraceEvent[]; + readonly errors: trace.ErrorTraceEvent[]; + readonly errorDescriptors: ErrorDescription[]; + readonly hasSource: boolean; + readonly hasStepData: boolean; + readonly sdkLanguage: Language | undefined; + readonly testIdAttributeName: string | undefined; + readonly sources: Map; + resources: ResourceSnapshot[]; + readonly actionCounters: Map; + readonly traceUrl: string; + + + constructor(traceUrl: string, contexts: ContextEntry[]) { + contexts.forEach(contextEntry => indexModel(contextEntry)); + const libraryContext = contexts.find(context => context.origin === 'library'); + + this.traceUrl = traceUrl; + this.browserName = libraryContext?.browserName || ''; + this.sdkLanguage = libraryContext?.sdkLanguage; + this.channel = libraryContext?.channel; + this.testIdAttributeName = libraryContext?.testIdAttributeName; + this.platform = libraryContext?.platform || ''; + this.playwrightVersion = contexts.find(c => c.playwrightVersion)?.playwrightVersion; + this.title = libraryContext?.title || ''; + this.options = libraryContext?.options || {}; + // Next call updates all timestamps for all events in library contexts, so it must be done first. + this.actions = mergeActionsAndUpdateTiming(contexts); + this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages)); + this.wallTime = contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE); + this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE); + this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE); + this.events = ([] as (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]).concat(...contexts.map(c => c.events)); + this.stdio = ([] as trace.StdioTraceEvent[]).concat(...contexts.map(c => c.stdio)); + this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors)); + this.hasSource = contexts.some(c => c.hasSource); + this.hasStepData = contexts.some(context => context.origin === 'testRunner'); + this.resources = [...contexts.map(c => c.resources)].flat(); + this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUrl })) ?? []); + this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_')); - constructor() { + this.events.sort((a1, a2) => a1.time - a2.time); + this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!); + this.errorDescriptors = this.hasStepData ? this._errorDescriptorsFromTestRunner() : this._errorDescriptorsFromActions(); + this.sources = collectSources(this.actions, this.errorDescriptors); + + this.actionCounters = new Map(); + for (const action of this.actions) { + action.group = action.group ?? getActionGroup({ type: action.class, method: action.method }); + if (action.group) + this.actionCounters.set(action.group, 1 + (this.actionCounters.get(action.group) || 0)); + } + } + + createRelativeUrl(path: string) { + const url = new URL('http://localhost/' + path); + url.searchParams.set('trace', this.traceUrl); + return url.toString().substring('http://localhost/'.length); } - async load(backend: TraceModelBackend, unzipProgress: (done: number, total: number) => void) { - this._backend = backend; + failedAction() { + // This find innermost action for nested ones. + return this.actions.findLast(a => a.error); + } + + filteredActions(actionsFilter: ActionGroup[]) { + const filter = new Set(actionsFilter); + return this.actions.filter(action => !action.group || filter.has(action.group)); + } - const ordinals: string[] = []; - let hasSource = false; - for (const entryName of await this._backend.entryNames()) { - const match = entryName.match(/(.+)\.trace$/); - if (match) - ordinals.push(match[1] || ''); - if (entryName.includes('src@')) - hasSource = true; + private _errorDescriptorsFromActions(): ErrorDescription[] { + const errors: ErrorDescription[] = []; + for (const action of this.actions || []) { + if (!action.error?.message) + continue; + errors.push({ + action, + stack: action.stack, + message: action.error.message, + }); } - if (!ordinals.length) - throw new Error('Cannot find .trace file'); - - this._snapshotStorage = new SnapshotStorage(); - - // 3 * ordinals progress increments below. - const total = ordinals.length * 3; - let done = 0; - for (const ordinal of ordinals) { - const contextEntry = createEmptyContext(); - contextEntry.hasSource = hasSource; - const modernizer = new TraceModernizer(contextEntry, this._snapshotStorage); - - const trace = await this._backend.readText(ordinal + '.trace') || ''; - modernizer.appendTrace(trace); - unzipProgress(++done, total); - - const network = await this._backend.readText(ordinal + '.network') || ''; - modernizer.appendTrace(network); - unzipProgress(++done, total); - - contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime); - - if (!backend.isLive()) { - // Terminate actions w/o after event gracefully. - // This would close after hooks event that has not been closed because - // the trace is usually saved before after hooks complete. - for (const action of contextEntry.actions.slice().reverse()) { - if (!action.endTime && !action.error) { - for (const a of contextEntry.actions) { - if (a.parentId === action.callId && action.endTime < a.endTime) - action.endTime = a.endTime; - } - } - } - } + return errors; + } - const stacks = await this._backend.readText(ordinal + '.stacks'); - if (stacks) { - const callMetadata = parseClientSideCallMetadata(JSON.parse(stacks)); - for (const action of contextEntry.actions) - action.stack = action.stack || callMetadata.get(action.callId); - } - unzipProgress(++done, total); + private _errorDescriptorsFromTestRunner(): ErrorDescription[] { + return this.errors.filter(e => !!e.message).map((error, i) => ({ + stack: error.stack, + message: error.message, + })); + } +} + +function indexModel(context: ContextEntry) { + for (const page of context.pages) + (page as any)[contextSymbol] = context; + for (let i = 0; i < context.actions.length; ++i) { + const action = context.actions[i] as any; + action[contextSymbol] = context; + } + let lastNonRouteAction = undefined; + for (let i = context.actions.length - 1; i >= 0; i--) { + const action = context.actions[i] as ActionTraceEvent; + (action as any)[nextInContextSymbol] = lastNonRouteAction; + if (action.class !== 'Route') + lastNonRouteAction = action; + } + for (const event of context.events) + (event as any)[contextSymbol] = context; + for (const resource of context.resources) + (resource as any)[contextSymbol] = context; +} + +function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) { + const result: ActionTraceEventInContext[] = []; + const actions = mergeActionsAndUpdateTimingSameTrace(contexts); + result.push(...actions); + + result.sort((a1, a2) => { + if (a2.parentId === a1.callId) + return 1; + if (a1.parentId === a2.callId) + return -1; + return a1.endTime - a2.endTime; + }); + + for (let i = 1; i < result.length; ++i) + (result[i] as any)[prevByEndTimeSymbol] = result[i - 1]; + + result.sort((a1, a2) => { + if (a2.parentId === a1.callId) + return -1; + if (a1.parentId === a2.callId) + return 1; + return a1.startTime - a2.startTime; + }); + + for (let i = 0; i + 1 < result.length; ++i) + (result[i] as any)[nextByStartTimeSymbol] = result[i + 1]; + + return result; +} + +let lastTmpStepId = 0; + +function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionTraceEventInContext[] { + const map = new Map(); - for (const resource of contextEntry.resources) { - if (resource.request.postData?._sha1) - this._resourceToContentType.set(resource.request.postData._sha1, stripEncodingFromContentType(resource.request.postData.mimeType)); - if (resource.response.content?._sha1) - this._resourceToContentType.set(resource.response.content._sha1, stripEncodingFromContentType(resource.response.content.mimeType)); + const libraryContexts = contexts.filter(context => context.origin === 'library'); + const testRunnerContexts = contexts.filter(context => context.origin === 'testRunner'); + + // With library-only or test-runner-only traces there is nothing to match. + if (!testRunnerContexts.length || !libraryContexts.length) { + return contexts.map(context => { + return context.actions.map(action => ({ ...action, context })); + }).flat(); + } + + for (const context of libraryContexts) { + for (const action of context.actions) { + // Never merge stepless events. + map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context }); + } + } + + // Protocol call aka library contexts have startTime/endTime as server-side times. + // Step aka test runner contexts have startTime/endTime as client-side times. + // Adjust startTime/endTime on the library contexts to align them with the test + // runner steps. + const delta = monotonicTimeDeltaBetweenLibraryAndRunner(testRunnerContexts, map); + if (delta) + adjustMonotonicTime(libraryContexts, delta); + + const nonPrimaryIdToPrimaryId = new Map(); + for (const context of testRunnerContexts) { + for (const action of context.actions) { + const existing = action.stepId && map.get(action.stepId); + if (existing) { + nonPrimaryIdToPrimaryId.set(action.callId, existing.callId); + if (action.error) + existing.error = action.error; + if (action.attachments) + existing.attachments = action.attachments; + if (action.annotations) + existing.annotations = action.annotations; + if (action.parentId) + existing.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId; + if (action.group) + existing.group = action.group; + // For the events that are present in the test runner context, always take + // their time from the test runner context to preserve client side order. + existing.startTime = action.startTime; + existing.endTime = action.endTime; + continue; } + if (action.parentId) + action.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId; + map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context }); + } + } + return [...map.values()]; +} - this.contextEntries.push(contextEntry); +function adjustMonotonicTime(contexts: ContextEntry[], monotonicTimeDelta: number) { + for (const context of contexts) { + context.startTime += monotonicTimeDelta; + context.endTime += monotonicTimeDelta; + for (const action of context.actions) { + if (action.startTime) + action.startTime += monotonicTimeDelta; + if (action.endTime) + action.endTime += monotonicTimeDelta; + } + for (const event of context.events) + event.time += monotonicTimeDelta; + for (const event of context.stdio) + event.timestamp += monotonicTimeDelta; + for (const page of context.pages) { + for (const frame of page.screencastFrames) + frame.timestamp += monotonicTimeDelta; } + for (const resource of context.resources) { + if (resource._monotonicTime) + resource._monotonicTime += monotonicTimeDelta; + } + } +} - this._snapshotStorage.finalize(); +function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEntry[], libraryActions: Map) { + // We cannot rely on wall time or monotonic time to be the in sync + // between library and test runner contexts. So we find first action + // that is present in both runner and library contexts and use it + // to calculate the time delta, assuming the two events happened at the + // same instant. + for (const context of nonPrimaryContexts) { + for (const action of context.actions) { + if (!action.startTime) + continue; + const libraryAction = action.stepId ? libraryActions.get(action.stepId) : undefined; + if (libraryAction) + return action.startTime - libraryAction.startTime; + } } + return 0; +} + +export function buildActionTree(actions: ActionTraceEventInContext[]): { rootItem: ActionTreeItem, itemMap: Map } { + const itemMap = new Map(); - async hasEntry(filename: string): Promise { - return this._backend.hasEntry(filename); + for (const action of actions) { + itemMap.set(action.callId, { + id: action.callId, + parent: undefined, + children: [], + action, + }); } - async resourceForSha1(sha1: string): Promise { - const blob = await this._backend.readBlob('resources/' + sha1); - const contentType = this._resourceToContentType.get(sha1); - // "x-unknown" in the har means "no content type". - if (!blob || contentType === undefined || contentType === 'x-unknown') - return blob; - return new Blob([blob], { type: contentType }); + const rootItem: ActionTreeItem = { id: '', parent: undefined, children: [] }; + for (const item of itemMap.values()) { + const parent = item.action!.parentId ? itemMap.get(item.action!.parentId) || rootItem : rootItem; + parent.children.push(item); + item.parent = parent; } + return { rootItem, itemMap }; +} + +export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry { + return (action as any)[contextSymbol]; +} + +function nextInContext(action: ActionTraceEvent): ActionTraceEvent { + return (action as any)[nextInContextSymbol]; +} + +export function previousActionByEndTime(action: ActionTraceEvent): ActionTraceEvent { + return (action as any)[prevByEndTimeSymbol]; +} + +export function nextActionByStartTime(action: ActionTraceEvent): ActionTraceEvent { + return (action as any)[nextByStartTimeSymbol]; +} - storage(): SnapshotStorage { - return this._snapshotStorage!; +export function stats(action: ActionTraceEvent): { errors: number, warnings: number } { + let errors = 0; + let warnings = 0; + for (const event of eventsForAction(action)) { + if (event.type === 'console') { + const type = event.messageType; + if (type === 'warning') + ++warnings; + else if (type === 'error') + ++errors; + } + if (event.type === 'event' && event.method === 'pageError') + ++errors; } + return { errors, warnings }; } -function stripEncodingFromContentType(contentType: string) { - const charset = contentType.match(/^(.*);\s*charset=.*$/); - if (charset) - return charset[1]; - return contentType; +export function eventsForAction(action: ActionTraceEvent): (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] { + let result: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] = (action as any)[eventsSymbol]; + if (result) + return result; + + const nextAction = nextInContext(action); + result = context(action).events.filter(event => { + return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime); + }); + (action as any)[eventsSymbol] = result; + return result; } -function createEmptyContext(): ContextEntry { - return { - origin: 'testRunner', - startTime: Number.MAX_SAFE_INTEGER, - wallTime: Number.MAX_SAFE_INTEGER, - endTime: 0, - browserName: '', - options: { - deviceScaleFactor: 1, - isMobile: false, - viewport: { width: 1280, height: 800 }, - }, - pages: [], - resources: [], - actions: [], - events: [], - errors: [], - stdio: [], - hasSource: false, - contextId: '', - }; +function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: ErrorDescription[]): Map { + const result = new Map(); + for (const action of actions) { + for (const frame of action.stack || []) { + let source = result.get(frame.file); + if (!source) { + source = { errors: [], content: undefined }; + result.set(frame.file, source); + } + } + } + + for (const error of errorDescriptors) { + const { action, stack, message } = error; + if (!action || !stack) + continue; + result.get(stack[0].file)?.errors.push({ + line: stack[0].line || 0, + message + }); + } + return result; } diff --git a/packages/trace-viewer/src/sw/main.ts b/packages/trace-viewer/src/sw/main.ts index 0b9371e520f01..40f50c028f0a1 100644 --- a/packages/trace-viewer/src/sw/main.ts +++ b/packages/trace-viewer/src/sw/main.ts @@ -15,11 +15,11 @@ */ import { SnapshotServer } from '@isomorphic/trace/snapshotServer'; -import { TraceModel } from '@isomorphic/trace/traceModel'; +import { TraceLoader } from '@isomorphic/trace/traceLoader'; import { TraceVersionError } from '@isomorphic/trace/traceModernizer'; import { Progress, splitProgress } from './progress'; -import { FetchTraceModelBackend, traceFileURL, ZipTraceModelBackend } from './traceModelBackends'; +import { FetchTraceLoaderBackend, traceFileURL, ZipTraceLoaderBackend } from './traceLoaderBackends'; type Client = { id: string; @@ -60,7 +60,7 @@ self.addEventListener('activate', function(event: any) { }); type LoadedTrace = { - traceModel: TraceModel; + traceLoader: TraceLoader; snapshotServer: SnapshotServer; }; @@ -106,23 +106,23 @@ function loadTrace(clientId: string, url: URL, isContextRequest: boolean, progre async function innerLoadTrace(traceUrl: string, progress: Progress): Promise { await gc(); - const traceModel = new TraceModel(); + const traceLoader = new TraceLoader(); try { // Allow 10% to hop from sw to page. const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]); - const backend = isLiveTrace(traceUrl) || traceUrl.endsWith('traces.dir') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress); - await traceModel.load(backend, unzipProgress); + const backend = isLiveTrace(traceUrl) || traceUrl.endsWith('traces.dir') ? new FetchTraceLoaderBackend(traceUrl) : new ZipTraceLoaderBackend(traceUrl, fetchProgress); + await traceLoader.load(backend, unzipProgress); } catch (error: any) { // eslint-disable-next-line no-console console.error(error); - if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html')) + if (error?.message?.includes('Cannot find .trace file') && await traceLoader.hasEntry('index.html')) throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.'); if (error instanceof TraceVersionError) throw new Error(`Could not load trace from ${traceUrl}. ${error.message}`); throw new Error(`Could not load trace from ${traceUrl}. Make sure a valid Playwright Trace is accessible over this url.`); } - const snapshotServer = new SnapshotServer(traceModel.storage(), sha1 => traceModel.resourceForSha1(sha1)); - return { traceModel, snapshotServer }; + const snapshotServer = new SnapshotServer(traceLoader.storage(), sha1 => traceLoader.resourceForSha1(sha1)); + return { traceLoader, snapshotServer }; } async function doFetch(event: FetchEvent): Promise { @@ -205,7 +205,7 @@ async function doFetch(event: FetchEvent): Promise { return errorResponse; if (relativePath === '/contexts') { - return new Response(JSON.stringify(loadedTrace!.traceModel.contextEntries), { + return new Response(JSON.stringify(loadedTrace!.traceLoader.contextEntries), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -222,7 +222,7 @@ async function doFetch(event: FetchEvent): Promise { } if (relativePath.startsWith('/sha1/')) { - const blob = await loadedTrace!.traceModel.resourceForSha1(relativePath.slice('/sha1/'.length)); + const blob = await loadedTrace!.traceLoader.resourceForSha1(relativePath.slice('/sha1/'.length)); if (blob) return new Response(blob, { status: 200, headers: downloadHeaders(url.searchParams) }); return new Response(null, { status: 404 }); diff --git a/packages/trace-viewer/src/sw/traceModelBackends.ts b/packages/trace-viewer/src/sw/traceLoaderBackends.ts similarity index 95% rename from packages/trace-viewer/src/sw/traceModelBackends.ts rename to packages/trace-viewer/src/sw/traceLoaderBackends.ts index d08a69088ae83..6ee4cb4d52033 100644 --- a/packages/trace-viewer/src/sw/traceModelBackends.ts +++ b/packages/trace-viewer/src/sw/traceLoaderBackends.ts @@ -17,13 +17,13 @@ import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js'; import type * as zip from '@zip.js/zip.js'; -import type { TraceModelBackend } from '@isomorphic/trace/traceModel'; +import type { TraceLoaderBackend } from '@isomorphic/trace/traceLoader'; const zipjs = zipImport as typeof zip; type Progress = (done: number, total: number) => undefined; -export class ZipTraceModelBackend implements TraceModelBackend { +export class ZipTraceLoaderBackend implements TraceLoaderBackend { private _zipReader: zip.ZipReader; private _entriesPromise: Promise>; private _traceURL: string; @@ -94,7 +94,7 @@ export class ZipTraceModelBackend implements TraceModelBackend { } } -export class FetchTraceModelBackend implements TraceModelBackend { +export class FetchTraceLoaderBackend implements TraceLoaderBackend { private _entriesPromise: Promise>; private _path: string; diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index b2b0ebc757523..16e4e716135b0 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -18,11 +18,11 @@ import type { ActionTraceEvent } from '@trace/trace'; import { clsx, msToString } from '@web/uiUtils'; import * as React from 'react'; import './actionList.css'; -import * as modelUtil from './modelUtil'; +import { stats, buildActionTree } from '@isomorphic/trace/traceModel'; import { asLocatorDescription, type Language } from '@isomorphic/locatorGenerators'; import type { TreeState } from '@web/components/treeView'; import { TreeView } from '@web/components/treeView'; -import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil'; +import type { ActionTraceEventInContext, ActionTreeItem } from '@isomorphic/trace/traceModel'; import type { Boundaries } from './geometry'; import { ToolbarButton } from '@web/components/toolbarButton'; import { testStatusIcon } from './testUtils'; @@ -60,7 +60,7 @@ export const ActionList: React.FC = ({ revealActionAttachment, isLive, }) => { - const { rootItem, itemMap } = React.useMemo(() => modelUtil.buildActionTree(actions), [actions]); + const { rootItem, itemMap } = React.useMemo(() => buildActionTree(actions), [actions]); const { selectedItem } = React.useMemo(() => { const selectedItem = selectedAction ? itemMap.get(selectedAction.callId) : undefined; @@ -122,7 +122,7 @@ export const renderAction = ( showAttachments?: boolean, }) => { const { sdkLanguage, revealConsole, revealActionAttachment, isLive, showDuration, showBadges, showAttachments } = options; - const { errors, warnings } = modelUtil.stats(action); + const { errors, warnings } = stats(action); const locator = action.params.selector ? asLocatorDescription(sdkLanguage || 'javascript', action.params.selector) : undefined; diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 1f7d81354213c..f7bdd6bc18c57 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -25,7 +25,7 @@ import { linkifyText } from '@web/renderUtils'; import { clsx, useFlash } from '@web/uiUtils'; import { useTraceModel } from './traceModelContext'; -import type { Attachment, MultiTraceModel } from './modelUtil'; +import type { Attachment, TraceModel } from '@isomorphic/trace/traceModel'; type ExpandableAttachmentProps = { attachment: Attachment; @@ -155,13 +155,13 @@ export const AttachmentsTab: React.FunctionComponent<{ ; }; -export function attachmentURL(model: MultiTraceModel | undefined, attachment: Attachment) { +export function attachmentURL(model: TraceModel | undefined, attachment: Attachment) { if (model && attachment.sha1) return model.createRelativeUrl(`sha1/${attachment.sha1}`) ; return `file?path=${encodeURIComponent(attachment.path!)}`; } -function downloadURL(model: MultiTraceModel | undefined, attachment: Attachment) { +function downloadURL(model: TraceModel | undefined, attachment: Attachment) { let suffix = attachment.contentType ? `&dn=${encodeURIComponent(attachment.name)}` : ''; if (attachment.contentType) suffix += `&dct=${encodeURIComponent(attachment.contentType)}`; diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index 5accfc860523f..223027fc8c01a 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -23,7 +23,7 @@ import { CopyToClipboard } from './copyToClipboard'; import { asLocator } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators'; import { PlaceholderPanel } from './placeholderPanel'; -import type { ActionTraceEventInContext } from './modelUtil'; +import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; import { renderTitleForCall } from './actionList'; export const CallTab: React.FunctionComponent<{ diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index f56005afdceb1..8194e973e4d44 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -17,7 +17,7 @@ import type * as channels from '@protocol/channels'; import * as React from 'react'; import './consoleTab.css'; -import type * as modelUtil from './modelUtil'; +import type { TraceModel } from '@isomorphic/trace/traceModel'; import { ListView } from '@web/components/listView'; import type { Boundaries } from './geometry'; import { clsx, msToString } from '@web/uiUtils'; @@ -47,7 +47,7 @@ type ConsoleTabModel = { const ConsoleListView = ListView; -export function useConsoleTabModel(model: modelUtil.MultiTraceModel | undefined, selectedTime: Boundaries | undefined): ConsoleTabModel { +export function useConsoleTabModel(model: TraceModel | undefined, selectedTime: Boundaries | undefined): ConsoleTabModel { const { entries } = React.useMemo(() => { if (!model) return { entries: [] }; diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index 21f12e83d4148..a93bcd1f82bae 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -16,7 +16,7 @@ import { ErrorMessage } from '@web/components/errorMessage'; import * as React from 'react'; -import type * as modelUtil from './modelUtil'; +import type { TraceModel, ErrorDescription } from '@isomorphic/trace/traceModel'; import { PlaceholderPanel } from './placeholderPanel'; import { renderAction } from './actionList'; import type { Language } from '@isomorphic/locatorGenerators'; @@ -41,21 +41,21 @@ const CopyPromptButton: React.FC<{ prompt: string }> = ({ prompt }) => { }; type ErrorsTabModel = { - errors: Map; + errors: Map; }; -export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): ErrorsTabModel { +export function useErrorsTabModel(model: TraceModel | undefined): ErrorsTabModel { return React.useMemo(() => { if (!model) return { errors: new Map() }; - const errors = new Map(); + const errors = new Map(); for (const error of model.errorDescriptors) errors.set(error.message, error); return { errors }; }, [model]); } -function ErrorView({ message, error, sdkLanguage, revealInSource }: { message: string, error: modelUtil.ErrorDescription, sdkLanguage: Language, revealInSource: (error: modelUtil.ErrorDescription) => void }) { +function ErrorView({ message, error, sdkLanguage, revealInSource }: { message: string, error: ErrorDescription, sdkLanguage: Language, revealInSource: (error: ErrorDescription) => void }) { let location: string | undefined; let longLocation: string | undefined; const stackFrame = error.stack?.[0]; @@ -88,7 +88,7 @@ export const ErrorsTab: React.FunctionComponent<{ errorsModel: ErrorsTabModel, wallTime: number, sdkLanguage: Language, - revealInSource: (error: modelUtil.ErrorDescription) => void, + revealInSource: (error: ErrorDescription) => void, testRunMetadata: MetadataWithCommitInfo | undefined, }> = ({ errorsModel, sdkLanguage, revealInSource, wallTime, testRunMetadata }) => { const model = useTraceModel(); @@ -99,7 +99,7 @@ export const ErrorsTab: React.FunctionComponent<{ return await fetch(attachmentURL(model, attachment)).then(r => r.text()); }, [model], undefined); - const buildCodeFrame = React.useCallback(async (error: modelUtil.ErrorDescription) => { + const buildCodeFrame = React.useCallback(async (error: ErrorDescription) => { const location = error.stack?.[0]; if (!location) return; diff --git a/packages/trace-viewer/src/ui/filmStrip.tsx b/packages/trace-viewer/src/ui/filmStrip.tsx index 1262936db09e8..c3910767c1a2a 100644 --- a/packages/trace-viewer/src/ui/filmStrip.tsx +++ b/packages/trace-viewer/src/ui/filmStrip.tsx @@ -19,7 +19,7 @@ import type { Boundaries, Size } from './geometry'; import * as React from 'react'; import { useMeasure, upperBound } from '@web/uiUtils'; import type { PageEntry } from '@isomorphic/trace/entries'; -import type { ActionTraceEventInContext } from './modelUtil'; +import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; import { renderAction } from './actionList'; import type { Language } from '@isomorphic/locatorGenerators'; import { useTraceModel } from './traceModelContext'; diff --git a/packages/trace-viewer/src/ui/liveWorkbenchLoader.tsx b/packages/trace-viewer/src/ui/liveWorkbenchLoader.tsx index ea8fbb81b20c0..10e98eb7a16c9 100644 --- a/packages/trace-viewer/src/ui/liveWorkbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/liveWorkbenchLoader.tsx @@ -15,14 +15,14 @@ */ import * as React from 'react'; -import { MultiTraceModel } from './modelUtil'; +import { TraceModel } from '@isomorphic/trace/traceModel'; import './workbenchLoader.css'; import { Workbench } from './workbench'; import type { ContextEntry } from '@isomorphic/trace/entries'; export const LiveWorkbenchLoader: React.FC<{ traceJson: string }> = ({ traceJson }) => { - const [model, setModel] = React.useState(undefined); + const [model, setModel] = React.useState(undefined); const [counter, setCounter] = React.useState(0); const pollTimer = React.useRef(null); @@ -36,7 +36,7 @@ export const LiveWorkbenchLoader: React.FC<{ traceJson: string }> = ({ traceJson const model = await loadSingleTraceFile(traceJson); setModel(model); } catch { - const model = new MultiTraceModel('', []); + const model = new TraceModel('', []); setModel(model); } finally { setCounter(counter + 1); @@ -51,10 +51,10 @@ export const LiveWorkbenchLoader: React.FC<{ traceJson: string }> = ({ traceJson return ; }; -async function loadSingleTraceFile(traceJson: string): Promise { +async function loadSingleTraceFile(traceJson: string): Promise { const params = new URLSearchParams(); params.set('trace', traceJson); const response = await fetch(`contexts?${params.toString()}`); const contextEntries = await response.json() as ContextEntry[]; - return new MultiTraceModel(traceJson, contextEntries); + return new TraceModel(traceJson, contextEntries); } diff --git a/packages/trace-viewer/src/ui/logTab.tsx b/packages/trace-viewer/src/ui/logTab.tsx index 89bf177461474..a98c433855ff0 100644 --- a/packages/trace-viewer/src/ui/logTab.tsx +++ b/packages/trace-viewer/src/ui/logTab.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { ActionTraceEventInContext } from './modelUtil'; +import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; import * as React from 'react'; import { ListView } from '@web/components/listView'; import { PlaceholderPanel } from './placeholderPanel'; diff --git a/packages/trace-viewer/src/ui/metadataView.tsx b/packages/trace-viewer/src/ui/metadataView.tsx index cb737a8e78584..20d3b6c5a9a27 100644 --- a/packages/trace-viewer/src/ui/metadataView.tsx +++ b/packages/trace-viewer/src/ui/metadataView.tsx @@ -16,11 +16,11 @@ import { msToString } from '@web/uiUtils'; import * as React from 'react'; -import type { MultiTraceModel } from './modelUtil'; +import type { TraceModel } from '@isomorphic/trace/traceModel'; import './callTab.css'; export const MetadataView: React.FunctionComponent<{ - model?: MultiTraceModel, + model?: TraceModel, }> = ({ model }) => { if (!model) return <>; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts deleted file mode 100644 index 0b5cc9e193779..0000000000000 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** - * 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 { getActionGroup } from '@isomorphic/protocolFormatter'; - -import type { Language } from '@isomorphic/locatorGenerators'; -import type { ResourceSnapshot } from '@trace/snapshot'; -import type * as trace from '@trace/trace'; -import type { ActionTraceEvent } from '@trace/trace'; -import type { ActionEntry, ContextEntry, PageEntry } from '@isomorphic/trace/entries'; -import type { StackFrame } from '@protocol/channels'; -import type { ActionGroup } from '@isomorphic/protocolFormatter'; - -const contextSymbol = Symbol('context'); -const nextInContextSymbol = Symbol('nextInContext'); -const prevByEndTimeSymbol = Symbol('prevByEndTime'); -const nextByStartTimeSymbol = Symbol('nextByStartTime'); -const eventsSymbol = Symbol('events'); - -export type SourceLocation = { - file: string; - line: number; - column: number; - source?: SourceModel; -}; - -export type SourceModel = { - errors: { line: number, message: string }[]; - content: string | undefined; -}; - -export type ActionTraceEventInContext = ActionEntry & { - context: ContextEntry; -}; - -export type ActionTreeItem = { - id: string; - children: ActionTreeItem[]; - parent: ActionTreeItem | undefined; - action?: ActionTraceEventInContext; -}; - -export type ErrorDescription = { - action?: ActionTraceEventInContext; - stack?: StackFrame[]; - message: string; -}; - -export type Attachment = trace.AfterActionTraceEventAttachment & { callId: string }; - -export class MultiTraceModel { - readonly startTime: number; - readonly endTime: number; - readonly browserName: string; - readonly channel?: string; - readonly platform?: string; - readonly playwrightVersion?: string; - readonly wallTime?: number; - readonly title?: string; - readonly options: trace.BrowserContextEventOptions; - readonly pages: PageEntry[]; - readonly actions: ActionTraceEventInContext[]; - readonly attachments: Attachment[]; - readonly visibleAttachments: Attachment[]; - readonly events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]; - readonly stdio: trace.StdioTraceEvent[]; - readonly errors: trace.ErrorTraceEvent[]; - readonly errorDescriptors: ErrorDescription[]; - readonly hasSource: boolean; - readonly hasStepData: boolean; - readonly sdkLanguage: Language | undefined; - readonly testIdAttributeName: string | undefined; - readonly sources: Map; - resources: ResourceSnapshot[]; - readonly actionCounters: Map; - readonly traceUrl: string; - - - constructor(traceUrl: string, contexts: ContextEntry[]) { - contexts.forEach(contextEntry => indexModel(contextEntry)); - const libraryContext = contexts.find(context => context.origin === 'library'); - - this.traceUrl = traceUrl; - this.browserName = libraryContext?.browserName || ''; - this.sdkLanguage = libraryContext?.sdkLanguage; - this.channel = libraryContext?.channel; - this.testIdAttributeName = libraryContext?.testIdAttributeName; - this.platform = libraryContext?.platform || ''; - this.playwrightVersion = contexts.find(c => c.playwrightVersion)?.playwrightVersion; - this.title = libraryContext?.title || ''; - this.options = libraryContext?.options || {}; - // Next call updates all timestamps for all events in library contexts, so it must be done first. - this.actions = mergeActionsAndUpdateTiming(contexts); - this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages)); - this.wallTime = contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE); - this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE); - this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE); - this.events = ([] as (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]).concat(...contexts.map(c => c.events)); - this.stdio = ([] as trace.StdioTraceEvent[]).concat(...contexts.map(c => c.stdio)); - this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors)); - this.hasSource = contexts.some(c => c.hasSource); - this.hasStepData = contexts.some(context => context.origin === 'testRunner'); - this.resources = [...contexts.map(c => c.resources)].flat(); - this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUrl })) ?? []); - this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_')); - - this.events.sort((a1, a2) => a1.time - a2.time); - this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!); - this.errorDescriptors = this.hasStepData ? this._errorDescriptorsFromTestRunner() : this._errorDescriptorsFromActions(); - this.sources = collectSources(this.actions, this.errorDescriptors); - - this.actionCounters = new Map(); - for (const action of this.actions) { - action.group = action.group ?? getActionGroup({ type: action.class, method: action.method }); - if (action.group) - this.actionCounters.set(action.group, 1 + (this.actionCounters.get(action.group) || 0)); - } - } - - createRelativeUrl(path: string) { - const url = new URL('http://localhost/' + path); - url.searchParams.set('trace', this.traceUrl); - return url.toString().substring('http://localhost/'.length); - } - - failedAction() { - // This find innermost action for nested ones. - return this.actions.findLast(a => a.error); - } - - filteredActions(actionsFilter: ActionGroup[]) { - const filter = new Set(actionsFilter); - return this.actions.filter(action => !action.group || filter.has(action.group)); - } - - private _errorDescriptorsFromActions(): ErrorDescription[] { - const errors: ErrorDescription[] = []; - for (const action of this.actions || []) { - if (!action.error?.message) - continue; - errors.push({ - action, - stack: action.stack, - message: action.error.message, - }); - } - return errors; - } - - private _errorDescriptorsFromTestRunner(): ErrorDescription[] { - return this.errors.filter(e => !!e.message).map((error, i) => ({ - stack: error.stack, - message: error.message, - })); - } -} - -function indexModel(context: ContextEntry) { - for (const page of context.pages) - (page as any)[contextSymbol] = context; - for (let i = 0; i < context.actions.length; ++i) { - const action = context.actions[i] as any; - action[contextSymbol] = context; - } - let lastNonRouteAction = undefined; - for (let i = context.actions.length - 1; i >= 0; i--) { - const action = context.actions[i] as ActionTraceEvent; - (action as any)[nextInContextSymbol] = lastNonRouteAction; - if (action.class !== 'Route') - lastNonRouteAction = action; - } - for (const event of context.events) - (event as any)[contextSymbol] = context; - for (const resource of context.resources) - (resource as any)[contextSymbol] = context; -} - -function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) { - const result: ActionTraceEventInContext[] = []; - const actions = mergeActionsAndUpdateTimingSameTrace(contexts); - result.push(...actions); - - result.sort((a1, a2) => { - if (a2.parentId === a1.callId) - return 1; - if (a1.parentId === a2.callId) - return -1; - return a1.endTime - a2.endTime; - }); - - for (let i = 1; i < result.length; ++i) - (result[i] as any)[prevByEndTimeSymbol] = result[i - 1]; - - result.sort((a1, a2) => { - if (a2.parentId === a1.callId) - return -1; - if (a1.parentId === a2.callId) - return 1; - return a1.startTime - a2.startTime; - }); - - for (let i = 0; i + 1 < result.length; ++i) - (result[i] as any)[nextByStartTimeSymbol] = result[i + 1]; - - return result; -} - -let lastTmpStepId = 0; - -function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionTraceEventInContext[] { - const map = new Map(); - - const libraryContexts = contexts.filter(context => context.origin === 'library'); - const testRunnerContexts = contexts.filter(context => context.origin === 'testRunner'); - - // With library-only or test-runner-only traces there is nothing to match. - if (!testRunnerContexts.length || !libraryContexts.length) { - return contexts.map(context => { - return context.actions.map(action => ({ ...action, context })); - }).flat(); - } - - for (const context of libraryContexts) { - for (const action of context.actions) { - // Never merge stepless events. - map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context }); - } - } - - // Protocol call aka library contexts have startTime/endTime as server-side times. - // Step aka test runner contexts have startTime/endTime as client-side times. - // Adjust startTime/endTime on the library contexts to align them with the test - // runner steps. - const delta = monotonicTimeDeltaBetweenLibraryAndRunner(testRunnerContexts, map); - if (delta) - adjustMonotonicTime(libraryContexts, delta); - - const nonPrimaryIdToPrimaryId = new Map(); - for (const context of testRunnerContexts) { - for (const action of context.actions) { - const existing = action.stepId && map.get(action.stepId); - if (existing) { - nonPrimaryIdToPrimaryId.set(action.callId, existing.callId); - if (action.error) - existing.error = action.error; - if (action.attachments) - existing.attachments = action.attachments; - if (action.annotations) - existing.annotations = action.annotations; - if (action.parentId) - existing.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId; - if (action.group) - existing.group = action.group; - // For the events that are present in the test runner context, always take - // their time from the test runner context to preserve client side order. - existing.startTime = action.startTime; - existing.endTime = action.endTime; - continue; - } - if (action.parentId) - action.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId; - map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context }); - } - } - return [...map.values()]; -} - -function adjustMonotonicTime(contexts: ContextEntry[], monotonicTimeDelta: number) { - for (const context of contexts) { - context.startTime += monotonicTimeDelta; - context.endTime += monotonicTimeDelta; - for (const action of context.actions) { - if (action.startTime) - action.startTime += monotonicTimeDelta; - if (action.endTime) - action.endTime += monotonicTimeDelta; - } - for (const event of context.events) - event.time += monotonicTimeDelta; - for (const event of context.stdio) - event.timestamp += monotonicTimeDelta; - for (const page of context.pages) { - for (const frame of page.screencastFrames) - frame.timestamp += monotonicTimeDelta; - } - for (const resource of context.resources) { - if (resource._monotonicTime) - resource._monotonicTime += monotonicTimeDelta; - } - } -} - -function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEntry[], libraryActions: Map) { - // We cannot rely on wall time or monotonic time to be the in sync - // between library and test runner contexts. So we find first action - // that is present in both runner and library contexts and use it - // to calculate the time delta, assuming the two events happened at the - // same instant. - for (const context of nonPrimaryContexts) { - for (const action of context.actions) { - if (!action.startTime) - continue; - const libraryAction = action.stepId ? libraryActions.get(action.stepId) : undefined; - if (libraryAction) - return action.startTime - libraryAction.startTime; - } - } - return 0; -} - -export function buildActionTree(actions: ActionTraceEventInContext[]): { rootItem: ActionTreeItem, itemMap: Map } { - const itemMap = new Map(); - - for (const action of actions) { - itemMap.set(action.callId, { - id: action.callId, - parent: undefined, - children: [], - action, - }); - } - - const rootItem: ActionTreeItem = { id: '', parent: undefined, children: [] }; - for (const item of itemMap.values()) { - const parent = item.action!.parentId ? itemMap.get(item.action!.parentId) || rootItem : rootItem; - parent.children.push(item); - item.parent = parent; - } - return { rootItem, itemMap }; -} - -export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry { - return (action as any)[contextSymbol]; -} - -function nextInContext(action: ActionTraceEvent): ActionTraceEvent { - return (action as any)[nextInContextSymbol]; -} - -export function previousActionByEndTime(action: ActionTraceEvent): ActionTraceEvent { - return (action as any)[prevByEndTimeSymbol]; -} - -export function nextActionByStartTime(action: ActionTraceEvent): ActionTraceEvent { - return (action as any)[nextByStartTimeSymbol]; -} - -export function stats(action: ActionTraceEvent): { errors: number, warnings: number } { - let errors = 0; - let warnings = 0; - for (const event of eventsForAction(action)) { - if (event.type === 'console') { - const type = event.messageType; - if (type === 'warning') - ++warnings; - else if (type === 'error') - ++errors; - } - if (event.type === 'event' && event.method === 'pageError') - ++errors; - } - return { errors, warnings }; -} - -export function eventsForAction(action: ActionTraceEvent): (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] { - let result: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] = (action as any)[eventsSymbol]; - if (result) - return result; - - const nextAction = nextInContext(action); - result = context(action).events.filter(event => { - return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime); - }); - (action as any)[eventsSymbol] = result; - return result; -} - -function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: ErrorDescription[]): Map { - const result = new Map(); - for (const action of actions) { - for (const frame of action.stack || []) { - let source = result.get(frame.file); - if (!source) { - source = { errors: [], content: undefined }; - result.set(frame.file, source); - } - } - } - - for (const error of errorDescriptors) { - const { action, stack, message } = error; - if (!action || !stack) - continue; - result.get(stack[0].file)?.errors.push({ - line: stack[0].line || 0, - message - }); - } - return result; -} diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index f67600f626610..6a6071b3ce40c 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -21,7 +21,8 @@ import './networkTab.css'; import { NetworkResourceDetails } from './networkResourceDetails'; import { bytesToString, msToString } from '@web/uiUtils'; import { PlaceholderPanel } from './placeholderPanel'; -import { context, type MultiTraceModel } from './modelUtil'; +import { context } from '@isomorphic/trace/traceModel'; +import type { TraceModel } from '@isomorphic/trace/traceModel'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; import type { ContextEntry } from '@isomorphic/trace/entries'; @@ -50,7 +51,7 @@ type ColumnName = keyof RenderedEntry; type Sorting = { by: ColumnName, negate: boolean}; const NetworkGridView = GridView; -export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedTime: Boundaries | undefined): NetworkTabModel { +export function useNetworkTabModel(model: TraceModel | undefined, selectedTime: Boundaries | undefined): NetworkTabModel { const resources = React.useMemo(() => { const resources = model?.resources || []; const filtered = resources.filter(resource => { @@ -218,7 +219,7 @@ class ContextIdMap { private _lastPageId = 0; private _lastApiRequestContextId = 0; - constructor(model: MultiTraceModel | undefined) {} + constructor(model: TraceModel | undefined) {} contextId(resource: Entry): string { if (resource.pageref) diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 382e459829532..94cf2c1be40c8 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -17,7 +17,8 @@ import './snapshotTab.css'; import * as React from 'react'; import type { ActionTraceEvent } from '@trace/trace'; -import { type MultiTraceModel, nextActionByStartTime, previousActionByEndTime } from './modelUtil'; +import { nextActionByStartTime, previousActionByEndTime } from '@isomorphic/trace/traceModel'; +import type { TraceModel } from '@isomorphic/trace/traceModel'; import { Toolbar } from '@web/components/toolbar'; import { ToolbarButton } from '@web/components/toolbarButton'; import { clsx, useMeasure, useSetting } from '@web/uiUtils'; @@ -40,7 +41,7 @@ export type HighlightedElement = { export const SnapshotTabsView: React.FunctionComponent<{ action: ActionTraceEvent | undefined, - model?: MultiTraceModel, + model?: TraceModel, sdkLanguage: Language, testIdAttributeName: string, isInspecting: boolean, diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index 3799096cb1f4f..a1fb1bd0a31ab 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -21,7 +21,7 @@ import './sourceTab.css'; import { StackTraceView } from './stackTrace'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import type { SourceHighlight } from '@web/components/codeMirrorWrapper'; -import type { SourceLocation, SourceModel } from './modelUtil'; +import type { SourceLocation, SourceModel } from '@isomorphic/trace/traceModel'; import type { StackFrame } from '@protocol/channels'; import { CopyToClipboard } from './copyToClipboard'; import { ToolbarButton } from '@web/components/toolbarButton'; diff --git a/packages/trace-viewer/src/ui/timeline.tsx b/packages/trace-viewer/src/ui/timeline.tsx index fe78c9f20f08e..c22bad94c78b2 100644 --- a/packages/trace-viewer/src/ui/timeline.tsx +++ b/packages/trace-viewer/src/ui/timeline.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import type { Boundaries } from './geometry'; import { FilmStrip } from './filmStrip'; import type { FilmStripPreviewPoint } from './filmStrip'; -import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; +import type { ActionTraceEventInContext, TraceModel } from '@isomorphic/trace/traceModel'; import './timeline.css'; import type { Language } from '@isomorphic/locatorGenerators'; import type { Entry } from '@trace/har'; @@ -40,7 +40,7 @@ type TimelineBar = { }; export const Timeline: React.FunctionComponent<{ - model: MultiTraceModel | undefined, + model: TraceModel | undefined, consoleEntries: ConsoleEntry[] | undefined, networkResources: Entry[] | undefined, boundaries: Boundaries, diff --git a/packages/trace-viewer/src/ui/traceModelContext.tsx b/packages/trace-viewer/src/ui/traceModelContext.tsx index 8e930ae7fab59..ab307147dc148 100644 --- a/packages/trace-viewer/src/ui/traceModelContext.tsx +++ b/packages/trace-viewer/src/ui/traceModelContext.tsx @@ -15,9 +15,9 @@ */ import * as React from 'react'; -import type { MultiTraceModel } from './modelUtil'; +import type { TraceModel } from '@isomorphic/trace/traceModel'; -export const TraceModelContext = React.createContext(undefined); +export const TraceModelContext = React.createContext(undefined); export const useTraceModel = () => { return React.useContext(TraceModelContext); diff --git a/packages/trace-viewer/src/ui/uiModeTestListView.tsx b/packages/trace-viewer/src/ui/uiModeTestListView.tsx index 0c900b0f3cc7e..fe76b8a744c03 100644 --- a/packages/trace-viewer/src/ui/uiModeTestListView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTestListView.tsx @@ -25,7 +25,7 @@ import '@web/third_party/vscode/codicon.css'; import { msToString } from '@web/uiUtils'; import type * as reporterTypes from 'playwright/types/testReporter'; import React from 'react'; -import type { SourceLocation } from './modelUtil'; +import type { SourceLocation } from '@isomorphic/trace/traceModel'; import { testStatusIcon } from './testUtils'; import './uiModeTestListView.css'; import type { TestServerConnection } from '@testIsomorphic/testServerConnection'; diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx index 90b9be2eb0ce0..9f618d892343c 100644 --- a/packages/trace-viewer/src/ui/uiModeTraceView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -21,8 +21,8 @@ import '@web/third_party/vscode/codicon.css'; import type * as reporterTypes from 'playwright/types/testReporter'; import React from 'react'; import type { ContextEntry } from '@isomorphic/trace/entries'; -import type { SourceLocation } from './modelUtil'; -import { MultiTraceModel } from './modelUtil'; +import type { SourceLocation } from '@isomorphic/trace/traceModel'; +import { TraceModel } from '@isomorphic/trace/traceModel'; import { Workbench } from './workbench'; export const TraceView: React.FC<{ @@ -32,7 +32,7 @@ export const TraceView: React.FC<{ revealSource?: boolean, pathSeparator: string, }> = ({ item, rootDir, onOpenExternally, revealSource, pathSeparator }) => { - const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(undefined); + const [model, setModel] = React.useState<{ model: TraceModel, isLive: boolean } | undefined>(undefined); const [counter, setCounter] = React.useState(0); const pollTimer = React.useRef(null); @@ -75,7 +75,7 @@ export const TraceView: React.FC<{ const model = await loadSingleTraceFile(traceLocation); setModel({ model, isLive: true }); } catch { - const model = new MultiTraceModel('', []); + const model = new TraceModel('', []); model.errorDescriptors.push(...result.errors.flatMap(error => !!error.message ? [{ message: error.message }] : [])); setModel({ model, isLive: false }); } finally { @@ -110,10 +110,10 @@ const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefi return undefined; }; -async function loadSingleTraceFile(url: string): Promise { +async function loadSingleTraceFile(url: string): Promise { const params = new URLSearchParams(); params.set('trace', url); const response = await fetch(`contexts?${params.toString()}`); const contextEntries = await response.json() as ContextEntry[]; - return new MultiTraceModel(url, contextEntries); + return new TraceModel(url, contextEntries); } diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 227c0ef332a47..9725a49c974a3 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -22,7 +22,7 @@ import { TeleSuiteUpdater, type TeleSuiteUpdaterProgress, type TeleSuiteUpdaterT import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; import type * as reporterTypes from 'playwright/types/testReporter'; import { SplitView } from '@web/components/splitView'; -import type { SourceLocation } from './modelUtil'; +import type { SourceLocation } from '@isomorphic/trace/traceModel'; import './uiModeView.css'; import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 36a38d155ac29..1475eee86175c 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -21,7 +21,7 @@ import { CallTab } from './callTab'; import { LogTab } from './logTab'; import { ErrorsTab, useErrorsTabModel } from './errorsTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab'; -import type * as modelUtil from './modelUtil'; +import type { TraceModel, SourceLocation, ActionTraceEventInContext, SourceModel } from '@isomorphic/trace/traceModel'; import { NetworkTab, useNetworkTabModel } from './networkTab'; import { SnapshotTabsView } from './snapshotTab'; import { SourceTab } from './sourceTab'; @@ -48,16 +48,16 @@ import { TraceModelContext } from './traceModelContext'; import type { TreeState } from '@web/components/treeView'; export type WorkbenchProps = { - model: modelUtil.MultiTraceModel | undefined; + model: TraceModel | undefined; showSourcesFirst?: boolean; rootDir?: string; - fallbackLocation?: modelUtil.SourceLocation; + fallbackLocation?: SourceLocation; isLive?: boolean; hideTimeline?: boolean; status?: UITestStatus; annotations?: TestAnnotation[]; inert?: boolean; - onOpenExternally?: (location: modelUtil.SourceLocation) => void; + onOpenExternally?: (location: SourceLocation) => void; revealSource?: boolean; testRunMetadata?: MetadataWithCommitInfo; }; @@ -95,7 +95,7 @@ const PartitionedWorkbench: React.FunctionComponent({ lastEdited: 'none' }); const [isInspecting, setIsInspectingState] = React.useState(false); - const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { + const setSelectedAction = React.useCallback((action: ActionTraceEventInContext | undefined) => { setSelectedCallId(action?.callId); setRevealedErrorKey(undefined); }, [setSelectedCallId, setRevealedErrorKey]); @@ -107,11 +107,11 @@ const PartitionedWorkbench: React.FunctionComponent a.callId === highlightedCallId); }, [actions, highlightedCallId]); - const setHighlightedAction = React.useCallback((highlightedAction: modelUtil.ActionTraceEventInContext | undefined) => { + const setHighlightedAction = React.useCallback((highlightedAction: ActionTraceEventInContext | undefined) => { setHighlightedCallId(highlightedAction?.callId); }, [setHighlightedCallId]); - const sources = React.useMemo(() => model?.sources || new Map(), [model]); + const sources = React.useMemo(() => model?.sources || new Map(), [model]); React.useEffect(() => { setSelectedTime(undefined); @@ -146,7 +146,7 @@ const PartitionedWorkbench: React.FunctionComponent { + const onActionSelected = React.useCallback((action: ActionTraceEventInContext) => { setSelectedAction(action); setHighlightedAction(undefined); }, [setSelectedAction, setHighlightedAction]); diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index a7773945121e4..4efc4ccf64594 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react'; -import { MultiTraceModel } from './modelUtil'; +import { TraceModel } from '@isomorphic/trace/traceModel'; import './workbenchLoader.css'; import { Workbench } from './workbench'; import { TestServerConnection, WebSocketTestServerTransport } from '@testIsomorphic/testServerConnection'; @@ -28,7 +28,7 @@ export const WorkbenchLoader: React.FunctionComponent<{ const [isServer, setIsServer] = React.useState(false); const [traceURL, setTraceURL] = React.useState(); const [uploadedTraceName, setUploadedTraceName] = React.useState(); - const [model, setModel] = React.useState(emptyModel); + const [model, setModel] = React.useState(emptyModel); const [progress, setProgress] = React.useState<{ done: number, total: number }>({ done: 0, total: 0 }); const [dragOver, setDragOver] = React.useState(false); const [processingErrorMessage, setProcessingErrorMessage] = React.useState(null); @@ -149,7 +149,7 @@ export const WorkbenchLoader: React.FunctionComponent<{ return; } const contextEntries = await response.json(); - const model = new MultiTraceModel(traceURL, contextEntries); + const model = new TraceModel(traceURL, contextEntries); setProgress({ done: 0, total: 0 }); setModel(model); } finally { @@ -228,4 +228,4 @@ export const WorkbenchLoader: React.FunctionComponent<{ ; }; -export const emptyModel = new MultiTraceModel('', []); +export const emptyModel = new TraceModel('', []); diff --git a/tests/config/utils.ts b/tests/config/utils.ts index 087115e07bc6d..3d6224841144b 100644 --- a/tests/config/utils.ts +++ b/tests/config/utils.ts @@ -16,12 +16,12 @@ import type { Frame, Page } from 'playwright-core'; import { ZipFile } from '../../packages/playwright-core/lib/server/utils/zipFile'; -import type { TraceModelBackend } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceModel'; +import type { TraceLoaderBackend } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceLoader'; import type { StackFrame } from '../../packages/protocol/src/channels'; import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils'; -import { TraceModel } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceModel'; -import type { ActionTreeItem } from '../../packages/trace-viewer/src/ui/modelUtil'; -import { buildActionTree, MultiTraceModel } from '../../packages/trace-viewer/src/ui/modelUtil'; +import { TraceLoader } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceLoader'; +import type { ActionTreeItem } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceModel'; +import { buildActionTree, TraceModel } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceModel'; import type { ActionTraceEvent, ConsoleMessageTraceEvent, EventTraceEvent, TraceEvent } from '@trace/trace'; import { renderTitleForCall } from '../../packages/playwright-core/lib/utils/isomorphic/protocolFormatter'; @@ -159,11 +159,11 @@ export async function parseTraceRaw(file: string): Promise<{ events: any[], reso }; } -export async function parseTrace(file: string): Promise<{ resources: Map, events: (EventTraceEvent | ConsoleMessageTraceEvent)[], actions: ActionTraceEvent[], titles: string[], traceModel: TraceModel, model: MultiTraceModel, actionTree: string[], errors: string[] }> { +export async function parseTrace(file: string): Promise<{ resources: Map, events: (EventTraceEvent | ConsoleMessageTraceEvent)[], actions: ActionTraceEvent[], titles: string[], loader: TraceLoader, model: TraceModel, actionTree: string[], errors: string[] }> { const backend = new TraceBackend(file); - const traceModel = new TraceModel(); - await traceModel.load(backend, () => {}); - const model = new MultiTraceModel(file, traceModel.contextEntries); + const loader = new TraceLoader(); + await loader.load(backend, () => {}); + const model = new TraceModel(file, loader.contextEntries); const actions = model.filteredActions([]); const { rootItem } = buildActionTree(actions); const actionTree: string[] = []; @@ -181,7 +181,7 @@ export async function parseTrace(file: string): Promise<{ resources: Map e.message), model, - traceModel, + loader, actionTree, }; } @@ -211,7 +211,7 @@ const ansiRegex = new RegExp('[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*( export function stripAnsi(str: string): string { return str.replace(ansiRegex, ''); } -class TraceBackend implements TraceModelBackend { +class TraceBackend implements TraceLoaderBackend { private _fileName: string; private _entriesPromise: Promise>; readonly entries = new Map(); diff --git a/tests/playwright-test/playwright.reuse.spec.ts b/tests/playwright-test/playwright.reuse.spec.ts index 6f863fd717fe5..1e3c1d797c3da 100644 --- a/tests/playwright-test/playwright.reuse.spec.ts +++ b/tests/playwright-test/playwright.reuse.spec.ts @@ -139,7 +139,7 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline ' Fixture "page"', ' Fixture "context"', ]); - expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); + expect(trace1.loader.storage().snapshotsForTest().length).toBeGreaterThan(0); expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace-1.zip'))).toBe(false); const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip')); @@ -156,7 +156,7 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline ' Fixture "context"', ' afterAll hook', ]); - expect(trace2.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); + expect(trace2.loader.storage().snapshotsForTest().length).toBeGreaterThan(0); }); test('should work with manually closed pages', async ({ runInlineTest }) => { @@ -482,7 +482,7 @@ test('should reset tracing', async ({ runInlineTest }, testInfo) => { 'Set content', 'Click', ]); - expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); + expect(trace1.loader.storage().snapshotsForTest().length).toBeGreaterThan(0); const trace2 = await parseTrace(traceFile2); expect(trace2.titles).toEqual([ @@ -490,7 +490,7 @@ test('should reset tracing', async ({ runInlineTest }, testInfo) => { 'Fill "value"', 'Click', ]); - expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); + expect(trace1.loader.storage().snapshotsForTest().length).toBeGreaterThan(0); }); test('should not delete others contexts', async ({ runInlineTest }) => { diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 82420aa520a64..acaed54aef31c 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -1331,7 +1331,7 @@ test('should record trace snapshot for more obscure commands', async ({ runInlin 'After Hooks', ]); - const snapshots = trace.traceModel.storage(); + const snapshots = trace.loader.storage(); const snapshotFrameOrPageId = snapshots.snapshotsForTest()[0]; const countAction = trace.actions.find(a => a.method === 'queryCount');