feat(har): allow saving har for context (#4214)

This commit is contained in:
Pavel Feldman 2020-10-26 14:32:07 -07:00 committed by GitHub
parent d5fbe3a662
commit 7fc4b797eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 772 additions and 6 deletions

View file

@ -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.

View file

@ -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> {}
}

View file

@ -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);

View file

@ -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();

View file

@ -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,

View file

@ -391,6 +391,11 @@ Browser:
properties:
width: number
height: number
recordHar:
type: object?
properties:
omitContent: boolean?
path: string
returns:
context: BrowserContext

View file

@ -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({

View file

@ -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;

View file

@ -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);
}

View file

@ -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
View 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
View 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;
}

View file

@ -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
View 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
View 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');
});

View file

@ -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');