feat(har): allow saving har for context (#4214)
This commit is contained in:
parent
d5fbe3a662
commit
7fc4b797eb
|
|
@ -226,6 +226,9 @@ Indicates that the browser is connected.
|
|||
- `videoSize` <[Object]> Specifies dimensions of the automatically recorded video. Can only be used if `videosPath` is set. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size.
|
||||
- `width` <[number]> Video frame width.
|
||||
- `height` <[number]> Video frame height.
|
||||
- `recordHar` <[Object]> Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `har.path` file. If not specified, the HAR is not recorded. Make sure to await [`browserContext.close`](#browsercontextclose) for the HAR to be saved.
|
||||
- `omitContent` <[boolean]> Optional setting to control whether to omit request content from the HAR. Defaults to `false`.
|
||||
- `path` <[string]> path on the filesystem to write the HAR file to.
|
||||
- returns: <[Promise]<[BrowserContext]>>
|
||||
|
||||
Creates a new browser context. It won't share cookies/cache with other browser contexts.
|
||||
|
|
@ -272,6 +275,9 @@ Creates a new browser context. It won't share cookies/cache with other browser c
|
|||
- `videoSize` <[Object]> Specifies dimensions of the automatically recorded video. Can only be used if `videosPath` is set. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size.
|
||||
- `width` <[number]> Video frame width.
|
||||
- `height` <[number]> Video frame height.
|
||||
- `recordHar` <[Object]> Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `har.path` file. If not specified, the HAR is not recorded. Make sure to await [`page.close`](#pagecontext) for the HAR to be saved.
|
||||
- `omitContent` <[boolean]> Optional setting to control whether to omit request content from the HAR. Defaults to `false`.
|
||||
- `path` <[string]> path on the filesystem to write the HAR file to
|
||||
- returns: <[Promise]<[Page]>>
|
||||
|
||||
Creates a new page in a new browser context. Closing this page will close the context as well.
|
||||
|
|
@ -4420,6 +4426,9 @@ const browser = await chromium.launch({ // Or 'firefox' or 'webkit'.
|
|||
- `videoSize` <[Object]> Specifies dimensions of the automatically recorded video. Can only be used if `videosPath` is set. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size.
|
||||
- `width` <[number]> Video frame width.
|
||||
- `height` <[number]> Video frame height.
|
||||
- `recordHar` <[Object]> Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all the pages into `har.path` file. If not specified, HAR is not recorded. Make sure to await [`page.close`](#pagecontext) for HAR to be saved.
|
||||
- `omitContent` <[boolean]> Optional setting to control whether to omit request content from the HAR. Defaults to false.
|
||||
- `path` <[string]> path on the filesystem to write the HAR file to
|
||||
- returns: <[Promise]<[BrowserContext]>> Promise that resolves to the persistent browser context instance.
|
||||
|
||||
Launches browser that uses persistent storage located at `userDataDir` and returns the only context. Closing this context will automatically close the browser.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,6 @@ class DebugController implements ContextListener {
|
|||
});
|
||||
}
|
||||
|
||||
async onContextDestroyed(context: BrowserContext): Promise<void> {
|
||||
}
|
||||
async onContextWillDestroy(context: BrowserContext): Promise<void> {}
|
||||
async onContextDidDestroy(context: BrowserContext): Promise<void> {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { Transport } from './protocol/transport';
|
|||
import { Electron } from './server/electron/electron';
|
||||
import { Playwright } from './server/playwright';
|
||||
import { gracefullyCloseAll } from './server/processLauncher';
|
||||
import { installHarTracer } from './trace/harTracer';
|
||||
import { installTracer } from './trace/tracer';
|
||||
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ export async function apiJson(): Promise<string> {
|
|||
export function runServer() {
|
||||
installDebugController();
|
||||
installTracer();
|
||||
installHarTracer();
|
||||
|
||||
const dispatcherConnection = new DispatcherConnection();
|
||||
const transport = new Transport(process.stdout, process.stdin);
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ import { Connection } from './client/connection';
|
|||
import { BrowserServerLauncherImpl } from './browserServerImpl';
|
||||
import { installDebugController } from './debug/debugController';
|
||||
import { installTracer } from './trace/tracer';
|
||||
import { installHarTracer } from './trace/harTracer';
|
||||
|
||||
export function setupInProcess(playwright: PlaywrightImpl): PlaywrightAPI {
|
||||
installDebugController();
|
||||
installTracer();
|
||||
installHarTracer();
|
||||
|
||||
const clientConnection = new Connection();
|
||||
const dispatcherConnection = new DispatcherConnection();
|
||||
|
|
|
|||
|
|
@ -394,6 +394,10 @@ export type BrowserNewContextParams = {
|
|||
width: number,
|
||||
height: number,
|
||||
},
|
||||
recordHar?: {
|
||||
omitContent?: boolean,
|
||||
path: string,
|
||||
},
|
||||
};
|
||||
export type BrowserNewContextOptions = {
|
||||
noDefaultViewport?: boolean,
|
||||
|
|
@ -434,6 +438,10 @@ export type BrowserNewContextOptions = {
|
|||
width: number,
|
||||
height: number,
|
||||
},
|
||||
recordHar?: {
|
||||
omitContent?: boolean,
|
||||
path: string,
|
||||
},
|
||||
};
|
||||
export type BrowserNewContextResult = {
|
||||
context: BrowserContextChannel,
|
||||
|
|
|
|||
|
|
@ -391,6 +391,11 @@ Browser:
|
|||
properties:
|
||||
width: number
|
||||
height: number
|
||||
recordHar:
|
||||
type: object?
|
||||
properties:
|
||||
omitContent: boolean?
|
||||
path: string
|
||||
returns:
|
||||
context: BrowserContext
|
||||
|
||||
|
|
|
|||
|
|
@ -229,6 +229,10 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
|
|||
width: tNumber,
|
||||
height: tNumber,
|
||||
})),
|
||||
recordHar: tOptional(tObject({
|
||||
omitContent: tOptional(tBoolean),
|
||||
path: tString,
|
||||
})),
|
||||
});
|
||||
scheme.BrowserCrNewBrowserCDPSessionParams = tOptional(tObject({}));
|
||||
scheme.BrowserCrStartTracingParams = tObject({
|
||||
|
|
|
|||
|
|
@ -82,7 +82,8 @@ export async function runAction<T>(task: (controller: ProgressController) => Pro
|
|||
|
||||
export interface ContextListener {
|
||||
onContextCreated(context: BrowserContext): Promise<void>;
|
||||
onContextDestroyed(context: BrowserContext): Promise<void>;
|
||||
onContextWillDestroy(context: BrowserContext): Promise<void>;
|
||||
onContextDidDestroy(context: BrowserContext): Promise<void>;
|
||||
}
|
||||
|
||||
export const contextListeners = new Set<ContextListener>();
|
||||
|
|
@ -270,6 +271,9 @@ export abstract class BrowserContext extends EventEmitter {
|
|||
if (this._closedStatus === 'open') {
|
||||
this._closedStatus = 'closing';
|
||||
|
||||
for (const listener of contextListeners)
|
||||
await listener.onContextWillDestroy(this);
|
||||
|
||||
// Collect videos/downloads that we will await.
|
||||
const promises: Promise<any>[] = [];
|
||||
for (const download of this._downloads)
|
||||
|
|
@ -297,7 +301,7 @@ export abstract class BrowserContext extends EventEmitter {
|
|||
|
||||
// Bookkeeping.
|
||||
for (const listener of contextListeners)
|
||||
await listener.onContextDestroyed(this);
|
||||
await listener.onContextDidDestroy(this);
|
||||
this._didCloseInternal();
|
||||
}
|
||||
await this._closePromise;
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export class Request {
|
|||
private _method: string;
|
||||
private _postData: Buffer | null;
|
||||
private _headers: types.HeadersArray;
|
||||
private _headersMap = new Map<string, string>();
|
||||
private _frame: frames.Frame;
|
||||
private _waitForResponsePromise: Promise<Response | null>;
|
||||
private _waitForResponsePromiseCallback: (value: Response | null) => void = () => {};
|
||||
|
|
@ -98,6 +99,8 @@ export class Request {
|
|||
this._method = method;
|
||||
this._postData = postData;
|
||||
this._headers = headers;
|
||||
for (const { name, value } of this._headers)
|
||||
this._headersMap.set(name.toLowerCase(), value);
|
||||
this._waitForResponsePromise = new Promise(f => this._waitForResponsePromiseCallback = f);
|
||||
this._isFavicon = url.endsWith('/favicon.ico');
|
||||
}
|
||||
|
|
@ -127,6 +130,10 @@ export class Request {
|
|||
return this._headers;
|
||||
}
|
||||
|
||||
headerValue(name: string): string | undefined {
|
||||
return this._headersMap.get(name);
|
||||
}
|
||||
|
||||
response(): Promise<Response | null> {
|
||||
return this._waitForResponsePromise;
|
||||
}
|
||||
|
|
@ -172,6 +179,9 @@ export class Request {
|
|||
|
||||
_updateWithRawHeaders(headers: types.HeadersArray) {
|
||||
this._headers = headers;
|
||||
this._headersMap.clear();
|
||||
for (const { name, value } of this._headers)
|
||||
this._headersMap.set(name.toLowerCase(), value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +246,7 @@ export class Response {
|
|||
private _statusText: string;
|
||||
private _url: string;
|
||||
private _headers: types.HeadersArray;
|
||||
private _headersMap = new Map<string, string>();
|
||||
private _getResponseBodyCallback: GetResponseBodyCallback;
|
||||
private _timing: ResourceTiming;
|
||||
|
||||
|
|
@ -246,6 +257,8 @@ export class Response {
|
|||
this._statusText = statusText;
|
||||
this._url = request.url();
|
||||
this._headers = headers;
|
||||
for (const { name, value } of this._headers)
|
||||
this._headersMap.set(name.toLowerCase(), value);
|
||||
this._getResponseBodyCallback = getResponseBodyCallback;
|
||||
this._finishedPromise = new Promise(f => {
|
||||
this._finishedPromiseCallback = f;
|
||||
|
|
@ -274,6 +287,10 @@ export class Response {
|
|||
return this._headers;
|
||||
}
|
||||
|
||||
headerValue(name: string): string | undefined {
|
||||
return this._headersMap.get(name);
|
||||
}
|
||||
|
||||
finished(): Promise<Error | null> {
|
||||
return this._finishedPromise.then(({ error }) => error ? new Error(error) : null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,6 +240,10 @@ export type BrowserContextOptions = {
|
|||
acceptDownloads?: boolean,
|
||||
videosPath?: string,
|
||||
videoSize?: Size,
|
||||
recordHar?: {
|
||||
omitContent?: boolean,
|
||||
path: string
|
||||
},
|
||||
_tracePath?: string,
|
||||
_traceResourcesPath?: string,
|
||||
};
|
||||
|
|
|
|||
146
src/trace/har.ts
Normal file
146
src/trace/har.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// see http://www.softwareishard.com/blog/har-12-spec/
|
||||
export type Log = {
|
||||
version: string;
|
||||
creator: Creator;
|
||||
browser: Browser;
|
||||
pages: Page[];
|
||||
entries: Entry[];
|
||||
};
|
||||
|
||||
export type Creator = {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type Browser = {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type Page = {
|
||||
startedDateTime: Date;
|
||||
id: string;
|
||||
title: string;
|
||||
pageTimings: PageTimings;
|
||||
};
|
||||
|
||||
export type PageTimings = {
|
||||
onContentLoad: number;
|
||||
onLoad: number;
|
||||
};
|
||||
|
||||
export type Entry = {
|
||||
pageref?: string;
|
||||
startedDateTime: Date;
|
||||
time: number;
|
||||
request: Request;
|
||||
response: Response;
|
||||
cache: Cache;
|
||||
timings: Timings;
|
||||
serverIPAddress?: string;
|
||||
connection?: string;
|
||||
};
|
||||
|
||||
export type Request = {
|
||||
method: string;
|
||||
url: string;
|
||||
httpVersion: string;
|
||||
cookies: Cookie[];
|
||||
headers: Header[];
|
||||
queryString: QueryParameter[];
|
||||
postData?: PostData;
|
||||
headersSize: number;
|
||||
bodySize: number;
|
||||
};
|
||||
|
||||
export type Response = {
|
||||
status: number;
|
||||
statusText: string;
|
||||
httpVersion: string;
|
||||
cookies: Cookie[];
|
||||
headers: Header[];
|
||||
content: Content;
|
||||
redirectURL: string;
|
||||
headersSize: number;
|
||||
bodySize: number;
|
||||
};
|
||||
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
path?: string;
|
||||
domain?: string;
|
||||
expires?: Date;
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
sameSite?: string;
|
||||
};
|
||||
|
||||
export type Header = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type QueryParameter = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type PostData = {
|
||||
mimeType: string;
|
||||
params: Param[];
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type Param = {
|
||||
name: string;
|
||||
value?: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type Content = {
|
||||
size: number;
|
||||
compression?: number;
|
||||
mimeType: string;
|
||||
text?: string;
|
||||
encoding?: string;
|
||||
};
|
||||
|
||||
export type Cache = {
|
||||
beforeRequest: CacheState | null;
|
||||
afterRequest: CacheState | null;
|
||||
};
|
||||
|
||||
export type CacheState = {
|
||||
expires?: string;
|
||||
lastAccess: string;
|
||||
eTag: string;
|
||||
hitCount: number;
|
||||
};
|
||||
|
||||
export type Timings = {
|
||||
blocked?: number;
|
||||
dns?: number;
|
||||
connect?: number;
|
||||
send: number;
|
||||
wait: number;
|
||||
receive: number;
|
||||
ssl?: number;
|
||||
};
|
||||
311
src/trace/harTracer.ts
Normal file
311
src/trace/harTracer.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* 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 * as fs from 'fs';
|
||||
import * as util from 'util';
|
||||
import { BrowserContext, ContextListener, contextListeners } from '../server/browserContext';
|
||||
import { helper } from '../server/helper';
|
||||
import * as network from '../server/network';
|
||||
import { Page } from '../server/page';
|
||||
import * as har from './har';
|
||||
|
||||
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
|
||||
|
||||
export function installHarTracer() {
|
||||
contextListeners.add(new HarTracer());
|
||||
}
|
||||
|
||||
class HarTracer implements ContextListener {
|
||||
private _contextTracers = new Map<BrowserContext, HarContextTracer>();
|
||||
|
||||
async onContextCreated(context: BrowserContext): Promise<void> {
|
||||
if (!context._options.recordHar)
|
||||
return;
|
||||
const contextTracer = new HarContextTracer(context, context._options.recordHar);
|
||||
this._contextTracers.set(context, contextTracer);
|
||||
}
|
||||
|
||||
async onContextWillDestroy(context: BrowserContext): Promise<void> {
|
||||
const contextTracer = this._contextTracers.get(context);
|
||||
if (contextTracer) {
|
||||
this._contextTracers.delete(context);
|
||||
await contextTracer.flush();
|
||||
}
|
||||
}
|
||||
|
||||
async onContextDidDestroy(context: BrowserContext): Promise<void> {
|
||||
const contextTracer = this._contextTracers.get(context);
|
||||
if (contextTracer) {
|
||||
this._contextTracers.delete(context);
|
||||
await contextTracer.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type HarOptions = {
|
||||
path: string;
|
||||
omitContent?: boolean;
|
||||
};
|
||||
|
||||
class HarContextTracer {
|
||||
private _options: HarOptions;
|
||||
private _browserName: string;
|
||||
private _log: har.Log;
|
||||
private _pageEntries = new Map<Page, har.Page>();
|
||||
private _entries = new Map<network.Request, har.Entry>();
|
||||
private _lastPage = 0;
|
||||
private _barrierPromises = new Map<Promise<void>, Page>();
|
||||
|
||||
constructor(context: BrowserContext, options: HarOptions) {
|
||||
this._browserName = context._browser._options.name;
|
||||
this._options = options;
|
||||
this._log = {
|
||||
version: '1.2',
|
||||
creator: {
|
||||
name: 'Playwright',
|
||||
version: require('../../package.json')['version'],
|
||||
},
|
||||
browser: {
|
||||
name: context._browser._options.name,
|
||||
version: context._browser.version()
|
||||
},
|
||||
pages: [],
|
||||
entries: []
|
||||
};
|
||||
context.on(BrowserContext.Events.Page, page => this._onPage(page));
|
||||
}
|
||||
|
||||
private _onPage(page: Page) {
|
||||
const pageEntry: har.Page = {
|
||||
startedDateTime: new Date(),
|
||||
id: `page_${this._lastPage++}`,
|
||||
title: '',
|
||||
pageTimings: {
|
||||
onContentLoad: -1,
|
||||
onLoad: -1,
|
||||
},
|
||||
};
|
||||
this._pageEntries.set(page, pageEntry);
|
||||
this._log.pages.push(pageEntry);
|
||||
page.on(Page.Events.Request, (request: network.Request) => this._onRequest(page, request));
|
||||
page.on(Page.Events.Response, (response: network.Response) => this._onResponse(page, response));
|
||||
|
||||
page.on(Page.Events.DOMContentLoaded, () => {
|
||||
const promise = page.mainFrame()._evaluateExpression(String(() => {
|
||||
return {
|
||||
title: document.title,
|
||||
domContentLoaded: performance.timing.domContentLoadedEventStart,
|
||||
};
|
||||
}), true, undefined, 'utility').then(result => {
|
||||
pageEntry.title = result.title;
|
||||
pageEntry.pageTimings.onContentLoad = result.domContentLoaded;
|
||||
}).catch(() => {});
|
||||
this._addBarrier(page, promise);
|
||||
});
|
||||
page.on(Page.Events.Load, () => {
|
||||
const promise = page.mainFrame()._evaluateExpression(String(() => {
|
||||
return {
|
||||
title: document.title,
|
||||
loaded: performance.timing.loadEventStart,
|
||||
};
|
||||
}), true, undefined, 'utility').then(result => {
|
||||
pageEntry.title = result.title;
|
||||
pageEntry.pageTimings.onLoad = result.loaded;
|
||||
}).catch(() => {});
|
||||
this._addBarrier(page, promise);
|
||||
});
|
||||
}
|
||||
|
||||
private _addBarrier(page: Page, promise: Promise<void>) {
|
||||
const race = Promise.race([
|
||||
new Promise(f => page.on('close', () => {
|
||||
this._barrierPromises.delete(race);
|
||||
f();
|
||||
})),
|
||||
promise
|
||||
]) as Promise<void>;
|
||||
this._barrierPromises.set(race, page);
|
||||
}
|
||||
|
||||
private _onRequest(page: Page, request: network.Request) {
|
||||
const pageEntry = this._pageEntries.get(page)!;
|
||||
const url = new URL(request.url());
|
||||
|
||||
const harEntry: har.Entry = {
|
||||
pageref: pageEntry.id,
|
||||
startedDateTime: new Date(),
|
||||
time: -1,
|
||||
request: {
|
||||
method: request.method(),
|
||||
url: request.url(),
|
||||
httpVersion: 'HTTP/1.1',
|
||||
cookies: [],
|
||||
headers: [],
|
||||
queryString: [...url.searchParams].map(e => ({ name: e[0], value: e[1] })),
|
||||
postData: undefined,
|
||||
headersSize: -1,
|
||||
bodySize: -1,
|
||||
},
|
||||
response: {
|
||||
status: -1,
|
||||
statusText: '',
|
||||
httpVersion: 'HTTP/1.1',
|
||||
cookies: [],
|
||||
headers: [],
|
||||
content: {
|
||||
size: -1,
|
||||
mimeType: request.headerValue('content-type') || 'application/octet-stream',
|
||||
},
|
||||
headersSize: -1,
|
||||
bodySize: -1,
|
||||
redirectURL: ''
|
||||
},
|
||||
cache: {
|
||||
beforeRequest: null,
|
||||
afterRequest: null,
|
||||
},
|
||||
timings: {
|
||||
send: -1,
|
||||
wait: -1,
|
||||
receive: -1
|
||||
},
|
||||
};
|
||||
if (request.redirectedFrom()) {
|
||||
const fromEntry = this._entries.get(request.redirectedFrom()!)!;
|
||||
fromEntry.response.redirectURL = request.url();
|
||||
}
|
||||
this._log.entries.push(harEntry);
|
||||
this._entries.set(request, harEntry);
|
||||
}
|
||||
|
||||
private _onResponse(page: Page, response: network.Response) {
|
||||
const pageEntry = this._pageEntries.get(page)!;
|
||||
const harEntry = this._entries.get(response.request())!;
|
||||
// Rewrite provisional headers with actual
|
||||
const request = response.request();
|
||||
harEntry.request.headers = request.headers().map(header => ({ name: header.name, value: header.value })),
|
||||
harEntry.request.cookies = cookiesForHar(request.headerValue('cookie'), ';');
|
||||
harEntry.request.postData = postDataForHar(request) || undefined;
|
||||
|
||||
harEntry.response = {
|
||||
status: response.status(),
|
||||
statusText: response.statusText(),
|
||||
httpVersion: 'HTTP/1.1',
|
||||
cookies: cookiesForHar(response.headerValue('set-cookie'), this._browserName === 'webkit' ? ',' : '\n'),
|
||||
headers: response.headers().map(header => ({ name: header.name, value: header.value })),
|
||||
content: {
|
||||
size: -1,
|
||||
mimeType: response.headerValue('content-type') || 'application/octet-stream',
|
||||
},
|
||||
headersSize: -1,
|
||||
bodySize: -1,
|
||||
redirectURL: ''
|
||||
};
|
||||
const timing = response.timing();
|
||||
if (pageEntry.startedDateTime.valueOf() > timing.startTime)
|
||||
pageEntry.startedDateTime = new Date(timing.startTime);
|
||||
harEntry.timings = {
|
||||
dns: timing.domainLookupEnd !== -1 ? helper.millisToRoundishMillis(timing.domainLookupEnd - timing.domainLookupStart) : -1,
|
||||
connect: timing.connectEnd !== -1 ? helper.millisToRoundishMillis(timing.connectEnd - timing.connectStart) : -1,
|
||||
ssl: timing.connectEnd !== -1 ? helper.millisToRoundishMillis(timing.connectEnd - timing.secureConnectionStart) : -1,
|
||||
send: 0,
|
||||
wait: timing.responseStart !== -1 ? helper.millisToRoundishMillis(timing.responseStart - timing.requestStart) : -1,
|
||||
receive: response.request()._responseEndTiming !== -1 ? helper.millisToRoundishMillis(response.request()._responseEndTiming - timing.responseStart) : -1,
|
||||
};
|
||||
if (!this._options.omitContent && response.status() === 200) {
|
||||
const promise = response.body().then(buffer => {
|
||||
harEntry.response.content.text = buffer.toString('base64');
|
||||
harEntry.response.content.encoding = 'base64';
|
||||
}).catch(() => {});
|
||||
this._addBarrier(page, promise);
|
||||
}
|
||||
}
|
||||
|
||||
async flush() {
|
||||
await Promise.all(this._barrierPromises.keys());
|
||||
for (const pageEntry of this._log.pages) {
|
||||
if (pageEntry.pageTimings.onContentLoad >= 0)
|
||||
pageEntry.pageTimings.onContentLoad -= pageEntry.startedDateTime.valueOf();
|
||||
else
|
||||
pageEntry.pageTimings.onContentLoad = -1;
|
||||
if (pageEntry.pageTimings.onLoad >= 0)
|
||||
pageEntry.pageTimings.onLoad -= pageEntry.startedDateTime.valueOf();
|
||||
else
|
||||
pageEntry.pageTimings.onLoad = -1;
|
||||
}
|
||||
await fsWriteFileAsync(this._options.path, JSON.stringify({ log: this._log }, undefined, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function postDataForHar(request: network.Request): har.PostData | null {
|
||||
const postData = request.postDataBuffer();
|
||||
if (!postData)
|
||||
return null;
|
||||
|
||||
const contentType = request.headerValue('content-type') || 'application/octet-stream';
|
||||
const result: har.PostData = {
|
||||
mimeType: contentType,
|
||||
text: contentType === 'application/octet-stream' ? '' : postData.toString(),
|
||||
params: []
|
||||
};
|
||||
if (contentType === 'application/x-www-form-urlencoded') {
|
||||
const parsed = new URLSearchParams(postData.toString());
|
||||
for (const [name, value] of parsed.entries())
|
||||
result.params.push({ name, value });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cookiesForHar(header: string | undefined, separator: string): har.Cookie[] {
|
||||
if (!header)
|
||||
return [];
|
||||
return header.split(separator).map(c => parseCookie(c));
|
||||
}
|
||||
|
||||
function parseCookie(c: string): har.Cookie {
|
||||
const cookie: har.Cookie = {
|
||||
name: '',
|
||||
value: ''
|
||||
};
|
||||
let first = true;
|
||||
for (const pair of c.split(/; */)) {
|
||||
const indexOfEquals = pair.indexOf('=');
|
||||
const name = indexOfEquals !== -1 ? pair.substr(0, indexOfEquals).trim() : pair.trim();
|
||||
const value = indexOfEquals !== -1 ? pair.substr(indexOfEquals + 1, pair.length).trim() : '';
|
||||
if (first) {
|
||||
first = false;
|
||||
cookie.name = name;
|
||||
cookie.value = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'Domain')
|
||||
cookie.domain = value;
|
||||
if (name === 'Expires')
|
||||
cookie.expires = new Date(value);
|
||||
if (name === 'HttpOnly')
|
||||
cookie.httpOnly = true;
|
||||
if (name === 'Max-Age')
|
||||
cookie.expires = new Date(Date.now() + (+value) * 1000);
|
||||
if (name === 'Path')
|
||||
cookie.path = value;
|
||||
if (name === 'SameSite')
|
||||
cookie.sameSite = value;
|
||||
if (name === 'Secure')
|
||||
cookie.secure = true;
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
|
@ -47,7 +47,9 @@ class Tracer implements ContextListener {
|
|||
this._contextTracers.set(context, contextTracer);
|
||||
}
|
||||
|
||||
async onContextDestroyed(context: BrowserContext): Promise<void> {
|
||||
async onContextWillDestroy(context: BrowserContext): Promise<void> {}
|
||||
|
||||
async onContextDidDestroy(context: BrowserContext): Promise<void> {
|
||||
const contextTracer = this._contextTracers.get(context);
|
||||
if (contextTracer) {
|
||||
await contextTracer.dispose().catch(e => {});
|
||||
|
|
|
|||
3
test/assets/har.html
Normal file
3
test/assets/har.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<title>HAR Page</title>
|
||||
<link rel='stylesheet' href='./one-style.css'>
|
||||
<div>hello, world!</div>
|
||||
249
test/har.spec.ts
Normal file
249
test/har.spec.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications 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 { folio as baseFolio } from './fixtures';
|
||||
import * as fs from 'fs';
|
||||
import type * as har from '../src/trace/har';
|
||||
import type { BrowserContext, Page } from '../index';
|
||||
|
||||
const builder = baseFolio.extend<{
|
||||
pageWithHar: {
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
path: string,
|
||||
log: () => Promise<har.Log>
|
||||
}
|
||||
}>();
|
||||
|
||||
builder.pageWithHar.init(async ({ contextFactory, testInfo }, run) => {
|
||||
const harPath = testInfo.outputPath('test.har');
|
||||
const context = await contextFactory({ recordHar: { path: harPath }, ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
await run({
|
||||
path: harPath,
|
||||
page,
|
||||
context,
|
||||
log: async () => {
|
||||
await context.close();
|
||||
return JSON.parse(fs.readFileSync(harPath).toString())['log'];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { expect, it } = builder.build();
|
||||
|
||||
it('should have version and creator', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.version).toBe('1.2');
|
||||
expect(log.creator.name).toBe('Playwright');
|
||||
expect(log.creator.version).toBe(require('../package.json')['version']);
|
||||
});
|
||||
|
||||
it('should have browser', async ({ browserName, browser, pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.browser.name.toLowerCase()).toBe(browserName);
|
||||
expect(log.browser.version).toBe(browser.version());
|
||||
});
|
||||
|
||||
it('should have pages', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto('data:text/html,<title>Hello</title>');
|
||||
// For data: load comes before domcontentloaded...
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.pages.length).toBe(1);
|
||||
const pageEntry = log.pages[0];
|
||||
expect(pageEntry.id).toBe('page_0');
|
||||
expect(pageEntry.title).toBe('Hello');
|
||||
expect(new Date(pageEntry.startedDateTime).valueOf()).toBeGreaterThan(Date.now() - 3600 * 1000);
|
||||
expect(pageEntry.pageTimings.onContentLoad).toBeGreaterThan(0);
|
||||
expect(pageEntry.pageTimings.onLoad).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include request', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.entries.length).toBe(1);
|
||||
const entry = log.entries[0];
|
||||
expect(entry.pageref).toBe('page_0');
|
||||
expect(entry.request.url).toBe(server.EMPTY_PAGE);
|
||||
expect(entry.request.method).toBe('GET');
|
||||
expect(entry.request.httpVersion).toBe('HTTP/1.1');
|
||||
expect(entry.request.headers.length).toBeGreaterThan(1);
|
||||
expect(entry.request.headers.find(h => h.name.toLowerCase() === 'user-agent')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should include response', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const log = await pageWithHar.log();
|
||||
const entry = log.entries[0];
|
||||
expect(entry.response.status).toBe(200);
|
||||
expect(entry.response.statusText).toBe('OK');
|
||||
expect(entry.response.httpVersion).toBe('HTTP/1.1');
|
||||
expect(entry.response.headers.length).toBeGreaterThan(1);
|
||||
expect(entry.response.headers.find(h => h.name.toLowerCase() === 'content-type').value).toContain('text/html');
|
||||
});
|
||||
|
||||
it('should include redirectURL', async ({ pageWithHar, server }) => {
|
||||
server.setRedirect('/foo.html', '/empty.html');
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.PREFIX + '/foo.html');
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.entries.length).toBe(2);
|
||||
const entry = log.entries[0];
|
||||
expect(entry.response.status).toBe(302);
|
||||
expect(entry.response.redirectURL).toBe(server.EMPTY_PAGE);
|
||||
});
|
||||
|
||||
it('should include query params', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.PREFIX + '/har.html?name=value');
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.entries[0].request.queryString).toEqual([{ name: 'name', value: 'value' }]);
|
||||
});
|
||||
|
||||
it('should include postData', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await page.evaluate(() => fetch('./post', { method: 'POST', body: 'Hello' }));
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.entries[1].request.postData).toEqual({
|
||||
mimeType: 'text/plain;charset=UTF-8',
|
||||
params: [],
|
||||
text: 'Hello'
|
||||
});
|
||||
});
|
||||
|
||||
it('should include binary postData', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await page.evaluate(async () => {
|
||||
await fetch('./post', { method: 'POST', body: new Uint8Array(Array.from(Array(16).keys())) });
|
||||
});
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.entries[1].request.postData).toEqual({
|
||||
mimeType: 'application/octet-stream',
|
||||
params: [],
|
||||
text: ''
|
||||
});
|
||||
});
|
||||
|
||||
it('should include form params', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await page.setContent(`<form method='POST' action='/post'><input type='text' name='foo' value='bar'><input type='number' name='baz' value='123'><input type='submit'></form>`);
|
||||
await page.click('input[type=submit]');
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.entries[1].request.postData).toEqual({
|
||||
mimeType: 'application/x-www-form-urlencoded',
|
||||
params: [
|
||||
{ name: 'foo', value: 'bar' },
|
||||
{ name: 'baz', value: '123' }
|
||||
],
|
||||
text: 'foo=bar&baz=123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should include cookies', (test, { browserName }) => {
|
||||
test.fail(browserName === 'webkit', 'WebKit is lacking raw headers w/ cookies on WebCore side');
|
||||
}, async ({ pageWithHar, server }) => {
|
||||
const { page, context } = pageWithHar;
|
||||
await context.addCookies([
|
||||
{ name: 'name1', value: '"value1"', domain: 'localhost', path: '/', httpOnly: true },
|
||||
{ name: 'name2', value: 'val"ue2', domain: 'localhost', path: '/', sameSite: 'Lax' },
|
||||
{ name: 'name3', value: 'val=ue3', domain: 'localhost', path: '/' },
|
||||
{ name: 'name4', value: 'val,ue4', domain: 'localhost', path: '/' },
|
||||
]);
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const log = await pageWithHar.log();
|
||||
expect(log.entries[0].request.cookies).toEqual([
|
||||
{ name: 'name1', value: '"value1"' },
|
||||
{ name: 'name2', value: 'val"ue2' },
|
||||
{ name: 'name3', value: 'val=ue3' },
|
||||
{ name: 'name4', value: 'val,ue4' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include set-cookies', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
server.setRoute('/empty.html', (req, res) => {
|
||||
res.setHeader('Set-Cookie', [
|
||||
'name1=value1; HttpOnly',
|
||||
'name2="value2"',
|
||||
'name3=value4; Path=/; Domain=example.com; Max-Age=1500',
|
||||
]);
|
||||
res.end();
|
||||
});
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const log = await pageWithHar.log();
|
||||
const cookies = log.entries[0].response.cookies;
|
||||
expect(cookies[0]).toEqual({ name: 'name1', value: 'value1', httpOnly: true });
|
||||
expect(cookies[1]).toEqual({ name: 'name2', value: '"value2"' });
|
||||
expect(new Date(cookies[2].expires).valueOf()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('should include set-cookies with comma', (test, { browserName }) => {
|
||||
test.fail(browserName === 'webkit', 'WebKit concatenates headers poorly');
|
||||
}, async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
server.setRoute('/empty.html', (req, res) => {
|
||||
res.setHeader('Set-Cookie', [
|
||||
'name1=val,ue1',
|
||||
]);
|
||||
res.end();
|
||||
});
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const log = await pageWithHar.log();
|
||||
const cookies = log.entries[0].response.cookies;
|
||||
expect(cookies[0]).toEqual({ name: 'name1', value: 'val,ue1' });
|
||||
});
|
||||
|
||||
it('should include secure set-cookies', async ({ pageWithHar, httpsServer }) => {
|
||||
const { page } = pageWithHar;
|
||||
httpsServer.setRoute('/empty.html', (req, res) => {
|
||||
res.setHeader('Set-Cookie', [
|
||||
'name1=value1; Secure',
|
||||
]);
|
||||
res.end();
|
||||
});
|
||||
await page.goto(httpsServer.EMPTY_PAGE);
|
||||
const log = await pageWithHar.log();
|
||||
const cookies = log.entries[0].response.cookies;
|
||||
expect(cookies[0]).toEqual({ name: 'name1', value: 'value1', secure: true });
|
||||
});
|
||||
|
||||
it('should include content', async ({ pageWithHar, server }) => {
|
||||
const { page } = pageWithHar;
|
||||
await page.goto(server.PREFIX + '/har.html');
|
||||
const log = await pageWithHar.log();
|
||||
|
||||
const content1 = log.entries[0].response.content;
|
||||
expect(content1.encoding).toBe('base64');
|
||||
expect(content1.mimeType).toBe('text/html; charset=utf-8');
|
||||
expect(Buffer.from(content1.text, 'base64').toString()).toContain('HAR Page');
|
||||
|
||||
const content2 = log.entries[1].response.content;
|
||||
expect(content2.encoding).toBe('base64');
|
||||
expect(content2.mimeType).toBe('text/css; charset=utf-8');
|
||||
expect(Buffer.from(content2.text, 'base64').toString()).toContain('pink');
|
||||
});
|
||||
|
|
@ -196,7 +196,7 @@ function classBody(classDesc) {
|
|||
if (!hasOwnMethod(classDesc, member.name))
|
||||
return '';
|
||||
if (member.templates.length)
|
||||
console.error(`expected an override for "${classDesc.name}.${member.name}" becasue it is templated`);
|
||||
console.error(`expected an override for "${classDesc.name}.${member.name}" because it is templated`);
|
||||
return `${jsdoc}${member.name}${args}: ${type};`
|
||||
}).filter(x => x).join('\n\n'));
|
||||
return parts.join('\n');
|
||||
|
|
|
|||
Loading…
Reference in a new issue