From 7fc4b797eb1e625bfc487af6246641389cf519a2 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 26 Oct 2020 14:32:07 -0700 Subject: [PATCH] feat(har): allow saving har for context (#4214) --- docs/api.md | 9 + src/debug/debugController.ts | 4 +- src/driver.ts | 2 + src/inprocess.ts | 2 + src/protocol/channels.ts | 8 + src/protocol/protocol.yml | 5 + src/protocol/validator.ts | 4 + src/server/browserContext.ts | 8 +- src/server/network.ts | 17 ++ src/server/types.ts | 4 + src/trace/har.ts | 146 ++++++++++++++++ src/trace/harTracer.ts | 311 ++++++++++++++++++++++++++++++++++ src/trace/tracer.ts | 4 +- test/assets/har.html | 3 + test/har.spec.ts | 249 +++++++++++++++++++++++++++ utils/generate_types/index.js | 2 +- 16 files changed, 772 insertions(+), 6 deletions(-) create mode 100644 src/trace/har.ts create mode 100644 src/trace/harTracer.ts create mode 100644 test/assets/har.html create mode 100644 test/har.spec.ts diff --git a/docs/api.md b/docs/api.md index b359b9fc4c..39154acbe4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/src/debug/debugController.ts b/src/debug/debugController.ts index 293f996635..b1ecc367f4 100644 --- a/src/debug/debugController.ts +++ b/src/debug/debugController.ts @@ -42,6 +42,6 @@ class DebugController implements ContextListener { }); } - async onContextDestroyed(context: BrowserContext): Promise { - } + async onContextWillDestroy(context: BrowserContext): Promise {} + async onContextDidDestroy(context: BrowserContext): Promise {} } diff --git a/src/driver.ts b/src/driver.ts index cd8e7b2a22..5f36fe0d37 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -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 { export function runServer() { installDebugController(); installTracer(); + installHarTracer(); const dispatcherConnection = new DispatcherConnection(); const transport = new Transport(process.stdout, process.stdin); diff --git a/src/inprocess.ts b/src/inprocess.ts index 069557abac..ae246e0984 100644 --- a/src/inprocess.ts +++ b/src/inprocess.ts @@ -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(); diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index a4b1787e36..81499253ec 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -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, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index d2a94fe458..6c9b7fe517 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -391,6 +391,11 @@ Browser: properties: width: number height: number + recordHar: + type: object? + properties: + omitContent: boolean? + path: string returns: context: BrowserContext diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index 5f2088bb7d..f07d9d45fa 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -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({ diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index 2b1e523def..aec7aef4ab 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -82,7 +82,8 @@ export async function runAction(task: (controller: ProgressController) => Pro export interface ContextListener { onContextCreated(context: BrowserContext): Promise; - onContextDestroyed(context: BrowserContext): Promise; + onContextWillDestroy(context: BrowserContext): Promise; + onContextDidDestroy(context: BrowserContext): Promise; } export const contextListeners = new Set(); @@ -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[] = []; 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; diff --git a/src/server/network.ts b/src/server/network.ts index 1e1d4d9de7..9b3502123c 100644 --- a/src/server/network.ts +++ b/src/server/network.ts @@ -78,6 +78,7 @@ export class Request { private _method: string; private _postData: Buffer | null; private _headers: types.HeadersArray; + private _headersMap = new Map(); private _frame: frames.Frame; private _waitForResponsePromise: Promise; 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 { 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(); 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 { return this._finishedPromise.then(({ error }) => error ? new Error(error) : null); } diff --git a/src/server/types.ts b/src/server/types.ts index 9cc9681c01..9c562fc921 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -240,6 +240,10 @@ export type BrowserContextOptions = { acceptDownloads?: boolean, videosPath?: string, videoSize?: Size, + recordHar?: { + omitContent?: boolean, + path: string + }, _tracePath?: string, _traceResourcesPath?: string, }; diff --git a/src/trace/har.ts b/src/trace/har.ts new file mode 100644 index 0000000000..b8d2c458ae --- /dev/null +++ b/src/trace/har.ts @@ -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; +}; diff --git a/src/trace/harTracer.ts b/src/trace/harTracer.ts new file mode 100644 index 0000000000..4b11d405ab --- /dev/null +++ b/src/trace/harTracer.ts @@ -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(); + + async onContextCreated(context: BrowserContext): Promise { + if (!context._options.recordHar) + return; + const contextTracer = new HarContextTracer(context, context._options.recordHar); + this._contextTracers.set(context, contextTracer); + } + + async onContextWillDestroy(context: BrowserContext): Promise { + const contextTracer = this._contextTracers.get(context); + if (contextTracer) { + this._contextTracers.delete(context); + await contextTracer.flush(); + } + } + + async onContextDidDestroy(context: BrowserContext): Promise { + 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(); + private _entries = new Map(); + private _lastPage = 0; + private _barrierPromises = new Map, 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) { + const race = Promise.race([ + new Promise(f => page.on('close', () => { + this._barrierPromises.delete(race); + f(); + })), + promise + ]) as Promise; + 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; +} diff --git a/src/trace/tracer.ts b/src/trace/tracer.ts index 2c951c4220..2338228a80 100644 --- a/src/trace/tracer.ts +++ b/src/trace/tracer.ts @@ -47,7 +47,9 @@ class Tracer implements ContextListener { this._contextTracers.set(context, contextTracer); } - async onContextDestroyed(context: BrowserContext): Promise { + async onContextWillDestroy(context: BrowserContext): Promise {} + + async onContextDidDestroy(context: BrowserContext): Promise { const contextTracer = this._contextTracers.get(context); if (contextTracer) { await contextTracer.dispose().catch(e => {}); diff --git a/test/assets/har.html b/test/assets/har.html new file mode 100644 index 0000000000..054bb0eb1a --- /dev/null +++ b/test/assets/har.html @@ -0,0 +1,3 @@ +HAR Page + +
hello, world!
diff --git a/test/har.spec.ts b/test/har.spec.ts new file mode 100644 index 0000000000..31e644e855 --- /dev/null +++ b/test/har.spec.ts @@ -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 + } +}>(); + +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,Hello'); + // 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(`
`); + 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'); +}); diff --git a/utils/generate_types/index.js b/utils/generate_types/index.js index ec69b5cfa5..720e960da7 100644 --- a/utils/generate_types/index.js +++ b/utils/generate_types/index.js @@ -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');