├── .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 |
3 |
4 |
5 | Request mocking for Puppeteer and Playwright
6 |
7 |
8 | [](https://www.npmjs.com/package/mockiavelli) [](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 |
--------------------------------------------------------------------------------