├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .huskyrc.js ├── CHANGELOG.md ├── CODEOWNERS ├── LICENCE ├── README.md ├── jest.config.js ├── mockiavelli-logo.png ├── package-lock.json ├── package.json ├── prettier.config.js ├── release.config.js ├── src ├── controllers │ ├── BrowserController.ts │ ├── BrowserControllerFactory.ts │ ├── PlaywrightController.ts │ └── PuppeteerController.ts ├── index.ts ├── mock.ts ├── mockiavelli.ts ├── types.ts └── utils.ts ├── test ├── integration │ ├── fixture │ │ ├── index.html │ │ ├── page1.html │ │ ├── script.js │ │ └── style.css │ ├── mockiavelli.int.test.ts │ ├── test-contexts │ │ ├── playwrightCtx.ts │ │ ├── puppeteerCtx.ts │ │ └── testCtx.ts │ ├── test-helpers │ │ ├── global-setup.ts │ │ ├── global-teardown.ts │ │ ├── make-request.ts │ │ └── server.ts │ └── tsconfig.json └── unit │ ├── __snapshots__ │ └── utils.test.ts.snap │ ├── fixtures │ ├── PuppeteerRequest.ts │ ├── browserRequest.ts │ ├── page.ts │ └── request.ts │ ├── http-mock.test.ts │ ├── mockiavelli.test.ts │ ├── puppeteerController.test.ts │ └── utils.test.ts └── tsconfig.json /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | include: 20 | - library: 'puppeteer' 21 | version: '2' 22 | - library: 'puppeteer' 23 | version: '3' 24 | - library: 'puppeteer' 25 | version: '4' 26 | - library: 'puppeteer' 27 | version: '5' 28 | - library: 'puppeteer' 29 | version: '6' 30 | - library: 'puppeteer' 31 | version: '7' 32 | - library: 'puppeteer' 33 | version: '8' 34 | - library: 'puppeteer' 35 | version: '9' 36 | - library: 'puppeteer' 37 | version: '10' 38 | - library: 'playwright' 39 | version: '1.10' 40 | steps: 41 | - uses: actions/checkout@v2 42 | - name: Use Node.js 43 | uses: actions/setup-node@v1 44 | with: 45 | node-version: 14.x 46 | - name: Install 47 | run: | 48 | npm ci 49 | npm install ${{matrix.library}}@${{matrix.version}} 50 | - name: Build 51 | run: npm run build 52 | - name: Run tests 53 | env: 54 | TEST_LIBRARY: ${{matrix.library}} 55 | TEST_LIBRARY_VERSION: ${{matrix.version}} 56 | run: npm test 57 | 58 | release: 59 | name: Release 60 | needs: build 61 | if: github.ref == 'refs/heads/master' 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: Checkout 65 | uses: actions/checkout@v2 66 | with: 67 | fetch-depth: 0 68 | - name: Setup Node.js 69 | uses: actions/setup-node@v1 70 | with: 71 | node-version: 14.x 72 | - name: Install 73 | run: npm ci 74 | - name: Build 75 | run: npm run build 76 | - name: Release 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 80 | run: npx semantic-release 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | node_modules 4 | reports 5 | coverage 6 | *.log 7 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'pre-commit': 'pretty-quick --staged', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.0 (2020-05-05) 2 | 3 | 4 | ### Features 5 | 6 | * Add PATCH method QA-97 ([031f11a](https://github.com/HLTech/mockiavelli/commit/031f11a80fe654bf0723cf865eb30bc3b51a5a30)) 7 | * Allow to match requests by body QA-95 ([a429531](https://github.com/HLTech/mockiavelli/commit/a42953190e823f56b57f90cc67ca5abc17f8d3f9)) 8 | * FE-132 add mock.waitForRequestsCount() ([142c6b2](https://github.com/HLTech/mockiavelli/commit/142c6b2961ffe5d017fa1be296c0d8c080e6c02e)) 9 | * FE-88 initial release ([5593d92](https://github.com/HLTech/mockiavelli/commit/5593d92ec6280d715acbf25a017f42cdecaad2c6)) 10 | * QA-111 add "params" to request object passed to mocked response function ([0784782](https://github.com/HLTech/mockiavelli/commit/07847823d1cf6fe6285a0e13957cf9d82cec86e9)) 11 | * QA-126 Add compability with playwright ([9970eaf](https://github.com/HLTech/mockiavelli/commit/9970eafdd21f54794a1b4867388f2e591030fa0b)) 12 | * QA-126 Add playwright tests + upgrade playwright to 0.12 ([4dc1e8a](https://github.com/HLTech/mockiavelli/commit/4dc1e8a907e0116254087754ab26044affc23288)) 13 | * QA-14 Implemented matching most recently added mock first. ([9699ef0](https://github.com/HLTech/mockiavelli/commit/9699ef0ff2fede55bb5891b63fa1cde1ea87b12c)) 14 | * QA-15 Added http methods for GET,POST,PUT,DELETE with filter as object or string. Renamed addRestMock to mockREST. ([e4c0444](https://github.com/HLTech/mockiavelli/commit/e4c04449a7743f04348af7421c93e606d0c576d5)) 15 | * QA-16 Added path variables handling feature. ([90258be](https://github.com/HLTech/mockiavelli/commit/90258be16a390ae6f591ffc6eb06081475fadd0e)) 16 | * QA-17 Added once option. ([9be9418](https://github.com/HLTech/mockiavelli/commit/9be94180ec55d5828854d856410d12749c479b8f)) 17 | * QA-20 add support for CORS requests ([4e11645](https://github.com/HLTech/mockiavelli/commit/4e11645c7123d9977b79a73eefb101c85383ab16)) 18 | * QA-20 allow to specify responses as functions ([b3c3622](https://github.com/HLTech/mockiavelli/commit/b3c36221338bb5ba984f6b005277b5f1e6676be0)) 19 | * QA-20 fixes to CORS ([9986645](https://github.com/HLTech/mockiavelli/commit/9986645a0c25f56039c1300a8611d20f9843d68a)) 20 | * QA-20 fixes to CORS [#2](https://github.com/HLTech/mockiavelli/issues/2) ([62d40ab](https://github.com/HLTech/mockiavelli/commit/62d40ab3311fd323a4d602755f74643f17234749)) 21 | * QA-20 fixes to CORS [#3](https://github.com/HLTech/mockiavelli/issues/3) ([2ea7efb](https://github.com/HLTech/mockiavelli/commit/2ea7efb265b71c8148f90881b0a29d97c758d4e3)) 22 | * **Mocketeer:** QA-12 mocketeer.getRequest throws error when matching request was not found ([6d37898](https://github.com/HLTech/mockiavelli/commit/6d37898376be47f4e557e3d76f1bada8e8cba8cd)) 23 | * **Mocketeer:** QA-13 add Mocketeer.setup static method, depracate mocketeer.activate ([a02881a](https://github.com/HLTech/mockiavelli/commit/a02881adc4b0b423e110e8de8a67976cc473e79e)) 24 | * **Mocketeer:** QA-20 allow to intercept requests of any type ([4d0a644](https://github.com/HLTech/mockiavelli/commit/4d0a6445a12997b349485457cb5affee976d6b77)) 25 | 26 | 27 | * feat!: QA-96 mocketeer.mock requires HTTP Method as parameter ([b247ea5](https://github.com/HLTech/mockiavelli/commit/b247ea554301c408ae1ed556083825c244faf9b9)) 28 | * chore!: Rename mock.getRequest to mock.waitForRequest QA-94 ([a0af628](https://github.com/HLTech/mockiavelli/commit/a0af628b29ada9f65b318898edfc741b67d07627)) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * FE-88 improve documentation and debug info ([80128a3](https://github.com/HLTech/mockiavelli/commit/80128a3bc4a24fcaba547bc065b425be8f2c0a10)) 34 | * FE-88 minor improvements to debug mode ([5708c58](https://github.com/HLTech/mockiavelli/commit/5708c58cd74a01ab6933d8e1054e288a0244141d)) 35 | * QA-100 stalled request when mocked body is empty and using ppter 2.0+ ([a823a61](https://github.com/HLTech/mockiavelli/commit/a823a619248566610cafee9e821dcd582ad8c3a2)) 36 | * QA-93 Upgrade path-to-regex ([0c0ffd1](https://github.com/HLTech/mockiavelli/commit/0c0ffd1fd2d3417f08b7b40ed9becb20f7bc58cf)) 37 | 38 | 39 | ### BREAKING CHANGES 40 | 41 | * - mocketeer.mock no longer defaults to GET method, but requires to be provided with HTTP method explicitly 42 | - rename RequestMatcherObject to RequestFilter 43 | * For consistency with puppeteer and playwright methods .waitForX rename mock.getRequest => mock.waitForRequest 44 | * **Mocketeer:** remove deprecated API methods 45 | * **Mocketeer:** rename types 46 | * **Mocketeer:** can mock any requests, not just FETCH/XHR calls 47 | * Changed order for adding mocks - newest first. 48 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @HLTech/frontend 2 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2020 HL Tech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Mockiavelli 3 |

4 |

5 | Request mocking for Puppeteer and Playwright 6 |

