├── .gitignore ├── src ├── playwright-har-config.ts └── playwright-har.ts ├── tsconfig.json ├── .github └── workflows │ └── main.yml ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .npmrc -------------------------------------------------------------------------------- /src/playwright-har-config.ts: -------------------------------------------------------------------------------- 1 | export class PlaywrightHarConfig { 2 | public recordResponses: boolean = true; 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "noImplicitAny": false, 7 | "outDir": "./dist", 8 | "lib": ["es6", "dom", "es2017"], 9 | "noEmitOnError": true, 10 | "allowJs": true, 11 | "sourceMap": true, 12 | "rootDir": "./src", 13 | "types": ["node"], 14 | "resolveJsonModule": true, 15 | "declaration": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | branches: [ main ] 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v1 19 | with: 20 | node-version: 12 21 | - run: npm ci 22 | - run: npm run build 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playwright-har", 3 | "version": "0.2.0", 4 | "description": "Generate HAR files from Playwright test execution", 5 | "main": "dist/playwright-har.js", 6 | "scripts": { 7 | "prebuild": "rimraf ./dist", 8 | "build": "tsc" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/janzaremski/playwright-har.git" 13 | }, 14 | "keywords": [ 15 | "playwright", 16 | "har" 17 | ], 18 | "author": "Jan Zaremski", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/janzaremski/playwright-har/issues" 22 | }, 23 | "homepage": "https://github.com/janzaremski/playwright-har#readme", 24 | "dependencies": { 25 | "chrome-har": "^0.11.12", 26 | "playwright-chromium": "^1.8.0" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^14.14.22", 30 | "rimraf": "^3.0.2", 31 | "ts-node": "^9.1.1", 32 | "typescript": "^4.1.3" 33 | }, 34 | "files": [ 35 | "dist/*" 36 | ], 37 | "publishConfig": { 38 | "registry": "https://registry.npmjs.org/" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # playwright-har 2 | 3 | ![npm](https://img.shields.io/npm/v/playwright-har?color=blue) 4 | 5 | playwright-har is capturing [HAR files](https://en.wikipedia.org/wiki/HAR_(file_format)) from browser network traffic and saves them to simplify debugging of failed tests. 6 | 7 | ## Credits 8 | 9 | This is a port of [puppeteer-har](https://github.com/Everettss/puppeteer-har) that was adjusted to work with Playwright. 10 | 11 | ## Install 12 | 13 | ``` 14 | npm i --save playwright-har 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### Quick start 20 | 21 | ```ts 22 | import { chromium } from 'playwright' 23 | import { PlaywrightHar } from 'playwright-har' 24 | 25 | (async () => { 26 | const browser = await chromium.launch(); 27 | const context = await browser.newContext(); 28 | const page = await context.newPage(); 29 | 30 | const playwrightHar = new PlaywrightHar(page); 31 | await playwrightHar.start(); 32 | 33 | await page.goto('http://whatsmyuseragent.org/'); 34 | // ... other actions ... 35 | 36 | await playwrightHar.stop('./example.har'); 37 | await browser.close(); 38 | })(); 39 | ``` 40 | 41 | ### Integration with [jest-playwright](https://github.com/playwright-community/jest-playwright) preset 42 | 43 | In [CustomEnvironment.js](https://github.com/playwright-community/jest-playwright#usage-with-custom-testenvironment) : 44 | 45 | ```js 46 | const PlaywrightEnvironment = require('jest-playwright-preset/lib/PlaywrightEnvironment').default 47 | const { PlaywrightHar } = require('playwright-har'); 48 | 49 | class CustomEnvironment extends PlaywrightEnvironment { 50 | 51 | constructor(config, context) { 52 | super(config, context); 53 | this.playwrightHar; 54 | } 55 | 56 | async handleTestEvent(event) { 57 | 58 | if (event.name == 'test_start') { 59 | if (this.global.browserName === 'chromium') { 60 | this.playwrightHar = new PlaywrightHar(this.global.page); 61 | await this.playwrightHar.start(); 62 | } 63 | } 64 | 65 | if (event.name == 'test_done') { 66 | if (this.global.browserName === 'chromium') { 67 | const parentName = event.test.parent.name.replace(/\W/g, '-'); 68 | const specName = event.test.name.replace(/\W/g, '-'); 69 | await this.playwrightHar.stop(`./${parentName}_${specName}.har`); 70 | } 71 | } 72 | } 73 | } 74 | 75 | module.exports = CustomEnvironment; 76 | ``` 77 | 78 | This setup will create `PlaywrightHar` instance for each `test` statement in `describe` statement in spec file. Browser network traffic will be collected from this step execution and save it in `.har` file with name corresponding to `describe` name followed by `test` name. 79 | 80 | ## Additional info 81 | 82 | * HAR files collection works only on chromium browser 83 | * `stop()` has an optional argument `path` - when specified, generated HAR file will be saved into provided path, otherwise it will be returned as an object 84 | 85 | -------------------------------------------------------------------------------- /src/playwright-har.ts: -------------------------------------------------------------------------------- 1 | import { harFromMessages } from 'chrome-har'; 2 | import { writeFileSync } from 'fs'; 3 | import { CDPSession, Page } from 'playwright-chromium'; 4 | import { PlaywrightHarConfig } from './playwright-har-config'; 5 | 6 | export class PlaywrightHar { 7 | 8 | private page: Page; 9 | private client: CDPSession; 10 | private addResponseBodyPromises = []; 11 | private events = []; 12 | private config: PlaywrightHarConfig; 13 | 14 | constructor(page: Page, config: PlaywrightHarConfig = null) { 15 | this.page = page; 16 | 17 | if (config == null) { 18 | this.config = new PlaywrightHarConfig(); 19 | } 20 | } 21 | 22 | async start() { 23 | //@ts-ignore 24 | // newCDPSession is only available for ChromiumBrowserContext 25 | this.client = await this.page.context().newCDPSession(this.page); 26 | await this.client.send('Page.enable'); 27 | await this.client.send('Network.enable'); 28 | const observe = [ 29 | 'Page.loadEventFired', 30 | 'Page.domContentEventFired', 31 | 'Page.frameStartedLoading', 32 | 'Page.frameAttached', 33 | 'Page.frameScheduledNavigation', 34 | 'Network.requestWillBeSent', 35 | 'Network.requestServedFromCache', 36 | 'Network.dataReceived', 37 | 'Network.responseReceived', 38 | 'Network.resourceChangedPriority', 39 | 'Network.loadingFinished', 40 | 'Network.loadingFailed', 41 | 'Network.getResponseBody' 42 | ]; 43 | observe.forEach(method => { 44 | //@ts-ignore 45 | // Doesn't work when array contains symbols instead of strings 46 | this.client.on(method, params => { 47 | const harEvent = { method, params }; 48 | this.events.push(harEvent); 49 | if (method === 'Network.responseReceived') { 50 | if (this.config.recordResponses === false) { 51 | return; 52 | } 53 | 54 | const response = harEvent.params.response; 55 | const requestId = harEvent.params.requestId; 56 | // Response body is unavailable for redirects, no-content, image, audio and video responses 57 | if ( 58 | response.status !== 204 && 59 | response.headers.location == null && 60 | !response.mimeType.includes('image') && 61 | !response.mimeType.includes('audio') && 62 | !response.mimeType.includes('video') 63 | ) { 64 | const addResponseBodyPromise = this.client.send('Network.getResponseBody', { requestId }).then( 65 | responseBody => { 66 | // Set the response so chrome-har can add it to the HAR file 67 | harEvent.params.response = { 68 | ...response, 69 | body: Buffer.from(responseBody.body, responseBody.base64Encoded ? 'base64' : undefined).toString() 70 | }; 71 | }, 72 | reason => { } 73 | ); 74 | this.addResponseBodyPromises.push(addResponseBodyPromise); 75 | } 76 | } 77 | }); 78 | }); 79 | } 80 | 81 | async stop(path?: string) { 82 | await Promise.all(this.addResponseBodyPromises); 83 | const harObject = harFromMessages(this.events, { includeTextFromResponseBody: this.config.recordResponses !== false }); 84 | this.events = []; 85 | this.addResponseBodyPromises = []; 86 | if (path) { 87 | writeFileSync(path, JSON.stringify(harObject)); 88 | } 89 | else { 90 | return harObject 91 | } 92 | 93 | } 94 | } 95 | --------------------------------------------------------------------------------