diff --git a/src/dispatchers/dispatcher.ts b/src/dispatchers/dispatcher.ts index aeee3d7708..7185f31430 100644 --- a/src/dispatchers/dispatcher.ts +++ b/src/dispatchers/dispatcher.ts @@ -206,7 +206,7 @@ export class DispatcherConnection { endTime: 0, type: dispatcher._type, method, - params, + params: params || {}, log: [], }; diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index 627efbd937..475d1169f5 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -16,7 +16,7 @@ */ import { TimeoutSettings } from '../utils/timeoutSettings'; -import { isDebugMode, mkdirIfNeeded } from '../utils/utils'; +import { isDebugMode, mkdirIfNeeded, createGuid } from '../utils/utils'; import { Browser, BrowserOptions } from './browser'; import { Download } from './download'; import * as frames from './frames'; @@ -380,6 +380,8 @@ export function validateBrowserContextOptions(options: types.BrowserContextOptio if (isDebugMode()) options.bypassCSP = true; verifyGeolocation(options.geolocation); + if (!options._debugName) + options._debugName = createGuid(); } export function verifyGeolocation(geolocation?: types.Geolocation) { diff --git a/src/server/chromium/crPage.ts b/src/server/chromium/crPage.ts index 7705721d4d..fa46cf2891 100644 --- a/src/server/chromium/crPage.ts +++ b/src/server/chromium/crPage.ts @@ -840,7 +840,12 @@ class FrameSession { _onScreencastFrame(payload: Protocol.Page.screencastFramePayload) { this._client.send('Page.screencastFrameAck', {sessionId: payload.sessionId}).catch(() => {}); const buffer = Buffer.from(payload.data, 'base64'); - this._page.emit(Page.Events.ScreencastFrame, { buffer, timestamp: payload.metadata.timestamp }); + this._page.emit(Page.Events.ScreencastFrame, { + buffer, + timestamp: payload.metadata.timestamp, + width: payload.metadata.deviceWidth, + height: payload.metadata.deviceHeight, + }); } async _createVideoRecorder(screencastId: string, options: types.PageScreencastOptions): Promise { diff --git a/src/server/snapshot/snapshotStorage.ts b/src/server/snapshot/snapshotStorage.ts index 831fb84556..ded0e57957 100644 --- a/src/server/snapshot/snapshotStorage.ts +++ b/src/server/snapshot/snapshotStorage.ts @@ -38,6 +38,13 @@ export abstract class BaseSnapshotStorage extends EventEmitter implements Snapsh }>(); protected _contextResources: ContextResources = new Map(); + clear() { + this._resources = []; + this._resourceMap.clear(); + this._frameSnapshots.clear(); + this._contextResources.clear(); + } + addResource(resource: ResourceSnapshot): void { this._resourceMap.set(resource.resourceId, resource); this._resources.push(resource); @@ -91,10 +98,14 @@ export abstract class BaseSnapshotStorage extends EventEmitter implements Snapsh const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); export class PersistentSnapshotStorage extends BaseSnapshotStorage { - private _resourcesDir: any; + private _resourcesDir: string; - async load(tracePrefix: string, resourcesDir: string) { + constructor(resourcesDir: string) { + super(); this._resourcesDir = resourcesDir; + } + + async load(tracePrefix: string) { const networkTrace = await fsReadFileAsync(tracePrefix + '-network.trace', 'utf8'); const resources = networkTrace.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as ResourceSnapshot[]; resources.forEach(r => this.addResource(r)); diff --git a/src/server/trace/common/traceEvents.ts b/src/server/trace/common/traceEvents.ts index 2c725a26f6..78e452ade4 100644 --- a/src/server/trace/common/traceEvents.ts +++ b/src/server/trace/common/traceEvents.ts @@ -53,7 +53,9 @@ export type ScreencastFrameTraceEvent = { contextId: string, pageId: string, pageTimestamp: number, - sha1: string + sha1: string, + width: number, + height: number, }; export type ActionTraceEvent = { diff --git a/src/server/trace/recorder/tracer.ts b/src/server/trace/recorder/tracer.ts index c954833d80..42b9a9f4a8 100644 --- a/src/server/trace/recorder/tracer.ts +++ b/src/server/trace/recorder/tracer.ts @@ -39,7 +39,7 @@ export class Tracer implements InstrumentationListener { if (!traceDir) return; const resourcesDir = envTrace || path.join(traceDir, 'resources'); - const tracePath = path.join(traceDir, createGuid()); + const tracePath = path.join(traceDir, context._options._debugName!); const contextTracer = new ContextTracer(context, resourcesDir, tracePath); await contextTracer.start(); this._contextTracers.set(context, contextTracer); @@ -201,6 +201,8 @@ class ContextTracer { contextId: this._contextId, sha1, pageTimestamp: params.timestamp, + width: params.width, + height: params.height, timestamp: monotonicTime() }; this._appendTraceEvent(event); diff --git a/src/server/trace/viewer/traceModel.ts b/src/server/trace/viewer/traceModel.ts index 32b8fd8db3..012b404c8b 100644 --- a/src/server/trace/viewer/traceModel.ts +++ b/src/server/trace/viewer/traceModel.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { createGuid } from '../../../utils/utils'; import * as trace from '../common/traceEvents'; import { ContextResources, ResourceSnapshot } from '../../snapshot/snapshotTypes'; import { SnapshotStorage } from '../../snapshot/snapshotStorage'; @@ -48,7 +47,6 @@ export class TraceModel { switch (event.type) { case 'context-created': { this.contextEntries.set(event.contextId, { - name: event.debugName || createGuid(), startTime: Number.MAX_VALUE, endTime: Number.MIN_VALUE, created: event, @@ -135,7 +133,6 @@ export class TraceModel { } export type ContextEntry = { - name: string; startTime: number; endTime: number; created: trace.ContextCreatedTraceEvent; @@ -150,7 +147,12 @@ export type PageEntry = { destroyed: trace.PageDestroyedTraceEvent; actions: ActionEntry[]; interestingEvents: InterestingPageEvent[]; - screencastFrames: { sha1: string, timestamp: number }[] + screencastFrames: { + sha1: string, + timestamp: number, + width: number, + height: number, + }[] } export type ActionEntry = trace.ActionTraceEvent & { diff --git a/src/server/trace/viewer/traceViewer.ts b/src/server/trace/viewer/traceViewer.ts index 5dbdf88987..da448bf00d 100644 --- a/src/server/trace/viewer/traceViewer.ts +++ b/src/server/trace/viewer/traceViewer.ts @@ -30,22 +30,12 @@ import { ProgressController } from '../../progress'; const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); -type TraceViewerDocument = { - resourcesDir: string; - model: TraceModel; -}; - class TraceViewer { - private _document: TraceViewerDocument | undefined; + private _server: HttpServer; - async show(traceDir: string, resourcesDir?: string) { + constructor(traceDir: string, resourcesDir?: string) { if (!resourcesDir) resourcesDir = path.join(traceDir, 'resources'); - const model = new TraceModel(); - this._document = { - model, - resourcesDir, - }; // Served by TraceServer // - "/tracemodel" - json with trace model. @@ -61,31 +51,48 @@ class TraceViewer { // - "/snapshot/pageId/..." - actual snapshot html. // - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources // and translates them into "/resources/". - const actionsTrace = fs.readdirSync(traceDir).find(name => name.endsWith('-actions.trace'))!; - const tracePrefix = path.join(traceDir, actionsTrace.substring(0, actionsTrace.indexOf('-actions.trace'))); - const server = new HttpServer(); - const snapshotStorage = new PersistentSnapshotStorage(); - await snapshotStorage.load(tracePrefix, resourcesDir); - new SnapshotServer(server, snapshotStorage); + const actionTraces = fs.readdirSync(traceDir).filter(name => name.endsWith('-actions.trace')); + const debugNames = actionTraces.map(name => { + const tracePrefix = path.join(traceDir, name.substring(0, name.indexOf('-actions.trace'))); + return path.basename(tracePrefix); + }); - const traceContent = await fsReadFileAsync(path.join(traceDir, actionsTrace), 'utf8'); - const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as TraceEvent[]; - model.appendEvents(events, snapshotStorage); + this._server = new HttpServer(); - const traceModelHandler: ServerRouteHandler = (request, response) => { + const traceListHandler: ServerRouteHandler = (request, response) => { response.statusCode = 200; response.setHeader('Content-Type', 'application/json'); - response.end(JSON.stringify(Array.from(this._document!.model.contextEntries.values()))); + response.end(JSON.stringify(debugNames)); return true; }; - server.routePath('/contexts', traceModelHandler); + this._server.routePath('/contexts', traceListHandler); + const snapshotStorage = new PersistentSnapshotStorage(resourcesDir); + new SnapshotServer(this._server, snapshotStorage); + + const traceModelHandler: ServerRouteHandler = (request, response) => { + const debugName = request.url!.substring('/context/'.length); + const tracePrefix = path.join(traceDir, debugName); + snapshotStorage.clear(); + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + (async () => { + await snapshotStorage.load(tracePrefix); + const traceContent = await fsReadFileAsync(tracePrefix + '-actions.trace', 'utf8'); + const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as TraceEvent[]; + const model = new TraceModel(); + model.appendEvents(events, snapshotStorage); + response.end(JSON.stringify(model.contextEntries.values().next().value)); + })().catch(e => console.error(e)); + return true; + }; + this._server.routePrefix('/context/', traceModelHandler); const traceViewerHandler: ServerRouteHandler = (request, response) => { const relativePath = request.url!.substring('/traceviewer/'.length); const absolutePath = path.join(__dirname, '..', '..', '..', 'web', ...relativePath.split('/')); - return server.serveFile(response, absolutePath); + return this._server.serveFile(response, absolutePath); }; - server.routePrefix('/traceviewer/', traceViewerHandler); + this._server.routePrefix('/traceviewer/', traceViewerHandler); const fileHandler: ServerRouteHandler = (request, response) => { try { @@ -93,24 +100,24 @@ class TraceViewer { const search = url.search; if (search[0] !== '?') return false; - return server.serveFile(response, search.substring(1)); + return this._server.serveFile(response, search.substring(1)); } catch (e) { return false; } }; - server.routePath('/file', fileHandler); + this._server.routePath('/file', fileHandler); const sha1Handler: ServerRouteHandler = (request, response) => { - if (!this._document) - return false; const sha1 = request.url!.substring('/sha1/'.length); if (sha1.includes('/')) return false; - return server.serveFile(response, path.join(this._document.resourcesDir, sha1)); + return this._server.serveFile(response, path.join(resourcesDir!, sha1)); }; - server.routePrefix('/sha1/', sha1Handler); + this._server.routePrefix('/sha1/', sha1Handler); + } - const urlPrefix = await server.start(); + async show() { + const urlPrefix = await this._server.start(); const traceViewerPlaywright = createPlaywright(true); const args = [ @@ -127,6 +134,7 @@ class TraceViewer { headless: !!process.env.PWCLI_HEADLESS_FOR_TEST, useWebSocket: isUnderTest() }); + const controller = new ProgressController(internalCallMetadata(), context._browser); await controller.run(async progress => { await context._browser._defaultContext!._loadDefaultContextAsIs(progress); @@ -139,6 +147,6 @@ class TraceViewer { } export async function showTraceViewer(traceDir: string, resourcesDir?: string) { - const traceViewer = new TraceViewer(); - await traceViewer.show(traceDir, resourcesDir); + const traceViewer = new TraceViewer(traceDir, resourcesDir); + await traceViewer.show(); } diff --git a/src/web/traceViewer/index.tsx b/src/web/traceViewer/index.tsx index 6187062542..45ab724a91 100644 --- a/src/web/traceViewer/index.tsx +++ b/src/web/traceViewer/index.tsx @@ -23,6 +23,6 @@ import '../common.css'; (async () => { applyTheme(); - const contexts = await fetch('/contexts').then(response => response.json()); - ReactDOM.render(, document.querySelector('#root')); + const debugNames = await fetch('/contexts').then(response => response.json()); + ReactDOM.render(, document.querySelector('#root')); })(); diff --git a/src/web/traceViewer/ui/contextSelector.tsx b/src/web/traceViewer/ui/contextSelector.tsx index 034ce95fd1..2021b6217c 100644 --- a/src/web/traceViewer/ui/contextSelector.tsx +++ b/src/web/traceViewer/ui/contextSelector.tsx @@ -15,27 +15,26 @@ */ import * as React from 'react'; -import { ContextEntry } from '../../../server/trace/viewer/traceModel'; import './contextSelector.css'; export const ContextSelector: React.FunctionComponent<{ - contexts: ContextEntry[], - context: ContextEntry, - onChange: (contextEntry: ContextEntry) => void, -}> = ({ contexts, context, onChange }) => { + debugNames: string[], + debugName: string, + onChange: (debugName: string) => void, +}> = ({ debugNames, debugName, onChange }) => { return ( ); }; diff --git a/src/web/traceViewer/ui/filmStrip.tsx b/src/web/traceViewer/ui/filmStrip.tsx index 7c2dd28add..6fa79d013e 100644 --- a/src/web/traceViewer/ui/filmStrip.tsx +++ b/src/web/traceViewer/ui/filmStrip.tsx @@ -18,7 +18,7 @@ import './filmStrip.css'; import { Boundaries, Size } from '../geometry'; import * as React from 'react'; import { useMeasure } from './helpers'; -import { lowerBound } from '../../uiUtils'; +import { upperBound } from '../../uiUtils'; import { ContextEntry, PageEntry } from '../../../server/trace/viewer/traceModel'; export const FilmStrip: React.FunctionComponent<{ @@ -33,15 +33,13 @@ export const FilmStrip: React.FunctionComponent<{ let previewImage = undefined; if (previewX !== undefined && context.pages.length) { const previewTime = boundaries.minimum + (boundaries.maximum - boundaries.minimum) * previewX / measure.width; - previewImage = screencastFrames[lowerBound(screencastFrames, previewTime, timeComparator)]; + previewImage = screencastFrames[upperBound(screencastFrames, previewTime, timeComparator) - 1]; } const previewSize = inscribe(context.created.viewportSize!, { width: 600, height: 600 }); - console.log(previewSize); return
{ context.pages.filter(p => p.screencastFrames.length).map((page, index) => - +
} ; @@ -62,13 +60,17 @@ export const FilmStrip: React.FunctionComponent<{ const FilmStripLane: React.FunctionComponent<{ boundaries: Boundaries, - viewportSize: Size, page: PageEntry, width: number, -}> = ({ boundaries, viewportSize, page, width }) => { +}> = ({ boundaries, page, width }) => { + const viewportSize = { width: 0, height: 0 }; + const screencastFrames = page.screencastFrames; + for (const frame of screencastFrames) { + viewportSize.width = Math.max(viewportSize.width, frame.width); + viewportSize.height = Math.max(viewportSize.height, frame.height); + } const frameSize = inscribe(viewportSize!, { width: 200, height: 45 }); const frameMargin = 2.5; - const screencastFrames = page.screencastFrames; const startTime = screencastFrames[0].timestamp; const endTime = screencastFrames[screencastFrames.length - 1].timestamp; @@ -76,12 +78,13 @@ const FilmStripLane: React.FunctionComponent<{ const gapLeft = (startTime - boundaries.minimum) / boundariesDuration * width; const gapRight = (boundaries.maximum - endTime) / boundariesDuration * width; const effectiveWidth = (endTime - startTime) / boundariesDuration * width; - const frameCount = effectiveWidth / (frameSize.width + 2 * frameMargin) | 0; + const frameCount = effectiveWidth / (frameSize.width + 2 * frameMargin) | 0 + 1; const frameDuration = (endTime - startTime) / frameCount; const frames: JSX.Element[] = []; - for (let time = startTime, i = 0; time <= endTime; time += frameDuration, ++i) { - const index = lowerBound(screencastFrames, time, timeComparator); + let i = 0; + for (let time = startTime; time <= endTime; time += frameDuration, ++i) { + const index = upperBound(screencastFrames, time, timeComparator) - 1; frames.push(
); } + // Always append last frame to show endgame. + frames.push(
); return
= ({ contexts }) => { - const [context, setContext] = React.useState(contexts[0]); + debugNames: string[], +}> = ({ debugNames }) => { + const [debugName, setDebugName] = React.useState(debugNames[0]); const [selectedAction, setSelectedAction] = React.useState(); const [highlightedAction, setHighlightedAction] = React.useState(); + let context = useAsyncMemo(async () => { + return (await fetch(`/context/${debugName}`).then(response => response.json())) as ContextEntry; + }, [debugName], emptyContext); + const actions = React.useMemo(() => { const actions: ActionEntry[] = []; for (const page of context.pages) @@ -51,10 +56,10 @@ export const Workbench: React.FunctionComponent<{
Playwright
{ - setContext(context); + debugNames={debugNames} + debugName={debugName} + onChange={debugName => { + setDebugName(debugName); setSelectedAction(undefined); }} /> @@ -89,3 +94,25 @@ export const Workbench: React.FunctionComponent<{
; }; + +const now = performance.now(); +const emptyContext: ContextEntry = { + startTime: now, + endTime: now, + created: { + timestamp: now, + type: 'context-created', + browserName: '', + contextId: '', + deviceScaleFactor: 1, + isMobile: false, + viewportSize: { width: 1280, height: 800 }, + debugName: '', + }, + destroyed: { + timestamp: now, + type: 'context-destroyed', + contextId: '', + }, + pages: [] +}; diff --git a/tests/config/browserEnv.ts b/tests/config/browserEnv.ts index e8ceb0ea62..454454e249 100644 --- a/tests/config/browserEnv.ts +++ b/tests/config/browserEnv.ts @@ -36,7 +36,7 @@ export type BrowserName = 'chromium' | 'firefox' | 'webkit'; type TestOptions = { mode: 'default' | 'driver' | 'service'; video?: boolean; - trace?: boolean; + traceDir?: string; }; class DriverMode { @@ -172,7 +172,7 @@ export class PlaywrightEnv implements Env { testInfo.data.mode = this._options.mode; if (this._options.video) testInfo.data.video = true; - if (this._options.trace) + if (this._options.traceDir) testInfo.data.trace = true; return { playwright: this._playwright, @@ -240,10 +240,11 @@ export class BrowserEnv extends PlaywrightEnv implements Env { async beforeEach(testInfo: TestInfo) { const result = await super.beforeEach(testInfo); - + const debugName = path.relative(testInfo.config.outputDir, testInfo.outputPath('')).replace(/[\/\\]/g, '-'); const contextOptions = { recordVideo: this._options.video ? { dir: testInfo.outputPath('') } : undefined, - _traceDir: this._options.trace ? testInfo.outputPath('') : undefined, + _traceDir: this._options.traceDir, + _debugName: debugName, ...this._contextOptions, } as BrowserContextOptions; diff --git a/tests/config/default.config.ts b/tests/config/default.config.ts index d94219029d..2f2827b2e4 100644 --- a/tests/config/default.config.ts +++ b/tests/config/default.config.ts @@ -30,7 +30,7 @@ import { CLIEnv } from './cliEnv'; const config: folio.Config = { testDir: path.join(__dirname, '..'), outputDir: path.join(__dirname, '..', '..', 'test-results'), - timeout: process.env.PWVIDEO ? 60000 : 30000, + timeout: process.env.PWVIDEO || process.env.PWTRACE ? 60000 : 30000, globalTimeout: 5400000, }; if (process.env.CI) { @@ -67,7 +67,7 @@ for (const browserName of browsers) { const options = { mode, executablePath, - trace: !!process.env.PWTRACE, + traceDir: process.env.PWTRACE ? path.join(config.outputDir, 'trace') : undefined, headless: !process.env.HEADFUL, channel: process.env.PW_CHROMIUM_CHANNEL as any, video: !!process.env.PWVIDEO,