2020-10-26 14:32:07 -07:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
|
2021-07-08 18:22:37 +02:00
|
|
|
import { URL } from 'url';
|
2021-02-11 06:36:15 -08:00
|
|
|
import fs from 'fs';
|
2021-02-09 14:44:48 -08:00
|
|
|
import { BrowserContext } from '../../browserContext';
|
2021-01-29 16:00:56 -08:00
|
|
|
import { helper } from '../../helper';
|
|
|
|
import * as network from '../../network';
|
|
|
|
import { Page } from '../../page';
|
2020-10-26 14:32:07 -07:00
|
|
|
import * as har from './har';
|
2021-07-08 18:22:37 +02:00
|
|
|
import * as types from '../../types';
|
|
|
|
|
|
|
|
const FALLBACK_HTTP_VERSION = 'HTTP/1.1';
|
2020-10-26 14:32:07 -07:00
|
|
|
|
|
|
|
type HarOptions = {
|
|
|
|
path: string;
|
|
|
|
omitContent?: boolean;
|
|
|
|
};
|
|
|
|
|
2021-04-23 18:34:52 -07:00
|
|
|
export class HarTracer {
|
2020-10-26 14:32:07 -07:00
|
|
|
private _options: HarOptions;
|
|
|
|
private _log: har.Log;
|
|
|
|
private _pageEntries = new Map<Page, har.Page>();
|
|
|
|
private _entries = new Map<network.Request, har.Entry>();
|
|
|
|
private _lastPage = 0;
|
2020-11-02 13:38:55 -08:00
|
|
|
private _barrierPromises = new Set<Promise<void>>();
|
2020-10-26 14:32:07 -07:00
|
|
|
|
|
|
|
constructor(context: BrowserContext, options: HarOptions) {
|
|
|
|
this._options = options;
|
|
|
|
this._log = {
|
|
|
|
version: '1.2',
|
|
|
|
creator: {
|
|
|
|
name: 'Playwright',
|
2021-01-29 16:00:56 -08:00
|
|
|
version: require('../../../../package.json')['version'],
|
2020-10-26 14:32:07 -07:00
|
|
|
},
|
|
|
|
browser: {
|
2021-01-29 16:00:56 -08:00
|
|
|
name: context._browser.options.name,
|
2020-10-26 14:32:07 -07:00
|
|
|
version: context._browser.version()
|
|
|
|
},
|
|
|
|
pages: [],
|
|
|
|
entries: []
|
|
|
|
};
|
2021-05-13 15:02:10 -07:00
|
|
|
context.on(BrowserContext.Events.Page, (page: Page) => this._ensurePageEntry(page));
|
|
|
|
context.on(BrowserContext.Events.Request, (request: network.Request) => this._onRequest(request));
|
2021-07-08 18:22:37 +02:00
|
|
|
context.on(BrowserContext.Events.RequestFinished, (request: network.Request) => this._onRequestFinished(request).catch(() => {}));
|
2021-05-13 15:02:10 -07:00
|
|
|
context.on(BrowserContext.Events.Response, (response: network.Response) => this._onResponse(response));
|
2020-10-26 14:32:07 -07:00
|
|
|
}
|
|
|
|
|
2021-05-13 15:02:10 -07:00
|
|
|
private _ensurePageEntry(page: Page) {
|
|
|
|
let pageEntry = this._pageEntries.get(page);
|
|
|
|
if (!pageEntry) {
|
|
|
|
page.on(Page.Events.DOMContentLoaded, () => this._onDOMContentLoaded(page));
|
|
|
|
page.on(Page.Events.Load, () => this._onLoad(page));
|
|
|
|
|
|
|
|
pageEntry = {
|
|
|
|
startedDateTime: new Date(),
|
|
|
|
id: `page_${this._lastPage++}`,
|
|
|
|
title: '',
|
|
|
|
pageTimings: {
|
|
|
|
onContentLoad: -1,
|
|
|
|
onLoad: -1,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
this._pageEntries.set(page, pageEntry);
|
|
|
|
this._log.pages.push(pageEntry);
|
|
|
|
}
|
|
|
|
return pageEntry;
|
|
|
|
}
|
|
|
|
|
|
|
|
private _onDOMContentLoaded(page: Page) {
|
|
|
|
const pageEntry = this._ensurePageEntry(page);
|
2021-07-09 16:19:42 +02:00
|
|
|
const promise = page.mainFrame().evaluateExpression(String(() => {
|
2021-05-13 15:02:10 -07:00
|
|
|
return {
|
|
|
|
title: document.title,
|
|
|
|
domContentLoaded: performance.timing.domContentLoadedEventStart,
|
|
|
|
};
|
2021-07-09 16:19:42 +02:00
|
|
|
}), true, undefined, 'utility').then(result => {
|
2021-05-13 15:02:10 -07:00
|
|
|
pageEntry.title = result.title;
|
|
|
|
pageEntry.pageTimings.onContentLoad = result.domContentLoaded;
|
|
|
|
}).catch(() => {});
|
|
|
|
this._addBarrier(page, promise);
|
|
|
|
}
|
2020-10-26 14:32:07 -07:00
|
|
|
|
2021-05-13 15:02:10 -07:00
|
|
|
private _onLoad(page: Page) {
|
|
|
|
const pageEntry = this._ensurePageEntry(page);
|
2021-07-09 16:19:42 +02:00
|
|
|
const promise = page.mainFrame().evaluateExpression(String(() => {
|
2021-05-13 15:02:10 -07:00
|
|
|
return {
|
|
|
|
title: document.title,
|
|
|
|
loaded: performance.timing.loadEventStart,
|
|
|
|
};
|
2021-07-09 16:19:42 +02:00
|
|
|
}), true, undefined, 'utility').then(result => {
|
2021-05-13 15:02:10 -07:00
|
|
|
pageEntry.title = result.title;
|
|
|
|
pageEntry.pageTimings.onLoad = result.loaded;
|
|
|
|
}).catch(() => {});
|
|
|
|
this._addBarrier(page, promise);
|
2020-10-26 14:32:07 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
private _addBarrier(page: Page, promise: Promise<void>) {
|
|
|
|
const race = Promise.race([
|
2021-03-18 01:47:07 +08:00
|
|
|
new Promise<void>(f => page.on('close', () => {
|
2020-10-26 14:32:07 -07:00
|
|
|
this._barrierPromises.delete(race);
|
|
|
|
f();
|
|
|
|
})),
|
|
|
|
promise
|
|
|
|
]) as Promise<void>;
|
2020-11-02 13:38:55 -08:00
|
|
|
this._barrierPromises.add(race);
|
2020-10-26 14:32:07 -07:00
|
|
|
}
|
|
|
|
|
2021-05-13 15:02:10 -07:00
|
|
|
private _onRequest(request: network.Request) {
|
|
|
|
const page = request.frame()._page;
|
2021-01-25 14:49:51 -08:00
|
|
|
const url = network.parsedURL(request.url());
|
|
|
|
if (!url)
|
|
|
|
return;
|
2020-10-26 14:32:07 -07:00
|
|
|
|
2021-05-13 15:02:10 -07:00
|
|
|
const pageEntry = this._ensurePageEntry(page);
|
2020-10-26 14:32:07 -07:00
|
|
|
const harEntry: har.Entry = {
|
|
|
|
pageref: pageEntry.id,
|
|
|
|
startedDateTime: new Date(),
|
|
|
|
time: -1,
|
|
|
|
request: {
|
|
|
|
method: request.method(),
|
|
|
|
url: request.url(),
|
2021-07-08 18:22:37 +02:00
|
|
|
httpVersion: FALLBACK_HTTP_VERSION,
|
2020-10-26 14:32:07 -07:00
|
|
|
cookies: [],
|
|
|
|
headers: [],
|
|
|
|
queryString: [...url.searchParams].map(e => ({ name: e[0], value: e[1] })),
|
2021-07-08 18:22:37 +02:00
|
|
|
postData: postDataForHar(request),
|
2020-10-26 14:32:07 -07:00
|
|
|
headersSize: -1,
|
2021-07-08 18:22:37 +02:00
|
|
|
bodySize: calculateRequestBodySize(request) || 0,
|
2020-10-26 14:32:07 -07:00
|
|
|
},
|
|
|
|
response: {
|
|
|
|
status: -1,
|
|
|
|
statusText: '',
|
2021-07-08 18:22:37 +02:00
|
|
|
httpVersion: FALLBACK_HTTP_VERSION,
|
2020-10-26 14:32:07 -07:00
|
|
|
cookies: [],
|
|
|
|
headers: [],
|
|
|
|
content: {
|
|
|
|
size: -1,
|
2021-07-08 18:22:37 +02:00
|
|
|
mimeType: request.headerValue('content-type') || 'x-unknown',
|
2020-10-26 14:32:07 -07:00
|
|
|
},
|
|
|
|
headersSize: -1,
|
|
|
|
bodySize: -1,
|
2021-07-08 18:22:37 +02:00
|
|
|
redirectURL: '',
|
|
|
|
_transferSize: -1
|
2020-10-26 14:32:07 -07:00
|
|
|
},
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2021-07-08 18:22:37 +02:00
|
|
|
private async _onRequestFinished(request: network.Request) {
|
|
|
|
const page = request.frame()._page;
|
|
|
|
const harEntry = this._entries.get(request)!;
|
|
|
|
const response = await request.response();
|
|
|
|
|
|
|
|
if (!response)
|
|
|
|
return;
|
|
|
|
|
|
|
|
const httpVersion = normaliseHttpVersion(response._httpVersion);
|
|
|
|
const transferSize = response._transferSize || -1;
|
|
|
|
const headersSize = calculateResponseHeadersSize(httpVersion, response.status(), response.statusText(), response.headers());
|
|
|
|
const bodySize = transferSize !== -1 ? transferSize - headersSize : -1;
|
|
|
|
|
|
|
|
harEntry.request.httpVersion = httpVersion;
|
|
|
|
harEntry.response.bodySize = bodySize;
|
|
|
|
harEntry.response.headersSize = headersSize;
|
|
|
|
harEntry.response._transferSize = transferSize;
|
|
|
|
harEntry.request.headersSize = calculateRequestHeadersSize(request.method(), request.url(), httpVersion, request.headers());
|
|
|
|
|
|
|
|
const promise = response.body().then(buffer => {
|
|
|
|
const content = harEntry.response.content;
|
|
|
|
content.size = buffer.length;
|
|
|
|
content.compression = harEntry.response.bodySize !== -1 ? buffer.length - harEntry.response.bodySize : 0;
|
|
|
|
|
|
|
|
if (!this._options.omitContent && buffer && buffer.length > 0) {
|
|
|
|
content.text = buffer.toString('base64');
|
|
|
|
content.encoding = 'base64';
|
|
|
|
}
|
|
|
|
}).catch(() => {});
|
|
|
|
this._addBarrier(page, promise);
|
|
|
|
}
|
|
|
|
|
2021-05-13 15:02:10 -07:00
|
|
|
private _onResponse(response: network.Response) {
|
|
|
|
const page = response.frame()._page;
|
|
|
|
const pageEntry = this._ensurePageEntry(page);
|
2020-10-26 14:32:07 -07:00
|
|
|
const harEntry = this._entries.get(response.request())!;
|
|
|
|
// Rewrite provisional headers with actual
|
|
|
|
const request = response.request();
|
2021-07-08 18:22:37 +02:00
|
|
|
|
2020-12-29 09:59:35 -08:00
|
|
|
harEntry.request.headers = request.headers().map(header => ({ name: header.name, value: header.value }));
|
2020-10-26 14:32:07 -07:00
|
|
|
harEntry.request.cookies = cookiesForHar(request.headerValue('cookie'), ';');
|
2021-07-08 18:22:37 +02:00
|
|
|
harEntry.request.postData = postDataForHar(request);
|
2020-10-26 14:32:07 -07:00
|
|
|
|
|
|
|
harEntry.response = {
|
|
|
|
status: response.status(),
|
|
|
|
statusText: response.statusText(),
|
2021-07-08 18:22:37 +02:00
|
|
|
httpVersion: normaliseHttpVersion(response._httpVersion),
|
2020-10-28 13:46:05 -07:00
|
|
|
cookies: cookiesForHar(response.headerValue('set-cookie'), '\n'),
|
2020-10-26 14:32:07 -07:00
|
|
|
headers: response.headers().map(header => ({ name: header.name, value: header.value })),
|
|
|
|
content: {
|
|
|
|
size: -1,
|
2021-07-08 18:22:37 +02:00
|
|
|
mimeType: response.headerValue('content-type') || 'x-unknown',
|
2020-10-26 14:32:07 -07:00
|
|
|
},
|
|
|
|
headersSize: -1,
|
|
|
|
bodySize: -1,
|
2021-07-08 18:22:37 +02:00
|
|
|
redirectURL: '',
|
|
|
|
_transferSize: -1
|
2020-10-26 14:32:07 -07:00
|
|
|
};
|
|
|
|
const timing = response.timing();
|
|
|
|
if (pageEntry.startedDateTime.valueOf() > timing.startTime)
|
|
|
|
pageEntry.startedDateTime = new Date(timing.startTime);
|
2021-05-12 04:28:17 +08:00
|
|
|
const dns = timing.domainLookupEnd !== -1 ? helper.millisToRoundishMillis(timing.domainLookupEnd - timing.domainLookupStart) : -1;
|
|
|
|
const connect = timing.connectEnd !== -1 ? helper.millisToRoundishMillis(timing.connectEnd - timing.connectStart) : -1;
|
|
|
|
const ssl = timing.connectEnd !== -1 ? helper.millisToRoundishMillis(timing.connectEnd - timing.secureConnectionStart) : -1;
|
|
|
|
const wait = timing.responseStart !== -1 ? helper.millisToRoundishMillis(timing.responseStart - timing.requestStart) : -1;
|
|
|
|
const receive = response.request()._responseEndTiming !== -1 ? helper.millisToRoundishMillis(response.request()._responseEndTiming - timing.responseStart) : -1;
|
2020-10-26 14:32:07 -07:00
|
|
|
harEntry.timings = {
|
2021-05-12 04:28:17 +08:00
|
|
|
dns,
|
|
|
|
connect,
|
|
|
|
ssl,
|
2020-10-26 14:32:07 -07:00
|
|
|
send: 0,
|
2021-05-12 04:28:17 +08:00
|
|
|
wait,
|
|
|
|
receive,
|
2020-10-26 14:32:07 -07:00
|
|
|
};
|
2021-05-12 04:28:17 +08:00
|
|
|
harEntry.time = [dns, connect, ssl, wait, receive].reduce((pre, cur) => cur > 0 ? cur + pre : pre, 0);
|
2021-06-15 00:48:08 -07:00
|
|
|
|
|
|
|
this._addBarrier(page, response.serverAddr().then(server => {
|
|
|
|
if (server?.ipAddress)
|
|
|
|
harEntry.serverIPAddress = server.ipAddress;
|
|
|
|
if (server?.port)
|
|
|
|
harEntry._serverPort = server.port;
|
|
|
|
}));
|
|
|
|
this._addBarrier(page, response.securityDetails().then(details => {
|
|
|
|
if (details)
|
|
|
|
harEntry._securityDetails = details;
|
|
|
|
}));
|
2020-10-26 14:32:07 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
async flush() {
|
2020-11-02 13:38:55 -08:00
|
|
|
await Promise.all(this._barrierPromises);
|
2020-10-26 14:32:07 -07:00
|
|
|
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;
|
|
|
|
}
|
2021-06-03 09:55:33 -07:00
|
|
|
await fs.promises.writeFile(this._options.path, JSON.stringify({ log: this._log }, undefined, 2));
|
2020-10-26 14:32:07 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-08 18:22:37 +02:00
|
|
|
function postDataForHar(request: network.Request): har.PostData | undefined {
|
2020-10-26 14:32:07 -07:00
|
|
|
const postData = request.postDataBuffer();
|
|
|
|
if (!postData)
|
2021-07-08 18:22:37 +02:00
|
|
|
return;
|
2020-10-26 14:32:07 -07:00
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
2021-07-08 18:22:37 +02:00
|
|
|
|
|
|
|
function calculateResponseHeadersSize(protocol: string, status: number, statusText: string , headers: types.HeadersArray) {
|
|
|
|
let rawHeaders = `${protocol} ${status} ${statusText}\r\n`;
|
|
|
|
for (const header of headers)
|
|
|
|
rawHeaders += `${header.name}: ${header.value}\r\n`;
|
|
|
|
rawHeaders += '\r\n';
|
|
|
|
return rawHeaders.length;
|
|
|
|
}
|
|
|
|
|
|
|
|
function calculateRequestHeadersSize(method: string, url: string, httpVersion: string, headers: types.HeadersArray) {
|
|
|
|
let rawHeaders = `${method} ${(new URL(url)).pathname} ${httpVersion}\r\n`;
|
|
|
|
for (const header of headers)
|
|
|
|
rawHeaders += `${header.name}: ${header.value}\r\n`;
|
|
|
|
return rawHeaders.length;
|
|
|
|
}
|
|
|
|
|
|
|
|
function normaliseHttpVersion(httpVersion?: string) {
|
|
|
|
if (!httpVersion)
|
|
|
|
return FALLBACK_HTTP_VERSION;
|
|
|
|
if (httpVersion === 'http/1.1')
|
|
|
|
return 'HTTP/1.1';
|
|
|
|
return httpVersion;
|
|
|
|
}
|
|
|
|
|
|
|
|
function calculateRequestBodySize(request: network.Request): number|undefined {
|
|
|
|
const postData = request.postDataBuffer();
|
|
|
|
if (!postData)
|
|
|
|
return;
|
|
|
|
return new TextEncoder().encode(postData.toString('utf8')).length;
|
|
|
|
}
|