7 | 8 | [![npm](https://img.shields.io/npm/v/mockiavelli)](https://www.npmjs.com/package/mockiavelli) [![Node.js CI](https://github.com/HLTech/mockiavelli/actions/workflows/node.js.yml/badge.svg)](https://github.com/HLTech/mockiavelli/actions/workflows/node.js.yml) 9 | 10 | Mockiavelli is HTTP request mocking library for [Puppeteer](http://pptr.dev/) and [Playwright](https://playwright.dev/). It was created to enable effective testing of Single Page Apps in isolation and independently from API services. 11 | 12 | Main features 13 | 14 | - simple, minimal API 15 | - mock network requests directly in the test case 16 | - inspect and assert requests payload 17 | - match request by method, url, path parameters and query strings 18 | - support for cross-origin requests 19 | - works with every testing framework running in node.js 20 | - fully typed in Typescript and well tested 21 | - lightweight - only 4 total dependencies (direct and indirect) 22 | 23 | ## Docs 24 | 25 | - [Installation](#installation) 26 | - [Getting started](#getting-started) 27 | - [Full example](#full-example) 28 | - [Usage guide](#guide) 29 | - [URL and method matching](#url-and-method-matching) 30 | - [Path parameters matching](#path-parameters-matching) 31 | - [Query params matching](#query-parameters-matching) 32 | - [Request assertion](#request-assertion) 33 | - [One-time mocks](#one-time-mocks) 34 | - [Matching order](#matching-order) 35 | - [Matching priority](#matching-priority) 36 | - [Specifying API base url](#base-url) 37 | - [Cross-origin (cross-domain) API requests](#cors) 38 | - [Stop mocking](#stop-mocking) 39 | - [Dynamic responses](#dynamic-responses) 40 | - [Not matched requests](#not-matched-requests) 41 | - [Debug mode](#debug-mode) 42 | - [API](#api) 43 | 44 | - [`Mockiavelli`](#Mockiavelli) 45 | - [`Mock`](#Mock) 46 | 47 | ## Installation 48 | 49 | ```bash 50 | npm install mockiavelli -D 51 | ``` 52 | 53 | or if you are using yarn: 54 | 55 | ```bash 56 | yarn add mockiavelli -D 57 | ``` 58 | 59 | - Mockiavelli requires one of the following to be installed separately: 60 | - [Puppeteer](https://pptr.dev/) (in versions 2.x - 8.x) 61 | - [Playwright](https://playwright.dev/) (in version 1.x) 62 | - If you're using [jest](jestjs.io/) we also recommend to install [jest-puppeteer](https://github.com/smooth-code/jest-puppeteer) or [jest-playwright](https://www.npmjs.com/package/jest-playwright-preset) 63 | 64 | ## Getting started 65 | 66 | To start using Mockiavelli, you need to instantiate it by providing it a `page` - instance of [Puppeteer Page](https://pptr.dev/#?product=Puppeteer&show=api-class-page) or [Playwright Page](https://playwright.dev/docs/api/class-page) 67 | 68 | ```typescript 69 | import { Mockiavelli } from 'mockiavelli'; 70 | import puppeteer from 'puppeteer'; 71 | 72 | const browser = await puppeteer.launch(); 73 | const page = await browser.newPage(); 74 | const mockiavelli = await Mockiavelli.setup(page); 75 | ``` 76 | 77 | Mockiavelli will start to intercept all HTTP requests issued from this page. 78 | 79 | To define response for a given request, call `mockiavelli.mock` with request URL and response object: 80 | 81 | ```typescript 82 | const getUsersMock = mockiavelli.mockGET('/api/users', { 83 | status: 200, 84 | body: [ 85 | { id: 123, name: 'John Doe' }, 86 | { id: 456, name: 'Mary Jane' }, 87 | ], 88 | }); 89 | ``` 90 | 91 | Now every `GET /api/users` request issued from this page will receive `200 OK` response with provided body. 92 | 93 | ```typescript 94 | const users = await page.evaluate(() => { 95 | return fetch('/api/users').then((res) => res.json()); 96 | }); 97 | console.log(users); // [{id: 123, name: 'John Doe' }, {id: 456, name: 'Mary Jane'}] 98 | ``` 99 | 100 | ## Full example 101 | 102 | The example below is a [Jest](https://jestjs.io/en) test case (with [jest-puppeteer preset](https://github.com/smooth-code/jest-puppeteer)) verifies a sign-up form in a locally running application. 103 | 104 | Mockiavelli is used to mock and assert request that the app makes to REST API upon form submission. 105 | 106 | ```typescript 107 | import { Mockiavelli } from 'mockiavelli'; 108 | 109 | test('Sign-up form', async () => { 110 | // Enable mocking on instance of puppeteer Page (provided by jest-puppeteer) 111 | const mockiavelli = await Mockiavelli.setup(page); 112 | 113 | // Navigate to application 114 | await page.goto('http://localhost:8000/'); 115 | 116 | // Configure mocked response 117 | const postUserMock = mockiavelli.mockPOST('/api/user', { 118 | status: 201, 119 | body: { 120 | userId: '123', 121 | }, 122 | }); 123 | 124 | // Perform interaction 125 | await page.type('input.name', 'John Doe'); 126 | await page.type('input.email', 'email@example.com'); 127 | await page.click('button.submit'); 128 | 129 | // Verify request payload 130 | const postUserRequest = await postUserMock.waitForRequest(); 131 | expect(postUserRequest.body).toEqual({ 132 | user_name: 'John Doe', 133 | user_email: 'email@example.com', 134 | }); 135 | 136 | // Verify message shown on the screen 137 | await expect(page).toMatch('Created account ID: 123'); 138 | }); 139 | ``` 140 | 141 | ## Usage guide 142 | 143 | ### URL and method matching 144 | 145 | Request can be matched by: 146 | 147 | - providing URL string to `mockiavelli.mock` method: 148 | 149 | ```typescript 150 | mockiavelli.mockGET('/api/users?age=30', {status: 200, body: [....]}) 151 | ``` 152 | 153 | - providing matcher object to `mockiavelli.mock` method 154 | 155 | ```typescript 156 | mockiavelli.mockGET({ 157 | url: '/api/users', 158 | query: { age: '30' } 159 | }, {status: 200, body: [....]}) 160 | ``` 161 | 162 | - providing full matcher object `mockiavelli.mock` method 163 | 164 | ```typescript 165 | mockiavelli.mock({ 166 | method: 'GET', 167 | url: '/api/users', 168 | query: { age: '30' } 169 | }, {status: 200, body: [...]}) 170 | ``` 171 | 172 | ### Path parameters matching 173 | 174 | Path parameters in the URL can be matched using `:param` notation, thanks to [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) library. 175 | 176 | If mock matches the request, those params are exposed in `request.params` property. 177 | 178 | ```typescript 179 | const getUserMock = mockiavelli.mockGET('/api/users/:userId', { status: 200 }); 180 | 181 | // GET /api/users/1234 => 200 182 | // GET /api/users => 404 183 | // GET /api/users/1234/categories => 404 184 | 185 | console.log(await getUserMock.waitForRequest()); 186 | // { params: {userId : "12345"}, path: "/api/users/12345", ... } 187 | ``` 188 | 189 | Mockiavelli uses 190 | 191 | ### Query params matching 192 | 193 | Mockiavelli supports matching requests by query parameters. All defined params are then required to match the request, but excess params are ignored: 194 | 195 | ```typescript 196 | mockiavelli.mockGET('/api/users?city=Warsaw&sort=asc', { status: 200 }); 197 | 198 | // GET /api/users?city=Warsaw&sort=asc => 200 199 | // GET /api/users?city=Warsaw&sort=asc&limit=10 => 200 200 | // GET /api/users?city=Warsaw => 404 201 | ``` 202 | 203 | It is also possible to define query parameters as object. This notation works great for matching array query params: 204 | 205 | ```typescript 206 | mockiavelli.mockGET( 207 | { url: '/api/users', query: { status: ['active', 'blocked'] } }, 208 | { status: 200 } 209 | ); 210 | 211 | // GET /api/users?status=active&status=blocked => 200 212 | ``` 213 | 214 | ### Request assertion 215 | 216 | `mockiavelli.mock` and `mockiavelli.mock` methods return an instance of `Mock` class that records all requests the matched given mock. 217 | 218 | To assert details of request made by application use async `mock.waitForRequest()` method. It will throw an error if no matching request was made. 219 | 220 | ```typescript 221 | const postUsersMock = mockiavelli.mockPOST('/api/users', { status: 200 }); 222 | 223 | // ... perform interaction on tested page ... 224 | 225 | const postUserRequest = await postUsersMock.waitForRequest(); // Throws if POST /api/users request was not made 226 | expect(postUserRequest.body).toBe({ 227 | name: 'John', 228 | email: 'john@example.com', 229 | }); 230 | ``` 231 | 232 | ### One-time mocks 233 | 234 | By default mock are persistent, meaning that they will respond to multiple matching requests: 235 | 236 | ```typescript 237 | mockiavelli.mockGET('/api/users', { status: 200 }); 238 | 239 | // GET /api/users => 200 240 | // GET /api/users => 200 241 | ``` 242 | 243 | To change this behaviour and disable mock once it matched a request use `once` option: 244 | 245 | ```typescript 246 | mockiavelli.mockGET('/api/users', { status: 200 }, { once: true }); 247 | 248 | // GET /api/users => 200 249 | // GET /api/users => 404 250 | ``` 251 | 252 | ### Matching order 253 | 254 | Mocks are matched in the "newest first" order. To override previously defined mock simply define new one: 255 | 256 | ```typescript 257 | mockiavelli.mockGET('/api/users', { status: 200 }); 258 | mockiavelli.mockGET('/api/users', { status: 401 }); 259 | 260 | // GET /api/users => 401 261 | 262 | mockiavelli.mockGET('/api/users', { status: 500 }); 263 | 264 | // GET /api/users => 500 265 | ``` 266 | 267 | ### Matching priority 268 | 269 | To change the default "newest first" matching order, you define mocks with combination of `once` and `priority` parameters: 270 | 271 | ```typescript 272 | mockiavelli.mockGET( 273 | '/api/users', 274 | { status: 404 }, 275 | { once: true, priority: 10 } 276 | ); 277 | mockiavelli.mockGET('/api/users', { status: 500 }, { once: true, priority: 5 }); 278 | mockiavelli.mockGET('/api/users', { status: 200 }); 279 | 280 | // GET /api/users => 404 281 | // GET /api/users => 500 282 | // GET /api/users => 200 283 | ``` 284 | 285 | ### Specifying API base url 286 | 287 | It is possible to initialize Mockiavelli instance with specified API base url. 288 | This API base url is added to every mocked request url. 289 | 290 | ```typescript 291 | const mockiavelli = await Mockiavelli.setup(page, { baseUrl: '/api/v1' }); 292 | 293 | mockiavelli.mockGET('/users', { status: 200 }); 294 | 295 | // GET /api/v1/users => 200 296 | ``` 297 | 298 | ### Cross-origin (cross-domain) API requests 299 | 300 | Mockiavelli has built-in support for cross-origin requests. If application and API are not on the same origin (domain) just provide the full request URL to `mockiavelli.mock` 301 | 302 | ```typescript 303 | mockiavelli.mockGET('http://api.example.com/api/users', { status: 200 }); 304 | 305 | // GET http://api.example.com/api/users => 200 306 | // GET http://another-domain.example.com/api/users => 404 307 | ``` 308 | 309 | ### Stop mocking 310 | 311 | To stop intercept requests you can call `mockiavelli.disable` method (all requests will send to real services). 312 | Then you can enable mocking again by `mockiavelli.enable` method. 313 | 314 | ```typescript 315 | mockiavelli.mockGET('/api/users/:userId', { 316 | status: 200, 317 | body: { name: 'John Doe' }, 318 | }); 319 | 320 | // GET /api/users/1234 => 200 { name: 'John Doe' } 321 | 322 | mockiavelli.disable(); 323 | 324 | // GET /api/users/1234 => 200 { name: 'Jacob Kowalski' } <- real data from backend 325 | 326 | mockiavelli.enable(); 327 | 328 | // GET /api/users/1234 => 200 { name: 'John Doe' } 329 | ``` 330 | 331 | ### Dynamic responses 332 | 333 | It is possible to define mocked response in function of incoming request. This is useful if you need to use some information from request URL or body in the response: 334 | 335 | ```typescript 336 | mockiavelli.mockGET('/api/users/:userId', (request) => { 337 | return { 338 | status: 200, 339 | body: { 340 | id: request.params.userId, 341 | name: 'John', 342 | email: 'john@example.com', 343 | ... 344 | }, 345 | }; 346 | }); 347 | 348 | // GET /api/users/123 => 200 {"id": "123", ... } 349 | ``` 350 | 351 | ### Not matched requests 352 | 353 | In usual scenarios, you should mock all requests done by your app. 354 | 355 | Any XHR or fetched request done by the page not matched by any mock will be responded with `404 Not Found`. Mockiavelli will also log this event to console: 356 | 357 | ```typescript 358 | Mock not found for request: type=fetch method=GET url=http://example.com 359 | ``` 360 | 361 | ### Debug mode 362 | 363 | Passing `{debug: true}` to `Mockiavelli.setup` enables rich debugging in console: 364 | 365 | ```typescript 366 | await Mockiavelli.setup(page, { debug: true }); 367 | ``` 368 | 369 | ## API 370 | 371 | ### `class Mockiavelli` 372 | 373 | #### `Mockiavelli.setup(page, options): Promise` 374 | 375 | Factory method used to set-up request mocking on provided Puppeteer or Playwright Page. It creates and returns an instance of [Mockiavelli](#Mockiavelli) 376 | 377 | Once created, mockiavelli will intercept all requests made by the page and match them with defined mocks. 378 | 379 | If request does not match any mocks, it will be responded with `404 Not Found`. 380 | 381 | ###### Arguments 382 | 383 | - `page` _(Page)_ instance of [Puppeteer Page](https://pptr.dev/#?product=Puppeteer&show=api-class-page) or [Playwright Page](https://playwright.dev/docs/api/class-page) 384 | - `options` _(object)_ configuration options 385 | - `baseUrl: string` specify the API base url, which will be added to every mocked request url 386 | - `debug: boolean` turns debug mode with logging to console (default: `false`) 387 | 388 | ###### Example 389 | 390 | ```typescript 391 | import { puppeteer } from 'puppeteer'; 392 | import { Mockiavelli } from 'mockiavelli'; 393 | 394 | const browser = await puppeteer.launch(); 395 | const page = await browser.newPage(); 396 | const mockiavelli = await Mockiavelli.setup(page); 397 | ``` 398 | 399 | ###### Returns 400 | 401 | Promise resolved with instance of `Mockiavelli` once request mocking is established. 402 | 403 | #### `mockiavelli.mock(matcher, response, options?)` 404 | 405 | Respond all requests of matching `matcher` with provided `response`. 406 | 407 | ###### Arguments 408 | 409 | - `matcher` _(object)_ matches request with mock. 410 | - `method: string` - any valid HTTP method 411 | - `url: string` - can be provided as path (`/api/endpoint`) or full URL (`http://example.com/endpoint`) for CORS requests. Supports path parameters (`/api/users/:user_id`) 412 | - `query?: object` object literal which accepts strings and arrays of strings as values, transformed to queryString 413 | - `response` _(object | function)_ content of mocked response. Can be a object or a function returning object with following properties: 414 | - `status: number` 415 | - `headers?: object` 416 | - `body?: any` 417 | - `options?` _(object)_ optional config object 418 | - `prority` _(number)_ when intercepted request matches multiple mock, mockiavelli will use the one with highest priority 419 | - `once` _(boolean)_ _(default: false)_ when set to true intercepted request will be matched only once 420 | 421 | ###### Returns 422 | 423 | Instance of [`Mock`](#Mock). 424 | 425 | ###### Example 426 | 427 | ```typescript 428 | mockiavelli.mock( 429 | { 430 | method: 'GET', 431 | url: '/api/clients', 432 | query: { 433 | city: 'Bristol', 434 | limit: 10, 435 | }, 436 | }, 437 | { 438 | status: 200, 439 | headers: {...}, 440 | body: [{...}], 441 | } 442 | ); 443 | ``` 444 | 445 | #### `mockiavelli.mock(matcher, response, options?)` 446 | 447 | Shorthand method for `mockiavelli.mock`. Matches all request with `HTTP_METHOD` method. In addition to matcher object, it also accepts URL string as first argument. 448 | 449 | - `matcher: (string | object)` URL string or object with following properties: 450 | - `url: string` - can be provided as path (`/api/endpoint`) or full URL (`http://example.com/endpoint`) for CORS requests. Supports path parameters (`/api/users/:user_id`) 451 | - `query?: object` object literal which accepts strings and arrays of strings as values, transformed to queryString 452 | - `response: (object | function)` content of mocked response. Can be a object or a function returning object with following properties: 453 | - `status: number` 454 | - `headers?: object` 455 | - `body?: any` 456 | - `options?: object` optional config object 457 | - `prority?: number` when intercepted request matches multiple mock, mockiavelli will use the one with highest priority. Default: `0` 458 | - `once: boolean` when set to true intercepted request will be matched only once. Default: `false` 459 | 460 | Available methods are: 461 | 462 | - `mockiavelli.mockGET` 463 | - `mockiavelli.mockPOST` 464 | - `mockiavelli.mockDELETE` 465 | - `mockiavelli.mockPUT` 466 | - `mockiavelli.mockPATCH` 467 | 468 | ###### Examples 469 | 470 | ```typescript 471 | // Basic example 472 | mockiavelli.mockPOST('/api/clients', { 473 | status: 201, 474 | body: {...}, 475 | }); 476 | ``` 477 | 478 | ```typescript 479 | // Match by query parameters passed in URL 480 | mockiavelli.mockGET('/api/clients?city=Bristol&limit=10', { 481 | status: 200, 482 | body: [{...}], 483 | }); 484 | ``` 485 | 486 | ```typescript 487 | // Match by path params 488 | mockiavelli.mockGET('/api/clients/:clientId', { 489 | status: 200, 490 | body: [{...}], 491 | }); 492 | ``` 493 | 494 | ```typescript 495 | // CORS requests 496 | mockiavelli.mockGET('http://example.com/api/clients/', { 497 | status: 200, 498 | body: [{...}], 499 | }); 500 | ``` 501 | 502 | #### `mockiavelli.disable()` 503 | 504 | Stop mocking of requests by Mockiavelli. 505 | After that all requests pass to real endpoints. 506 | This method does not reset set mocks or possibility to set mocks, so when we then enable again mocking by `mockiavelli.enable()`, all set mocks works again. 507 | 508 | #### `mockiavelli.enable()` 509 | 510 | To enable mocking of requests by Mockiavelli when previously `mockiavelli.diable()` was called. 511 | 512 | --- 513 | 514 | ### `class Mock` 515 | 516 | #### `waitForRequest(index?: number): Promise` 517 | 518 | Retrieve n-th request matched by the mock. The method is async - it will wait 100ms for requests to be intercepted to avoid race condition issue. Throws if mock was not matched by any request. 519 | 520 | ###### Arguments 521 | 522 | - `index` _(number)_ index of request to return. Default: 0. 523 | 524 | ###### Returns 525 | 526 | Promise resolved with `MatchedRequest` - object representing request that matched the mock: 527 | 528 | - `method: string` - request's method (GET, POST, etc.) 529 | - `url: string` - request's full URL. Example: `http://example.com/api/clients?name=foo` 530 | - `hostname: string` - request protocol and host. Example: `http://example.com` 531 | - `headers: object` - object with HTTP headers associated with the request. All header names are lower-case. 532 | - `path: string` - request's url path, without query string. Example: `'/api/clients'` 533 | - `query: object` - request's query object, as returned from [`querystring.parse`](https://nodejs.org/docs/latest/api/querystring.html#querystring_querystring_parse_str_sep_eq_options). Example: `{name: 'foo'}` 534 | - `body: any` - JSON deserialized request's post body, if any 535 | - `type: string` - request's resource type. Possible values are `xhr` and `fetch` 536 | - `params: object` - object with path parameters specified in `url` 537 | 538 | ###### Example 539 | 540 | ```typescript 541 | const patchClientMock = mockiavelli.mockPATCH('/api/client/:clientId', { status: 200 }); 542 | 543 | // .. send request from page ... 544 | 545 | const patchClientRequest = await patchClientMock.waitForRequest(); 546 | 547 | expect(patchClientRequest).toEqual({ 548 | method: 'PATCH', 549 | url: 'http://example.com/api/client/1020', 550 | hostname: 'http://example.com', 551 | headers: {...}, 552 | path: '/api/client/1020', 553 | query: {}, 554 | body: {name: 'John', email: 'john@example.com'} 555 | rawBody: '{\"name\":\"John\",\"email\":\"john@example.com\"}', 556 | type: 'fetch', 557 | params: { clientId: '' } 558 | }) 559 | ``` 560 | 561 | #### `waitForRequestCount(n: number): Promise` 562 | 563 | Waits until mock is matched my `n` requests. Throws error when timeout (equal to 100ms) is exceeded. 564 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: ['./src/**/*.ts'], 3 | 4 | // Configure jest-junit reporter on CI. Otherwise use only default reporter 5 | reporters: [ 6 | 'default', 7 | ...(Boolean(process.env.CI) 8 | ? [ 9 | [ 10 | 'jest-junit', 11 | { 12 | outputDirectory: './reports', 13 | outputName: 'junit.xml', 14 | classNameTemplate: '{classname}', 15 | titleTemplate: '{title}', 16 | ancestorSeparator: ' ', 17 | suiteNameTemplate: '{filename}', 18 | }, 19 | ], 20 | ] 21 | : []), 22 | ], 23 | 24 | projects: [ 25 | { 26 | displayName: 'unit', 27 | preset: 'ts-jest', 28 | testEnvironment: 'node', 29 | roots: ['test/unit'], 30 | clearMocks: true, 31 | }, 32 | { 33 | displayName: 'int', 34 | preset: 'ts-jest', 35 | testEnvironment: 'node', 36 | roots: ['test/integration'], 37 | globalSetup: './test/integration/test-helpers/global-setup.ts', 38 | globalTeardown: 39 | './test/integration/test-helpers/global-teardown.ts', 40 | globals: { 41 | 'ts-jest': { 42 | tsConfig: 'test/integration/tsconfig.json', 43 | }, 44 | }, 45 | restoreMocks: true, 46 | }, 47 | ], 48 | }; 49 | -------------------------------------------------------------------------------- /mockiavelli-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HLTech/mockiavelli/88560959e81314c2503471e02584aa1f0a39df15/mockiavelli-logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mockiavelli", 3 | "version": "1.0.0", 4 | "description": "HTTP request mocking library for Puppeteer and Playwright", 5 | "keywords": [ 6 | "mock", 7 | "puppeteer", 8 | "playwright", 9 | "mocking", 10 | "http" 11 | ], 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "license": "MIT", 15 | "scripts": { 16 | "build": "tsc -p tsconfig.json", 17 | "test": "jest", 18 | "release": "semantic-release" 19 | }, 20 | "dependencies": { 21 | "debug": "^4.3.7", 22 | "lodash.isequal": "^4.5.0", 23 | "path-to-regexp": "^8.1.0" 24 | }, 25 | "devDependencies": { 26 | "@semantic-release/changelog": "^5.0.1", 27 | "@semantic-release/git": "^9.0.1", 28 | "@types/debug": "^4.1.12", 29 | "@types/jest": "^24.9.1", 30 | "@types/lodash.isequal": "^4.5.8", 31 | "factory.ts": "^0.5.2", 32 | "husky": "^1.3.1", 33 | "jest": "^24.9.0", 34 | "jest-junit": "^6.4.0", 35 | "playwright": "^1.47.0", 36 | "prettier": "^2.8.8", 37 | "pretty-quick": "^1.11.1", 38 | "puppeteer": "^10.0.0", 39 | "semantic-release": "^17.4.7", 40 | "ts-jest": "^24.3.0", 41 | "typescript": "^3.9.10" 42 | }, 43 | "files": [ 44 | "dist" 45 | ], 46 | "repository": { 47 | "type": "git", 48 | "url": "https://github.com/HLTech/mockiavelli.git" 49 | }, 50 | "homepage": "https://github.com/HLTech/mockiavelli", 51 | "bugs": { 52 | "url": "https://github.com/HLTech/mockiavelli/issues" 53 | }, 54 | "publishConfig": { 55 | "registry": "https://registry.npmjs.org/", 56 | "tag": "latest" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | tabWidth: 4, 4 | trailingComma: 'es5', 5 | }; 6 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | '@semantic-release/commit-analyzer', 4 | '@semantic-release/release-notes-generator', 5 | '@semantic-release/npm', 6 | '@semantic-release/github', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /src/controllers/BrowserController.ts: -------------------------------------------------------------------------------- 1 | import { QueryObject } from '../types'; 2 | 3 | /** 4 | * Interface used by Mockiavelli to communicate with browser automation libraries 5 | */ 6 | export interface BrowserController { 7 | startInterception(): Promise; 8 | stopInterception(): Promise; 9 | } 10 | 11 | /** 12 | * Callback function called whenever a request is intercepted in the browser 13 | */ 14 | export type BrowserRequestHandler = ( 15 | request: BrowserRequest, 16 | respond: (response: ResponseData) => Promise, 17 | skip: () => void 18 | ) => void; 19 | 20 | /** 21 | * Represents data of intercepted browser request 22 | */ 23 | export interface BrowserRequest { 24 | type: BrowserRequestType; 25 | method: string; 26 | url: string; 27 | headers: Record; 28 | body: any; 29 | path: string; 30 | hostname: string; 31 | query: QueryObject; 32 | sourceOrigin: string; 33 | } 34 | 35 | /** 36 | * Content of response 37 | */ 38 | export interface ResponseData { 39 | status: number; 40 | body?: Buffer | string; 41 | headers?: Record; 42 | } 43 | 44 | export type BrowserRequestType = 45 | | 'document' 46 | | 'stylesheet' 47 | | 'image' 48 | | 'media' 49 | | 'font' 50 | | 'script' 51 | | 'texttrack' 52 | | 'xhr' 53 | | 'fetch' 54 | | 'eventsource' 55 | | 'websocket' 56 | | 'manifest' 57 | | 'other'; 58 | -------------------------------------------------------------------------------- /src/controllers/BrowserControllerFactory.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightController, PlaywrightPage } from './PlaywrightController'; 2 | import { PuppeteerController, PuppeteerPage } from './PuppeteerController'; 3 | import { BrowserController, BrowserRequestHandler } from './BrowserController'; 4 | 5 | /** 6 | * Type of supported page objects 7 | */ 8 | export type BrowserPage = PlaywrightPage | PuppeteerPage; 9 | 10 | export class BrowserControllerFactory { 11 | /** 12 | * Returns instance of BrowserController corresponding to provided page 13 | */ 14 | public static createForPage( 15 | page: BrowserPage, 16 | onRequest: BrowserRequestHandler 17 | ): BrowserController { 18 | if (this.isPlaywrightPage(page)) { 19 | return new PlaywrightController(page, onRequest); 20 | } else if (this.isPuppeteerPage(page)) { 21 | return new PuppeteerController(page, onRequest); 22 | } else { 23 | throw new Error( 24 | 'Expected instance of Puppeteer or Playwright Page. Got: ' + 25 | page 26 | ); 27 | } 28 | } 29 | 30 | private static isPlaywrightPage(page: any): page is PlaywrightPage { 31 | return typeof page['route'] === 'function'; 32 | } 33 | 34 | private static isPuppeteerPage(page: any): page is PuppeteerPage { 35 | return typeof page['setRequestInterception'] === 'function'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/controllers/PlaywrightController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserController, 3 | BrowserRequest, 4 | BrowserRequestHandler, 5 | BrowserRequestType, 6 | ResponseData, 7 | } from './BrowserController'; 8 | import { getOrigin, tryJsonParse } from '../utils'; 9 | import { parse } from 'url'; 10 | 11 | export class PlaywrightController implements BrowserController { 12 | constructor( 13 | private readonly page: PlaywrightPage, 14 | private readonly onRequest: BrowserRequestHandler 15 | ) {} 16 | 17 | public async startInterception() { 18 | await this.page.route('**/*', this.requestHandler); 19 | } 20 | 21 | public async stopInterception() { 22 | await this.page.unroute('**/*', this.requestHandler); 23 | } 24 | 25 | private requestHandler = (route: PlaywrightRoute) => { 26 | this.onRequest( 27 | this.toBrowserRequest(route.request()), 28 | (data) => this.respond(route, data), 29 | () => this.skip(route) 30 | ); 31 | }; 32 | 33 | private toBrowserRequest(request: PlaywrightRequest): BrowserRequest { 34 | // TODO find a better alternative for url.parse 35 | const { pathname, query, protocol, host } = parse(request.url(), true); 36 | 37 | return { 38 | type: request.resourceType() as BrowserRequestType, 39 | url: request.url(), 40 | body: tryJsonParse(request.postData()), 41 | method: request.method(), 42 | headers: (request.headers() as Record) || {}, 43 | path: pathname ?? '', 44 | hostname: `${protocol}//${host}`, 45 | query: query, 46 | sourceOrigin: this.getRequestOrigin(request), 47 | }; 48 | } 49 | 50 | private async respond(route: PlaywrightRoute, response: ResponseData) { 51 | await route.fulfill({ 52 | headers: response.headers || {}, 53 | status: response.status, 54 | body: response.body ? response.body : '', 55 | contentType: response.headers?.['content-type'], 56 | }); 57 | } 58 | 59 | private async skip(route: PlaywrightRoute) { 60 | await route.continue(); 61 | } 62 | 63 | /** 64 | * Obtain request origin url from originating frame url 65 | */ 66 | private getRequestOrigin(request: PlaywrightRequest): string { 67 | return getOrigin(request.frame().url()); 68 | } 69 | } 70 | 71 | /** 72 | * Mirror of playwright's Page interface 73 | */ 74 | export interface PlaywrightPage { 75 | route( 76 | url: string, 77 | handler: (route: PlaywrightRoute, request: PlaywrightRequest) => void 78 | ): Promise; 79 | unroute( 80 | url: string, 81 | handler: (route: PlaywrightRoute, request: PlaywrightRequest) => void 82 | ): Promise; 83 | } 84 | 85 | /** 86 | * Mirror of playwright's Route interface 87 | */ 88 | interface PlaywrightRoute { 89 | fulfill(response: PlaywrightRouteFulfillResponse): Promise; 90 | request(): PlaywrightRequest; 91 | continue(): Promise; 92 | } 93 | 94 | /** 95 | * Mirror of playwright's Response interface 96 | */ 97 | interface PlaywrightRouteFulfillResponse { 98 | status?: number; 99 | headers?: { [key: string]: string }; 100 | contentType?: string; 101 | body?: string | Buffer; 102 | path?: string; 103 | } 104 | 105 | /** 106 | * Mirror of playwright's Request interface 107 | */ 108 | interface PlaywrightRequest { 109 | frame(): { 110 | url(): string; 111 | }; 112 | headers(): { [key: string]: string }; 113 | method(): string; 114 | postData(): null | string; 115 | resourceType(): string; 116 | url(): string; 117 | } 118 | -------------------------------------------------------------------------------- /src/controllers/PuppeteerController.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'url'; 2 | import { 3 | BrowserController, 4 | BrowserRequest, 5 | BrowserRequestHandler, 6 | } from './BrowserController'; 7 | import { getOrigin, tryJsonParse } from '../utils'; 8 | 9 | export class PuppeteerController implements BrowserController { 10 | constructor( 11 | private readonly page: PuppeteerPage, 12 | private readonly onRequest: BrowserRequestHandler 13 | ) {} 14 | 15 | public async startInterception() { 16 | await this.page.setRequestInterception(true); 17 | await this.page.on('request', this.requestHandler); 18 | } 19 | 20 | public async stopInterception() { 21 | await this.page.setRequestInterception(false); 22 | await this.page.off('request', this.requestHandler); 23 | } 24 | 25 | private requestHandler = (request: PuppeteerRequest) => { 26 | this.onRequest( 27 | this.toBrowserRequest(request), 28 | (response) => request.respond(response), 29 | () => request.continue() 30 | ); 31 | }; 32 | 33 | private toBrowserRequest(request: PuppeteerRequest): BrowserRequest { 34 | // TODO find a better alternative for url.parse 35 | const { pathname, query, protocol, host } = parse(request.url(), true); 36 | 37 | return { 38 | type: request.resourceType(), 39 | url: request.url(), 40 | body: tryJsonParse(request.postData()), 41 | method: request.method(), 42 | headers: request.headers() || {}, 43 | path: pathname ?? '', 44 | hostname: `${protocol}//${host}`, 45 | query: query, 46 | sourceOrigin: this.getRequestOrigin(request), 47 | }; 48 | } 49 | 50 | /** 51 | * Obtain request origin url from originating frame url 52 | */ 53 | private getRequestOrigin(request: PuppeteerRequest) { 54 | return getOrigin(request.frame()?.url()); 55 | } 56 | } 57 | 58 | /** 59 | * Mirror of puppeteer Page interface 60 | */ 61 | export interface PuppeteerPage { 62 | setRequestInterception(enabled: boolean): Promise; 63 | on(eventName: 'request', handler: (e: PuppeteerRequest) => void): any; 64 | off(eventName: 'request', handler: (e: PuppeteerRequest) => void): any; 65 | } 66 | 67 | /** 68 | * Mirror of puppeteer Request interface 69 | */ 70 | export interface PuppeteerRequest { 71 | continue(): Promise; 72 | frame(): { 73 | url(): string; 74 | } | null; 75 | headers(): Record; 76 | method(): string; 77 | postData(): string | undefined; 78 | resourceType(): 79 | | 'document' 80 | | 'stylesheet' 81 | | 'image' 82 | | 'media' 83 | | 'font' 84 | | 'script' 85 | | 'texttrack' 86 | | 'xhr' 87 | | 'fetch' 88 | | 'eventsource' 89 | | 'websocket' 90 | | 'manifest' 91 | | 'other'; 92 | respond(response: PuppeteerRespondOptions): Promise; 93 | url(): string; 94 | } 95 | 96 | /** 97 | * Mirror of puppeteer Response interface 98 | */ 99 | interface PuppeteerRespondOptions { 100 | status?: number; 101 | headers?: Record; 102 | contentType?: string; 103 | body?: Buffer | string; 104 | } 105 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mockiavelli'; 2 | export * from './mock'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /src/mock.ts: -------------------------------------------------------------------------------- 1 | import dbg from 'debug'; 2 | import { 3 | MatchedRequest, 4 | MockedResponse, 5 | MockOptions, 6 | QueryObject, 7 | MockedResponseObject, 8 | RequestMatcher, 9 | PathParameters, 10 | } from './types'; 11 | import { waitFor, TimeoutError, nth } from './utils'; 12 | import isEqual from 'lodash.isequal'; 13 | import { parse } from 'url'; 14 | import { stringify } from 'querystring'; 15 | import { match, MatchFunction } from 'path-to-regexp'; 16 | import { BrowserRequest } from './controllers/BrowserController'; 17 | 18 | const debug = dbg('mockiavelli:mock'); 19 | 20 | const GET_REQUEST_TIMEOUT = 100; 21 | 22 | let debugId = 1; 23 | 24 | export class Mock { 25 | private matcher: { 26 | method: string; 27 | hostname: string | undefined; 28 | path: string; 29 | query: QueryObject; 30 | pathMatch: MatchFunction; 31 | body: any; 32 | }; 33 | private response: MockedResponse; 34 | private requests: Array = []; 35 | private debugId = debugId++; 36 | public options: MockOptions = { 37 | priority: 0, 38 | once: false, 39 | }; 40 | 41 | constructor( 42 | matcher: RequestMatcher, 43 | response: MockedResponse, 44 | options: Partial = {} 45 | ) { 46 | this.matcher = this.parseMatcher(matcher); 47 | this.response = response; 48 | this.options = { ...this.options, ...options }; 49 | this.debug( 50 | '+', 51 | `created mock: method=${matcher.method} url=${matcher.url}` 52 | ); 53 | } 54 | 55 | private debug(symbol: string, message: string): void { 56 | debug(`${symbol} (${this.debugId}) ${message} `); 57 | } 58 | 59 | private debugMiss( 60 | reason: string, 61 | requestValue: string, 62 | matcherValue: string 63 | ) { 64 | this.debug( 65 | `·`, 66 | `${reason} not matched: mock=${matcherValue} req=${requestValue} ` 67 | ); 68 | } 69 | 70 | public async waitForRequest(index: number = 0): Promise { 71 | try { 72 | await waitFor( 73 | () => Boolean(this.requests[index]), 74 | GET_REQUEST_TIMEOUT 75 | ); 76 | } catch (e) { 77 | if (e instanceof TimeoutError) { 78 | if (this.requests.length === 0 && index === 0) { 79 | throw new Error( 80 | `No request matching mock [${this.prettyPrint()}] found` 81 | ); 82 | } else { 83 | throw new Error( 84 | `${nth( 85 | index + 1 86 | )} request matching mock [${this.prettyPrint()}] was not found` 87 | ); 88 | } 89 | } 90 | throw e; 91 | } 92 | return this.requests[index]; 93 | } 94 | 95 | public async waitForRequestsCount(count: number): Promise { 96 | try { 97 | await waitFor( 98 | () => this.requests.length === count, 99 | GET_REQUEST_TIMEOUT 100 | ); 101 | } catch (e) { 102 | if (e instanceof TimeoutError) { 103 | throw new Error( 104 | `Expected ${count} requests to match mock [${this.prettyPrint()}]. Instead ${ 105 | this.requests.length 106 | } request were matched.` 107 | ); 108 | } 109 | throw e; 110 | } 111 | } 112 | 113 | public getResponseForRequest( 114 | request: BrowserRequest 115 | ): MockedResponseObject | null { 116 | if (this.options.once && this.requests.length > 0) { 117 | this.debug('once', 'Request already matched'); 118 | return null; 119 | } 120 | 121 | const matchedRequest = this.getRequestMatch(request); 122 | 123 | if (matchedRequest === null) { 124 | return null; 125 | } 126 | 127 | this.requests.push(matchedRequest); 128 | 129 | const response = 130 | typeof this.response === 'function' 131 | ? this.response(matchedRequest) 132 | : this.response; 133 | 134 | return response; 135 | } 136 | 137 | private getRequestMatch(request: BrowserRequest): MatchedRequest | null { 138 | if (request.method !== this.matcher.method) { 139 | this.debugMiss('method', request.method, this.matcher.method); 140 | return null; 141 | } 142 | 143 | const matcherOrigin = this.matcher.hostname || request.sourceOrigin; 144 | 145 | if (matcherOrigin !== request.hostname) { 146 | this.debugMiss( 147 | 'url', 148 | request.hostname || `Request origin missing`, 149 | matcherOrigin || `Matcher origin missing` 150 | ); 151 | return null; 152 | } 153 | 154 | const pathMatch = this.matcher.pathMatch(request.path); 155 | 156 | if (!pathMatch) { 157 | this.debugMiss( 158 | 'url', 159 | request.path || `Request path missing`, 160 | this.matcher.path || `Matcher path missing` 161 | ); 162 | return null; 163 | } 164 | 165 | if (!this.requestParamsMatch(request.query, this.matcher.query)) { 166 | this.debugMiss( 167 | 'query', 168 | JSON.stringify(request.query), 169 | JSON.stringify(this.matcher.query) 170 | ); 171 | return null; 172 | } 173 | 174 | if (this.matcher.body && !isEqual(request.body, this.matcher.body)) { 175 | this.debugMiss( 176 | 'body', 177 | JSON.stringify(request.body), 178 | JSON.stringify(this.matcher.body) 179 | ); 180 | return null; 181 | } 182 | 183 | this.debug('=', `matched mock`); 184 | 185 | return { 186 | url: request.url, 187 | path: request.path, 188 | query: request.query, 189 | method: request.method, 190 | body: request.body, 191 | headers: request.headers, 192 | params: pathMatch.params, 193 | }; 194 | } 195 | 196 | private parseMatcher(matcher: RequestMatcher) { 197 | // TODO find a better alternative for url.parse 198 | const { protocol, host, pathname, query } = parse(matcher.url, true); 199 | const hasHostname = protocol && host; 200 | 201 | return { 202 | method: matcher.method, 203 | hostname: hasHostname ? `${protocol}//${host}` : undefined, 204 | query: matcher.query ? matcher.query : query, 205 | path: pathname ?? '', 206 | pathMatch: match(pathname ?? ''), 207 | body: matcher.body, 208 | }; 209 | } 210 | 211 | private requestParamsMatch( 212 | requestQuery: QueryObject, 213 | filterQuery: QueryObject 214 | ): boolean { 215 | return Object.keys(filterQuery).every((key: string) => { 216 | return ( 217 | key in requestQuery && 218 | isEqual(filterQuery[key], requestQuery[key]) 219 | ); 220 | }); 221 | } 222 | 223 | private prettyPrint(): string { 224 | const qs = stringify(this.matcher.query); 225 | return `(${this.debugId}) ${this.matcher.method || 'HTTP'} ${ 226 | this.matcher.path + (qs ? '?' + qs : '') 227 | }`; 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/mockiavelli.ts: -------------------------------------------------------------------------------- 1 | import dbg from 'debug'; 2 | import { 3 | BrowserController, 4 | BrowserRequestHandler, 5 | BrowserRequestType, 6 | } from './controllers/BrowserController'; 7 | import { 8 | BrowserControllerFactory, 9 | BrowserPage, 10 | } from './controllers/BrowserControllerFactory'; 11 | import { Mock } from './mock'; 12 | import { 13 | MockedResponse, 14 | MockOptions, 15 | RequestMatcher, 16 | ShorthandRequestMatcher, 17 | } from './types'; 18 | import { 19 | addMockByPriority, 20 | createRequestMatcher, 21 | getCorsHeaders, 22 | printRequest, 23 | printResponse, 24 | sanitizeHeaders, 25 | } from './utils'; 26 | 27 | const debug = dbg('mockiavelli:main'); 28 | 29 | const interceptedTypes: BrowserRequestType[] = ['xhr', 'fetch']; 30 | 31 | export interface MockiavelliOptions { 32 | debug: boolean; 33 | baseUrl: string; 34 | } 35 | 36 | export class Mockiavelli { 37 | private readonly baseUrl: string = ''; 38 | private mocks: Mock[] = []; 39 | private controller: BrowserController; 40 | 41 | constructor(page: BrowserPage, options: Partial = {}) { 42 | this.controller = BrowserControllerFactory.createForPage( 43 | page, 44 | this.onRequest 45 | ); 46 | 47 | if (options.baseUrl) { 48 | this.baseUrl = options.baseUrl; 49 | } 50 | 51 | if (options.debug) { 52 | dbg.enable('mockiavelli:*'); 53 | } 54 | debug('Initialized'); 55 | } 56 | 57 | public static async setup( 58 | page: BrowserPage, 59 | options: Partial = {} 60 | ): Promise { 61 | const instance = new Mockiavelli(page, options); 62 | await instance.enable(); 63 | return instance; 64 | } 65 | 66 | public async enable(): Promise { 67 | await this.controller.startInterception(); 68 | } 69 | 70 | public async disable(): Promise { 71 | await this.controller.stopInterception(); 72 | } 73 | 74 | public mock( 75 | matcher: RequestMatcher, 76 | response: MockedResponse, 77 | options?: Partial 78 | ): Mock { 79 | const matcherWithBaseUrl = { 80 | ...matcher, 81 | url: this.baseUrl + matcher.url, 82 | }; 83 | const mock = new Mock(matcherWithBaseUrl, response, { ...options }); 84 | addMockByPriority(this.mocks, mock); 85 | return mock; 86 | } 87 | 88 | public mockGET( 89 | matcher: ShorthandRequestMatcher, 90 | response: MockedResponse, 91 | options?: Partial 92 | ): Mock { 93 | return this.mock( 94 | createRequestMatcher(matcher, 'GET'), 95 | response, 96 | options 97 | ); 98 | } 99 | 100 | public mockPOST( 101 | matcher: ShorthandRequestMatcher, 102 | response: MockedResponse, 103 | options?: Partial 104 | ): Mock { 105 | return this.mock( 106 | createRequestMatcher(matcher, 'POST'), 107 | response, 108 | options 109 | ); 110 | } 111 | 112 | public mockPUT( 113 | matcher: ShorthandRequestMatcher, 114 | response: MockedResponse, 115 | options?: Partial 116 | ): Mock { 117 | return this.mock( 118 | createRequestMatcher(matcher, 'PUT'), 119 | response, 120 | options 121 | ); 122 | } 123 | 124 | public mockDELETE( 125 | matcher: ShorthandRequestMatcher, 126 | response: MockedResponse, 127 | options?: Partial 128 | ): Mock { 129 | return this.mock( 130 | createRequestMatcher(matcher, 'DELETE'), 131 | response, 132 | options 133 | ); 134 | } 135 | 136 | public mockPATCH( 137 | matcher: ShorthandRequestMatcher, 138 | response: MockedResponse, 139 | options?: Partial 140 | ): Mock { 141 | return this.mock( 142 | createRequestMatcher(matcher, 'PATCH'), 143 | response, 144 | options 145 | ); 146 | } 147 | 148 | public removeMock(mock: Mock): Mock | void { 149 | const index = this.mocks.indexOf(mock); 150 | if (index > -1) { 151 | return this.mocks.splice(index, 1)[0]; 152 | } 153 | } 154 | 155 | private onRequest: BrowserRequestHandler = async ( 156 | request, 157 | respond, 158 | skip 159 | ): Promise => { 160 | debug(`> req: ${printRequest(request)} `); 161 | 162 | // Handle preflight requests 163 | if (request.method === 'OPTIONS') { 164 | return await respond({ 165 | status: 204, 166 | headers: sanitizeHeaders(getCorsHeaders(request)), 167 | }); 168 | } 169 | 170 | for (const mock of this.mocks) { 171 | const response = mock.getResponseForRequest(request); 172 | 173 | if (response) { 174 | const status = response.status || 200; 175 | 176 | // Convert response body to Buffer. 177 | // A bug in puppeteer causes stalled response when body is equal to "" or undefined. 178 | // Providing response as Buffer fixes it. 179 | let body: Buffer; 180 | let contentType: string | undefined; 181 | 182 | if (typeof response.body === 'string') { 183 | body = Buffer.from(response.body); 184 | } else if ( 185 | response.body === undefined || 186 | response.body === null 187 | ) { 188 | body = Buffer.alloc(0); 189 | } else { 190 | try { 191 | body = Buffer.from(JSON.stringify(response.body)); 192 | contentType = 'application/json; charset=utf-8'; 193 | } catch (e) { 194 | // Response body in either not JSON-serializable or something else 195 | // that cannot be handled. In this case we throw an error 196 | console.error('Could not serialize response body', e); 197 | throw e; 198 | } 199 | } 200 | 201 | // Set default value of Content-Type header 202 | const headers = sanitizeHeaders({ 203 | 'content-length': String(body.length), 204 | 'content-type': contentType, 205 | ...getCorsHeaders(request), 206 | ...response.headers, 207 | }); 208 | 209 | try { 210 | await respond({ 211 | status, 212 | headers, 213 | body, 214 | }); 215 | debug(`< res: ${printResponse(status, headers, body)}`); 216 | return; 217 | } catch (e) { 218 | console.error( 219 | `Failed to reply with mocked response for ${printRequest( 220 | request 221 | )}` 222 | ); 223 | console.error(e); 224 | throw e; 225 | } 226 | } 227 | } 228 | 229 | const should404 = interceptedTypes.includes(request.type); 230 | 231 | // Request was not matched - log error and return 404 232 | if (should404) { 233 | debug(`< res: status=404`); 234 | console.error( 235 | `Mock not found for request: ${printRequest(request)}` 236 | ); 237 | return respond({ 238 | status: 404, 239 | body: 'No mock provided for request', 240 | }); 241 | } 242 | 243 | // Do not intercept non xhr/fetch requests 244 | debug(`< res: continue`); 245 | try { 246 | return await skip(); 247 | } catch (e) { 248 | console.error(e); 249 | // Request could be already handled so ignore this error 250 | return; 251 | } 252 | }; 253 | } 254 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type QueryObject = Record; 2 | 3 | export interface RequestMatcher { 4 | method: HttpMethod; 5 | url: string; 6 | query?: QueryObject; 7 | body?: any; 8 | } 9 | 10 | export type ShorthandRequestMatcher = 11 | | { 12 | url: string; 13 | query?: QueryObject; 14 | body?: any; 15 | } 16 | | string; 17 | 18 | export type MockedResponse = 19 | | ((req: MatchedRequest) => MockedResponseObject) 20 | | MockedResponseObject; 21 | 22 | export interface MockedResponseObject { 23 | status: number; 24 | headers?: Record; 25 | body?: TResponseBody; 26 | } 27 | 28 | export type PathParameters = Record; 29 | 30 | export interface MatchedRequest { 31 | url: string; 32 | method: string; 33 | body?: any; 34 | headers: Record; 35 | path: string; 36 | query: QueryObject; 37 | params: PathParameters; 38 | } 39 | 40 | export interface MockOptions { 41 | priority: number; 42 | once: boolean; 43 | } 44 | 45 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; 46 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod, RequestMatcher, ShorthandRequestMatcher } from './types'; 2 | import { BrowserRequest } from './controllers/BrowserController'; 3 | import { parse } from 'url'; 4 | 5 | export function tryJsonParse(data: any): any | undefined { 6 | try { 7 | return JSON.parse(data); 8 | } catch (e) { 9 | return data; 10 | } 11 | } 12 | 13 | export class TimeoutError extends Error {} 14 | 15 | export function waitFor(fn: () => boolean, timeout = 100): Promise { 16 | const timeStart = Date.now(); 17 | return new Promise((resolve, reject) => { 18 | const intervalId = setInterval(() => { 19 | if (fn()) { 20 | clearInterval(intervalId); 21 | return resolve(); 22 | } else if (Date.now() - timeStart > timeout) { 23 | clearInterval(intervalId); 24 | return reject( 25 | new TimeoutError( 26 | `waitFor timeout - provided function did not return true in ${timeout}ms` 27 | ) 28 | ); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | export function printRequest(request: BrowserRequest): string { 35 | return `type=${request.type} method=${request.method} url=${request.url}`; 36 | } 37 | 38 | export function nth(d: number): string { 39 | if (d > 3 && d < 21) return `${d}th`; 40 | switch (d % 10) { 41 | case 1: 42 | return `${d}st`; 43 | case 2: 44 | return `${d}nd`; 45 | case 3: 46 | return `${d}rd`; 47 | default: 48 | return `${d}th`; 49 | } 50 | } 51 | 52 | export function addMockByPriority( 53 | mockArr: T[], 54 | mock: T 55 | ) { 56 | const index = mockArr.findIndex( 57 | (item: T) => item.options.priority <= mock.options.priority 58 | ); 59 | const calculatedIndex = index === -1 ? mockArr.length : index; 60 | mockArr.splice(calculatedIndex, 0, mock); 61 | return mockArr; 62 | } 63 | 64 | export function createRequestMatcher( 65 | input: ShorthandRequestMatcher, 66 | method: HttpMethod 67 | ): RequestMatcher { 68 | if (typeof input === 'string') { 69 | return { 70 | method, 71 | url: input, 72 | }; 73 | } else { 74 | return { 75 | method, 76 | ...input, 77 | }; 78 | } 79 | } 80 | 81 | export function getCorsHeaders( 82 | request: BrowserRequest 83 | ): Record { 84 | const requestHeaders = request.headers; 85 | const origin = request.sourceOrigin; 86 | const headers = { 87 | 'Access-Control-Allow-Origin': origin, 88 | 'Access-Control-Allow-Credentials': 'true', 89 | 'Access-Control-Allow-Methods': 90 | requestHeaders['access-control-request-method'], 91 | 'Access-Control-Allow-Headers': 92 | requestHeaders['access-control-request-headers'], 93 | }; 94 | 95 | return headers; 96 | } 97 | 98 | /** 99 | * Sanitize headers object from empty or null values 100 | * because they make request hang indefinitely in puppeteer 101 | */ 102 | export function sanitizeHeaders( 103 | headers: Record 104 | ): Record { 105 | return Object.keys(headers).reduce>((acc, key) => { 106 | if (Boolean(headers[key])) { 107 | acc[key] = headers[key] as string; 108 | } 109 | return acc; 110 | }, {}); 111 | } 112 | 113 | export function printResponse( 114 | status: number, 115 | headers: Record, 116 | body: Buffer 117 | ): string { 118 | const headersStr = JSON.stringify(headers); 119 | const bodyStr = body.toString('utf8'); 120 | return `status=${status} headers=${headersStr} body=${bodyStr}`; 121 | } 122 | 123 | export function getOrigin(originUrl?: string) { 124 | const { protocol, host } = parse(originUrl ?? ''); 125 | if (protocol && host) { 126 | return `${protocol}//${host}`; 127 | } 128 | return ''; 129 | } 130 | -------------------------------------------------------------------------------- /test/integration/fixture/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Testbed 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/integration/fixture/page1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | page1 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/integration/fixture/script.js: -------------------------------------------------------------------------------- 1 | window.scriptLoaded = true; 2 | -------------------------------------------------------------------------------- /test/integration/fixture/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: rgb(255, 0, 0); 3 | } 4 | -------------------------------------------------------------------------------- /test/integration/mockiavelli.int.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod } from '../../src'; 2 | import { setupTestCtx } from './test-contexts/testCtx'; 3 | 4 | const PORT = 9000; 5 | 6 | const METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; 7 | 8 | type Methods = 'mockGET' | 'mockPUT' | 'mockPOST' | 'mockDELETE' | 'mockPATCH'; 9 | 10 | const TEST_LIBRARY = process.env.TEST_LIBRARY || 'puppeteer'; 11 | const TEST_LIBRARY_VERSION = process.env.TEST_LIBRARY_VERSION || '1.10'; 12 | 13 | describe(`Mockiavelli integration [${TEST_LIBRARY}@${TEST_LIBRARY_VERSION}]`, () => { 14 | const ctx = setupTestCtx(TEST_LIBRARY); 15 | 16 | test.each(METHODS)('matches request with .mock method ', async (METHOD) => { 17 | ctx.mockiavelli.mock( 18 | { 19 | method: METHOD, 20 | url: '/foo', 21 | }, 22 | { status: 200, body: METHOD } 23 | ); 24 | const result = await ctx.makeRequest(METHOD, '/foo'); 25 | expect(result.body).toEqual(METHOD); 26 | }); 27 | 28 | test.each(METHODS)( 29 | 'matches request with .mock%s() method and URL string', 30 | async (METHOD) => { 31 | ctx.mockiavelli[('mock' + METHOD) as Methods]('/example', { 32 | status: 200, 33 | body: METHOD, 34 | }); 35 | const response = await ctx.makeRequest(METHOD, '/example'); 36 | expect(response.body).toEqual(METHOD); 37 | } 38 | ); 39 | 40 | test.each(METHODS)( 41 | 'matches request with .mock%s() method matcher object', 42 | async (METHOD) => { 43 | ctx.mockiavelli[('mock' + METHOD) as Methods]( 44 | { url: '/example' }, 45 | { 46 | status: 200, 47 | body: METHOD, 48 | } 49 | ); 50 | const response = await ctx.makeRequest(METHOD, '/example'); 51 | expect(response.body).toEqual(METHOD); 52 | } 53 | ); 54 | 55 | test('matches request when filter does not define query params but request has', async () => { 56 | ctx.mockiavelli.mockGET('/example', { status: 200 }); 57 | const result = await ctx.makeRequest('GET', '/example?param=value'); 58 | expect(result.status).toEqual(200); 59 | }); 60 | 61 | test('does not match request when methods does not match', async () => { 62 | jest.spyOn(console, 'error').mockImplementation(() => {}); 63 | ctx.mockiavelli.mock( 64 | { method: 'GET', url: '/example' }, 65 | { status: 200, body: 'ok' } 66 | ); 67 | const response = await ctx.makeRequest('POST', '/example?param=value'); 68 | expect(response.body).not.toEqual('ok'); 69 | }); 70 | 71 | test('does not match request when URLs does not match', async () => { 72 | jest.spyOn(console, 'error').mockImplementation(() => {}); 73 | ctx.mockiavelli.mock( 74 | { method: 'GET', url: '/example' }, 75 | { status: 200, body: 'ok' } 76 | ); 77 | const response1 = await ctx.makeRequest('GET', '/exampleFoo'); 78 | const response2 = await ctx.makeRequest('GET', '/exampl'); 79 | expect(response1.body).not.toEqual('ok'); 80 | expect(response2.body).not.toEqual('ok'); 81 | }); 82 | 83 | test('match by request body', async () => { 84 | ctx.mockiavelli.mockPOST( 85 | { url: '/example', body: { key: 'value' } }, 86 | { status: 200 } 87 | ); 88 | const response = await ctx.makeRequest( 89 | 'POST', 90 | '/example', 91 | {}, 92 | JSON.stringify({ key: 'value' }) 93 | ); 94 | expect(response.status).toEqual(200); 95 | }); 96 | 97 | test('match by request body - negative scenario', async () => { 98 | jest.spyOn(console, 'error').mockImplementation(() => {}); 99 | ctx.mockiavelli.mockPOST( 100 | { url: '/example', body: { key: 'value' } }, 101 | { status: 200 } 102 | ); 103 | const response = await ctx.makeRequest( 104 | 'POST', 105 | '/example', 106 | {}, 107 | JSON.stringify({ key: 'non_matching_value' }) 108 | ); 109 | expect(response.status).toEqual(404); 110 | }); 111 | 112 | it('mocks multiple requests', async () => { 113 | ctx.mockiavelli.mockGET('/foo', { status: 200 }); 114 | const res1 = await ctx.makeRequest('GET', '/foo'); 115 | const res2 = await ctx.makeRequest('GET', '/foo'); 116 | expect(res1.status).toEqual(200); 117 | expect(res2.status).toEqual(200); 118 | }); 119 | 120 | it('can defined mocked response with status 500', async () => { 121 | ctx.mockiavelli.mockGET('/foo', { status: 500 }); 122 | const res = await ctx.makeRequest('GET', '/foo'); 123 | expect(res.status).toEqual(500); 124 | }); 125 | 126 | test('matches request with query passed in URL', async () => { 127 | ctx.mockiavelli.mockGET('/example?param=value', { status: 200 }); 128 | const response = await ctx.makeRequest('GET', '/example?param=value'); 129 | expect(response.status).toEqual(200); 130 | }); 131 | 132 | test('matches request with query defined as object', async () => { 133 | ctx.mockiavelli.mockGET( 134 | { 135 | url: '/example', 136 | query: { 137 | param: 'value', 138 | }, 139 | }, 140 | { status: 200 } 141 | ); 142 | const response = await ctx.makeRequest('GET', '/example?param=value'); 143 | expect(response.status).toEqual(200); 144 | }); 145 | 146 | test('matches request by query - ignores params order', async () => { 147 | ctx.mockiavelli.mockGET('/example?param=value¶m2=value2', { 148 | status: 200, 149 | }); 150 | const response = await ctx.makeRequest( 151 | 'GET', 152 | '/example?param2=value2¶m=value' 153 | ); 154 | expect(response.status).toEqual(200); 155 | }); 156 | 157 | test('matches request by query - ignores excessive params', async () => { 158 | ctx.mockiavelli.mockGET('/example?param=value', { status: 200 }); 159 | const response = await ctx.makeRequest( 160 | 'GET', 161 | '/example?param=value&foo=bar' 162 | ); 163 | expect(response.status).toEqual(200); 164 | }); 165 | 166 | test('does not match requests with non-matching query params', async () => { 167 | jest.spyOn(console, 'error').mockImplementation(() => {}); 168 | ctx.mockiavelli.mockGET('/example?param=value', { status: 200 }); 169 | const response = await ctx.makeRequest( 170 | 'GET', 171 | '/example?param=nonMatching' 172 | ); 173 | expect(response.status).toEqual(404); 174 | }); 175 | 176 | it('.waitForRequest() returns intercepted request data', async () => { 177 | const mock = await ctx.mockiavelli.mockPOST('/foo', { status: 200 }); 178 | 179 | const headers = { 180 | 'x-header': 'FOO', 181 | }; 182 | const body = { payload: 'ok' }; 183 | await ctx.makeRequest('POST', '/foo', headers, JSON.stringify(body)); 184 | 185 | await expect(mock.waitForRequest()).resolves.toMatchObject({ 186 | method: 'POST', 187 | path: '/foo', 188 | body: body, 189 | url: `http://localhost:${PORT}/foo`, 190 | headers: headers, 191 | }); 192 | }); 193 | 194 | it('.waitForRequest() rejects when request matching mock was not found', async () => { 195 | const mock = await ctx.mockiavelli.mock( 196 | { method: 'GET', url: '/some_endpoint' }, 197 | { status: 200, body: 'OK' } 198 | ); 199 | 200 | await expect(mock.waitForRequest(0)).rejects.toMatchObject({ 201 | message: expect.stringMatching( 202 | /No request matching mock \[\(\d+\) GET \/some_endpoint\] found/ 203 | ), 204 | }); 205 | 206 | await ctx.makeRequest('GET', '/some_endpoint'); 207 | 208 | await expect(mock.waitForRequest(0)).resolves.toEqual( 209 | expect.anything() 210 | ); 211 | await expect(mock.waitForRequest(1)).rejects.toMatchObject({ 212 | message: expect.stringMatching( 213 | /2nd request matching mock \[\(\d+\) GET \/some_endpoint\] was not found/ 214 | ), 215 | }); 216 | }); 217 | 218 | it('notifies when mock was called', async () => { 219 | const mock = await ctx.mockiavelli.mockGET('/foo', { status: 200 }); 220 | 221 | // @ts-ignore 222 | await ctx.page.evaluate(() => { 223 | setTimeout(() => { 224 | fetch('/foo'); 225 | }, 10); 226 | }); 227 | 228 | await expect(mock.waitForRequest()).resolves.toBeTruthy(); 229 | }); 230 | 231 | it('can set priorities on mocks', async () => { 232 | const mock = await ctx.mockiavelli.mockGET('/foo', { status: 200 }); 233 | 234 | const mockWithPriority = await ctx.mockiavelli.mockGET( 235 | '/foo', 236 | { status: 200 }, 237 | { 238 | priority: 10, 239 | } 240 | ); 241 | 242 | await ctx.makeRequest('GET', '/foo'); 243 | 244 | await expect(mock.waitForRequest()).rejects.toEqual(expect.anything()); 245 | await expect(mockWithPriority.waitForRequest()).resolves.toEqual( 246 | expect.anything() 247 | ); 248 | }); 249 | 250 | it('can remove mock so it is no longer called', async () => { 251 | const mock = await ctx.mockiavelli.mockGET('/foo', { 252 | status: 200, 253 | body: { id: 1 }, 254 | }); 255 | 256 | await ctx.makeRequest('GET', '/foo'); 257 | 258 | ctx.mockiavelli.removeMock(mock); 259 | 260 | await ctx.mockiavelli.mockGET('/foo', { 261 | status: 200, 262 | body: { id: 2 }, 263 | }); 264 | 265 | const result = await ctx.makeRequest('GET', '/foo'); 266 | 267 | expect(result.body).toEqual({ id: 2 }); 268 | }); 269 | 270 | it('can inspect requests that are invoke asynchronously', async () => { 271 | const mock = await ctx.mockiavelli.mockGET('/foo', { status: 200 }); 272 | 273 | // @ts-ignore 274 | await ctx.page.evaluate(() => { 275 | document.body.innerHTML = 'content'; 276 | document.body.addEventListener('click', () => { 277 | setTimeout(() => fetch('/foo'), 10); 278 | }); 279 | }); 280 | 281 | await ctx.page.click('body'); 282 | 283 | await expect(mock.waitForRequest()).resolves.toEqual(expect.anything()); 284 | }); 285 | 286 | describe('ordering', () => { 287 | it('matches only once request with once set to true', async () => { 288 | spyOn(console, 'error'); 289 | await ctx.mockiavelli.mockGET( 290 | '/foo', 291 | { status: 200 }, 292 | { 293 | once: true, 294 | } 295 | ); 296 | 297 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 298 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(404); 299 | expect(console.error).toHaveBeenCalled(); 300 | }); 301 | 302 | it('fallbacks to previously defined mock when once=true', async () => { 303 | await ctx.mockiavelli.mockGET('/foo', { status: 200 }); 304 | await ctx.mockiavelli.mockGET( 305 | '/foo', 306 | { status: 201 }, 307 | { 308 | once: true, 309 | } 310 | ); 311 | 312 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(201); 313 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 314 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 315 | }); 316 | 317 | it('matches only once every request in order with once set to true', async () => { 318 | await ctx.mockiavelli.mockGET( 319 | '/foo', 320 | { status: 200, body: {} }, 321 | { once: true } 322 | ); 323 | 324 | await ctx.mockiavelli.mockGET( 325 | '/foo', 326 | { status: 201, body: {} }, 327 | { 328 | once: true, 329 | } 330 | ); 331 | 332 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(201); 333 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 334 | }); 335 | 336 | it('matches newest request when added mock with same filter', async () => { 337 | await ctx.mockiavelli.mockGET('/foo', { status: 200, body: {} }); 338 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 339 | 340 | await ctx.mockiavelli.mockGET('/foo', { status: 201, body: {} }); 341 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(201); 342 | }); 343 | 344 | it('matches newest request when multiple mocks have same filter', async () => { 345 | await ctx.mockiavelli.mockGET('/foo', { status: 200, body: {} }); 346 | await ctx.mockiavelli.mockGET('/foo', { status: 201, body: {} }); 347 | 348 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(201); 349 | }); 350 | 351 | it('matches newest request when added mock with same filter and older mock has once set to true ', async () => { 352 | await ctx.mockiavelli.mockGET( 353 | '/foo', 354 | { status: 200, body: {} }, 355 | { once: true } 356 | ); 357 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 358 | 359 | await ctx.mockiavelli.mockGET('/foo', { status: 201, body: {} }); 360 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(201); 361 | }); 362 | 363 | it('matches requests with once set to true in correct order when multiple mocks have same filter', async () => { 364 | await ctx.mockiavelli.mockGET( 365 | '/foo', 366 | { status: 200, body: {} }, 367 | { once: true } 368 | ); 369 | 370 | await ctx.mockiavelli.mockGET( 371 | '/foo', 372 | { status: 201, body: {} }, 373 | { 374 | once: true, 375 | } 376 | ); 377 | 378 | await ctx.mockiavelli.mockGET( 379 | '/foo', 380 | { status: 202, body: {} }, 381 | { 382 | once: true, 383 | } 384 | ); 385 | 386 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(202); 387 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(201); 388 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 389 | }); 390 | 391 | it('matches request with highest priority when multiple mocks have same filter', async () => { 392 | await ctx.mockiavelli.mockGET( 393 | '/foo', 394 | { status: 200, body: {} }, 395 | { priority: 10 } 396 | ); 397 | 398 | await ctx.mockiavelli.mockGET('/foo', { status: 201, body: {} }); 399 | 400 | await ctx.mockiavelli.mockGET( 401 | '/foo', 402 | { status: 202, body: {} }, 403 | { priority: 5 } 404 | ); 405 | 406 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 407 | }); 408 | 409 | it('matches request in correct order with priority and once set to true when multiple mocks have same filter', async () => { 410 | await ctx.mockiavelli.mockGET('/foo', { status: 200, body: {} }); 411 | 412 | await ctx.mockiavelli.mockGET( 413 | '/foo', 414 | { status: 201, body: {} }, 415 | { once: true, priority: 10 } 416 | ); 417 | 418 | await ctx.mockiavelli.mockGET( 419 | '/foo', 420 | { status: 202, body: {} }, 421 | { once: true } 422 | ); 423 | 424 | await ctx.mockiavelli.mockGET( 425 | '/foo', 426 | { status: 203, body: {} }, 427 | { once: true, priority: 10 } 428 | ); 429 | 430 | await ctx.mockiavelli.mockGET( 431 | '/foo', 432 | { status: 204, body: {} }, 433 | { once: true, priority: 5 } 434 | ); 435 | 436 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(203); 437 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(201); 438 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(204); 439 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(202); 440 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 441 | expect((await ctx.makeRequest('GET', '/foo')).status).toBe(200); 442 | }); 443 | }); 444 | 445 | describe('path variables', () => { 446 | it('mocks fetch GET request with path variable as number', async () => { 447 | const mock = await ctx.mockiavelli.mockGET('/foo/:id', { 448 | status: 200, 449 | }); 450 | 451 | await ctx.makeRequest('GET', '/foo/123'); 452 | const request = await mock.waitForRequest(); 453 | 454 | await expect(request.params.id).toBe('123'); 455 | }); 456 | 457 | it('mocks fetch GET request with path variable as string', async () => { 458 | const mock = await ctx.mockiavelli.mockGET('/foo/:id', { 459 | status: 200, 460 | }); 461 | 462 | await ctx.makeRequest('GET', '/foo/test'); 463 | const request = await mock.waitForRequest(); 464 | 465 | await expect(request.params.id).toBe('test'); 466 | }); 467 | 468 | it('mocks fetch GET request with path variable and query', async () => { 469 | const mock = await ctx.mockiavelli.mock( 470 | { url: '/foo/:id?param=fooParam', method: 'GET' }, 471 | { status: 200 } 472 | ); 473 | await ctx.makeRequest('GET', '/foo/123?param=fooParam'); 474 | const request = await mock.waitForRequest(); 475 | 476 | await expect(request.params.id).toBe('123'); 477 | }); 478 | 479 | it('mocks fetch GET request with schema, origin, path variable and query', async () => { 480 | const mock = await ctx.mockiavelli.mock( 481 | { 482 | url: 'https://localhost:3000/foo/:id?param=fooParam', 483 | method: 'GET', 484 | }, 485 | { status: 200 } 486 | ); 487 | await ctx.makeRequest( 488 | 'GET', 489 | 'https://localhost:3000/foo/123?param=fooParam' 490 | ); 491 | const request = await mock.waitForRequest(); 492 | 493 | await expect(request.params.id).toBe('123'); 494 | }); 495 | 496 | it('mocks fetch GET request with multiple path variables', async () => { 497 | const mock = await ctx.mockiavelli.mock( 498 | { url: '/foo/:id/:name', method: 'GET' }, 499 | { status: 200 } 500 | ); 501 | await ctx.makeRequest('GET', '/foo/123/mike'); 502 | const request = await mock.waitForRequest(); 503 | 504 | await expect(request.params.id).toBe('123'); 505 | await expect(request.params.name).toBe('mike'); 506 | }); 507 | }); 508 | 509 | test('does not mock request to assets (scripts, styles) by default', async () => { 510 | await ctx.page.goto(`http://localhost:${PORT}/page1.html`); 511 | await expect(ctx.page.title()).resolves.toEqual('page1'); 512 | await expect( 513 | // @ts-ignore 514 | ctx.page.evaluate(() => window['scriptLoaded']) 515 | ).resolves.toEqual(true); 516 | await expect( 517 | // @ts-ignore 518 | ctx.page.evaluate(() => 519 | window.getComputedStyle(document.body).getPropertyValue('color') 520 | ) 521 | ).resolves.toEqual('rgb(255, 0, 0)'); 522 | }); 523 | 524 | test('respond with 404 for unmocked fetch requests', async () => { 525 | jest.spyOn(console, 'error').mockImplementation(() => {}); 526 | const response = await ctx.makeRequest('GET', '/unmocked'); 527 | expect(response.status).toEqual(404); 528 | }); 529 | 530 | test('respond with 404 for unmocked XHR requests', async () => { 531 | jest.spyOn(console, 'error').mockImplementation(() => {}); 532 | await expect( 533 | // @ts-ignore 534 | ctx.page.evaluate( 535 | () => 536 | new Promise((resolve) => { 537 | var xhr = new XMLHttpRequest(); 538 | xhr.open('GET', '/unmocked', true); 539 | xhr.onloadend = () => resolve(xhr.status); 540 | xhr.send(null); 541 | }) 542 | ) 543 | ).resolves.toEqual(404); 544 | }); 545 | 546 | test('mock redirect requests', async () => { 547 | await ctx.mockiavelli.mockGET('/redirect', { 548 | status: 302, 549 | headers: { 550 | Location: `http://localhost:${PORT}/page1.html`, 551 | }, 552 | }); 553 | 554 | await Promise.all([ 555 | // @ts-ignore 556 | ctx.page.evaluate(() => window.location.assign('/redirect')), 557 | ctx.page.waitForNavigation(), 558 | ]); 559 | 560 | await expect(ctx.page.title()).resolves.toEqual('page1'); 561 | }); 562 | 563 | test('mock request with string in response (instead of JSON)', async () => { 564 | await ctx.mockiavelli.mockGET('/resource', { 565 | status: 200, 566 | body: 'testBody', 567 | }); 568 | const result = await ctx.makeRequest('GET', '/resource'); 569 | expect(result.body).toEqual('testBody'); 570 | }); 571 | 572 | test('allow to provide content-type manually', async () => { 573 | await ctx.mockiavelli.mockGET('/resource', { 574 | status: 200, 575 | headers: { 576 | 'content-type': 'text/html', 577 | }, 578 | body: '
test
', 579 | }); 580 | const result = await ctx.makeRequest('GET', '/resource'); 581 | expect(result.headers['content-type']).toEqual('text/html'); 582 | expect(result.body).toEqual('
test
'); 583 | }); 584 | 585 | test('mock request to assets', async () => { 586 | ctx.mockiavelli.mockGET('/script.js', { 587 | headers: { 588 | 'Content-Type': 'text/javascript; charset=UTF-8', 589 | }, 590 | status: 200, 591 | body: `window.mockLoaded = true`, 592 | }); 593 | await ctx.page.goto(`http://localhost:${PORT}/page1.html`); 594 | await expect( 595 | // @ts-ignore 596 | ctx.page.evaluate(() => window['mockLoaded']) 597 | ).resolves.toEqual(true); 598 | }); 599 | 600 | test('mocked response as a function', async () => { 601 | await ctx.mockiavelli.mockGET('/resource', () => { 602 | const body = 'hello' + 'world'; 603 | return { 604 | status: 200, 605 | body, 606 | }; 607 | }); 608 | const respose = await ctx.makeRequest('GET', '/resource'); 609 | expect(respose.body).toEqual('helloworld'); 610 | }); 611 | 612 | test('mocked response in function of request data', async () => { 613 | await ctx.mockiavelli.mockPOST('/resource/:id', (request) => { 614 | return { 615 | status: 200, 616 | body: { 617 | query: request.query, 618 | url: request.url, 619 | body: request.body, 620 | method: request.method, 621 | params: request.params, 622 | }, 623 | }; 624 | }); 625 | 626 | const response = await ctx.makeRequest( 627 | 'POST', 628 | '/resource/123?param=testParam', 629 | {}, 630 | 'testBody' 631 | ); 632 | expect(response.body).toEqual({ 633 | query: { 634 | param: 'testParam', 635 | }, 636 | url: 'http://localhost:9000/resource/123?param=testParam', 637 | method: 'POST', 638 | body: 'testBody', 639 | params: { id: '123' }, 640 | }); 641 | }); 642 | 643 | test('mock cross-origin requests', async () => { 644 | await ctx.mockiavelli.mockPOST('http://example.com/resource', { 645 | status: 200, 646 | body: '', 647 | }); 648 | const response = await ctx.makeRequest( 649 | 'POST', 650 | 'http://example.com/resource' 651 | ); 652 | expect(response.status).toEqual(200); 653 | }); 654 | 655 | test('mock cross-origin redirect requests', async () => { 656 | await ctx.mockiavelli.mockGET('http://example.com/redirect', { 657 | status: 302, 658 | headers: { 659 | Location: `http://localhost:${PORT}/page1.html`, 660 | }, 661 | }); 662 | 663 | await Promise.all([ 664 | // @ts-ignore 665 | ctx.page.evaluate(() => 666 | window.location.assign('http://example.com/redirect') 667 | ), 668 | ctx.page.waitForNavigation(), 669 | ]); 670 | 671 | await expect(ctx.page.title()).resolves.toEqual('page1'); 672 | }); 673 | 674 | test('set correct response headers when response body is empty', async () => { 675 | await ctx.mockiavelli.mockGET('/example', { status: 200 }); 676 | const response = await ctx.makeRequest('GET', '/example'); 677 | expect(response.headers['content-length']).toBe('0'); 678 | expect(response.headers['content-type']).toBe(undefined); 679 | }); 680 | 681 | test('set correct response headers when response body is not empty', async () => { 682 | await ctx.mockiavelli.mockGET('/example', { 683 | status: 200, 684 | body: { ok: 'yes' }, 685 | }); 686 | const response = await ctx.makeRequest('GET', '/example'); 687 | expect(response.headers['content-length']).toBe('12'); 688 | expect(response.headers['content-type']).toContain( 689 | 'application/json; charset=utf-8' 690 | ); 691 | }); 692 | 693 | test('wait for a number of requests to be matched', async () => { 694 | const mock = await ctx.mockiavelli.mockGET('/example', { status: 200 }); 695 | await ctx.makeRequest('GET', '/example'); 696 | await ctx.makeRequest('GET', '/example'); 697 | await mock.waitForRequestsCount(2); 698 | }); 699 | 700 | test('wait for a number of requests to be matched - async scenario', async () => { 701 | const mock = await ctx.mockiavelli.mockGET('/example', { status: 200 }); 702 | await ctx.makeRequest('GET', '/example', {}, undefined, { 703 | waitForRequestEnd: false, 704 | }); 705 | await ctx.makeRequest('GET', '/example', {}, undefined, { 706 | waitForRequestEnd: false, 707 | }); 708 | await mock.waitForRequestsCount(2); 709 | }); 710 | 711 | test('disable() method disables mocking of requests', async () => { 712 | // Puppeteer.Page.off is broken in puppeteer 10.2+. 713 | // Disable test until fix is released 714 | // https://github.com/puppeteer/puppeteer/pull/7624 715 | if (TEST_LIBRARY === 'puppeteer' && TEST_LIBRARY_VERSION === '10') { 716 | return; 717 | } 718 | 719 | const mockedFun = jest.fn().mockReturnValue({ status: 200 }); 720 | await ctx.mockiavelli.mockGET('/example', mockedFun); 721 | 722 | const response1 = await ctx.makeRequest('GET', '/example'); 723 | expect(response1.status).toEqual(200); 724 | 725 | await ctx.mockiavelli.disable(); 726 | const response2 = await ctx.makeRequest('GET', '/example'); 727 | expect(response2.status).toEqual(404); 728 | 729 | await ctx.mockiavelli.enable(); 730 | const response3 = await ctx.makeRequest('GET', '/example'); 731 | expect(response3.status).toEqual(200); 732 | }); 733 | }); 734 | -------------------------------------------------------------------------------- /test/integration/test-contexts/playwrightCtx.ts: -------------------------------------------------------------------------------- 1 | import { chromium, BrowserContext, Page, Browser } from 'playwright'; 2 | import { Mockiavelli } from '../../../src'; 3 | import { makeRequest } from '../test-helpers/make-request'; 4 | 5 | const PORT = 9000; 6 | 7 | export interface PlaywrightTestCtx { 8 | page?: Page; 9 | mockiavelli?: Mockiavelli; 10 | makeRequest?: ReturnType; 11 | } 12 | 13 | export function setupPlaywrightCtx(): PlaywrightTestCtx { 14 | let browser: Browser; 15 | let context: BrowserContext; 16 | 17 | const testCtx: PlaywrightTestCtx = {}; 18 | 19 | beforeAll(async () => { 20 | browser = await chromium.launch({ 21 | headless: true, 22 | devtools: false, 23 | args: ['--no-sandbox'], 24 | }); 25 | context = await browser.newContext(); 26 | }); 27 | 28 | afterAll(async () => { 29 | await browser.close(); 30 | }); 31 | 32 | beforeEach(async () => { 33 | // Setup new page (tab) 34 | testCtx.page = await context.newPage(); 35 | await testCtx.page.goto(`http://localhost:${PORT}`); 36 | testCtx.mockiavelli = await Mockiavelli.setup(testCtx.page); 37 | testCtx.makeRequest = makeRequestFactory(testCtx.page); 38 | }); 39 | 40 | afterEach(async () => { 41 | await testCtx.page.close(); 42 | }); 43 | 44 | return testCtx; 45 | } 46 | 47 | function makeRequestFactory(page: Page) { 48 | return ( 49 | method: string, 50 | url: string, 51 | headers?: Record, 52 | body?: any, 53 | options?: { waitForRequestEnd: boolean } 54 | ): ReturnType => 55 | page.evaluate(makeRequest, { 56 | url, 57 | method, 58 | headers, 59 | body, 60 | options, 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /test/integration/test-contexts/puppeteerCtx.ts: -------------------------------------------------------------------------------- 1 | import { Browser, launch, Page } from 'puppeteer'; 2 | import { Mockiavelli } from '../../../src'; 3 | import { makeRequest } from '../test-helpers/make-request'; 4 | 5 | const PORT = 9000; 6 | 7 | export interface PuppeteerTestCtx { 8 | page?: Page; 9 | mockiavelli?: Mockiavelli; 10 | makeRequest?: ReturnType; 11 | } 12 | 13 | export function setupPuppeteerCtx() { 14 | let browser: Browser; 15 | 16 | const testCtx: PuppeteerTestCtx = {}; 17 | 18 | beforeAll(async () => { 19 | browser = await launch({ 20 | headless: true, 21 | devtools: false, 22 | args: ['--no-sandbox'], 23 | }); 24 | }); 25 | 26 | afterAll(async () => { 27 | await browser.close(); 28 | }); 29 | 30 | beforeEach(async () => { 31 | // Setup new page (tab) 32 | testCtx.page = await browser.newPage(); 33 | await testCtx.page.goto(`http://localhost:${PORT}`); 34 | 35 | // Instantiate 36 | testCtx.mockiavelli = await Mockiavelli.setup(testCtx.page); 37 | testCtx.makeRequest = makeRequestFactory(testCtx.page); 38 | }); 39 | 40 | afterEach(async () => { 41 | await testCtx.page.close(); 42 | }); 43 | 44 | return testCtx; 45 | } 46 | 47 | function makeRequestFactory(page: Page) { 48 | return ( 49 | method: string, 50 | url: string, 51 | headers?: Record, 52 | body?: any, 53 | options?: { waitForRequestEnd: boolean } 54 | ): ReturnType => 55 | page.evaluate(makeRequest, { 56 | url, 57 | method, 58 | headers, 59 | body, 60 | options, 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /test/integration/test-contexts/testCtx.ts: -------------------------------------------------------------------------------- 1 | import { setupPlaywrightCtx } from './playwrightCtx'; 2 | import { setupPuppeteerCtx } from './puppeteerCtx'; 3 | 4 | export function setupTestCtx(controller) { 5 | if (controller === 'playwright') { 6 | return setupPlaywrightCtx(); 7 | } else if (controller === 'puppeteer') { 8 | return setupPuppeteerCtx(); 9 | } else { 10 | throw new Error('Unsupported controller ' + controller); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/integration/test-helpers/global-setup.ts: -------------------------------------------------------------------------------- 1 | import { startServer } from './server'; 2 | 3 | export default async () => await startServer(9000); 4 | -------------------------------------------------------------------------------- /test/integration/test-helpers/global-teardown.ts: -------------------------------------------------------------------------------- 1 | import { stopServer } from './server'; 2 | 3 | export default async () => await stopServer(); 4 | -------------------------------------------------------------------------------- /test/integration/test-helpers/make-request.ts: -------------------------------------------------------------------------------- 1 | interface MakeRequestParams { 2 | url: string; 3 | method: string; 4 | headers: Record; 5 | body: any; 6 | options?: { waitForRequestEnd: boolean }; 7 | } 8 | 9 | export function makeRequest(params: MakeRequestParams) { 10 | const { 11 | url, 12 | method, 13 | headers, 14 | body, 15 | options = { waitForRequestEnd: true }, 16 | } = params; 17 | function headersToObject(headers: Headers): Record { 18 | const headerObj = {}; 19 | const keyVals = [...headers.entries()]; 20 | keyVals.forEach(([key, val]) => { 21 | headerObj[key] = val; 22 | }); 23 | return headerObj; 24 | } 25 | 26 | function getParsedBody(body: string): string { 27 | try { 28 | return JSON.parse(body); 29 | } catch (e) { 30 | return body; 31 | } 32 | } 33 | 34 | function makeRequest() { 35 | return fetch(url, { method, headers, body }) 36 | .then(res => { 37 | return Promise.all([res.status, res.headers, res.text()]); 38 | }) 39 | .then(([status, headers, body]) => { 40 | return { 41 | status, 42 | body: getParsedBody(body), 43 | headers: headersToObject(headers), 44 | }; 45 | }); 46 | } 47 | 48 | const request = makeRequest(); 49 | 50 | if (options.waitForRequestEnd === true) { 51 | return request; 52 | } else { 53 | return null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/integration/test-helpers/server.ts: -------------------------------------------------------------------------------- 1 | // Based on https://adrianmejia.com/blog/2016/08/24/building-a-node-js-static-file-server-files-over-http-using-es6/ 2 | import * as http from 'http'; 3 | import * as url from 'url'; 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | import * as util from 'util'; 7 | import { Server } from 'http'; 8 | 9 | // maps file extention to MIME types 10 | const mimeType = { 11 | '.ico': 'image/x-icon', 12 | '.html': 'text/html', 13 | '.js': 'text/javascript', 14 | '.json': 'application/json', 15 | '.css': 'text/css', 16 | '.png': 'image/png', 17 | '.jpg': 'image/jpeg', 18 | '.wav': 'audio/wav', 19 | '.mp3': 'audio/mpeg', 20 | '.svg': 'image/svg+xml', 21 | '.pdf': 'application/pdf', 22 | '.doc': 'application/msword', 23 | '.eot': 'appliaction/vnd.ms-fontobject', 24 | '.ttf': 'aplication/font-sfnt', 25 | }; 26 | 27 | const DIR = '../fixture'; 28 | 29 | function respondNotFound(res, pathname) { 30 | res.statusCode = 404; 31 | res.end(`File ${pathname} not found!`); 32 | return; 33 | } 34 | 35 | function handler(req, res) { 36 | // parse URL 37 | // TODO find a better alternative for url.parse 38 | const parsedUrl = url.parse(req.url); 39 | 40 | // extract URL path 41 | // Avoid https://en.wikipedia.org/wiki/Directory_traversal_attack 42 | // e.g curl --path-as-is http://localhost:9000/../fileInDanger.txt 43 | // by limiting the path to current directory only 44 | const sanitizePath = path 45 | .normalize(parsedUrl.pathname) 46 | .replace(/^(\.\.[\/\\])+/, ''); 47 | let pathname = path.join(__dirname, DIR, sanitizePath); 48 | 49 | // check if path exists 50 | if (!fs.existsSync(pathname)) { 51 | return respondNotFound(res, pathname); 52 | } 53 | 54 | // if is a directory, then look for index.html 55 | if (fs.statSync(pathname).isDirectory()) { 56 | pathname += '/index.html'; 57 | if (!fs.existsSync(pathname)) { 58 | return respondNotFound(res, pathname); 59 | } 60 | } 61 | 62 | // read file from file system 63 | let data; 64 | try { 65 | data = fs.readFileSync(pathname); 66 | } catch (err) { 67 | res.statusCode = 500; 68 | res.end(`Error getting the file: ${err}.`); 69 | return; 70 | } 71 | 72 | // based on the URL path, extract the file extention. e.g. .js, .doc, ... 73 | const ext = path.parse(pathname).ext; 74 | // if the file is found, set Content-type and send data 75 | res.setHeader('Content-type', mimeType[ext] || 'text/plain'); 76 | res.end(data); 77 | } 78 | 79 | let server: Server; 80 | 81 | export async function startServer(port = 9000): Promise { 82 | if (server) return; 83 | server = http.createServer(handler); 84 | await util.promisify(server.listen.bind(server))(port); 85 | console.log(`Server listening on port ${port}`); 86 | } 87 | 88 | export async function stopServer(): Promise { 89 | if (server) { 90 | server.close(); 91 | server = null; 92 | } 93 | } 94 | 95 | if (require.main === module) { 96 | const port = parseInt(process.argv[2]) || undefined; 97 | startServer(port); 98 | } 99 | -------------------------------------------------------------------------------- /test/integration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "strict": false 5 | }, 6 | "include": ["**/*.ts", "../../src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/utils.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`printResponse() 1`] = `"status=200 headers={\\"content-type\\":\\"text/html; charset=utf-8\\",\\"date\\":\\"Thu, 13 Feb 2020 17:03:57 GMT\\",\\"content-language\\":\\"de-DE, en-CA\\"} body={\\"foo\\":\\"bar\\"}"`; 4 | -------------------------------------------------------------------------------- /test/unit/fixtures/PuppeteerRequest.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'url'; 2 | import { PuppeteerRequest } from '../../../src/controllers/PuppeteerController'; 3 | 4 | /** 5 | * Test implementation of puppeteer Request interface 6 | */ 7 | export class PuppeteerRequestMock implements PuppeteerRequest { 8 | private _postData: string = ''; 9 | private _url: string = ''; 10 | private _method: ReturnType = 'GET'; 11 | private _headers = {}; 12 | private _resourceType: ReturnType = 'xhr'; 13 | private _isNavigationRequest: boolean = false; 14 | private _frame = null; 15 | private _redirectChain: PuppeteerRequestMock[] = []; 16 | private _response: Response | null = null; 17 | 18 | public static create( 19 | data: Partial<{ 20 | postData: string; 21 | url: string; 22 | method: ReturnType; 23 | headers: ReturnType; 24 | resourceType: ReturnType; 25 | isNavigationRequest: boolean; 26 | frame: ReturnType; 27 | redirectChain: PuppeteerRequestMock[]; 28 | response: Response; 29 | origin: string; 30 | }> 31 | ) { 32 | const req = new PuppeteerRequestMock(); 33 | 34 | if (data.postData !== undefined) { 35 | req._postData = data.postData; 36 | } 37 | 38 | if (data.url !== undefined) { 39 | req._url = data.url; 40 | } 41 | 42 | if (data.method !== undefined) { 43 | req._method = data.method; 44 | } 45 | 46 | if (data.headers !== undefined) { 47 | req._headers = data.headers; 48 | } 49 | 50 | if (data.isNavigationRequest !== undefined) { 51 | req._isNavigationRequest = data.isNavigationRequest; 52 | } 53 | 54 | if (data.redirectChain) { 55 | req._redirectChain = data.redirectChain; 56 | } 57 | 58 | if (data.resourceType) { 59 | req._resourceType = data.resourceType; 60 | } 61 | 62 | if (data.response) { 63 | req._response = data.response; 64 | } 65 | 66 | if (data.origin !== undefined) { 67 | // @ts-ignore 68 | req._frame = new FrameFixture(data.origin); 69 | } else { 70 | const { protocol, host } = parse(req._url); 71 | const origin = `${protocol}//${host}`; 72 | // @ts-ignore 73 | req._frame = new FrameFixture(origin); 74 | } 75 | 76 | return req; 77 | } 78 | 79 | postData() { 80 | return this._postData; 81 | } 82 | 83 | url() { 84 | return this._url; 85 | } 86 | 87 | method() { 88 | return this._method; 89 | } 90 | 91 | headers() { 92 | return this._headers; 93 | } 94 | 95 | resourceType() { 96 | return this._resourceType; 97 | } 98 | 99 | isNavigationRequest() { 100 | return this._isNavigationRequest; 101 | } 102 | 103 | frame() { 104 | return this._frame; 105 | } 106 | 107 | redirectChain() { 108 | return this._redirectChain; 109 | } 110 | 111 | response() { 112 | return this._response; 113 | } 114 | 115 | respond = jest.fn().mockResolvedValue(undefined); 116 | abort = jest.fn().mockResolvedValue(undefined); 117 | continue = jest.fn().mockResolvedValue(undefined); 118 | failure = jest.fn().mockResolvedValue(undefined); 119 | } 120 | 121 | class FrameFixture { 122 | constructor(private _url: string) {} 123 | 124 | url() { 125 | return this._url; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /test/unit/fixtures/browserRequest.ts: -------------------------------------------------------------------------------- 1 | import { makeFactory } from 'factory.ts'; 2 | import { parse } from 'url'; 3 | import { BrowserRequest } from '../../../src/controllers/BrowserController'; 4 | 5 | export const browserRequest = makeFactory({ 6 | url: '/', 7 | method: 'GET', 8 | path: '', 9 | hostname: '', 10 | body: '', 11 | type: 'xhr', 12 | headers: {}, 13 | query: {}, 14 | sourceOrigin: '', 15 | }) 16 | .withDerivation('path', req => parse(req.url).pathname || '') 17 | .withDerivation('hostname', req => parse(req.url).hostname || '') 18 | .withDerivation('query', req => parse(req.url, true).query || {}) 19 | .withDerivation('hostname', req => { 20 | const { protocol, host } = parse(req.url); 21 | return `${protocol}//${host}`; 22 | }) 23 | .withDerivation('sourceOrigin', req => req.hostname); 24 | -------------------------------------------------------------------------------- /test/unit/fixtures/page.ts: -------------------------------------------------------------------------------- 1 | export const createMockPage = () => 2 | ({ 3 | setRequestInterception: jest.fn().mockResolvedValue(''), 4 | on: jest.fn(), 5 | url: jest.fn().mockResolvedValue(''), 6 | // @ts-ignore 7 | _triggerRequest: (req) => this.on.mock.calls[0][1](req), 8 | } as any); 9 | -------------------------------------------------------------------------------- /test/unit/fixtures/request.ts: -------------------------------------------------------------------------------- 1 | import { Mock, MockOptions, RequestMatcher } from '../../../src'; 2 | 3 | const mockedResponse = { 4 | status: 200, 5 | body: {}, 6 | }; 7 | 8 | export const createRestMock = ( 9 | change: Partial = {}, 10 | options?: Partial 11 | ) => { 12 | return new Mock( 13 | { 14 | url: '/foo', 15 | method: 'GET', 16 | ...change, 17 | }, 18 | mockedResponse, 19 | options 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /test/unit/http-mock.test.ts: -------------------------------------------------------------------------------- 1 | import { createRestMock } from './fixtures/request'; 2 | import { Mock } from '../../src'; 3 | import { browserRequest } from './fixtures/browserRequest'; 4 | 5 | test('.getResponseForRequest matches GET request', () => { 6 | const mock = createRestMock(); 7 | expect( 8 | mock.getResponseForRequest( 9 | browserRequest.build({ url: 'http://example.com/foo' }) 10 | ) 11 | ).not.toBeNull(); 12 | }); 13 | 14 | test('.getResponseForRequest matches GET request when query params passed as an argument', () => { 15 | const mock = createRestMock({ 16 | query: { 17 | example: 'firstExample', 18 | secondExample: 'secondExampleParam', 19 | }, 20 | }); 21 | 22 | expect( 23 | mock.getResponseForRequest( 24 | browserRequest.build({ 25 | url: 26 | 'http://example/foo?example=firstExample&secondExample=secondExampleParam', 27 | }) 28 | ) 29 | ).not.toBeNull(); 30 | }); 31 | 32 | test('.getResponseForRequest matches GET request when query params are numbers', () => { 33 | const mock = createRestMock({ 34 | query: { 35 | exampleNum: '111', 36 | }, 37 | }); 38 | 39 | expect( 40 | mock.getResponseForRequest( 41 | browserRequest.build({ 42 | url: 'http://example/foo?exampleNum=111', 43 | }) 44 | ) 45 | ).not.toBeNull(); 46 | }); 47 | 48 | test('.getResponseForRequest matches GET request when query params are arrays', () => { 49 | const mock = createRestMock({ 50 | query: { 51 | exampleArray: ['122', '3223'], 52 | }, 53 | }); 54 | 55 | expect( 56 | mock.getResponseForRequest( 57 | browserRequest.build({ 58 | url: 'http://example/foo?exampleArray=122&exampleArray=3223', 59 | }) 60 | ) 61 | ).not.toBeNull(); 62 | }); 63 | 64 | test('.getResponseForRequest does not match GET request when some query params are missing from the actual request', () => { 65 | const mock = createRestMock({ 66 | query: { 67 | example: 'exampleParam', 68 | secondExample: 'secondExampleParam', 69 | }, 70 | }); 71 | 72 | expect( 73 | mock.getResponseForRequest( 74 | browserRequest.build({ 75 | url: 'http://example/foo?example=exampleParam', 76 | }) 77 | ) 78 | ).toBeNull(); 79 | }); 80 | 81 | test('.getResponseForRequest does not match GET request when query params values are different', () => { 82 | const mock = createRestMock({ 83 | query: { 84 | example: 'exampleParamFoo', 85 | }, 86 | }); 87 | 88 | expect( 89 | mock.getResponseForRequest( 90 | browserRequest.build({ 91 | url: 'http://example/foo?example=exampleParam', 92 | }) 93 | ) 94 | ).toBeNull(); 95 | }); 96 | 97 | test('.getResponseForRequest matches GET request with query params passed in the url', () => { 98 | const mock = createRestMock({ 99 | url: '/foo?example=exampleParam', 100 | }); 101 | 102 | expect( 103 | mock.getResponseForRequest( 104 | browserRequest.build({ 105 | url: 'http://example/foo?example=exampleParam', 106 | }) 107 | ) 108 | ).not.toBeNull(); 109 | }); 110 | 111 | test('.getResponseForRequest matches GET request with specified origin', () => { 112 | const mock = createRestMock({ 113 | url: 'http://example/foo', 114 | }); 115 | 116 | expect( 117 | mock.getResponseForRequest( 118 | browserRequest.build({ 119 | url: 'http://example/foo', 120 | }) 121 | ) 122 | ).not.toBeNull(); 123 | }); 124 | 125 | test('.getResponseForRequest does not match GET request with specified origin', () => { 126 | const mock = createRestMock({ 127 | url: 'http://example.com/foo', 128 | }); 129 | 130 | expect( 131 | mock.getResponseForRequest( 132 | browserRequest.build({ 133 | url: 'http://foo.example.com/foo', 134 | sourceOrigin: 'http://bar.example.com', 135 | }) 136 | ) 137 | ).toBeNull(); 138 | }); 139 | 140 | test('.getResponseForRequest does not match GET request when pageOrigin is different than the request hostname', () => { 141 | const mock = createRestMock(); 142 | 143 | expect( 144 | mock.getResponseForRequest( 145 | browserRequest.build({ 146 | url: 'http://fooExample/foo', 147 | sourceOrigin: 'http://localhost', 148 | }) 149 | ) 150 | ).toBeNull(); 151 | }); 152 | 153 | test('.getResponseForRequest matches GET request first time when once option is set to true', () => { 154 | const mock = createRestMock(undefined, { once: true }); 155 | 156 | expect( 157 | mock.getResponseForRequest( 158 | browserRequest.build({ 159 | url: 'http://example/foo', 160 | }) 161 | ) 162 | ).not.toBeNull(); 163 | }); 164 | 165 | test('.getResponseForRequest does not match second GET request when once option is set to true', () => { 166 | const mock = createRestMock(undefined, { once: true }); 167 | const exampleRequest = browserRequest.build({ 168 | url: 'http://example/foo', 169 | }); 170 | mock.getResponseForRequest(exampleRequest); 171 | 172 | expect(mock.getResponseForRequest(exampleRequest)).toBeNull(); 173 | }); 174 | 175 | test('.getResponseForRequest matches GET request with path variable', () => { 176 | const mock = createRestMock({ url: '/foo/:id' }); 177 | const exampleRequest = browserRequest.build({ 178 | url: 'http://example/foo/param', 179 | }); 180 | mock.getResponseForRequest(exampleRequest); 181 | 182 | expect(mock.getResponseForRequest(exampleRequest)).not.toBeNull(); 183 | }); 184 | 185 | test('.getResponseForRequest matches GET request with multiple path variables', () => { 186 | const mock = createRestMock({ url: '/foo/:id/:resource' }); 187 | const exampleRequest = browserRequest.build({ 188 | url: 'http://example/foo/param/second', 189 | }); 190 | mock.getResponseForRequest(exampleRequest); 191 | 192 | expect(mock.getResponseForRequest(exampleRequest)).not.toBeNull(); 193 | }); 194 | 195 | test('.getResponseForRequest does not match GET request when path variables are set and not present in request', () => { 196 | const mock = createRestMock({ url: '/foo/:id' }); 197 | const exampleRequest = browserRequest.build({ 198 | url: 'http://example/foo', 199 | }); 200 | mock.getResponseForRequest(exampleRequest); 201 | 202 | expect(mock.getResponseForRequest(exampleRequest)).toBeNull(); 203 | }); 204 | 205 | test('.getResponseForRequest returns truthy when filter.body matches request body ', () => { 206 | const mock = new Mock( 207 | { 208 | method: 'GET', 209 | url: '/example', 210 | body: { 211 | key: 'value', 212 | }, 213 | }, 214 | { status: 200 } 215 | ); 216 | const matchingRequest = browserRequest.build({ 217 | url: '/example', 218 | body: { 219 | key: 'value', 220 | } as any, 221 | }); 222 | const nonMatchingRequest = browserRequest.build({ 223 | url: '/example', 224 | body: { 225 | key: 'another', 226 | } as any, 227 | }); 228 | expect(mock.getResponseForRequest(matchingRequest)).toBeTruthy(); 229 | expect(mock.getResponseForRequest(nonMatchingRequest)).toBeFalsy(); 230 | }); 231 | 232 | test('.getResponseForRequest returns truthy when filter.body matches request body regardless of key order ', () => { 233 | const mock = new Mock( 234 | { 235 | method: 'GET', 236 | url: '/example', 237 | body: { 238 | key1: 'value1', 239 | key2: 'value2', 240 | }, 241 | }, 242 | { status: 200 } 243 | ); 244 | const matchingRequest = browserRequest.build({ 245 | url: '/example', 246 | body: { 247 | key2: 'value2', 248 | key1: 'value1', 249 | } as any, 250 | }); 251 | expect(mock.getResponseForRequest(matchingRequest)).toBeTruthy(); 252 | }); 253 | 254 | test('.getResponseForRequest returns truthy when filter body matches request body and body is string', () => { 255 | const mock = new Mock( 256 | { 257 | method: 'GET', 258 | url: '/example', 259 | body: 'body_value', 260 | }, 261 | { status: 200 } 262 | ); 263 | const matchingRequest = browserRequest.build({ 264 | url: '/example', 265 | body: 'body_value' as any, 266 | }); 267 | expect(mock.getResponseForRequest(matchingRequest)).toBeTruthy(); 268 | }); 269 | -------------------------------------------------------------------------------- /test/unit/mockiavelli.test.ts: -------------------------------------------------------------------------------- 1 | import { Mockiavelli, Mock } from '../../src'; 2 | import { BrowserPage } from '../../src/controllers/BrowserControllerFactory'; 3 | import { createMockPage } from './fixtures/page'; 4 | jest.mock('../../src/mock'); 5 | jest.mock('../../src/controllers/BrowserControllerFactory', () => ({ 6 | BrowserControllerFactory: class { 7 | static createForPage = jest.fn().mockReturnValue({ 8 | startInterception: jest.fn(), 9 | }); 10 | }, 11 | })); 12 | 13 | describe('Mockiavelli', () => { 14 | describe('setup()', () => { 15 | test('returns a promise resolving to instance of Mockiavelli', async () => { 16 | const page = createMockPage(); 17 | await expect(Mockiavelli.setup(page)).resolves.toBeInstanceOf( 18 | Mockiavelli 19 | ); 20 | }); 21 | }); 22 | 23 | describe('mock http methods', () => { 24 | let mockiavelli: Mockiavelli; 25 | const url = 'url'; 26 | const filter = { url }; 27 | const filterWithQuery = { 28 | url, 29 | query: { 30 | param: 'fooParam', 31 | }, 32 | }; 33 | const mockResponse = { status: 404, body: {} }; 34 | 35 | beforeEach(async () => { 36 | mockiavelli = new Mockiavelli({} as BrowserPage); 37 | }); 38 | 39 | describe('mock()', () => { 40 | test('should add API base url to request matcher', () => { 41 | const mockiavelli = new Mockiavelli({} as BrowserPage, { 42 | baseUrl: '/api/foo', 43 | }); 44 | mockiavelli.mock({ url: '/boo', method: 'GET' }, mockResponse); 45 | expect(Mock).toHaveBeenCalledWith( 46 | { url: '/api/foo/boo', method: 'GET' }, 47 | mockResponse, 48 | expect.anything() 49 | ); 50 | }); 51 | }); 52 | 53 | describe('mockGET()', () => { 54 | test('should create RestMock using GET method and filter as object', () => { 55 | mockiavelli.mockGET(filter, mockResponse); 56 | expect(Mock).toHaveBeenCalledWith( 57 | expect.objectContaining({ method: 'GET', ...filter }), 58 | mockResponse, 59 | expect.anything() 60 | ); 61 | }); 62 | 63 | test('should create RestMock using GET method with filter and query as objects', () => { 64 | mockiavelli.mockGET(filterWithQuery, mockResponse); 65 | expect(Mock).toHaveBeenCalledWith( 66 | expect.objectContaining({ 67 | method: 'GET', 68 | ...filterWithQuery, 69 | }), 70 | mockResponse, 71 | expect.anything() 72 | ); 73 | }); 74 | 75 | test('should create RestMock using GET method and filter as url string', () => { 76 | mockiavelli.mockGET(url, mockResponse); 77 | expect(Mock).toHaveBeenCalledWith( 78 | expect.objectContaining({ method: 'GET', ...filter }), 79 | mockResponse, 80 | expect.anything() 81 | ); 82 | }); 83 | }); 84 | 85 | describe('mockPOST()', () => { 86 | test('should create RestMock using POST method and filter as object', () => { 87 | mockiavelli.mockPOST(filter, mockResponse); 88 | expect(Mock).toHaveBeenCalledWith( 89 | expect.objectContaining({ 90 | method: 'POST', 91 | ...filter, 92 | }), 93 | mockResponse, 94 | expect.anything() 95 | ); 96 | }); 97 | 98 | test('should create RestMock using POST method with filter and query as objects', () => { 99 | mockiavelli.mockPOST(filterWithQuery, mockResponse); 100 | expect(Mock).toHaveBeenCalledWith( 101 | expect.objectContaining({ 102 | method: 'POST', 103 | ...filterWithQuery, 104 | }), 105 | mockResponse, 106 | expect.anything() 107 | ); 108 | }); 109 | 110 | test('should create RestMock using POST method and filter as url string', () => { 111 | mockiavelli.mockPOST(url, mockResponse); 112 | expect(Mock).toHaveBeenCalledWith( 113 | expect.objectContaining({ 114 | method: 'POST', 115 | ...filter, 116 | }), 117 | mockResponse, 118 | expect.anything() 119 | ); 120 | }); 121 | }); 122 | 123 | describe('mockPUT()', () => { 124 | test('should create RestMock using PUT method and filter as object', () => { 125 | mockiavelli.mockPUT(filter, mockResponse); 126 | expect(Mock).toHaveBeenCalledWith( 127 | expect.objectContaining({ method: 'PUT', ...filter }), 128 | mockResponse, 129 | expect.anything() 130 | ); 131 | }); 132 | 133 | test('should create RestMock using PUT method with filter and query as objects', () => { 134 | mockiavelli.mockPUT(filterWithQuery, mockResponse); 135 | expect(Mock).toHaveBeenCalledWith( 136 | expect.objectContaining({ 137 | method: 'PUT', 138 | ...filterWithQuery, 139 | }), 140 | mockResponse, 141 | expect.anything() 142 | ); 143 | }); 144 | 145 | test('should create RestMock using PUT method and filter as url string', () => { 146 | mockiavelli.mockPUT(url, mockResponse); 147 | expect(Mock).toHaveBeenCalledWith( 148 | expect.objectContaining({ method: 'PUT', ...filter }), 149 | mockResponse, 150 | expect.anything() 151 | ); 152 | }); 153 | }); 154 | 155 | describe('mockDELETE()', () => { 156 | test('should create RestMock using DELETE method and filter as object', () => { 157 | mockiavelli.mockDELETE(filter, mockResponse); 158 | expect(Mock).toHaveBeenCalledWith( 159 | expect.objectContaining({ 160 | method: 'DELETE', 161 | ...filter, 162 | }), 163 | mockResponse, 164 | expect.anything() 165 | ); 166 | }); 167 | 168 | test('should create RestMock using DELETE method with filter and query as objects', () => { 169 | mockiavelli.mockDELETE(filterWithQuery, mockResponse); 170 | expect(Mock).toHaveBeenCalledWith( 171 | expect.objectContaining({ 172 | method: 'DELETE', 173 | ...filterWithQuery, 174 | }), 175 | mockResponse, 176 | expect.anything() 177 | ); 178 | }); 179 | 180 | test('should create RestMock using DELETE method and filter as url string', () => { 181 | mockiavelli.mockDELETE(url, mockResponse); 182 | expect(Mock).toHaveBeenCalledWith( 183 | expect.objectContaining({ 184 | method: 'DELETE', 185 | ...filter, 186 | }), 187 | mockResponse, 188 | expect.anything() 189 | ); 190 | }); 191 | }); 192 | 193 | describe('mockPUT()', () => { 194 | test('should create RestMock using PATCH method and filter as url string', () => { 195 | mockiavelli.mockPATCH(url, mockResponse); 196 | expect(Mock).toHaveBeenCalledWith( 197 | expect.objectContaining({ 198 | method: 'PATCH', 199 | ...filter, 200 | }), 201 | mockResponse, 202 | expect.anything() 203 | ); 204 | }); 205 | }); 206 | }); 207 | }); 208 | -------------------------------------------------------------------------------- /test/unit/puppeteerController.test.ts: -------------------------------------------------------------------------------- 1 | import { PuppeteerController } from '../../src/controllers/PuppeteerController'; 2 | import { createMockPage } from './fixtures/page'; 3 | import { PuppeteerRequestMock } from './fixtures/PuppeteerRequest'; 4 | 5 | describe('PuppeteerAdapter', () => { 6 | test('.start() subscribes for page request event', async () => { 7 | const page = createMockPage(); 8 | const adapter = new PuppeteerController(page, () => {}); 9 | await adapter.startInterception(); 10 | expect(page.setRequestInterception).toHaveBeenCalledWith(true); 11 | expect(page.on).toHaveBeenCalledWith('request', expect.any(Function)); 12 | }); 13 | 14 | test('returns serialized request object', async () => { 15 | const page = createMockPage(); 16 | const handler = jest.fn(); 17 | const adapter = new PuppeteerController(page, handler); 18 | await adapter.startInterception(); 19 | 20 | // Trigger request 21 | page.on.mock.calls[0][1]( 22 | PuppeteerRequestMock.create({ 23 | postData: JSON.stringify({ foo: 'bar' }), 24 | url: 'http://example.com:8000/some/path?foo=bar#baz', 25 | method: 'GET', 26 | headers: { header: 'header' }, 27 | resourceType: 'xhr', 28 | }) 29 | ); 30 | 31 | expect(handler).toHaveBeenCalledWith( 32 | { 33 | headers: { 34 | header: 'header', 35 | }, 36 | hostname: 'http://example.com:8000', 37 | method: 'GET', 38 | path: '/some/path', 39 | query: { 40 | foo: 'bar', 41 | }, 42 | body: { 43 | foo: 'bar', 44 | }, 45 | sourceOrigin: 'http://example.com:8000', 46 | type: 'xhr', 47 | url: 'http://example.com:8000/some/path?foo=bar#baz', 48 | }, 49 | expect.anything(), 50 | expect.anything() 51 | ); 52 | }); 53 | 54 | test('returns correct path and url when origin contains trailing slash', async () => { 55 | const page = createMockPage(); 56 | const handler = jest.fn(); 57 | const adapter = new PuppeteerController(page, handler); 58 | await adapter.startInterception(); 59 | 60 | // Trigger request 61 | page.on.mock.calls[0][1]( 62 | PuppeteerRequestMock.create({ 63 | url: 'http://origin:8000/some/path', 64 | }) 65 | ); 66 | 67 | expect(handler).toHaveBeenCalledWith( 68 | expect.objectContaining({ 69 | url: 'http://origin:8000/some/path', 70 | path: '/some/path', 71 | }), 72 | expect.anything(), 73 | expect.anything() 74 | ); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | waitFor, 3 | addMockByPriority, 4 | createRequestMatcher, 5 | printResponse, 6 | getOrigin, 7 | } from '../../src/utils'; 8 | import { createRestMock } from './fixtures/request'; 9 | 10 | test('waitFor', async () => { 11 | const now = Date.now(); 12 | await expect(waitFor(() => true)).resolves.toEqual(undefined); 13 | await expect(waitFor(() => Date.now() > now)).resolves.toEqual(undefined); 14 | await expect(waitFor(() => Date.now() > now + 50)).resolves.toEqual( 15 | undefined 16 | ); 17 | await expect(waitFor(() => Date.now() > now - 50)).resolves.toEqual( 18 | undefined 19 | ); 20 | }); 21 | 22 | test('waitFor throws after 100ms by default', async () => { 23 | expect.assertions(1); 24 | const now = Date.now(); 25 | try { 26 | await waitFor(() => Date.now() > now + 150); 27 | } catch (e) { 28 | expect(e).not.toBeFalsy(); 29 | } 30 | }); 31 | 32 | test('waitFor throws after provided timeout', async () => { 33 | expect.assertions(1); 34 | const now = Date.now(); 35 | try { 36 | await waitFor(() => Date.now() > now + 55, 50); 37 | } catch (e) { 38 | expect(e).not.toBeFalsy(); 39 | } 40 | }); 41 | 42 | describe('addMockByPriority', () => { 43 | test('adds mocks with higher priority first', () => { 44 | const mocks = [createRestMock()]; 45 | const higherPriorityMock = createRestMock({}, { priority: 10 }); 46 | 47 | expect(addMockByPriority(mocks, higherPriorityMock)[0]).toBe( 48 | higherPriorityMock 49 | ); 50 | }); 51 | 52 | test('adds mock in correct order basing on priority', () => { 53 | const mocks = [createRestMock()]; 54 | const higherPriorityMock = createRestMock({}, { priority: 10 }); 55 | const middlePriorityMock = createRestMock({}, { priority: 5 }); 56 | 57 | expect(addMockByPriority(mocks, higherPriorityMock)[0]).toBe( 58 | higherPriorityMock 59 | ); 60 | expect(addMockByPriority(mocks, middlePriorityMock)[1]).toBe( 61 | middlePriorityMock 62 | ); 63 | }); 64 | 65 | test('adds mock to end when mock has lowest priority', () => { 66 | const mocks = [ 67 | createRestMock({}, { priority: 10 }), 68 | createRestMock({}, { priority: 5 }), 69 | ]; 70 | const lowestPriorityMock = createRestMock({}, { priority: 3 }); 71 | 72 | expect(addMockByPriority(mocks, lowestPriorityMock)[2]).toBe( 73 | lowestPriorityMock 74 | ); 75 | }); 76 | 77 | test('adds mock before mock with same priority', () => { 78 | const mocks = [ 79 | createRestMock({}, { priority: 10 }), 80 | createRestMock({}, { priority: 5 }), 81 | ]; 82 | const samePriorityMock = createRestMock({}, { priority: 5 }); 83 | 84 | expect(addMockByPriority(mocks, samePriorityMock)[1]).toBe( 85 | samePriorityMock 86 | ); 87 | }); 88 | }); 89 | 90 | describe('createRequestMatcher', () => { 91 | test('return object when provided input as string', () => { 92 | expect(createRequestMatcher('http://example.com', 'POST')).toEqual({ 93 | method: 'POST', 94 | url: 'http://example.com', 95 | }); 96 | }); 97 | 98 | test('return object when provided input as object', () => { 99 | expect( 100 | createRequestMatcher( 101 | { 102 | url: 'http://example.com', 103 | }, 104 | 'POST' 105 | ) 106 | ).toEqual({ 107 | url: 'http://example.com', 108 | method: 'POST', 109 | }); 110 | }); 111 | }); 112 | 113 | test('printResponse()', () => { 114 | expect( 115 | printResponse( 116 | 200, 117 | { 118 | 'content-type': 'text/html; charset=utf-8', 119 | date: 'Thu, 13 Feb 2020 17:03:57 GMT', 120 | 'content-language': 'de-DE, en-CA', 121 | }, 122 | Buffer.from(JSON.stringify({ foo: 'bar' })) 123 | ) 124 | ).toMatchSnapshot(); 125 | }); 126 | 127 | test.each([ 128 | ['http://example.com/endpoint', 'http://example.com'], 129 | ['https://example.com/endpoint', 'https://example.com'], 130 | ['http://example.com/', 'http://example.com'], 131 | ['http://example.com', 'http://example.com'], 132 | ['https://example.com:8080', 'https://example.com:8080'], 133 | ['http://user:pass@example.com', 'http://example.com'], 134 | ['', ''], 135 | [undefined, ''], 136 | ['not_ValiD_URL!', ''], 137 | ])('getOrigin(%s) returns %s', (url, expectedOrigin) => { 138 | expect(getOrigin(url as string)).toBe(expectedOrigin); 139 | }); 140 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true /* Generates corresponding '.d.ts' file. */, 11 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 12 | "sourceMap": true /* Generates corresponding '.map' file. */, 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./dist" /* Redirect output structure to the directory. */, 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "incremental": true, /* Enable incremental compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true /* Enable all strict type-checking options. */, 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 34 | 35 | /* Additional Checks */ 36 | "noUnusedLocals": true /* Report errors on unused locals. */, 37 | "noUnusedParameters": true /* Report errors on unused parameters. */, 38 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 39 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | }, 62 | "include": ["./src/**/*.ts"] 63 | } 64 | --------------------------------------------------------------------------------