├── .babelrc ├── .github └── workflows │ ├── ci_build.yml │ └── codeql-analysis.yml ├── .gitignore ├── .mocharc.json ├── .nycrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── docs ├── development.md └── logo.png ├── examples ├── request-handler │ ├── README.md │ ├── index.html │ ├── package.json │ ├── start.js │ ├── tapes │ │ ├── github │ │ │ └── user-profile.json5 │ │ └── weather │ │ │ └── weather.json5 │ └── yarn.lock ├── server │ ├── README.md │ ├── httpsCert │ │ ├── localhost.crt │ │ └── localhost.key │ ├── package.json │ ├── start.js │ ├── tapes │ │ ├── deep │ │ │ └── orgs-wildcard.json5 │ │ ├── fake-auth.json5 │ │ ├── fake-post.json5 │ │ ├── not-valid-request.json5 │ │ ├── profile-errorRate.json5 │ │ ├── slow-profile.json5 │ │ ├── talkback-repo.json5 │ │ ├── user-profile-HEAD.json5 │ │ ├── user-profile-compression-base64.json5 │ │ ├── user-profile-compression-plain.json5 │ │ └── user-profile.json5 │ ├── test_script.sh │ └── yarn.lock └── unit-tests │ ├── .mocharc.json │ ├── README.md │ ├── package.json │ ├── src │ ├── mocha-setup.js │ ├── talkback-start.js │ ├── tapes │ │ └── test-tape.json5 │ ├── test.jest.spec.js │ └── test.mocha.spec.js │ └── yarn.lock ├── mocha-setup.js ├── package.json ├── scripts ├── build.js └── test_examples.sh ├── src ├── es6.ts ├── features │ ├── error-rate.ts │ └── latency.ts ├── index.ts ├── logger.ts ├── options.ts ├── request-handler.ts ├── server.ts ├── summary.ts ├── talkback-factory.ts ├── tape-matcher.ts ├── tape-renderer.ts ├── tape-store.ts ├── tape.ts ├── types.ts └── utils │ ├── content-encoding.ts │ ├── headers.ts │ └── media-type.ts ├── test ├── features │ └── error-rate.spec.ts ├── integration │ └── talkback-server.spec.ts ├── logger.spec.ts ├── options.spec.ts ├── request-handler.spec.ts ├── summary.spec.ts ├── support │ ├── factories.ts │ └── test-server.ts ├── tape-matcher.spec.ts ├── tape-renderer.spec.ts ├── tapes │ ├── deep-directory │ │ └── echo.json5 │ ├── malformed-tape.json │ ├── pretty-printed-json.json5 │ └── saved-request.json5 └── utils │ ├── content-encoding.spec.ts │ ├── headers.spec.ts │ └── media-type.spec.ts ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | ["@babel/env", { 5 | "targets": { 6 | "node": "current" 7 | } 8 | }] 9 | ], 10 | "plugins": [ 11 | "istanbul", 12 | "@babel/plugin-proposal-class-properties", 13 | "@babel/plugin-proposal-object-rest-spread", 14 | "@babel/plugin-transform-runtime" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/ci_build.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [18.x, 20.x] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Get version 15 | run: TALKBACK_VERSION=$(cat package.json | jq -r '.version') && echo "TALKBACK_VERSION=$TALKBACK_VERSION" >> $GITHUB_ENV && echo $TALKBACK_VERSION 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Cache Node.js modules 21 | uses: actions/cache@v4 22 | with: 23 | # npm cache files are stored in `~/.npm` on Linux/macOS 24 | path: ~/.npm 25 | key: ${{ runner.OS }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.OS }}-node-${{ matrix.node-version }} 28 | - run: yarn install --frozen-lockfile 29 | - run: yarn ci 30 | - name: Save artifact 31 | if: ${{ success() && contains(matrix.node-version, '20') && contains(github.ref, 'refs/tags/v') }} 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: talkback-dist-${{ env.TALKBACK_VERSION }} 35 | path: ./dist 36 | retention-days: 1 37 | publish: 38 | needs: build 39 | if: ${{ success() && contains(github.ref, 'refs/tags/v') }} 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Get version 44 | run: TALKBACK_VERSION=$(cat package.json | jq -r '.version') && echo "TALKBACK_VERSION=$TALKBACK_VERSION" >> $GITHUB_ENV && echo $TALKBACK_VERSION 45 | - name: Use Node.js 20.x 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: '20.x' 49 | registry-url: 'https://registry.npmjs.org' 50 | always-auth: true 51 | - uses: actions/download-artifact@v4 52 | with: 53 | name: talkback-dist-${{ env.TALKBACK_VERSION }} 54 | path: ./dist 55 | - run: yarn publish 56 | working-directory: dist 57 | env: 58 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # From: https://github.com/github/codeql-action 2 | 3 | name: "Code Scanning - Action" 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | schedule: 11 | # ┌───────────── minute (0 - 59) 12 | # │ ┌───────────── hour (0 - 23) 13 | # │ │ ┌───────────── day of the month (1 - 31) 14 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 15 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 16 | # │ │ │ │ │ 17 | # │ │ │ │ │ 18 | # │ │ │ │ │ 19 | # * * * * * 20 | - cron: '30 1 * * 0' 21 | 22 | jobs: 23 | CodeQL-Build: 24 | # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest 25 | runs-on: ubuntu-latest 26 | 27 | permissions: 28 | # required for all workflows 29 | security-events: write 30 | 31 | # only required for workflows in private repositories 32 | actions: read 33 | contents: read 34 | 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v3 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v2 42 | with: 43 | languages: javascript 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below). 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v2 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following 54 | # three lines and modify them (or add more) to build your code if your 55 | # project uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | /dist/* 5 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "mocha-setup.js" 3 | } 4 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-typescript", 3 | "all": true, 4 | "include": ["src/**/*.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## v4.2.0 3 | - Add [`httpClient` option](/README.md#http-client-options) 4 | 5 | ## v4.1.0 6 | - Update default tape naming format and tapeGenerator to use epoch instead of counter as tape id - from `unnammed-1.json5` to `unnamed-1715145207909.json5` (thanks **[@raphaeleidus](https://github.com/raphaeleidus)**) 7 | 8 | ## v4.0.0 9 | - Drop support for node 14 and node 16. Minimum required version 18.0 10 | - Dependencies updates 11 | - Close process with exit code 0 when handling process signals (thank **[@unstubbable](https://github.com/unstubbable)**) 12 | 13 | ## v3.0.4 14 | - Dependencies updates 15 | 16 | ## v3.0.3 17 | - `application/graphql` added to the list of JSON media-types 18 | - Add logo! (thanks **[@denislutz](https://github.com/denislutz)**) 19 | 20 | ## v3.0.2 21 | - Fix: logger options getting overriden when running multiple instances with different logging options. 22 | - Dependencies updates 23 | 24 | ## v3.0.1 25 | - Drop support for node 10. Minimum required version 12.0 26 | - Add support for brotli (br) encoding 27 | - Dependencies updates 28 | 29 | ## v2.5.0 30 | - Structured log format 31 | 32 | ## v2.4.3 33 | - Add `allowHeaders` option 34 | - Dependencies updates 35 | 36 | ## v2.4.2 37 | - Add `application/x-amz-json-1.0` and `application/x-amz-json-1.1` as json media types (thanks **[@brandonc](https://github.com/brandonc)**) 38 | 39 | ## v2.4.1 40 | - Fix handling of responses with JSON content-type, but malformed body (thanks **[@SebFlippence](https://github.com/SebFlippence)**) 41 | - Dependencies updates 42 | 43 | ## v2.4.0 44 | - Add [`tapeDecorator` option](/README.md#custom-tape-decorator) 45 | - Add [`MatchingContext` object](/README.md#matching-context) as decorators parameter 46 | - Dependencies updates 47 | 48 | ## v2.3.0 49 | - Fix for node 15 (thanks **[@halilb](https://github.com/halilb)**) 50 | - Dependencies updates 51 | 52 | ## v2.2.2 53 | - Dependencies updates 54 | 55 | ## v2.2.1 56 | - Dependencies updates 57 | 58 | ## v2.2.0 59 | - Expose requestHandler as first-class citizen 60 | - Add requestHandler example 61 | 62 | ## v2.1.0 63 | - Add support for JSON Schema media-types 64 | - Rewrite talkback to Typescript 65 | - Now you can also `import talkback from "talkback/es6"` 66 | 67 | ## v2.0.0 68 | - Drop node 8 support. Min. required version is node 10 69 | - Order of properties is ignored when matching JSON tapes body 70 | 71 | ## v1.12.0 72 | - Store compressed (gzip, deflate) human-readable bodies as plain text 73 | 74 | ## v1.11.1 75 | - Dependencies updates 76 | 77 | ## v1.11.0 78 | - Add `latency` option 79 | - Add `errorRate` option 80 | - Add `requestDecorator` option 81 | - Expose default options as `talkback.Options.Default` 82 | 83 | ## v1.10.0 84 | - Load tapes from deep directories 85 | - Add `tapeNameGenerator` option (thanks **[@tartale](https://github.com/tartale)**) 86 | - Introduce record modes through `record` option. 87 | - Allow `record` option to take a function to change recording mode based on the request 88 | - Allow `fallbackMode` option to take a function to change fallback mode based on the request 89 | 90 | - Bugfix: wrong Content-Length when tapes contain multi-bytes characters (thanks **[@sebflipper](https://github.com/sebflipper)**) 91 | - **DEPRECATION**: `record` option will no longer take boolean values 92 | - **DEPRECATION**: `fallbackMode` options `404` and `proxy` have been replaced by `NOT_FOUND` and `PROXY` respectively 93 | 94 | ## v1.9.0 95 | - `responseDecorator` is now called for both matched tapes and the initial response returned by the proxied server 96 | 97 | ## v1.8.1 98 | - Fix bug with HEAD requests 99 | 100 | ## v1.8.0 101 | - Pretty print JSON requests & responses in saved tapes 102 | - Always ignore `content-length` header for tape matching 103 | - Add `name` option 104 | - Print `name` in Summary title 105 | 106 | ## v1.7.0 107 | - Add `https` server option. 108 | - Add `urlMatcher` option to customize how the request URL is matched against saved tapes. 109 | 110 | ## v1.6.0 111 | - Add `responseDecorator` option to add dynamism to saved tapes responses. 112 | - Add `hasTapeBeenUsed` and `resetTapeUsage` methods to the server interface (thanks **[@sjaakieb](https://github.com/sjaakieb)**) 113 | 114 | ## v1.5.0 115 | - Add `bodyMatcher` option to customize how the request body is matched against saved tapes. 116 | 117 | ## v1.4.0 118 | - Add `ignoreBody` option (thanks **[@meop](https://github.com/meop)**) 119 | - Add `fallbackMode` option to allow to proxy unknown requests if no tape exists. Defaults to 404 error (thanks **[@meop](https://github.com/meop)**) 120 | 121 | ## v1.3.0 122 | - Add `ignoreQueryParams` option 123 | - Updated dependencies 124 | 125 | ## v1.2.0 126 | - Add `debug` option to print verbose information about tapes matching. Defaults to `false` 127 | - Fix bug that mixed `req` and `res` humanReadable property (thanks **[@roypa](https://github.com/roypa)**) 128 | 129 | ## v1.1.4 130 | - Add `silent` option to mute information console output on requests 131 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-present Ignacio Piantanida 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. -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Build 4 | 5 | ## Tests 6 | 7 | ### Unit tests 8 | 9 | ### Integration tests 10 | 11 | ### Black box testing 12 | 13 | ## CI 14 | 15 | ## Release -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ijpiantanida/talkback/2f4cf8a693b529498de5b4fbf7a6f035bc15a172/docs/logo.png -------------------------------------------------------------------------------- /examples/request-handler/README.md: -------------------------------------------------------------------------------- 1 | ## Request Handler Example 2 | 3 | Example of talkback used as a library where requests are routed by the user. 4 | Multiple request handlers are instantiated for different hosts. 5 | 6 | 7 | To run tests `yarn test`. 8 | The `index.html` page is loaded with puppeteer. Requests are then intercepted and proxied through talkback with a portion of the response content rendered to the page. The test then asserts that the page has the expected content. 9 | 10 | 11 | The original tape responses were modified so that it's obvious if the tape is not being served. 12 | -------------------------------------------------------------------------------- /examples/request-handler/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
Loading...
4 |
Loading...
5 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/request-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "talkback-example-request-handler", 3 | "version": "0.0.1", 4 | "main": "start.js", 5 | "license": "MIT", 6 | "description": "Talkback Example - Request Handler", 7 | "dependencies": { 8 | "puppeteer": "^22.6.0" 9 | }, 10 | "devDependencies": { 11 | "talkback": "*" 12 | }, 13 | "author": "Ignacio Piantanida", 14 | "scripts": { 15 | "test": "node start.js" 16 | } 17 | } -------------------------------------------------------------------------------- /examples/request-handler/start.js: -------------------------------------------------------------------------------- 1 | let talkback 2 | if (process.env.USE_NPM) { 3 | talkback = require("talkback") 4 | console.log("Using NPM talkback") 5 | } else { 6 | talkback = require("../../dist") 7 | } 8 | const puppeteer = require("puppeteer") 9 | 10 | const githubHost = "https://api.github.com" 11 | const weatherHost = "https://api.open-meteo.com" 12 | 13 | 14 | async function start() { 15 | const requestHandlerGithub = await talkback.requestHandler({ 16 | host: githubHost, 17 | path: __dirname + "/tapes/github", 18 | record: process.env.RECORD === "true" ? talkback.Options.RecordMode.NEW : talkback.Options.RecordMode.DISABLED, 19 | debug: false, 20 | name: "Example - Request Handler Github", 21 | allowHeaders: [], 22 | summary: true, 23 | }) 24 | const requestHandlerWeather = await talkback.requestHandler({ 25 | host: weatherHost, 26 | path: __dirname + "/tapes/weather", 27 | record: process.env.RECORD === "true" ? talkback.Options.RecordMode.NEW : talkback.Options.RecordMode.DISABLED, 28 | debug: false, 29 | name: "Example - Request Handler Weather", 30 | allowHeaders: [], 31 | summary: true, 32 | }) 33 | 34 | const browser = await puppeteer.launch() 35 | const page = await browser.newPage() 36 | 37 | await page.setRequestInterception(true) 38 | page.on("request", async interceptedRequest => { 39 | const parsedUrl = new URL(interceptedRequest.url()) 40 | const parsedHost = `${parsedUrl.protocol}//${parsedUrl.hostname}` 41 | if (parsedHost == githubHost ) { 42 | let body = Buffer.alloc(0) 43 | if (interceptedRequest.postData()) { 44 | body = Buffer.from(interceptedRequest.postData()) 45 | } 46 | 47 | const talkbackRequest = { 48 | url: interceptedRequest.url().substring(githubHost.length), 49 | method: interceptedRequest.method(), 50 | headers: interceptedRequest.headers(), 51 | body: body 52 | } 53 | 54 | requestHandlerGithub.handle(talkbackRequest) 55 | .then(r => interceptedRequest.respond(r)) 56 | .catch(error => { 57 | console.log("Error handling talkback request", error) 58 | interceptedRequest.abort() 59 | }) 60 | } else if(parsedHost == weatherHost) { 61 | let body = Buffer.alloc(0) 62 | if (interceptedRequest.postData()) { 63 | body = Buffer.from(interceptedRequest.postData()) 64 | } 65 | 66 | const talkbackRequest = { 67 | url: interceptedRequest.url().substring(weatherHost.length), 68 | method: interceptedRequest.method(), 69 | headers: interceptedRequest.headers(), 70 | body: body 71 | } 72 | 73 | requestHandlerWeather.handle(talkbackRequest) 74 | .then(r => interceptedRequest.respond(r)) 75 | .catch(error => { 76 | console.log("Error handling talkback request", error) 77 | interceptedRequest.abort() 78 | }) 79 | } else { 80 | interceptedRequest.continue() 81 | } 82 | }) 83 | 84 | await page.goto("file://" + __dirname + "/index.html", {waitUntil: "networkidle2"}) 85 | const elementContent = await page.$eval("#github-content", e => e.innerText) 86 | const weatherContent = await page.$eval("#weather-content", e => e.innerText) 87 | await browser.close() 88 | 89 | if (elementContent === "ijpiantanida-from-tape" && weatherContent === "380000") { 90 | console.log("SUCCESS") 91 | } else { 92 | console.log("FAILED") 93 | process.exit(1) 94 | } 95 | 96 | } 97 | 98 | start() 99 | .catch(err => { 100 | console.log(err) 101 | }) 102 | -------------------------------------------------------------------------------- /examples/request-handler/tapes/github/user-profile.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2022-09-02T03:17:48.478Z', 4 | host: 'https://api.github.com', 5 | resHumanReadable: true, 6 | }, 7 | req: { 8 | url: '/users/ijpiantanida', 9 | method: 'GET', 10 | headers: {}, 11 | body: '', 12 | }, 13 | res: { 14 | status: 200, 15 | headers: { 16 | server: [ 17 | 'GitHub.com', 18 | ], 19 | date: [ 20 | 'Fri, 02 Sep 2022 03:17:48 GMT', 21 | ], 22 | 'content-type': [ 23 | 'application/json; charset=utf-8', 24 | ], 25 | 'cache-control': [ 26 | 'public, max-age=60, s-maxage=60', 27 | ], 28 | vary: [ 29 | 'Accept, Accept-Encoding, Accept, X-Requested-With', 30 | ], 31 | etag: [ 32 | 'W/"13c20ec7fc400a67438c23920cce1d19bf63c19e15d2e03f9a2ae2c1dc90eacd"', 33 | ], 34 | 'last-modified': [ 35 | 'Wed, 24 Aug 2022 11:07:39 GMT', 36 | ], 37 | 'x-github-media-type': [ 38 | 'github.v3; format=json', 39 | ], 40 | 'access-control-expose-headers': [ 41 | 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset', 42 | ], 43 | 'access-control-allow-origin': [ 44 | '*', 45 | ], 46 | 'strict-transport-security': [ 47 | 'max-age=31536000; includeSubdomains; preload', 48 | ], 49 | 'x-frame-options': [ 50 | 'deny', 51 | ], 52 | 'x-content-type-options': [ 53 | 'nosniff', 54 | ], 55 | 'x-xss-protection': [ 56 | '0', 57 | ], 58 | 'referrer-policy': [ 59 | 'origin-when-cross-origin, strict-origin-when-cross-origin', 60 | ], 61 | 'content-security-policy': [ 62 | "default-src 'none'", 63 | ], 64 | 'x-ratelimit-limit': [ 65 | '60', 66 | ], 67 | 'x-ratelimit-remaining': [ 68 | '56', 69 | ], 70 | 'x-ratelimit-reset': [ 71 | '1662092091', 72 | ], 73 | 'x-ratelimit-resource': [ 74 | 'core', 75 | ], 76 | 'x-ratelimit-used': [ 77 | '4', 78 | ], 79 | 'accept-ranges': [ 80 | 'bytes', 81 | ], 82 | 'content-length': [ 83 | '1389', 84 | ], 85 | 'x-github-request-id': [ 86 | 'D9B5:4F9F:4796F0C:49983AF:631175DC', 87 | ], 88 | connection: [ 89 | 'close', 90 | ], 91 | }, 92 | body: { 93 | login: 'ijpiantanida-from-tape', 94 | id: 1858238, 95 | node_id: 'MDQ6VXNlcjE4NTgyMzg=', 96 | avatar_url: 'https://avatars.githubusercontent.com/u/1858238?v=4', 97 | gravatar_id: '', 98 | url: 'https://api.github.com/users/ijpiantanida', 99 | html_url: 'https://github.com/ijpiantanida', 100 | followers_url: 'https://api.github.com/users/ijpiantanida/followers', 101 | following_url: 'https://api.github.com/users/ijpiantanida/following{/other_user}', 102 | gists_url: 'https://api.github.com/users/ijpiantanida/gists{/gist_id}', 103 | starred_url: 'https://api.github.com/users/ijpiantanida/starred{/owner}{/repo}', 104 | subscriptions_url: 'https://api.github.com/users/ijpiantanida/subscriptions', 105 | organizations_url: 'https://api.github.com/users/ijpiantanida/orgs', 106 | repos_url: 'https://api.github.com/users/ijpiantanida/repos', 107 | events_url: 'https://api.github.com/users/ijpiantanida/events{/privacy}', 108 | received_events_url: 'https://api.github.com/users/ijpiantanida/received_events', 109 | type: 'User', 110 | site_admin: false, 111 | name: 'Ignacio Piantanida', 112 | company: '8th Light', 113 | blog: '', 114 | location: 'Los Angeles, USA', 115 | email: null, 116 | hireable: true, 117 | bio: 'One line at a time', 118 | twitter_username: null, 119 | public_repos: 17, 120 | public_gists: 1, 121 | followers: 19, 122 | following: 0, 123 | created_at: '2012-06-17T04:39:06Z', 124 | updated_at: '2022-08-24T11:07:39Z', 125 | }, 126 | }, 127 | } -------------------------------------------------------------------------------- /examples/server/README.md: -------------------------------------------------------------------------------- 1 | ## Server Requests Example 2 | 3 | Example of a talkback standalone server proxying and recording Github API requests. 4 | 5 | To run tests `yarn test` 6 | -------------------------------------------------------------------------------- /examples/server/httpsCert/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDXTCCAkWgAwIBAgIJALQIqgtg8b5XMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV 3 | BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX 4 | aWRnaXRzIFB0eSBMdGQwHhcNMTgxMTA3MDYzMTUzWhcNMTkxMTA3MDYzMTUzWjBF 5 | MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 6 | ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 7 | CgKCAQEAsPe/RyN2Ym60huUXC4fVq8ck+pJY7BIO1DBRCwvmwXJUsRIzigmzf5lY 8 | 1vmRhdOySZSqI3zY3/Hta1+iczGzC1p6ChlQuIfWfnsRxL1Qm/xkoTAm7iC1IPAZ 9 | jKOJw5Dw1aRfbgQPovQPn++MBUfsgLwip44RFWl+pjRbMbZzpxBanjRP/RG58MpN 10 | Z63UR0adMSrGNzW630gjxxlJYDMkFeiUVR/sV1a+ChIWaXHN5gTtfkixOA1XbMHA 11 | kg1SDXRBm/3h8CzcwaGM3UdQvkwoV8rFVK0StKQ2wCXkfgZtsv/Wx7H1JHCe1Yl/ 12 | kbsWRMEK07ZJyqY6+CryBut9wVNpvQIDAQABo1AwTjAdBgNVHQ4EFgQU1A7Od97F 13 | fDVmFogHH3baWdxGwsIwHwYDVR0jBBgwFoAU1A7Od97FfDVmFogHH3baWdxGwsIw 14 | DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAN7euotfHBXlOcwrjZ9C9 15 | Lb3BzRF6MLKhTcW8GdM9CvUYaTrzWyiH6ORU5yBM7eo5tcnTPSFmhvQqjGACrLQ+ 16 | Haz/RLXc+ZRnmL2OZmdYhVE428mtU36zeWC/tTdyu3R+7K/Yhg10XlME7q/GSyE7 17 | bvYULIWqD37pe72xIwyg2LuMHXwaVj1alvuZjphdVm/Eqk59OU6vsT4eNEDgZLNC 18 | Hc8+SForOqGtLLHrQedonvS9AdLNSYPhaU2J72pzK5CMgUpk64be65gpuyaVhQtB 19 | Z1SMsDzyVT9XbkmW2QT30xXRGf6xIziw0MvxYrD7OT3VUYNWcodnR4/v9EHgV2LA 20 | vA== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /examples/server/httpsCert/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCw979HI3ZibrSG 3 | 5RcLh9WrxyT6kljsEg7UMFELC+bBclSxEjOKCbN/mVjW+ZGF07JJlKojfNjf8e1r 4 | X6JzMbMLWnoKGVC4h9Z+exHEvVCb/GShMCbuILUg8BmMo4nDkPDVpF9uBA+i9A+f 5 | 74wFR+yAvCKnjhEVaX6mNFsxtnOnEFqeNE/9Ebnwyk1nrdRHRp0xKsY3NbrfSCPH 6 | GUlgMyQV6JRVH+xXVr4KEhZpcc3mBO1+SLE4DVdswcCSDVINdEGb/eHwLNzBoYzd 7 | R1C+TChXysVUrRK0pDbAJeR+Bm2y/9bHsfUkcJ7ViX+RuxZEwQrTtknKpjr4KvIG 8 | 633BU2m9AgMBAAECggEAXE+/nVIoTMxGqx8RWFhw4vwsk/CHJg19Yr4ZaFO6+Sm0 9 | d/FwpQ7ObT8Gkoz4lgCJvcwou/5B+v9tw1bNnJ1OMhvuERqHetqQzsVPzXqbc+LC 10 | czp09D5nfVkBWtVr5XHTzv3BMdg4d94r0FfaiF6uRbDdut1ml+7Bu90PvOzZg0ks 11 | IPyjVPvmEg34MyTEdfJVUGhUOrkleEyv/s6L3BJjp+PVDxJC9YxA0IBhQdM7QUQ4 12 | jP/UKqNdEYREOyk/vwCmF8cU3FP/QK5wWd3YMCJNQq2+5IkfpZv7RlwTW5F1sjv0 13 | voKEXSk2GbKEIUBTFDM2ESAC+d2MpCLuUjG6Sk+jAQKBgQDiY2bEEkJOvUw+oSUl 14 | BhIbpPmUoe06KZ2zmIjE9c7QrXJwl41lnfQJRldStCIremnZ+IHwz3zRcx8fTyRb 15 | BZFRrNGF0dXzu5ZUQWh3Y2ZzuUhUywHvPcjtmUj6SBZlOz66Ei1SJ4o5umsuTGnA 16 | v0r8R45u570IbOGIm1fJrpqeHQKBgQDIHYCbnaexMEHRmgAZKnqtpiZYfDdxUUoa 17 | yZYVas8z9Fl3fAnge2rGpC3kxpNllJn4JpDATNSzSeLtd9PH5YqgEwTn6tAEO0Yl 18 | wCxLTKaNcn/4vEpPwqwhwpFMdjZa5yPcyMEEVxBlSzg2u8Q7+1eTrK9V0U8kLwCb 19 | D2in/IqoIQKBgGBb/Obh+rU3H5fc0Umj/tsjalQYZDgIdKZ3+2cSVVg/K2G/MCEb 20 | jT7RYOPD5nNpJFrxyqUsO62O+aVC82+GvCbujzQNb6rRopf0Sznd5kLFj4L/8a/a 21 | NYbkYsqdGmM2R2m9yOqaB9ywe2R2g+DVy138OyT7oFtQtOKHdGNU3V0FAoGBAKQV 22 | lMOElOCzyfQ8iwIXk7nY964sRCW1Wsb2LgrnpnhaThWr7klTySyRqFPjAy8SluEj 23 | diNHnExaNCk0zMEmlPFGRwqGvgQKOi1wEqG3ewWWMhpZAbG+1Pdlm1APyefliMFb 24 | FvEhFn+IGtK+SVxJbfjXank6g+MOaze5fb3oVCUhAoGAVVaLc7eNG0MrFu8wavXN 25 | JaZ2DFWMGL9fP0UoP1NpYZe/JH31EKReRW6knwf9zTo7N4Hk03vwxVnsAtXFYNiY 26 | RkOuXClwIx4TRY/Q7V7Ylf1fx174jmZbH4rxcm2dUS4U1U3rlQOctb0BpnYm5BgH 27 | hUSWqfeqqzDtDjQlqUpvUS4= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /examples/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "talkback-example-server", 3 | "version": "0.0.1", 4 | "main": "start.js", 5 | "license": "MIT", 6 | "description": "Talkback Example - Server", 7 | "dependencies": {}, 8 | "devDependencies": { 9 | "talkback": "*" 10 | }, 11 | "author": "Ignacio Piantanida", 12 | "scripts": { 13 | "test": "node start.js & TALKBACK_PID=$! && sleep 1 && ./test_script.sh && kill -15 $TALKBACK_PID" 14 | } 15 | } -------------------------------------------------------------------------------- /examples/server/start.js: -------------------------------------------------------------------------------- 1 | var talkback 2 | if (process.env.USE_NPM) { 3 | talkback = require("talkback") 4 | console.log("Using NPM talkback") 5 | } else { 6 | talkback = require("../../dist") 7 | } 8 | 9 | var host = "https://api.github.com" 10 | 11 | function fallbackMode(req) { 12 | if (req.url.includes("/mytest")) { 13 | return talkback.Options.FallbackMode.PROXY 14 | } 15 | return talkback.Options.FallbackMode.NOT_FOUND 16 | } 17 | 18 | function bodyMatcher(tape, req) { 19 | if (tape.meta.tag === "fake-post") { 20 | var tapeBody = JSON.parse(tape.req.body.toString()) 21 | var reqBody = JSON.parse(req.body.toString()) 22 | 23 | return tapeBody.username === reqBody.username 24 | } 25 | return false 26 | } 27 | 28 | function urlMatcher(tape, req) { 29 | if (tape.meta.tag === "orgs-wildcard") { 30 | return !!req.url.match(/\/orgs\/[a-zA-Z0-9]/) 31 | } 32 | return false 33 | } 34 | 35 | var requestStartTime = {} 36 | 37 | function requestDecorator(req, context) { 38 | requestStartTime[context.id] = new Date().getTime() 39 | 40 | const acceptEncoding = req.headers["accept-encoding"] 41 | if (acceptEncoding && acceptEncoding.includes("test")) { 42 | delete req.headers["accept-encoding"] 43 | } 44 | return req 45 | } 46 | 47 | function tapeDecorator(tape, context) { 48 | var originalDurationMs = new Date().getTime() - requestStartTime[context.id] 49 | delete requestStartTime[context.id] 50 | 51 | tape.meta.originalDurationMs = originalDurationMs 52 | tape.meta.latency = [Math.floor(0.5*originalDurationMs), Math.floor(1.5*originalDurationMs)] 53 | 54 | return tape 55 | } 56 | 57 | function responseDecorator(tape, req, context) { 58 | if (tape.meta.tag === "auth") { 59 | var tapeBody = JSON.parse(tape.res.body.toString()) 60 | var expiration = new Date() 61 | expiration.setDate(expiration.getDate() + 1) 62 | var expirationEpoch = Math.floor(expiration.getTime() / 1000) 63 | tapeBody.expiration = expirationEpoch 64 | 65 | var newBody = JSON.stringify(tapeBody) 66 | tape.res.body = Buffer.from(newBody) 67 | } 68 | return tape 69 | } 70 | 71 | var server = talkback({ 72 | host: host, 73 | path: __dirname + "/tapes", 74 | record: process.env.RECORD === "true" ? talkback.Options.RecordMode.NEW : talkback.Options.RecordMode.DISABLED, 75 | fallbackMode: fallbackMode, 76 | debug: false, 77 | name: "Example - Server", 78 | ignoreQueryParams: ["t"], 79 | ignoreHeaders: ["user-agent"], 80 | bodyMatcher: bodyMatcher, 81 | urlMatcher: urlMatcher, 82 | requestDecorator: requestDecorator, 83 | responseDecorator: responseDecorator, 84 | tapeDecorator: tapeDecorator, 85 | https: { 86 | enabled: true, 87 | keyPath: __dirname + "/httpsCert/localhost.key", 88 | certPath: __dirname + "/httpsCert/localhost.crt" 89 | } 90 | }) 91 | 92 | server.start() 93 | -------------------------------------------------------------------------------- /examples/server/tapes/deep/orgs-wildcard.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2018-11-07T07:28:44.060Z', 4 | host: 'https://api.github.com', 5 | resHumanReadable: true, 6 | tag: "orgs-wildcard" 7 | }, 8 | req: { 9 | url: '/orgs/github', 10 | method: 'GET', 11 | headers: { 12 | 'user-agent': 'curl/7.54.0', 13 | accept: '*/*', 14 | }, 15 | body: '', 16 | }, 17 | res: { 18 | status: 200, 19 | headers: { 20 | server: [ 21 | 'GitHub.com', 22 | ], 23 | date: [ 24 | 'Wed, 07 Nov 2018 07:28:44 GMT', 25 | ], 26 | 'content-type': [ 27 | 'application/json; charset=utf-8', 28 | ], 29 | 'content-length': [ 30 | '1113', 31 | ], 32 | connection: [ 33 | 'close', 34 | ], 35 | status: [ 36 | '200 OK', 37 | ], 38 | 'x-ratelimit-limit': [ 39 | '60', 40 | ], 41 | 'x-ratelimit-remaining': [ 42 | '56', 43 | ], 44 | 'x-ratelimit-reset': [ 45 | '1541579280', 46 | ], 47 | 'cache-control': [ 48 | 'public, max-age=60, s-maxage=60', 49 | ], 50 | vary: [ 51 | 'Accept', 52 | ], 53 | etag: [ 54 | '"2563edeb951f3eef53fe01172326b159"', 55 | ], 56 | 'last-modified': [ 57 | 'Tue, 06 Nov 2018 21:24:34 GMT', 58 | ], 59 | 'x-github-media-type': [ 60 | 'github.v3; format=json', 61 | ], 62 | 'access-control-expose-headers': [ 63 | 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type', 64 | ], 65 | 'access-control-allow-origin': [ 66 | '*', 67 | ], 68 | 'strict-transport-security': [ 69 | 'max-age=31536000; includeSubdomains; preload', 70 | ], 71 | 'x-frame-options': [ 72 | 'deny', 73 | ], 74 | 'x-content-type-options': [ 75 | 'nosniff', 76 | ], 77 | 'x-xss-protection': [ 78 | '1; mode=block', 79 | ], 80 | 'referrer-policy': [ 81 | 'origin-when-cross-origin, strict-origin-when-cross-origin', 82 | ], 83 | 'content-security-policy': [ 84 | "default-src 'none'", 85 | ], 86 | 'x-github-request-id': [ 87 | 'F925:6D28:806E7C:11B84DE:5BE2942C', 88 | ], 89 | }, 90 | body: '{\n "login": "github",\n "id": 9919,\n "node_id": "MDEyOk9yZ2FuaXphdGlvbjk5MTk=",\n "url": "https://api.github.com/orgs/github",\n "repos_url": "https://api.github.com/orgs/github/repos",\n "events_url": "https://api.github.com/orgs/github/events",\n "hooks_url": "https://api.github.com/orgs/github/hooks",\n "issues_url": "https://api.github.com/orgs/github/issues",\n "members_url": "https://api.github.com/orgs/github/members{/member}",\n "public_members_url": "https://api.github.com/orgs/github/public_members{/member}",\n "avatar_url": "https://avatars1.githubusercontent.com/u/9919?v=4",\n "description": "How people build software.",\n "name": "GitHub",\n "company": null,\n "blog": "https://github.com/about",\n "location": "San Francisco, CA",\n "email": "support@github.com",\n "is_verified": true,\n "has_organization_projects": true,\n "has_repository_projects": true,\n "public_repos": 283,\n "public_gists": 0,\n "followers": 0,\n "following": 0,\n "html_url": "https://github.com/github",\n "created_at": "2008-05-11T04:37:31Z",\n "updated_at": "2018-11-06T21:24:34Z",\n "type": "Organization Wildcard Tape"\n}\n', 91 | }, 92 | } 93 | -------------------------------------------------------------------------------- /examples/server/tapes/fake-auth.json5: -------------------------------------------------------------------------------- 1 | // Tapes can have comments because they are JSON5 2 | { 3 | meta: { 4 | createdAt: "2017-09-13T23:36:27.952Z", 5 | host: "https://api.github.com", 6 | resHumanReadable: true, 7 | reqHumanReadable: true, 8 | tag: "auth" 9 | }, 10 | req: { 11 | url: "/auth", 12 | method: "POST", 13 | headers: { 14 | "user-agent": "curl/7.54.0", 15 | "content-type": "application/json", 16 | accept: "*/*" 17 | }, 18 | body: "{\"username\": \"james\", \"password\": \"moriarty\"}" 19 | }, 20 | res: { 21 | status: 200, 22 | headers: { 23 | server: [ 24 | "GitHub.com" 25 | ], 26 | date: [ 27 | "Wed, 13 Sep 2017 23:36:28 GMT" 28 | ], 29 | "content-type": [ 30 | "application/json; charset=utf-8" 31 | ], 32 | connection: [ 33 | "close" 34 | ], 35 | status: [ 36 | "200 OK" 37 | ], 38 | "x-ratelimit-limit": [ 39 | "60" 40 | ], 41 | "x-ratelimit-remaining": [ 42 | "59" 43 | ], 44 | "x-ratelimit-reset": [ 45 | "1505349388" 46 | ], 47 | "cache-control": [ 48 | "public, max-age=60, s-maxage=60" 49 | ], 50 | vary: [ 51 | "Accept" 52 | ], 53 | etag: [ 54 | "\"052de87e9283eccfa78a9f70e5cf4830\"" 55 | ], 56 | "last-modified": [ 57 | "Fri, 25 Aug 2017 04:10:24 GMT" 58 | ], 59 | "x-github-media-type": [ 60 | "github.v3; format=json" 61 | ], 62 | "access-control-expose-headers": [ 63 | "ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval" 64 | ], 65 | "access-control-allow-origin": [ 66 | "*" 67 | ], 68 | "content-security-policy": [ 69 | "default-src 'none'" 70 | ], 71 | "strict-transport-security": [ 72 | "max-age=31536000; includeSubdomains; preload" 73 | ], 74 | "x-content-type-options": [ 75 | "nosniff" 76 | ], 77 | "x-frame-options": [ 78 | "deny" 79 | ], 80 | "x-xss-protection": [ 81 | "1; mode=block" 82 | ], 83 | "x-runtime-rack": [ 84 | "0.033883" 85 | ], 86 | "x-github-request-id": [ 87 | "CE64:309E:255CC6F:4E0823D:59B9C0FC" 88 | ] 89 | }, 90 | body: "{\"token\": \"abcdefg\", \"expiration\": 1533942696}" 91 | } 92 | } -------------------------------------------------------------------------------- /examples/server/tapes/fake-post.json5: -------------------------------------------------------------------------------- 1 | // Tapes can have comments because they are JSON5 2 | { 3 | meta: { 4 | createdAt: "2017-09-13T23:36:27.952Z", 5 | host: "https://api.github.com", 6 | resHumanReadable: true, 7 | reqHumanReadable: true, 8 | tag: "fake-post" 9 | }, 10 | req: { 11 | url: "/users", 12 | method: "POST", 13 | headers: { 14 | "user-agent": "curl/7.54.0", 15 | "content-type": "application/json", 16 | accept: "*/*" 17 | }, 18 | body: { 19 | username: "james", 20 | ignore: 3, 21 | } 22 | }, 23 | res: { 24 | status: 200, 25 | headers: { 26 | server: [ 27 | "GitHub.com" 28 | ], 29 | date: [ 30 | "Wed, 13 Sep 2017 23:36:28 GMT" 31 | ], 32 | "content-type": [ 33 | "application/json; charset=utf-8" 34 | ], 35 | "content-length": [ 36 | "1376" 37 | ], 38 | connection: [ 39 | "close" 40 | ], 41 | status: [ 42 | "200 OK" 43 | ], 44 | "x-ratelimit-limit": [ 45 | "60" 46 | ], 47 | "x-ratelimit-remaining": [ 48 | "59" 49 | ], 50 | "x-ratelimit-reset": [ 51 | "1505349388" 52 | ], 53 | "cache-control": [ 54 | "public, max-age=60, s-maxage=60" 55 | ], 56 | vary: [ 57 | "Accept" 58 | ], 59 | etag: [ 60 | "\"052de87e9283eccfa78a9f70e5cf4830\"" 61 | ], 62 | "last-modified": [ 63 | "Fri, 25 Aug 2017 04:10:24 GMT" 64 | ], 65 | "x-github-media-type": [ 66 | "github.v3; format=json" 67 | ], 68 | "access-control-expose-headers": [ 69 | "ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval" 70 | ], 71 | "access-control-allow-origin": [ 72 | "*" 73 | ], 74 | "content-security-policy": [ 75 | "default-src 'none'" 76 | ], 77 | "strict-transport-security": [ 78 | "max-age=31536000; includeSubdomains; preload" 79 | ], 80 | "x-content-type-options": [ 81 | "nosniff" 82 | ], 83 | "x-frame-options": [ 84 | "deny" 85 | ], 86 | "x-xss-protection": [ 87 | "1; mode=block" 88 | ], 89 | "x-runtime-rack": [ 90 | "0.033883" 91 | ], 92 | "x-github-request-id": [ 93 | "CE64:309E:255CC6F:4E0823D:59B9C0FC" 94 | ] 95 | }, 96 | body: "{\n \"login\": \"ijpiantanida\",\n \"id\": 1858238,\n \"avatar_url\": \"https://avatars3.githubusercontent.com/u/1858238?v=4\",\n \"gravatar_id\": \"\",\n \"url\": \"https://api.github.com/users/ijpiantanida\",\n \"html_url\": \"https://github.com/ijpiantanida\",\n \"followers_url\": \"https://api.github.com/users/ijpiantanida/followers\",\n \"following_url\": \"https://api.github.com/users/ijpiantanida/following{/other_user}\",\n \"gists_url\": \"https://api.github.com/users/ijpiantanida/gists{/gist_id}\",\n \"starred_url\": \"https://api.github.com/users/ijpiantanida/starred{/owner}{/repo}\",\n \"subscriptions_url\": \"https://api.github.com/users/ijpiantanida/subscriptions\",\n \"organizations_url\": \"https://api.github.com/users/ijpiantanida/orgs\",\n \"repos_url\": \"https://api.github.com/users/ijpiantanida/repos\",\n \"events_url\": \"https://api.github.com/users/ijpiantanida/events{/privacy}\",\n \"received_events_url\": \"https://api.github.com/users/ijpiantanida/received_events\",\n \"type\": \"User\",\n \"site_admin\": false,\n \"name\": \"Ignacio Piantanida\",\n \"company\": \"@10Pines \",\n \"blog\": \"https://www.10pines.com\",\n \"location\": \"Buenos Aires, Argentina\",\n \"email\": null,\n \"hireable\": true,\n \"bio\": \"Coding stuff is fun. Let's code!\\r\\n\\r\\n\",\n \"public_repos\": 19,\n \"public_gists\": 1,\n \"followers\": 3,\n \"following\": 0,\n \"created_at\": \"2012-06-17T04:39:06Z\",\n \"updated_at\": \"2017-08-25T04:10:24Z\"\n}\n" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /examples/server/tapes/not-valid-request.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: "2017-09-13T23:38:04.538Z", 4 | host: "https://api.github.com", 5 | resHumanReadable: true 6 | }, 7 | req: { 8 | url: "/repos/not-valid", 9 | method: "GET", 10 | headers: { 11 | "user-agent": "curl/7.54.0", 12 | accept: "*/*" 13 | }, 14 | body: "" 15 | }, 16 | res: { 17 | status: 400, 18 | headers: { 19 | server: [ 20 | "GitHub.com" 21 | ], 22 | date: [ 23 | "Wed, 13 Sep 2017 23:38:05 GMT" 24 | ], 25 | "content-type": [ 26 | "application/json; charset=utf-8" 27 | ], 28 | "content-length": [ 29 | "87" 30 | ], 31 | connection: [ 32 | "close" 33 | ], 34 | status: [ 35 | "404 Not Found" 36 | ], 37 | "x-ratelimit-limit": [ 38 | "60" 39 | ], 40 | "x-ratelimit-remaining": [ 41 | "57" 42 | ], 43 | "x-ratelimit-reset": [ 44 | "1505349388" 45 | ], 46 | "x-github-media-type": [ 47 | "github.v3; format=json" 48 | ], 49 | "access-control-expose-headers": [ 50 | "ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval" 51 | ], 52 | "access-control-allow-origin": [ 53 | "*" 54 | ], 55 | "content-security-policy": [ 56 | "default-src 'none'" 57 | ], 58 | "strict-transport-security": [ 59 | "max-age=31536000; includeSubdomains; preload" 60 | ], 61 | "x-content-type-options": [ 62 | "nosniff" 63 | ], 64 | "x-frame-options": [ 65 | "deny" 66 | ], 67 | "x-xss-protection": [ 68 | "1; mode=block" 69 | ], 70 | "x-runtime-rack": [ 71 | "0.025337" 72 | ], 73 | "x-github-request-id": [ 74 | "CE99:309C:1973077:378DBC2:59B9C15C" 75 | ] 76 | }, 77 | body: "{\n \"message\": \"Not Found\",\n \"documentation_url\": \"https://developer.github.com/v3\"\n}\n" 78 | } 79 | } -------------------------------------------------------------------------------- /examples/server/tapes/profile-errorRate.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2018-12-07T02:49:53.859Z', 4 | host: 'https://api.github.com', 5 | errorRate: 100 6 | }, 7 | req: { 8 | url: '/users/errorRate', 9 | method: 'GET', 10 | headers: { 11 | accept: '*/*', 12 | }, 13 | body: '', 14 | }, 15 | res: { 16 | status: 200, 17 | headers: { 18 | }, 19 | body: "" 20 | }, 21 | } -------------------------------------------------------------------------------- /examples/server/tapes/slow-profile.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2018-12-07T02:49:53.859Z', 4 | host: 'https://api.github.com', 5 | resHumanReadable: true, 6 | latency: [200, 1000] 7 | }, 8 | req: { 9 | url: '/users/slow', 10 | method: 'GET', 11 | headers: { 12 | accept: '*/*', 13 | }, 14 | body: '', 15 | }, 16 | res: { 17 | status: 200, 18 | headers: { 19 | server: [ 20 | 'GitHub.com', 21 | ], 22 | date: [ 23 | 'Sat, 23 Mar 2019 10:04:25 GMT', 24 | ], 25 | 'content-type': [ 26 | 'application/json; charset=utf-8', 27 | ], 28 | 'content-length': [ 29 | '1383', 30 | ], 31 | connection: [ 32 | 'close', 33 | ], 34 | status: [ 35 | '200 OK', 36 | ], 37 | 'x-ratelimit-limit': [ 38 | '60', 39 | ], 40 | 'x-ratelimit-remaining': [ 41 | '50', 42 | ], 43 | 'x-ratelimit-reset': [ 44 | '1553339065', 45 | ], 46 | 'cache-control': [ 47 | 'public, max-age=60, s-maxage=60', 48 | ], 49 | vary: [ 50 | 'Accept', 51 | ], 52 | etag: [ 53 | '"867aa13132df76dac955d0d9870f666c"', 54 | ], 55 | 'last-modified': [ 56 | 'Thu, 14 Mar 2019 16:30:46 GMT', 57 | ], 58 | 'x-github-media-type': [ 59 | 'github.v3; format=json', 60 | ], 61 | 'access-control-expose-headers': [ 62 | 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type', 63 | ], 64 | 'access-control-allow-origin': [ 65 | '*', 66 | ], 67 | 'strict-transport-security': [ 68 | 'max-age=31536000; includeSubdomains; preload', 69 | ], 70 | 'x-frame-options': [ 71 | 'deny', 72 | ], 73 | 'x-content-type-options': [ 74 | 'nosniff', 75 | ], 76 | 'x-xss-protection': [ 77 | '1; mode=block', 78 | ], 79 | 'referrer-policy': [ 80 | 'origin-when-cross-origin, strict-origin-when-cross-origin', 81 | ], 82 | 'content-security-policy': [ 83 | "default-src 'none'", 84 | ], 85 | 'x-github-request-id': [ 86 | '92A3:3C1E:7ACA06:13D6A73:5C9604A8', 87 | ], 88 | }, 89 | body: { 90 | login: 'ijpiantanida', 91 | id: 1858238, 92 | node_id: 'MDQ6VXNlcjE4NTgyMzg=', 93 | avatar_url: 'https://avatars3.githubusercontent.com/u/1858238?v=4', 94 | gravatar_id: '', 95 | url: 'https://api.github.com/users/ijpiantanida', 96 | html_url: 'https://github.com/ijpiantanida', 97 | followers_url: 'https://api.github.com/users/ijpiantanida/followers', 98 | following_url: 'https://api.github.com/users/ijpiantanida/following{/other_user}', 99 | gists_url: 'https://api.github.com/users/ijpiantanida/gists{/gist_id}', 100 | starred_url: 'https://api.github.com/users/ijpiantanida/starred{/owner}{/repo}', 101 | subscriptions_url: 'https://api.github.com/users/ijpiantanida/subscriptions', 102 | organizations_url: 'https://api.github.com/users/ijpiantanida/orgs', 103 | repos_url: 'https://api.github.com/users/ijpiantanida/repos', 104 | events_url: 'https://api.github.com/users/ijpiantanida/events{/privacy}', 105 | received_events_url: 'https://api.github.com/users/ijpiantanida/received_events', 106 | type: 'User', 107 | site_admin: false, 108 | name: 'Ignacio Piantanida', 109 | company: '@10Pines ', 110 | blog: '', 111 | location: 'Los Angeles, USA', 112 | email: null, 113 | hireable: true, 114 | bio: "Coding stuff is fun. Let's code!\r\n\r\n", 115 | public_repos: 15, 116 | public_gists: 1, 117 | followers: 9, 118 | following: 0, 119 | created_at: '2012-06-17T04:39:06Z', 120 | updated_at: '2019-03-14T16:30:46Z', 121 | }, 122 | }, 123 | } -------------------------------------------------------------------------------- /examples/server/tapes/talkback-repo.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: "2017-09-13T23:37:48.247Z", 4 | host: "https://api.github.com", 5 | resHumanReadable: true 6 | }, 7 | req: { 8 | url: "/repos/ijpiantanida/talkback", 9 | method: "GET", 10 | headers: { 11 | "user-agent": "curl/7.54.0", 12 | accept: "*/*" 13 | }, 14 | body: "" 15 | }, 16 | res: { 17 | status: 200, 18 | headers: { 19 | server: [ 20 | "GitHub.com" 21 | ], 22 | date: [ 23 | "Wed, 13 Sep 2017 23:37:49 GMT" 24 | ], 25 | "content-type": [ 26 | "application/json; charset=utf-8" 27 | ], 28 | "content-length": [ 29 | "5283" 30 | ], 31 | connection: [ 32 | "close" 33 | ], 34 | status: [ 35 | "200 OK" 36 | ], 37 | "x-ratelimit-limit": [ 38 | "60" 39 | ], 40 | "x-ratelimit-remaining": [ 41 | "58" 42 | ], 43 | "x-ratelimit-reset": [ 44 | "1505349388" 45 | ], 46 | "cache-control": [ 47 | "public, max-age=60, s-maxage=60" 48 | ], 49 | vary: [ 50 | "Accept" 51 | ], 52 | etag: [ 53 | "\"412896c22637a9de72dce2042327bb43\"" 54 | ], 55 | "last-modified": [ 56 | "Mon, 11 Sep 2017 22:40:57 GMT" 57 | ], 58 | "x-github-media-type": [ 59 | "github.v3; format=json" 60 | ], 61 | "access-control-expose-headers": [ 62 | "ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval" 63 | ], 64 | "access-control-allow-origin": [ 65 | "*" 66 | ], 67 | "content-security-policy": [ 68 | "default-src 'none'" 69 | ], 70 | "strict-transport-security": [ 71 | "max-age=31536000; includeSubdomains; preload" 72 | ], 73 | "x-content-type-options": [ 74 | "nosniff" 75 | ], 76 | "x-frame-options": [ 77 | "deny" 78 | ], 79 | "x-xss-protection": [ 80 | "1; mode=block" 81 | ], 82 | "x-runtime-rack": [ 83 | "1.092651" 84 | ], 85 | "x-github-request-id": [ 86 | "CE8E:309E:255EF2D:4E0D213:59B9C14C" 87 | ] 88 | }, 89 | body: "{\n \"id\": 103073370,\n \"name\": \"talkback\",\n \"full_name\": \"ijpiantanida/talkback\",\n \"owner\": {\n \"login\": \"ijpiantanida\",\n \"id\": 1858238,\n \"avatar_url\": \"https://avatars3.githubusercontent.com/u/1858238?v=4\",\n \"gravatar_id\": \"\",\n \"url\": \"https://api.github.com/users/ijpiantanida\",\n \"html_url\": \"https://github.com/ijpiantanida\",\n \"followers_url\": \"https://api.github.com/users/ijpiantanida/followers\",\n \"following_url\": \"https://api.github.com/users/ijpiantanida/following{/other_user}\",\n \"gists_url\": \"https://api.github.com/users/ijpiantanida/gists{/gist_id}\",\n \"starred_url\": \"https://api.github.com/users/ijpiantanida/starred{/owner}{/repo}\",\n \"subscriptions_url\": \"https://api.github.com/users/ijpiantanida/subscriptions\",\n \"organizations_url\": \"https://api.github.com/users/ijpiantanida/orgs\",\n \"repos_url\": \"https://api.github.com/users/ijpiantanida/repos\",\n \"events_url\": \"https://api.github.com/users/ijpiantanida/events{/privacy}\",\n \"received_events_url\": \"https://api.github.com/users/ijpiantanida/received_events\",\n \"type\": \"User\",\n \"site_admin\": false\n },\n \"private\": false,\n \"html_url\": \"https://github.com/ijpiantanida/talkback\",\n \"description\": \"A node.js HTTP proxy that records and playbacks requests\",\n \"fork\": false,\n \"url\": \"https://api.github.com/repos/ijpiantanida/talkback\",\n \"forks_url\": \"https://api.github.com/repos/ijpiantanida/talkback/forks\",\n \"keys_url\": \"https://api.github.com/repos/ijpiantanida/talkback/keys{/key_id}\",\n \"collaborators_url\": \"https://api.github.com/repos/ijpiantanida/talkback/collaborators{/collaborator}\",\n \"teams_url\": \"https://api.github.com/repos/ijpiantanida/talkback/teams\",\n \"hooks_url\": \"https://api.github.com/repos/ijpiantanida/talkback/hooks\",\n \"issue_events_url\": \"https://api.github.com/repos/ijpiantanida/talkback/issues/events{/number}\",\n \"events_url\": \"https://api.github.com/repos/ijpiantanida/talkback/events\",\n \"assignees_url\": \"https://api.github.com/repos/ijpiantanida/talkback/assignees{/user}\",\n \"branches_url\": \"https://api.github.com/repos/ijpiantanida/talkback/branches{/branch}\",\n \"tags_url\": \"https://api.github.com/repos/ijpiantanida/talkback/tags\",\n \"blobs_url\": \"https://api.github.com/repos/ijpiantanida/talkback/git/blobs{/sha}\",\n \"git_tags_url\": \"https://api.github.com/repos/ijpiantanida/talkback/git/tags{/sha}\",\n \"git_refs_url\": \"https://api.github.com/repos/ijpiantanida/talkback/git/refs{/sha}\",\n \"trees_url\": \"https://api.github.com/repos/ijpiantanida/talkback/git/trees{/sha}\",\n \"statuses_url\": \"https://api.github.com/repos/ijpiantanida/talkback/statuses/{sha}\",\n \"languages_url\": \"https://api.github.com/repos/ijpiantanida/talkback/languages\",\n \"stargazers_url\": \"https://api.github.com/repos/ijpiantanida/talkback/stargazers\",\n \"contributors_url\": \"https://api.github.com/repos/ijpiantanida/talkback/contributors\",\n \"subscribers_url\": \"https://api.github.com/repos/ijpiantanida/talkback/subscribers\",\n \"subscription_url\": \"https://api.github.com/repos/ijpiantanida/talkback/subscription\",\n \"commits_url\": \"https://api.github.com/repos/ijpiantanida/talkback/commits{/sha}\",\n \"git_commits_url\": \"https://api.github.com/repos/ijpiantanida/talkback/git/commits{/sha}\",\n \"comments_url\": \"https://api.github.com/repos/ijpiantanida/talkback/comments{/number}\",\n \"issue_comment_url\": \"https://api.github.com/repos/ijpiantanida/talkback/issues/comments{/number}\",\n \"contents_url\": \"https://api.github.com/repos/ijpiantanida/talkback/contents/{+path}\",\n \"compare_url\": \"https://api.github.com/repos/ijpiantanida/talkback/compare/{base}...{head}\",\n \"merges_url\": \"https://api.github.com/repos/ijpiantanida/talkback/merges\",\n \"archive_url\": \"https://api.github.com/repos/ijpiantanida/talkback/{archive_format}{/ref}\",\n \"downloads_url\": \"https://api.github.com/repos/ijpiantanida/talkback/downloads\",\n \"issues_url\": \"https://api.github.com/repos/ijpiantanida/talkback/issues{/number}\",\n \"pulls_url\": \"https://api.github.com/repos/ijpiantanida/talkback/pulls{/number}\",\n \"milestones_url\": \"https://api.github.com/repos/ijpiantanida/talkback/milestones{/number}\",\n \"notifications_url\": \"https://api.github.com/repos/ijpiantanida/talkback/notifications{?since,all,participating}\",\n \"labels_url\": \"https://api.github.com/repos/ijpiantanida/talkback/labels{/name}\",\n \"releases_url\": \"https://api.github.com/repos/ijpiantanida/talkback/releases{/id}\",\n \"deployments_url\": \"https://api.github.com/repos/ijpiantanida/talkback/deployments\",\n \"created_at\": \"2017-09-11T00:54:27Z\",\n \"updated_at\": \"2017-09-11T22:40:57Z\",\n \"pushed_at\": \"2017-09-13T23:29:29Z\",\n \"git_url\": \"git://github.com/ijpiantanida/talkback.git\",\n \"ssh_url\": \"git@github.com:ijpiantanida/talkback.git\",\n \"clone_url\": \"https://github.com/ijpiantanida/talkback.git\",\n \"svn_url\": \"https://github.com/ijpiantanida/talkback\",\n \"homepage\": \"\",\n \"size\": 55,\n \"stargazers_count\": 0,\n \"watchers_count\": 0,\n \"language\": \"JavaScript\",\n \"has_issues\": true,\n \"has_projects\": true,\n \"has_downloads\": true,\n \"has_wiki\": true,\n \"has_pages\": false,\n \"forks_count\": 0,\n \"mirror_url\": null,\n \"open_issues_count\": 0,\n \"forks\": 0,\n \"open_issues\": 0,\n \"watchers\": 0,\n \"default_branch\": \"master\",\n \"network_count\": 0,\n \"subscribers_count\": 1\n}\n" 90 | }, 91 | } -------------------------------------------------------------------------------- /examples/server/tapes/user-profile-HEAD.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2019-02-01T19:52:48.403Z', 4 | host: 'https://api.github.com', 5 | }, 6 | req: { 7 | url: '/users/ijpiantanida', 8 | method: 'HEAD', 9 | headers: { 10 | accept: '*/*', 11 | }, 12 | body: '', 13 | }, 14 | res: { 15 | status: 200, 16 | headers: { 17 | date: [ 18 | 'Fri, 01 Feb 2019 19:52:48 GMT', 19 | ], 20 | 'content-type': [ 21 | 'application/json; charset=utf-8', 22 | ], 23 | 'content-length': [ 24 | '1406', 25 | ], 26 | connection: [ 27 | 'close', 28 | ], 29 | server: [ 30 | 'GitHub.com', 31 | ], 32 | status: [ 33 | '200 OK', 34 | ], 35 | 'x-ratelimit-limit': [ 36 | '60', 37 | ], 38 | 'x-ratelimit-remaining': [ 39 | '58', 40 | ], 41 | 'x-ratelimit-reset': [ 42 | '1549054261', 43 | ], 44 | 'cache-control': [ 45 | 'public, max-age=60, s-maxage=60', 46 | ], 47 | vary: [ 48 | 'Accept, Accept-Encoding', 49 | ], 50 | etag: [ 51 | '"ac69824f68b6fcc2d73fb4ced15ca708"', 52 | ], 53 | 'last-modified': [ 54 | 'Thu, 24 Jan 2019 15:27:56 GMT', 55 | ], 56 | 'x-github-media-type': [ 57 | 'github.v3; format=json', 58 | ], 59 | 'access-control-expose-headers': [ 60 | 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type', 61 | ], 62 | 'access-control-allow-origin': [ 63 | '*', 64 | ], 65 | 'strict-transport-security': [ 66 | 'max-age=31536000; includeSubdomains; preload', 67 | ], 68 | 'x-frame-options': [ 69 | 'deny', 70 | ], 71 | 'x-content-type-options': [ 72 | 'nosniff', 73 | ], 74 | 'x-xss-protection': [ 75 | '1; mode=block', 76 | ], 77 | 'referrer-policy': [ 78 | 'origin-when-cross-origin, strict-origin-when-cross-origin', 79 | ], 80 | 'content-security-policy': [ 81 | "default-src 'none'", 82 | ], 83 | 'x-github-request-id': [ 84 | 'E1E9:0945:FBC84:12FB5B:5C54A390', 85 | ], 86 | }, 87 | body: '', 88 | }, 89 | } -------------------------------------------------------------------------------- /examples/server/tapes/user-profile-compression-base64.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2019-05-07T01:06:38.759Z', 4 | host: 'https://api.github.com', 5 | }, 6 | req: { 7 | url: '/users/ijpiantanida', 8 | method: 'GET', 9 | headers: { 10 | accept: '*/*', 11 | 'accept-encoding': 'gzip', 12 | }, 13 | body: '', 14 | }, 15 | res: { 16 | status: 200, 17 | headers: { 18 | date: [ 19 | 'Tue, 07 May 2019 01:06:39 GMT', 20 | ], 21 | 'content-type': [ 22 | 'application/json; charset=utf-8', 23 | ], 24 | 'transfer-encoding': [ 25 | 'chunked', 26 | ], 27 | connection: [ 28 | 'close', 29 | ], 30 | server: [ 31 | 'GitHub.com', 32 | ], 33 | status: [ 34 | '200 OK', 35 | ], 36 | 'x-ratelimit-limit': [ 37 | '60', 38 | ], 39 | 'x-ratelimit-remaining': [ 40 | '55', 41 | ], 42 | 'x-ratelimit-reset': [ 43 | '1557194095', 44 | ], 45 | 'cache-control': [ 46 | 'public, max-age=60, s-maxage=60', 47 | ], 48 | vary: [ 49 | 'Accept, Accept-Encoding', 50 | ], 51 | etag: [ 52 | 'W/"0e371dc37f480264fa014480ee3afa3e"', 53 | ], 54 | 'last-modified': [ 55 | 'Sun, 05 May 2019 23:50:46 GMT', 56 | ], 57 | 'x-github-media-type': [ 58 | 'github.v3; format=json', 59 | ], 60 | 'access-control-expose-headers': [ 61 | 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type', 62 | ], 63 | 'access-control-allow-origin': [ 64 | '*', 65 | ], 66 | 'strict-transport-security': [ 67 | 'max-age=31536000; includeSubdomains; preload', 68 | ], 69 | 'x-frame-options': [ 70 | 'deny', 71 | ], 72 | 'x-content-type-options': [ 73 | 'nosniff', 74 | ], 75 | 'x-xss-protection': [ 76 | '1; mode=block', 77 | ], 78 | 'referrer-policy': [ 79 | 'origin-when-cross-origin, strict-origin-when-cross-origin', 80 | ], 81 | 'content-security-policy': [ 82 | "default-src 'none'", 83 | ], 84 | 'content-encoding': [ 85 | 'gzip', 86 | ], 87 | 'x-github-request-id': [ 88 | '6291:6822:21EF623:2920B81:5CD0DA1F', 89 | ], 90 | }, 91 | body: 'H4sIAAAAAAAAA51UUWvbMBB+z68wfm5qJ06y1FBGYXsYtN3G0jH6EmRbla/IkpHklNT0v++kc7fUfRgzGBt9vu+7T3c69bMoiqUWoOI8iuGxBaYcU1Cx+Mz/ggrxxXa9XWbbAChd8X1A45tP3zc/f93K8vHz6nYnjjfP4pJY7MAcM/vOSB9XO9faPEkItdm5AFd3RWe5KbVyXLnzUjdJlwx5Ph4uV6QjzKBECQkcq7YwCJIKqtrk/UZq18ixIfIRaO8JD1pK/YRiYxb7V8LkD5UM0xKUmKqE1D7RruZYUtzey1AcsG6COeFpfeI/2MdBy2K7DK/+3+BARHtPCp31ieGtfhXtClsaaB1oNcGoPaXTlrUReDaf2URFpFsS8i4nWAo0UuAHPLcTJIjXJ62BAyuPQ6kMLzkcsAFTZUcC5NEdW+4H8A4PDSEWHN+zqgnj/sCk5TTUrAmBX4RiJejo2+gWwAlpmTr6kK2ro2sQtSPBAu8OD9NK6jL0xiPX2kZXSnDJ7Vl09+NqKFvDwF8KqpMypK7BcFZIn96ZjuwUoL3CV8UjCfhiLmKRA/QYKG1XSCj31Ay8ndanaDjeyF4E8O8kIpKdQDhSGJQGpEQHDmvPnM+6TBfLebqZLz7s0lWeXeTp5p7ydm31Nu5inq7x2S2zfJ3mK4ybvcx+A3rBHDJSBQAA', 92 | }, 93 | } -------------------------------------------------------------------------------- /examples/server/tapes/user-profile-compression-plain.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2019-05-07T01:02:20.829Z', 4 | host: 'https://api.github.com', 5 | resHumanReadable: true, 6 | resUncompressed: true, 7 | }, 8 | req: { 9 | url: '/users/ijpiantanida', 10 | method: 'GET', 11 | headers: { 12 | accept: '*/*', 13 | 'accept-encoding': 'gzip, deflate, br', 14 | }, 15 | body: '', 16 | }, 17 | res: { 18 | status: 200, 19 | headers: { 20 | date: [ 21 | 'Tue, 07 May 2019 01:02:24 GMT', 22 | ], 23 | 'content-type': [ 24 | 'application/json; charset=utf-8', 25 | ], 26 | 'transfer-encoding': [ 27 | 'chunked', 28 | ], 29 | connection: [ 30 | 'close', 31 | ], 32 | server: [ 33 | 'GitHub.com', 34 | ], 35 | status: [ 36 | '200 OK', 37 | ], 38 | 'x-ratelimit-limit': [ 39 | '60', 40 | ], 41 | 'x-ratelimit-remaining': [ 42 | '58', 43 | ], 44 | 'x-ratelimit-reset': [ 45 | '1557194095', 46 | ], 47 | 'cache-control': [ 48 | 'public, max-age=60, s-maxage=60', 49 | ], 50 | vary: [ 51 | 'Accept, Accept-Encoding', 52 | ], 53 | etag: [ 54 | 'W/"0e371dc37f480264fa014480ee3afa3e"', 55 | ], 56 | 'last-modified': [ 57 | 'Sun, 05 May 2019 23:50:46 GMT', 58 | ], 59 | 'x-github-media-type': [ 60 | 'github.v3; format=json', 61 | ], 62 | 'access-control-expose-headers': [ 63 | 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type', 64 | ], 65 | 'access-control-allow-origin': [ 66 | '*', 67 | ], 68 | 'strict-transport-security': [ 69 | 'max-age=31536000; includeSubdomains; preload', 70 | ], 71 | 'x-frame-options': [ 72 | 'deny', 73 | ], 74 | 'x-content-type-options': [ 75 | 'nosniff', 76 | ], 77 | 'x-xss-protection': [ 78 | '1; mode=block', 79 | ], 80 | 'referrer-policy': [ 81 | 'origin-when-cross-origin, strict-origin-when-cross-origin', 82 | ], 83 | 'content-security-policy': [ 84 | "default-src 'none'", 85 | ], 86 | 'content-encoding': [ 87 | 'gzip', 88 | ], 89 | 'x-github-request-id': [ 90 | '8B25:7D84:D1DC2E:FE66F5:5CD0D91F', 91 | ], 92 | }, 93 | body: { 94 | login: 'ijpiantanida', 95 | id: 1858238, 96 | node_id: 'MDQ6VXNlcjE4NTgyMzg=', 97 | avatar_url: 'https://avatars3.githubusercontent.com/u/1858238?v=4', 98 | gravatar_id: '', 99 | url: 'https://api.github.com/users/ijpiantanida', 100 | html_url: 'https://github.com/ijpiantanida', 101 | followers_url: 'https://api.github.com/users/ijpiantanida/followers', 102 | following_url: 'https://api.github.com/users/ijpiantanida/following{/other_user}', 103 | gists_url: 'https://api.github.com/users/ijpiantanida/gists{/gist_id}', 104 | starred_url: 'https://api.github.com/users/ijpiantanida/starred{/owner}{/repo}', 105 | subscriptions_url: 'https://api.github.com/users/ijpiantanida/subscriptions', 106 | organizations_url: 'https://api.github.com/users/ijpiantanida/orgs', 107 | repos_url: 'https://api.github.com/users/ijpiantanida/repos', 108 | events_url: 'https://api.github.com/users/ijpiantanida/events{/privacy}', 109 | received_events_url: 'https://api.github.com/users/ijpiantanida/received_events', 110 | type: 'User', 111 | site_admin: false, 112 | name: 'Ignacio Piantanida', 113 | company: '8th Light', 114 | blog: '', 115 | location: 'Los Angeles, USA', 116 | email: null, 117 | hireable: true, 118 | bio: 'One line at a time', 119 | public_repos: 15, 120 | public_gists: 1, 121 | followers: 13, 122 | following: 0, 123 | created_at: '2012-06-17T04:39:06Z', 124 | updated_at: '2019-05-05T23:50:46Z', 125 | }, 126 | }, 127 | } -------------------------------------------------------------------------------- /examples/server/tapes/user-profile.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2018-12-07T02:49:53.859Z', 4 | host: 'https://api.github.com', 5 | resHumanReadable: true, 6 | }, 7 | req: { 8 | url: '/users/ijpiantanida', 9 | method: 'GET', 10 | headers: { 11 | accept: '*/*', 12 | }, 13 | body: '', 14 | }, 15 | res: { 16 | status: 200, 17 | headers: { 18 | server: [ 19 | 'GitHub.com', 20 | ], 21 | date: [ 22 | 'Sat, 23 Mar 2019 10:04:25 GMT', 23 | ], 24 | 'content-type': [ 25 | 'application/json; charset=utf-8', 26 | ], 27 | 'content-length': [ 28 | '1383', 29 | ], 30 | connection: [ 31 | 'close', 32 | ], 33 | status: [ 34 | '200 OK', 35 | ], 36 | 'x-ratelimit-limit': [ 37 | '60', 38 | ], 39 | 'x-ratelimit-remaining': [ 40 | '50', 41 | ], 42 | 'x-ratelimit-reset': [ 43 | '1553339065', 44 | ], 45 | 'cache-control': [ 46 | 'public, max-age=60, s-maxage=60', 47 | ], 48 | vary: [ 49 | 'Accept', 50 | ], 51 | etag: [ 52 | '"867aa13132df76dac955d0d9870f666c"', 53 | ], 54 | 'last-modified': [ 55 | 'Thu, 14 Mar 2019 16:30:46 GMT', 56 | ], 57 | 'x-github-media-type': [ 58 | 'github.v3; format=json', 59 | ], 60 | 'access-control-expose-headers': [ 61 | 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type', 62 | ], 63 | 'access-control-allow-origin': [ 64 | '*', 65 | ], 66 | 'strict-transport-security': [ 67 | 'max-age=31536000; includeSubdomains; preload', 68 | ], 69 | 'x-frame-options': [ 70 | 'deny', 71 | ], 72 | 'x-content-type-options': [ 73 | 'nosniff', 74 | ], 75 | 'x-xss-protection': [ 76 | '1; mode=block', 77 | ], 78 | 'referrer-policy': [ 79 | 'origin-when-cross-origin, strict-origin-when-cross-origin', 80 | ], 81 | 'content-security-policy': [ 82 | "default-src 'none'", 83 | ], 84 | 'x-github-request-id': [ 85 | '92A3:3C1E:7ACA06:13D6A73:5C9604A8', 86 | ], 87 | }, 88 | body: { 89 | login: 'ijpiantanida', 90 | id: 1858238, 91 | node_id: 'MDQ6VXNlcjE4NTgyMzg=', 92 | avatar_url: 'https://avatars3.githubusercontent.com/u/1858238?v=4', 93 | gravatar_id: '', 94 | url: 'https://api.github.com/users/ijpiantanida', 95 | html_url: 'https://github.com/ijpiantanida', 96 | followers_url: 'https://api.github.com/users/ijpiantanida/followers', 97 | following_url: 'https://api.github.com/users/ijpiantanida/following{/other_user}', 98 | gists_url: 'https://api.github.com/users/ijpiantanida/gists{/gist_id}', 99 | starred_url: 'https://api.github.com/users/ijpiantanida/starred{/owner}{/repo}', 100 | subscriptions_url: 'https://api.github.com/users/ijpiantanida/subscriptions', 101 | organizations_url: 'https://api.github.com/users/ijpiantanida/orgs', 102 | repos_url: 'https://api.github.com/users/ijpiantanida/repos', 103 | events_url: 'https://api.github.com/users/ijpiantanida/events{/privacy}', 104 | received_events_url: 'https://api.github.com/users/ijpiantanida/received_events', 105 | type: 'User', 106 | site_admin: false, 107 | name: 'Ignacio Piantanida', 108 | company: '@10Pines ', 109 | blog: '', 110 | location: 'Los Angeles, USA', 111 | email: null, 112 | hireable: true, 113 | bio: "Coding stuff is fun. Let's code!\r\n\r\n", 114 | public_repos: 15, 115 | public_gists: 1, 116 | followers: 9, 117 | following: 0, 118 | created_at: '2012-06-17T04:39:06Z', 119 | updated_at: '2019-03-14T16:30:46Z', 120 | }, 121 | }, 122 | } -------------------------------------------------------------------------------- /examples/server/test_script.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | BASE_URL=https://localhost:8080 4 | 5 | error=false 6 | 7 | function make_request() { 8 | status=$(curl -k -o /dev/null -s -w "%{http_code}" "${@:2}") 9 | if [ "$status" -ne "$1" ]; then 10 | error=true 11 | echo "Request "${@:2}" failed with status $status (expected $1)" 12 | fi 13 | } 14 | 15 | # Pertty printed tape 16 | make_request 200 "$BASE_URL/users/ijpiantanida" 17 | 18 | # HEAD requests 19 | make_request 200 "$BASE_URL/users/ijpiantanida" -I 20 | 21 | # tape's latency 22 | make_request 200 "$BASE_URL/users/slow" 23 | 24 | # responseDecorator 25 | make_request 200 "$BASE_URL/auth" -H "content-type: application/json" -d '{"username": "james", "password": "moriarty"}' 26 | 27 | # urlMatcher 28 | make_request 200 "$BASE_URL/orgs/test" 29 | 30 | # bodyMatcher 31 | make_request 200 "$BASE_URL/users" -H "content-type: application/json" -d '{"username": "james", "ignore": "abc"}' 32 | 33 | # Non-200 tape 34 | make_request 400 "$BASE_URL/repos/not-valid" 35 | 36 | # Not pretty body printed 37 | make_request 200 "$BASE_URL/repos/ijpiantanida/talkback" 38 | 39 | # Fails because of tape's errorRate 40 | make_request 503 "$BASE_URL/users/errorRate" 41 | 42 | # Removed by requestDecorator 43 | make_request 200 "$BASE_URL/users/ijpiantanida" -H "accept-encoding: gzip, deflate, br, test" 44 | 45 | # Compressed with supported algorithm and saved as plain text 46 | make_request 200 "$BASE_URL/users/ijpiantanida" -H "accept-encoding: gzip, deflate, br" 47 | 48 | # Compressed with supported algorithm but saved as base64 49 | make_request 200 "$BASE_URL/users/ijpiantanida" -H "accept-encoding: gzip" 50 | 51 | if [ "$error" = true ]; then 52 | echo "FAILED" 53 | exit 1 54 | else 55 | echo "SUCCESS" 56 | fi -------------------------------------------------------------------------------- /examples/server/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | ansi-regex@6.0.1: 6 | version "6.0.1" 7 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" 8 | integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== 9 | 10 | buffer-shims@^1.0.0: 11 | version "1.0.0" 12 | resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51" 13 | integrity sha512-Zy8ZXMyxIT6RMTeY7OP/bDndfj6bwCan7SS98CEndS6deHwWPpseeHlwarNcBim+etXnF9HBc1non5JgDaJU1g== 14 | 15 | content-type@^1.0.5: 16 | version "1.0.5" 17 | resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" 18 | integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== 19 | 20 | json5@^2.2.3: 21 | version "2.2.3" 22 | resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" 23 | integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== 24 | 25 | lodash@^4.17.21: 26 | version "4.17.21" 27 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" 28 | integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== 29 | 30 | mkdirp@^3.0.1: 31 | version "3.0.1" 32 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" 33 | integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== 34 | 35 | node-fetch@2.7.0: 36 | version "2.7.0" 37 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" 38 | integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== 39 | dependencies: 40 | whatwg-url "^5.0.0" 41 | 42 | talkback@*: 43 | version "4.0.0" 44 | resolved "https://registry.yarnpkg.com/talkback/-/talkback-4.0.0.tgz#3f8b4ce009f66952a9e054b44cf2972d37eb445a" 45 | integrity sha512-dX3m8VoEVZ8HPlKVJs+lHL8IdUVMSRluQYCgrenMJrQrkr97WkW49Y35gj+8b1y5i7FDZ88BBImaih0JuetiVg== 46 | dependencies: 47 | ansi-regex "6.0.1" 48 | buffer-shims "^1.0.0" 49 | content-type "^1.0.5" 50 | json5 "^2.2.3" 51 | lodash "^4.17.21" 52 | mkdirp "^3.0.1" 53 | node-fetch "2.7.0" 54 | uuid "^9.0.0" 55 | 56 | tr46@~0.0.3: 57 | version "0.0.3" 58 | resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" 59 | integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== 60 | 61 | uuid@^9.0.0: 62 | version "9.0.1" 63 | resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" 64 | integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== 65 | 66 | webidl-conversions@^3.0.0: 67 | version "3.0.1" 68 | resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" 69 | integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== 70 | 71 | whatwg-url@^5.0.0: 72 | version "5.0.0" 73 | resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" 74 | integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== 75 | dependencies: 76 | tr46 "~0.0.3" 77 | webidl-conversions "^3.0.0" 78 | -------------------------------------------------------------------------------- /examples/unit-tests/.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "./src/mocha-setup.js" 3 | } 4 | -------------------------------------------------------------------------------- /examples/unit-tests/README.md: -------------------------------------------------------------------------------- 1 | ## Unit-Test Example 2 | 3 | Example of using talkback for test suites with Mocha and Jest. 4 | 5 | The test runner, before running any test will start talkback and once finished will gracefully shut it down. 6 | 7 | To run `yarn test` 8 | -------------------------------------------------------------------------------- /examples/unit-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "talkback-example-server", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "description": "Talkback Example - Unit Tests", 6 | "devDependencies": { 7 | "@types/mocha": "^10.0.6", 8 | "jest": "^29.7.0", 9 | "mocha": "^10.3.0", 10 | "npm-run-all": "^4.1.5", 11 | "talkback": "*" 12 | }, 13 | "scripts": { 14 | "test": "npm-run-all test-jest test-mocha", 15 | "test-jest": "jest 'src/.*\\.jest\\.spec\\.js'", 16 | "test-mocha": "mocha 'src/**/*.mocha.spec.js'" 17 | }, 18 | "author": "Ignacio Piantanida", 19 | "dependencies": { 20 | "node-fetch": "2.7.0" 21 | } 22 | } -------------------------------------------------------------------------------- /examples/unit-tests/src/mocha-setup.js: -------------------------------------------------------------------------------- 1 | let talkbackStart = require("./talkback-start") 2 | 3 | let talkbackInstance 4 | 5 | exports.mochaHooks = { 6 | beforeAll() { 7 | talkbackInstance = talkbackStart() 8 | }, 9 | afterAll() { 10 | talkbackInstance.close() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/unit-tests/src/talkback-start.js: -------------------------------------------------------------------------------- 1 | let talkback 2 | if (process.env.USE_NPM) { 3 | talkback = require("talkback") 4 | console.log("Using NPM talkback") 5 | } else { 6 | talkback = require("../../../dist") 7 | } 8 | 9 | let talkbackInstance 10 | 11 | module.exports = function talkbackStart() { 12 | if (!talkbackInstance) { 13 | talkbackInstance = talkback({ 14 | host: "http://localhost:8080", 15 | name: "Example - Unit Testing", 16 | port: 8080, 17 | path: __dirname + "/tapes", 18 | record: talkback.Options.RecordMode.DISABLED, 19 | ignoreHeaders: ["user-agent", "referer", "accept", "accept-encoding", "connection"] 20 | }) 21 | 22 | talkbackInstance.start() 23 | } 24 | 25 | return talkbackInstance 26 | } 27 | -------------------------------------------------------------------------------- /examples/unit-tests/src/tapes/test-tape.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2021-02-04T021:55:42.821Z', 4 | host: 'http://localhost:99999', 5 | resHumanReadable: true, 6 | }, 7 | req: { 8 | url: '/', 9 | method: 'GET', 10 | headers: { 11 | referer: '', 12 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36', 13 | }, 14 | body: '', 15 | }, 16 | res: { 17 | status: 200, 18 | headers: { 19 | 'content-type': [ 20 | 'application/json; charset=utf-8', 21 | ] 22 | }, 23 | body: { 24 | ok: true 25 | }, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /examples/unit-tests/src/test.jest.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require("assert") 2 | const fetch = require("node-fetch") 3 | let talkbackStart = require("./talkback-start") 4 | 5 | let talkback 6 | beforeAll(() => { 7 | talkback = talkbackStart() 8 | }) 9 | 10 | afterAll(() => { 11 | talkback.close() 12 | }) 13 | 14 | test("works", async () => { 15 | const result = await fetch("http://localhost:8080") 16 | 17 | assert.strictEqual(result.status, 200) 18 | }) 19 | -------------------------------------------------------------------------------- /examples/unit-tests/src/test.mocha.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require("assert") 2 | const fetch = require("node-fetch") 3 | 4 | describe("A test that depends on talkback", () => { 5 | it("works", async () => { 6 | const result = await fetch("http://localhost:8080") 7 | 8 | assert.strictEqual(result.status, 200) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /mocha-setup.js: -------------------------------------------------------------------------------- 1 | require("ts-node/register") 2 | require("source-map-support/register") 3 | const chai = require("chai"); 4 | const td = require("testdouble"); 5 | 6 | global.expect = chai.expect; 7 | global.td = td; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "talkback", 3 | "version": "4.2.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "description": "A node.js HTTP proxy that records and playbacks requests", 7 | "dependencies": { 8 | "@types/node-fetch": "^2.6.11", 9 | "ansi-regex": "6.0.1", 10 | "buffer-shims": "^1.0.0", 11 | "content-type": "^1.0.5", 12 | "json5": "^2.2.3", 13 | "lodash": "^4.17.21", 14 | "mkdirp": "^3.0.1", 15 | "node-fetch": "2.7.0", 16 | "uuid": "^9.0.0" 17 | }, 18 | "devDependencies": { 19 | "@istanbuljs/nyc-config-typescript": "^1.0.2", 20 | "@types/chai": "^4.3.5", 21 | "@types/mocha": "^10.0.1", 22 | "@types/node": "^20.3.1", 23 | "chai": "4.3.7", 24 | "cross-env": "^7.0.3", 25 | "del": "^6.0.0", 26 | "mocha": "^10.2.0", 27 | "nyc": "^15.1.0", 28 | "source-map-support": "^0.5.21", 29 | "testdouble": "^3.18.0", 30 | "ts-node": "^10.9.1", 31 | "typescript": "^5.1.3" 32 | }, 33 | "engines": { 34 | "node": ">=18" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/ijpiantanida/talkback" 39 | }, 40 | "keywords": [ 41 | "http-proxy", 42 | "record", 43 | "vcr", 44 | "playback" 45 | ], 46 | "author": "Ignacio Piantanida", 47 | "scripts": { 48 | "build": "node scripts/build.js", 49 | "ci": "yarn ts-check && yarn test && yarn build && USE_DIST=1 yarn test", 50 | "start": "node examples/server/start.js", 51 | "test": "cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text-summary mocha -r mocha-setup.js --extensions ts,js \"test/**/*.spec.{ts,js}\"", 52 | "ts-check": "tsc --noEmit" 53 | } 54 | } -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const del = require("del") 3 | const pkg = require("../package.json") 4 | 5 | const exec = require("child_process").exec 6 | 7 | let promise = Promise.resolve() 8 | .then(() => del(["dist/*"])) 9 | .then(() => new Promise((resolve, reject) => { 10 | exec("yarn run tsc", function(error, stdout, stderr) { 11 | console.info(stdout) 12 | if (error) { 13 | reject(error) 14 | } else { 15 | resolve() 16 | } 17 | }) 18 | })) 19 | .then(() => { 20 | delete pkg.private 21 | delete pkg.devDependencies 22 | delete pkg.scripts 23 | delete pkg.nyc 24 | fs.writeFileSync("dist/package.json", JSON.stringify(pkg, null, " "), "utf-8") 25 | fs.writeFileSync("dist/CHANGELOG.md", fs.readFileSync("CHANGELOG.md", "utf-8"), "utf-8") 26 | fs.writeFileSync("dist/LICENSE.md", fs.readFileSync("LICENSE.md", "utf-8"), "utf-8") 27 | fs.writeFileSync("dist/README.md", fs.readFileSync("README.md", "utf-8"), "utf-8") 28 | 29 | }) 30 | 31 | promise.catch(err => console.error(err.stack)) 32 | -------------------------------------------------------------------------------- /scripts/test_examples.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | yarn --cwd examples/request-handler test && \ 4 | yarn --cwd examples/server test && \ 5 | yarn --cwd examples/unit-tests test 6 | -------------------------------------------------------------------------------- /src/es6.ts: -------------------------------------------------------------------------------- 1 | import talkback from "./index" 2 | 3 | export default talkback 4 | -------------------------------------------------------------------------------- /src/features/error-rate.ts: -------------------------------------------------------------------------------- 1 | import OptionsFactory, {Options} from "../options" 2 | import Tape from "../tape" 3 | import {Req, Res} from "../types" 4 | import {Logger} from "../logger" 5 | 6 | export default class ErrorRate { 7 | private readonly options: Options 8 | private readonly logger: Logger 9 | 10 | constructor(options: Options) { 11 | this.options = options 12 | 13 | this.logger = Logger.for(this.options) 14 | } 15 | 16 | shouldSimulate(req:Req, tape?: Tape) { 17 | const globalErrorRate = typeof (this.options.errorRate) === 'number' ? this.options.errorRate : this.options.errorRate(req) 18 | 19 | const errorRate = tape && tape.meta.errorRate !== undefined ? tape.meta.errorRate : globalErrorRate 20 | 21 | OptionsFactory.validateErrorRate(errorRate) 22 | 23 | const random = Math.random() * 100 24 | return random < errorRate 25 | } 26 | 27 | simulate(req: Req) { 28 | this.logger.info(`Simulating error for ${req.url}`) 29 | return { 30 | status: 503, 31 | headers: {'content-type': ['text/plain']}, 32 | body: Buffer.from("talkback - failure injection") 33 | } as Res 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/features/latency.ts: -------------------------------------------------------------------------------- 1 | import OptionsFactory, {Options} from "../options" 2 | import {Req} from "../types" 3 | import Tape from "../tape" 4 | 5 | export default class Latency { 6 | private options 7 | : Options 8 | 9 | constructor(options: Options) { 10 | this.options = options 11 | } 12 | 13 | async simulate(req: Req, tape?: Tape) { 14 | const resolved = Promise.resolve() 15 | 16 | const latencyGenerator = tape && tape.meta.latency !== undefined ? tape.meta.latency : this.options.latency 17 | if (!latencyGenerator) { 18 | return resolved 19 | } 20 | 21 | OptionsFactory.validateLatency(latencyGenerator) 22 | 23 | let latency = 0 24 | 25 | const type = typeof latencyGenerator 26 | if (type === "number") { 27 | latency = latencyGenerator as number 28 | } else if (Array.isArray(latencyGenerator)) { 29 | const high = latencyGenerator[1] 30 | const low = latencyGenerator[0] 31 | latency = Math.random() * (high - low) + low 32 | } else { 33 | latency = (latencyGenerator as (_: Req) => number)(req) 34 | } 35 | 36 | return new Promise(r => setTimeout(r, latency)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import TalkbackFactory from "./talkback-factory" 2 | import {DefaultOptions, FallbackMode, RecordMode, Options} from "./options" 3 | 4 | const talkback = (options: Partial) => { 5 | return TalkbackFactory.server(options) 6 | } 7 | 8 | talkback.Options = { 9 | Default: DefaultOptions, 10 | FallbackMode, 11 | RecordMode 12 | } 13 | 14 | talkback.requestHandler = (options: Partial) => TalkbackFactory.requestHandler(options) 15 | 16 | export default talkback 17 | module.exports = talkback 18 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import {Options} from "./options" 2 | 3 | export class Logger { 4 | static for(options: Options): Logger { 5 | return new Logger(options) 6 | } 7 | 8 | options: Options 9 | 10 | constructor(options: Options) { 11 | this.options = options 12 | if (this.options.debug) { 13 | this.debug("DEBUG mode active") 14 | } 15 | } 16 | 17 | info(message: any) { 18 | if (!this.options.silent || this.options.debug) { 19 | console.log(this.formatMessage(message, "INFO")) 20 | } 21 | } 22 | 23 | debug(message: any) { 24 | if (this.options.debug) { 25 | console.debug(this.formatMessage(message, "DEBUG")) 26 | } 27 | } 28 | 29 | error(message: any, ...optionalParameters: any[]) { 30 | console.error(this.formatMessage(message, "ERROR"), ...optionalParameters) 31 | } 32 | 33 | formatMessage(message: any, level: string) { 34 | const now = new Date() 35 | const formattedNow = now.toISOString() 36 | let messageString: string 37 | if (typeof message == "object") { 38 | messageString = JSON.stringify(message) 39 | } else { 40 | messageString = message 41 | } 42 | return `${formattedNow} [${this.options.name}] [${level}] ${messageString}` 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import Tape from "./tape" 2 | import { Req, MatchingContext } from "./types" 3 | import { RequestInit } from "node-fetch" 4 | 5 | export const RecordMode = { 6 | NEW: "NEW", // If no tape matches the request, proxy it and save the response to a tape 7 | OVERWRITE: "OVERWRITE", // Always proxy the request and save the response to a tape, overwriting any existing one 8 | DISABLED: "DISABLED", // If a matching tape exists, return it. Otherwise, don't proxy the request and use `fallbackMode` for the response 9 | ALL: [] as string[] 10 | } 11 | RecordMode.ALL = [RecordMode.NEW, RecordMode.OVERWRITE, RecordMode.DISABLED] 12 | 13 | export const FallbackMode = { 14 | NOT_FOUND: "NOT_FOUND", 15 | PROXY: "PROXY", 16 | ALL: [] as string[] 17 | } 18 | FallbackMode.ALL = [FallbackMode.NOT_FOUND, FallbackMode.PROXY] 19 | 20 | export interface Options { 21 | host: string, 22 | port: number, 23 | path: string, 24 | record: string | ((req: Req) => string), 25 | fallbackMode: string | ((req: Req) => string), 26 | name: string, 27 | tapeNameGenerator?: (tapeNumber: number, tape: Tape) => string, 28 | 29 | httpClient: { 30 | fetchOptions: Partial, 31 | } 32 | 33 | https: { 34 | enabled: boolean, 35 | keyPath?: string, 36 | certPath?: string 37 | }, 38 | allowHeaders: string[], 39 | ignoreHeaders: string[], 40 | ignoreQueryParams: string[], 41 | ignoreBody: boolean, 42 | 43 | bodyMatcher?: (tape: Tape, req: Req) => boolean, 44 | urlMatcher?: (tape: Tape, req: Req) => boolean, 45 | 46 | requestDecorator?: (req: Req, context: MatchingContext) => Req, 47 | responseDecorator?: (tape: Tape, req: Req, context: MatchingContext) => Tape, 48 | tapeDecorator?: (tape: Tape, context: MatchingContext) => Tape, 49 | 50 | latency: number | number[] | ((req: Req) => number), 51 | errorRate: number | ((req: Req) => number), 52 | 53 | silent: boolean, 54 | summary: boolean, 55 | debug: boolean, 56 | } 57 | 58 | export const DefaultOptions: Options = { 59 | host: "", 60 | port: 8080, 61 | path: "./tapes/", 62 | record: RecordMode.NEW, 63 | fallbackMode: FallbackMode.NOT_FOUND, 64 | name: "unnamed server", 65 | tapeNameGenerator: undefined, 66 | 67 | httpClient: { 68 | fetchOptions: {}, 69 | }, 70 | 71 | https: { 72 | enabled: false, 73 | keyPath: undefined, 74 | certPath: undefined 75 | }, 76 | 77 | allowHeaders: undefined, 78 | ignoreHeaders: ["content-length", "host"], 79 | ignoreQueryParams: [], 80 | ignoreBody: false, 81 | 82 | bodyMatcher: undefined, 83 | urlMatcher: undefined, 84 | 85 | requestDecorator: undefined, 86 | responseDecorator: undefined, 87 | tapeDecorator: undefined, 88 | 89 | latency: 0, 90 | errorRate: 0, 91 | 92 | silent: false, 93 | summary: true, 94 | debug: false, 95 | } 96 | 97 | export default class OptionsFactory { 98 | static prepare(usrOpts: Partial = {}) { 99 | const opts: typeof DefaultOptions = { 100 | ...DefaultOptions, 101 | name: usrOpts.host! || DefaultOptions.name, 102 | ...usrOpts, 103 | ignoreHeaders: [ 104 | ...DefaultOptions.ignoreHeaders, 105 | ...(usrOpts.ignoreHeaders || []) 106 | ] 107 | } 108 | 109 | this.validateOptions(opts) 110 | 111 | return opts 112 | } 113 | 114 | static validateOptions(opts: Options) { 115 | this.validateRecord(opts.record) 116 | this.validateFallbackMode(opts.fallbackMode) 117 | this.validateLatency(opts.latency) 118 | this.validateErrorRate(opts.errorRate) 119 | } 120 | 121 | static validateRecord(record: any) { 122 | if (typeof (record) === "string" && !RecordMode.ALL.includes(record)) { 123 | throw `INVALID OPTION: record has an invalid value of '${record}'` 124 | } 125 | } 126 | 127 | static validateFallbackMode(fallbackMode: any) { 128 | if (typeof (fallbackMode) === "string" && !FallbackMode.ALL.includes(fallbackMode)) { 129 | throw `INVALID OPTION: fallbackMode has an invalid value of '${fallbackMode}'` 130 | } 131 | } 132 | 133 | static validateLatency(latency: any) { 134 | if (Array.isArray(latency) && latency.length !== 2) { 135 | throw `Invalid LATENCY option. If using a range, the array should only have 2 values [min, max]. Current=[${latency}]` 136 | } 137 | } 138 | 139 | static validateErrorRate(errorRate: any) { 140 | if (typeof (errorRate) !== "function" && (errorRate < 0 || errorRate > 100)) { 141 | throw `Invalid ERRORRATE option. Value should be between 0 and 100. Current=[${errorRate}]` 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/request-handler.ts: -------------------------------------------------------------------------------- 1 | import TapeStore from "./tape-store" 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | import fetch from 'node-fetch' 5 | 6 | import Tape from "./tape" 7 | import OptionsFactory, { RecordMode, FallbackMode, Options } from "./options" 8 | import ErrorRate from "./features/error-rate" 9 | import Latency from "./features/latency" 10 | import { HttpRequest, HttpResponse, MatchingContext } from "./types" 11 | import { Logger } from "./logger" 12 | 13 | export default class RequestHandler { 14 | private readonly tapeStore: TapeStore 15 | private readonly options: Options 16 | private readonly errorRate: ErrorRate 17 | private readonly latency: Latency 18 | private readonly logger: Logger; 19 | 20 | constructor(tapeStore: TapeStore, options: Options) { 21 | this.tapeStore = tapeStore 22 | this.options = options 23 | this.errorRate = new ErrorRate(this.options) 24 | this.latency = new Latency(this.options) 25 | this.logger = Logger.for(this.options) 26 | } 27 | 28 | async handle(req: HttpRequest): Promise { 29 | const matchingContext: MatchingContext = { 30 | id: uuidv4() 31 | } 32 | const recordMode = typeof (this.options.record) === "string" ? this.options.record : this.options.record(req) 33 | 34 | OptionsFactory.validateRecord(recordMode) 35 | 36 | if (this.options.requestDecorator) { 37 | req = this.options.requestDecorator(req, matchingContext) 38 | if (!req) { 39 | throw new Error("requestDecorator didn't return a req object") 40 | } 41 | } 42 | 43 | let newTape = new Tape(req, this.options) 44 | let matchingTape = this.tapeStore.find(newTape) 45 | let resObj, responseTape 46 | 47 | if (recordMode !== RecordMode.OVERWRITE && matchingTape) { 48 | responseTape = matchingTape 49 | 50 | if (this.errorRate.shouldSimulate(req, matchingTape)) { 51 | return this.errorRate.simulate(req) 52 | } 53 | 54 | await this.latency.simulate(req, matchingTape) 55 | } else { 56 | if (matchingTape) { 57 | responseTape = matchingTape 58 | } else { 59 | responseTape = newTape 60 | } 61 | 62 | if (recordMode === RecordMode.NEW || recordMode === RecordMode.OVERWRITE) { 63 | resObj = await this.makeRealRequest(req) 64 | responseTape.res = { ...resObj } 65 | if (this.options.tapeDecorator) { 66 | responseTape = this.options.tapeDecorator(responseTape, matchingContext) 67 | if (!responseTape) { 68 | throw new Error("tapeDecorator didn't return a tape object") 69 | } 70 | } 71 | await this.tapeStore.save(responseTape) 72 | } else { 73 | resObj = await this.onNoRecord(req) 74 | responseTape.res = { ...resObj } 75 | } 76 | } 77 | 78 | resObj = responseTape.res 79 | 80 | if (this.options.responseDecorator) { 81 | const clonedTape = await responseTape.clone() 82 | const resTape = this.options.responseDecorator(clonedTape, req, matchingContext) 83 | if (!resTape) { 84 | throw new Error("responseDecorator didn't return a tape object") 85 | } 86 | 87 | if (resTape.res.headers["content-length"]) { 88 | resTape.res.headers["content-length"] = resTape.res.body.length 89 | } 90 | resObj = resTape.res 91 | } 92 | 93 | return resObj 94 | } 95 | 96 | private async onNoRecord(req: HttpRequest) { 97 | const fallbackMode = typeof (this.options.fallbackMode) === "string" ? this.options.fallbackMode : this.options.fallbackMode(req) 98 | 99 | OptionsFactory.validateFallbackMode(fallbackMode) 100 | 101 | this.logger.info(`Tape for ${req.url} not found and recording is disabled (fallbackMode: ${fallbackMode})`) 102 | this.logger.info({ 103 | url: req.url, 104 | headers: req.headers 105 | }) 106 | 107 | if (fallbackMode === FallbackMode.PROXY) { 108 | if (this.errorRate.shouldSimulate(req, undefined)) { 109 | return this.errorRate.simulate(req) 110 | } 111 | 112 | await this.latency.simulate(req, undefined) 113 | return await this.makeRealRequest(req) 114 | } 115 | 116 | return { 117 | status: 404, 118 | headers: { "content-type": ["text/plain"] }, 119 | body: Buffer.from("talkback - tape not found") 120 | } as HttpResponse 121 | } 122 | 123 | private async makeRealRequest(req: HttpRequest) { 124 | let fetchBody: Buffer | null 125 | let { method, url, body } = req 126 | fetchBody = body 127 | const headers = { ...req.headers } 128 | delete headers.host 129 | 130 | const host = this.options.host 131 | this.logger.info(`Making real request to ${host}${url}`) 132 | 133 | if (method === "GET" || method === "HEAD") { 134 | fetchBody = null 135 | } 136 | 137 | const fRes = await fetch(host + url, { method, headers, body: fetchBody, compress: false, redirect: "manual", ...this.options.httpClient.fetchOptions }) 138 | const buff = await fRes.buffer() 139 | return { 140 | status: fRes.status, 141 | headers: fRes.headers.raw(), 142 | body: buff 143 | } as HttpResponse 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import RequestHandler from "./request-handler" 2 | import Summary from "./summary" 3 | import TapeStore from "./tape-store" 4 | 5 | import * as http from "http" 6 | import * as https from "https" 7 | import * as fs from "fs" 8 | import { Options } from "./options" 9 | import { Req } from "./types" 10 | import { Logger } from "./logger" 11 | 12 | export default class TalkbackServer { 13 | private readonly options: Options 14 | readonly tapeStore: TapeStore 15 | private requestHandler: RequestHandler 16 | private readonly closeSignalHandler?: (...args: any[]) => void 17 | private server?: http.Server 18 | private closed: boolean = false 19 | private readonly logger: Logger 20 | 21 | constructor(options: Options) { 22 | this.options = options 23 | this.tapeStore = new TapeStore(this.options) 24 | this.requestHandler = new RequestHandler(this.tapeStore, this.options) 25 | 26 | this.closeSignalHandler = this.closeForSignalHandler.bind(this) 27 | this.logger = Logger.for(this.options) 28 | } 29 | 30 | handleRequest(rawReq: http.IncomingMessage, res: http.ServerResponse) { 31 | let reqBody = [] as Uint8Array[] 32 | rawReq.on("data", (chunk) => { 33 | reqBody.push(chunk) 34 | }).on("end", async () => { 35 | try { 36 | const req: Req = { 37 | headers: rawReq.headers, 38 | url: rawReq.url, 39 | method: rawReq.method, 40 | body: Buffer.concat(reqBody) 41 | } 42 | const fRes = await this.requestHandler.handle(req) 43 | 44 | res.writeHead(fRes.status, fRes.headers) 45 | res.end(fRes.body) 46 | } catch (ex) { 47 | console.error("Error handling request", ex) 48 | res.statusCode = 500 49 | res.end() 50 | } 51 | }) 52 | } 53 | 54 | async start(callback?: () => void) { 55 | await this.tapeStore.load() 56 | const handleRequest = this.handleRequest.bind(this) 57 | 58 | const serverFactory = this.options.https.enabled ? () => { 59 | const httpsOpts = { 60 | key: fs.readFileSync(this.options.https.keyPath!), 61 | cert: fs.readFileSync(this.options.https.certPath!) 62 | } 63 | return https.createServer(httpsOpts, handleRequest) 64 | } : () => http.createServer(handleRequest) 65 | 66 | this.server = serverFactory() 67 | this.logger.info(`Starting talkback on port ${this.options.port}`) 68 | this.server.listen(this.options.port, callback) 69 | 70 | process.on("exit", this.closeSignalHandler as any) 71 | process.on("SIGINT", this.closeSignalHandler as any) 72 | process.on("SIGTERM", this.closeSignalHandler as any) 73 | 74 | return this.server 75 | } 76 | 77 | hasTapeBeenUsed(tapeName: string) { 78 | return this.tapeStore.hasTapeBeenUsed(tapeName) 79 | } 80 | 81 | resetTapeUsage() { 82 | this.tapeStore.resetTapeUsage() 83 | } 84 | 85 | private closeForSignalHandler() { 86 | this.close() 87 | process.exit(0) 88 | } 89 | 90 | close(callback?: () => void) { 91 | this.logger.debug(`Closing server ${this.options.name}`) 92 | if (this.closed) { 93 | return 94 | } 95 | this.closed = true 96 | this.server!.close(callback) 97 | 98 | process.removeListener("exit", this.closeSignalHandler as any) 99 | process.removeListener("SIGINT", this.closeSignalHandler as any) 100 | process.removeListener("SIGTERM", this.closeSignalHandler as any) 101 | 102 | if (this.options.summary) { 103 | const summary = new Summary(this.tapeStore.tapes, this.options) 104 | summary.print() 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/summary.ts: -------------------------------------------------------------------------------- 1 | import Tape from "./tape" 2 | import {Options} from "./options" 3 | import {Logger} from "./logger" 4 | 5 | export default class Summary { 6 | private tapes: Tape[] 7 | private options: Options 8 | private logger: Logger 9 | 10 | constructor(tapes: Tape[], options: Options) { 11 | this.tapes = tapes 12 | this.options = options 13 | 14 | this.logger = Logger.for(this.options) 15 | } 16 | 17 | print() { 18 | let message = `===== SUMMARY =====\n` 19 | const newTapes = this.tapes.filter(t => t.new) 20 | if (newTapes.length > 0) { 21 | message += "New tapes:\n" 22 | newTapes.forEach(t => message += `- ${t.path}\n`) 23 | } 24 | const unusedTapes = this.tapes.filter(t => !t.used) 25 | if (unusedTapes.length > 0) { 26 | message += "Unused tapes:\n" 27 | unusedTapes.forEach(t => message += `- ${t.path}\n`) 28 | } 29 | this.logger.info(message) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/talkback-factory.ts: -------------------------------------------------------------------------------- 1 | import Options from "./options" 2 | import TapeStore from "./tape-store" 3 | import TalkbackServer from "./server" 4 | import RequestHandler from "./request-handler" 5 | 6 | export default class TalkbackFactory { 7 | static server(options: Partial) { 8 | const fullOptions = Options.prepare(options) 9 | return new TalkbackServer(fullOptions) 10 | } 11 | 12 | static async requestHandler(options: Partial) { 13 | const fullOptions = Options.prepare(options) 14 | const tapeStore = new TapeStore(fullOptions) 15 | await tapeStore.load() 16 | return new RequestHandler(tapeStore, fullOptions) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/tape-matcher.ts: -------------------------------------------------------------------------------- 1 | import ContentEncoding from "./utils/content-encoding" 2 | import MediaType from "./utils/media-type" 3 | import {Options} from "./options" 4 | import Tape from "./tape" 5 | import {Req} from "./types" 6 | import {Logger} from "./logger" 7 | 8 | const isEqual = require("lodash/isEqual") 9 | 10 | export default class TapeMatcher { 11 | private readonly tape: Tape 12 | private readonly options: Options 13 | private readonly logger: Logger 14 | 15 | constructor(tape: Tape, options: Options) { 16 | this.tape = tape 17 | this.options = options 18 | 19 | this.logger = Logger.for(this.options) 20 | } 21 | 22 | sameAs(otherTape: Tape) { 23 | const otherReq = otherTape.req 24 | const req = this.tape.req 25 | if (!this.isSameUrl(req, otherReq)) { 26 | return false 27 | } 28 | 29 | if (!this.isSameMethod(req, otherReq)) { 30 | return false 31 | } 32 | 33 | if (!this.isSameHeaders(req, otherReq)) { 34 | return false 35 | } 36 | 37 | return this.options.ignoreBody || this.isSameBody(req, otherReq); 38 | 39 | } 40 | 41 | private isSameBody(req: Req, otherReq: Req) { 42 | const mediaType = new MediaType(req) 43 | const contentEncoding = new ContentEncoding(req) 44 | 45 | let sameBody: boolean 46 | if (contentEncoding.isUncompressed() && mediaType.isJSON() && req.body.length > 0 && otherReq.body.length > 0) { 47 | const parsedReqBody = JSON.parse(req.body.toString()) 48 | const parsedOtherReqBody = JSON.parse(otherReq.body.toString()) 49 | sameBody = isEqual(parsedReqBody, parsedOtherReqBody) 50 | } else { 51 | sameBody = req.body.equals(otherReq.body) 52 | } 53 | 54 | if (!sameBody) { 55 | if (!this.options.bodyMatcher) { 56 | this.logger.debug(`Not same BODY ${req.body} vs ${otherReq.body}`) 57 | return false 58 | } 59 | 60 | const bodyMatches = this.options.bodyMatcher(this.tape, otherReq) 61 | if (!bodyMatches) { 62 | this.logger.debug(`Not same bodyMatcher ${req.body} vs ${otherReq.body}`) 63 | return false 64 | } 65 | } 66 | return true 67 | } 68 | 69 | private isSameHeaders(req: Req, otherReq: Req) { 70 | const currentHeadersLength = Object.keys(req.headers).length 71 | const otherHeadersLength = Object.keys(otherReq.headers).length 72 | const sameNumberOfHeaders = currentHeadersLength === otherHeadersLength 73 | if (!sameNumberOfHeaders) { 74 | this.logger.debug(`Not same #HEADERS ${JSON.stringify(req.headers)} vs ${JSON.stringify(otherReq.headers)}`) 75 | return false 76 | } 77 | 78 | let headersSame = true 79 | Object.keys(req.headers).forEach(k => { 80 | const entryHeader = req.headers[k] 81 | const header = otherReq.headers[k] 82 | 83 | headersSame = headersSame && entryHeader === header 84 | }) 85 | if (!headersSame) { 86 | this.logger.debug(`Not same HEADERS values ${JSON.stringify(req.headers)} vs ${JSON.stringify(otherReq.headers)}`) 87 | return false 88 | } 89 | return true 90 | } 91 | 92 | private isSameMethod(req: Req, otherReq: Req) { 93 | const sameMethod = req.method === otherReq.method 94 | if (!sameMethod) { 95 | this.logger.debug(`Not same METHOD ${req.method} vs ${otherReq.method}`) 96 | return false 97 | } 98 | return true 99 | } 100 | 101 | private isSameUrl(req: Req, otherReq: Req) { 102 | const sameURL = req.url === otherReq.url 103 | if (!sameURL) { 104 | if (!this.options.urlMatcher) { 105 | this.logger.debug(`Not same URL ${req.url} vs ${otherReq.url}`) 106 | return false 107 | } 108 | 109 | const urlMatches = this.options.urlMatcher(this.tape, otherReq) 110 | if (!urlMatches) { 111 | this.logger.debug(`Not same urlMatcher ${req.url} vs ${otherReq.url}`) 112 | return false 113 | } 114 | } 115 | return true 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/tape-renderer.ts: -------------------------------------------------------------------------------- 1 | import Headers from "./utils/headers" 2 | import MediaType from "./utils/media-type" 3 | import Tape from "./tape" 4 | import ContentEncoding from "./utils/content-encoding" 5 | import {Options} from "./options" 6 | import {ReqRes} from "./types" 7 | 8 | const bufferShim = require("buffer-shims") 9 | 10 | export default class TapeRenderer { 11 | private tape: Tape 12 | 13 | constructor(tape: Tape) { 14 | this.tape = tape 15 | } 16 | 17 | static async fromStore(raw: any, options: Options) { 18 | const req = {...raw.req} 19 | 20 | req.body = await this.prepareBody(raw, req, req.body, "req") 21 | 22 | const tape = new Tape(req, options) 23 | tape.meta = {...raw.meta} 24 | const baseRes = {...raw.res} 25 | const resBody = await this.prepareBody(tape, baseRes, baseRes.body, "res") 26 | 27 | tape.res = { 28 | ...baseRes, 29 | body: resBody 30 | } 31 | 32 | return tape 33 | } 34 | 35 | static async prepareBody(tape: Tape, reqResObj: ReqRes, rawBody: string, metaPrefix: "res" | "req") { 36 | const contentEncoding = new ContentEncoding(reqResObj) 37 | const isTapeUncompressed = (tape.meta as any)[metaPrefix + "Uncompressed"] 38 | const isTapeHumanReadable = (tape.meta as any)[metaPrefix + "HumanReadable"] 39 | const isTapeInPlainText = isTapeUncompressed || contentEncoding.isUncompressed() 40 | 41 | if (isTapeHumanReadable && isTapeInPlainText) { 42 | const mediaType = new MediaType(reqResObj) 43 | let bufferContent = rawBody 44 | const isResAnObject = typeof (bufferContent) === "object" 45 | 46 | if (isResAnObject && mediaType.isJSON()) { 47 | bufferContent = JSON.stringify(bufferContent, null, 2) 48 | } 49 | 50 | if (Headers.read(reqResObj.headers, "content-length")) { 51 | Headers.write(reqResObj.headers, "content-length", Buffer.byteLength(bufferContent).toString(), metaPrefix) 52 | } 53 | 54 | if (isTapeUncompressed) { 55 | return await contentEncoding.compressedBody(bufferContent) 56 | } 57 | 58 | return bufferShim.from(bufferContent) 59 | } else { 60 | return bufferShim.from(rawBody, "base64") 61 | } 62 | } 63 | 64 | async render() { 65 | const reqBody = await this.bodyFor(this.tape.req, "req") 66 | const resBody = await this.bodyFor(this.tape.res!, "res") 67 | return { 68 | meta: this.tape.meta, 69 | req: { 70 | ...this.tape.req, 71 | body: reqBody 72 | }, 73 | res: { 74 | ...this.tape.res, 75 | body: resBody 76 | } 77 | } 78 | } 79 | 80 | async bodyFor(reqResObj: ReqRes, metaPrefix: "req" | "res") { 81 | const mediaType = new MediaType(reqResObj) 82 | const contentEncoding = new ContentEncoding(reqResObj) 83 | const bodyLength = reqResObj.body.length 84 | 85 | const isUncompressed = contentEncoding.isUncompressed() 86 | const contentEncodingSupported = isUncompressed || contentEncoding.supportedAlgorithm() 87 | 88 | if (mediaType.isHumanReadable() && contentEncodingSupported && bodyLength > 0) { 89 | (this.tape.meta as any)[metaPrefix + "HumanReadable"] = true 90 | 91 | let body = reqResObj.body 92 | 93 | if (!isUncompressed) { 94 | (this.tape.meta as any)[metaPrefix + "Uncompressed"] = true 95 | body = await contentEncoding.uncompressedBody(body) 96 | } 97 | 98 | const rawBody = body.toString("utf8") 99 | 100 | if (mediaType.isJSON()) { 101 | try { 102 | return JSON.parse(rawBody); 103 | } catch { 104 | return rawBody; 105 | } 106 | } else { 107 | return rawBody 108 | } 109 | } else { 110 | return reqResObj.body.toString("base64") 111 | } 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/tape-store.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "./options" 2 | 3 | const fs = require("fs") 4 | const path = require("path") 5 | const JSON5 = require("json5") 6 | const mkdirp = require("mkdirp") 7 | 8 | import Tape from "./tape" 9 | import TapeMatcher from "./tape-matcher" 10 | import TapeRenderer from "./tape-renderer" 11 | import { Logger } from "./logger" 12 | 13 | export default class TapeStore { 14 | private readonly path: string 15 | private readonly options: Options 16 | private readonly logger: Logger 17 | tapes: Tape[] 18 | lastTapeId: number | undefined 19 | 20 | constructor(options: Options) { 21 | this.path = path.normalize(options.path + "/") 22 | this.options = options 23 | this.tapes = [] 24 | 25 | this.logger = Logger.for(this.options) 26 | } 27 | 28 | async load() { 29 | mkdirp.sync(this.path) 30 | 31 | await this.loadTapesAtDir(this.path) 32 | this.logger.info(`Loaded ${this.tapes.length} tapes from ${this.path}`) 33 | } 34 | 35 | async loadTapesAtDir(directory: string) { 36 | const items = fs.readdirSync(directory) as string[] 37 | for (let i = 0; i < items.length; i++) { 38 | const filename = items[i] 39 | const fullPath = `${directory}${filename}` 40 | const stat = fs.statSync(fullPath) 41 | if (!stat.isDirectory()) { 42 | try { 43 | const data = fs.readFileSync(fullPath, "utf8") 44 | const raw = JSON5.parse(data) 45 | const tape = await Tape.fromStore(raw, this.options) 46 | tape.path = filename 47 | this.tapes.push(tape) 48 | } catch (e) { 49 | this.logger.error(`Error reading tape ${fullPath}`, e.message) 50 | } 51 | } else { 52 | this.loadTapesAtDir(fullPath + "/") 53 | } 54 | } 55 | } 56 | 57 | find(newTape: Tape) { 58 | const foundTape = this.tapes.find(t => { 59 | this.logger.debug(`Comparing against tape ${t.path}`) 60 | return new TapeMatcher(t, this.options).sameAs(newTape) 61 | }) 62 | 63 | if (foundTape) { 64 | foundTape.used = true 65 | this.logger.info(`Found matching tape for ${newTape.req.url} at ${foundTape.path}`) 66 | return foundTape 67 | } 68 | } 69 | 70 | async save(tape: Tape) { 71 | tape.new = true 72 | tape.used = true 73 | 74 | const tapePath = tape.path 75 | let fullFilename 76 | 77 | if (tapePath) { 78 | fullFilename = path.join(this.path, tapePath) 79 | } else { 80 | // If the tape doesn't have a path then it's new 81 | this.tapes.push(tape) 82 | 83 | fullFilename = this.createTapePath(tape) 84 | tape.path = path.relative(this.path, fullFilename) 85 | } 86 | this.logger.info(`Saving request ${tape.req.url} at ${tape.path}`) 87 | 88 | const tapeRenderer = new TapeRenderer(tape) 89 | const toSave = await tapeRenderer.render() 90 | fs.writeFileSync(fullFilename, JSON5.stringify(toSave, null, 4)) 91 | } 92 | 93 | hasTapeBeenUsed(tapeName: string) { 94 | return this.tapes.some(t => t.used && t.path === tapeName) 95 | } 96 | 97 | resetTapeUsage() { 98 | return this.tapes.forEach(t => t.used = false) 99 | } 100 | 101 | createTapePath(tape: Tape) { 102 | let nextTapeId = Date.now() 103 | if (this.lastTapeId && nextTapeId <= this.lastTapeId) { 104 | nextTapeId = this.lastTapeId + 1 105 | } 106 | 107 | let tapePath = `unnamed-${nextTapeId}.json5` 108 | if (this.options.tapeNameGenerator) { 109 | tapePath = this.options.tapeNameGenerator(nextTapeId, tape) 110 | } 111 | let result = path.normalize(path.join(this.options.path, tapePath)) 112 | if (!result.endsWith(".json5")) { 113 | result = `${result}.json5` 114 | } 115 | const dir = path.dirname(result) 116 | mkdirp.sync(dir) 117 | 118 | this.lastTapeId = nextTapeId 119 | 120 | return result 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/tape.ts: -------------------------------------------------------------------------------- 1 | import MediaType from "./utils/media-type" 2 | import TapeRenderer from "./tape-renderer" 3 | import ContentEncoding from "./utils/content-encoding" 4 | import {Options} from "./options" 5 | import {Metadata, Req, Res} from "./types" 6 | 7 | const URL = require("url") 8 | const querystring = require("querystring") 9 | 10 | export default class Tape { 11 | req: Req 12 | res?: Res 13 | options: Options 14 | queryParamsToIgnore: string[] 15 | meta: Metadata 16 | path?: string 17 | 18 | new: boolean = false 19 | used: boolean = false 20 | 21 | constructor(req: Req, options: Options) { 22 | this.req = {...req} 23 | this.options = options 24 | 25 | // This needs to happen before we erase headers since we could lose information 26 | this.normalizeReqBody() 27 | 28 | this.cleanupReqHeaders() 29 | 30 | this.queryParamsToIgnore = this.options.ignoreQueryParams 31 | this.cleanupQueryParams() 32 | 33 | 34 | this.meta = { 35 | createdAt: new Date(), 36 | host: this.options.host 37 | } 38 | } 39 | 40 | static async fromStore(raw: any, options: Options) { 41 | return TapeRenderer.fromStore(raw, options) 42 | } 43 | 44 | cleanupReqHeaders() { 45 | let newHeaders = {} 46 | if (this.options.allowHeaders != undefined) { 47 | newHeaders = this.options.allowHeaders.reduce((headers, header) => { 48 | const lowerHeader = header.toLowerCase() 49 | if (lowerHeader in this.req.headers) { 50 | headers[lowerHeader] = this.req.headers[lowerHeader] 51 | } 52 | return headers 53 | }, {}) 54 | } else { 55 | newHeaders = {...this.req.headers} 56 | } 57 | this.options.ignoreHeaders.forEach(h => delete newHeaders[h]) 58 | this.req = { 59 | ...this.req, 60 | headers: newHeaders 61 | } 62 | } 63 | 64 | cleanupQueryParams() { 65 | if (this.queryParamsToIgnore.length === 0) { 66 | return 67 | } 68 | 69 | const url = URL.parse(this.req.url, true) 70 | if (!url.search) { 71 | return 72 | } 73 | 74 | const query = {...url.query} 75 | this.queryParamsToIgnore.forEach(q => delete query[q]) 76 | 77 | const newQuery = querystring.stringify(query) 78 | if (newQuery) { 79 | url.query = query 80 | url.search = "?" + newQuery 81 | } else { 82 | url.query = null 83 | url.search = null 84 | } 85 | this.req.url = URL.format(url) 86 | } 87 | 88 | normalizeReqBody() { 89 | const mediaType = new MediaType(this.req) 90 | const contentEncoding = new ContentEncoding(this.req) 91 | if (contentEncoding.isUncompressed() && mediaType.isJSON() && this.req.body.length > 0) { 92 | this.req.body = Buffer.from(JSON.stringify(JSON.parse(this.req.body.toString()), null, 2)) 93 | } 94 | } 95 | 96 | async clone() { 97 | const tapeRenderer = new TapeRenderer(this) 98 | const raw = await tapeRenderer.render() 99 | return Tape.fromStore(raw, this.options) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {FallbackMode, Options, RecordMode} from "./options" 2 | import TalkbackServer from "./server" 3 | import RequestHandler from "./request-handler" 4 | 5 | export {} 6 | 7 | export interface More { 8 | [key: string]: any 9 | } 10 | 11 | export interface MatchingContext extends More { 12 | id: string; 13 | } 14 | 15 | export interface ReqRes { 16 | headers: any, 17 | body: Buffer 18 | } 19 | 20 | export interface Req extends ReqRes { 21 | url: string, 22 | method: string 23 | } 24 | 25 | export type HttpRequest = Req 26 | 27 | export interface Res extends ReqRes { 28 | status: number 29 | } 30 | 31 | export type HttpResponse = Res 32 | 33 | export interface Metadata extends More { 34 | createdAt: Date, 35 | host: string, 36 | tag?: string, 37 | errorRate?: number 38 | latency?: number | number[], 39 | reqUncompressed?: boolean, 40 | resUncompressed?: boolean, 41 | reqHumanReadable?: boolean, 42 | resHumanReadable?: boolean 43 | } 44 | 45 | type TalkbackBase = (options: Partial) => TalkbackServer 46 | 47 | export interface Talkback extends TalkbackBase { 48 | Options: { 49 | Default: Options, 50 | FallbackMode: typeof FallbackMode, 51 | RecordMode: typeof RecordMode 52 | } 53 | 54 | requestHandler(options: Partial): RequestHandler 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/content-encoding.ts: -------------------------------------------------------------------------------- 1 | import {ReqRes} from "../types" 2 | 3 | const zlib = require("zlib") 4 | import Headers from "./headers" 5 | 6 | const ALGORITHMS = { 7 | gzip: {compress: zlib.gzipSync, uncompress: zlib.gunzipSync}, 8 | deflate: {compress: zlib.deflateSync, uncompress: zlib.inflateSync}, 9 | br: {compress: zlib.brotliCompressSync, uncompress: zlib.brotliDecompressSync} 10 | } 11 | 12 | type SupportedAlgorithms = keyof typeof ALGORITHMS 13 | 14 | export default class ContentEncoding { 15 | private reqRes: ReqRes 16 | 17 | constructor(reqRes: ReqRes) { 18 | this.reqRes = reqRes 19 | } 20 | 21 | isUncompressed() { 22 | const contentEncoding = this.contentEncoding() 23 | return !contentEncoding || contentEncoding === "identity" 24 | } 25 | 26 | supportedAlgorithm() { 27 | const contentEncoding = this.contentEncoding() 28 | return Object.keys(ALGORITHMS).includes(contentEncoding) 29 | } 30 | 31 | contentEncoding() { 32 | return Headers.read(this.reqRes.headers, "content-encoding") 33 | } 34 | 35 | async uncompressedBody(body: Buffer) { 36 | const contentEncoding = this.contentEncoding() 37 | 38 | if (!this.supportedAlgorithm()) { 39 | throw new Error(`Unsupported content-encoding ${contentEncoding}`) 40 | } 41 | 42 | return ALGORITHMS[contentEncoding as SupportedAlgorithms].uncompress(body) 43 | } 44 | 45 | async compressedBody(body: string) { 46 | const contentEncoding = this.contentEncoding() 47 | 48 | if (!this.supportedAlgorithm()) { 49 | throw new Error(`Unsupported content-encoding ${contentEncoding}`) 50 | } 51 | 52 | return ALGORITHMS[contentEncoding as SupportedAlgorithms].compress(body) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/headers.ts: -------------------------------------------------------------------------------- 1 | export default class Headers { 2 | static read(headers: any, headerName: string) { 3 | const value = headers[headerName] 4 | if (Array.isArray(value)) { 5 | return value[0] 6 | } else { 7 | return value 8 | } 9 | } 10 | 11 | static write(headers: any, headerName: string, value: string, type: "req"|"res") { 12 | headers[headerName] = type === "req" ? value : [value] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/media-type.ts: -------------------------------------------------------------------------------- 1 | import {ReqRes} from "../types" 2 | import Headers from "./headers" 3 | 4 | const contentTypeParser = require("content-type") 5 | 6 | const equals = (to: string) => (contentType: string) => to == contentType 7 | 8 | export const jsonTypes = [ 9 | equals("application/graphql"), 10 | equals("application/json"), 11 | equals("application/x-amz-json-1.0"), 12 | equals("application/x-amz-json-1.1"), 13 | (contentType: string) => contentType.startsWith("application/") && contentType.endsWith("+json") 14 | ] 15 | 16 | const humanReadableContentTypes = [ 17 | equals("application/javascript"), 18 | equals("text/css"), 19 | equals("text/html"), 20 | equals("text/javascript"), 21 | equals("text/plain"), 22 | ...jsonTypes 23 | ] 24 | 25 | export default class MediaType { 26 | private htmlReqRes: ReqRes 27 | 28 | constructor(htmlReqRes: ReqRes) { 29 | this.htmlReqRes = htmlReqRes 30 | } 31 | 32 | isHumanReadable() { 33 | const contentType = this.contentType() 34 | if (!contentType) { 35 | return false 36 | } 37 | 38 | return humanReadableContentTypes.some(comparator => comparator(contentType)) 39 | } 40 | 41 | isJSON() { 42 | const contentType = this.contentType() 43 | if (!contentType) { 44 | return false 45 | } 46 | 47 | return jsonTypes.some(comparator => comparator(contentType)) 48 | } 49 | 50 | contentType() { 51 | const contentTypeHeader = Headers.read(this.headers(), "content-type") 52 | if (!contentTypeHeader) { 53 | return null 54 | } 55 | const parsedContentType = contentTypeParser.parse(contentTypeHeader) 56 | return parsedContentType.type as string 57 | } 58 | 59 | headers() { 60 | return this.htmlReqRes.headers 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/features/error-rate.spec.ts: -------------------------------------------------------------------------------- 1 | import ErrorRate from "../../src/features/error-rate" 2 | import OptionsFactory, {Options} from "../../src/options" 3 | import {expect} from "chai" 4 | import * as td from "testdouble" 5 | import {Req} from "../../src/types" 6 | import Tape from "../../src/tape" 7 | 8 | let errorRate: ErrorRate, opts: Options 9 | 10 | const emptyReq: Req = { 11 | url: "http://base.com", 12 | method: "GET", 13 | headers: {}, 14 | body: Buffer.from("FOOBAR") 15 | } 16 | 17 | describe("ErrorRate", () => { 18 | beforeEach(() => { 19 | opts = OptionsFactory.prepare({silent: true, errorRate: 30}) 20 | errorRate = new ErrorRate(opts) 21 | 22 | const randomTd = td.function() 23 | td.replace(Math, "random", randomTd) 24 | td.when(randomTd()).thenReturn(0.3) 25 | }) 26 | 27 | describe("#shouldSimulate", () => { 28 | afterEach(() => td.reset()) 29 | 30 | context("when there isn't a matching tape", () => { 31 | context("when errorRate is a number", () => { 32 | it("returns true when falling inside errorRate", () => { 33 | opts.errorRate = 40 34 | 35 | expect(errorRate.shouldSimulate(emptyReq, undefined)).to.eql(true) 36 | }) 37 | 38 | it("returns false when falling inside errorRate", () => { 39 | opts.errorRate = 20 40 | 41 | expect(errorRate.shouldSimulate(emptyReq, undefined)).to.eql(false) 42 | }) 43 | }) 44 | 45 | context("when errorRate is a function", () => { 46 | it("returns what the function returns", () => { 47 | opts.errorRate = (req) => req.url.includes("fail") ? 100 : 0 48 | 49 | expect(errorRate.shouldSimulate({...emptyReq, url: "http://pass"}, undefined)).to.eql(false) 50 | expect(errorRate.shouldSimulate({...emptyReq, url: "http://fail"}, undefined)).to.eql(true) 51 | }) 52 | }) 53 | }) 54 | 55 | context("when there's a matching tape", () => { 56 | it("uses the tape's errorRate", () => { 57 | opts.errorRate = 40 58 | const tape = new Tape(emptyReq, opts) 59 | tape.meta.errorRate = 10 60 | 61 | expect(errorRate.shouldSimulate(emptyReq, tape)).to.eql(false) 62 | }) 63 | }) 64 | }) 65 | 66 | describe("#simulate", () => { 67 | it("returns an error response object", () => { 68 | const resp = errorRate.simulate(emptyReq) 69 | expect(resp.status).to.eql(503) 70 | }) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /test/integration/talkback-server.spec.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "../../src/options" 2 | import testServer from "../support/test-server" 3 | import { expect } from "chai" 4 | import * as td from "testdouble" 5 | import { HttpRequest, MatchingContext, Req, Talkback } from "../../src/types" 6 | import TalkbackServer from "../../src/server" 7 | import * as http from "http" 8 | import Tape from "../../src/tape" 9 | 10 | let talkback: Talkback 11 | if (process.env.USE_DIST) { 12 | talkback = require("../../dist") 13 | console.log("Using DIST talkback") 14 | } else { 15 | talkback = require("../../src") 16 | } 17 | 18 | const JSON5 = require("json5") 19 | const fs = require("fs") 20 | const path = require("path") 21 | const fetch = require("node-fetch") 22 | const del = require("del") 23 | 24 | const RecordMode = talkback.Options.RecordMode 25 | const FallbackMode = talkback.Options.FallbackMode 26 | 27 | let talkbackServer: TalkbackServer | null, proxiedServer: http.Server | null 28 | const proxiedPort = 8898 29 | const proxiedHost = `http://localhost:${proxiedPort}` 30 | const tapesPath = path.join(__dirname, "..", "/tapes") 31 | 32 | const talkbackPort = 8899 33 | const talkbackHost = `http://localhost:${talkbackPort}` 34 | 35 | const fullOptions = (opts?: Partial) => { 36 | return { 37 | path: tapesPath, 38 | port: talkbackPort, 39 | host: proxiedHost, 40 | record: RecordMode.NEW, 41 | silent: false, 42 | debug: true, 43 | ignoreHeaders: ["connection", "user-agent"], 44 | bodyMatcher: (tape, req) => { 45 | return tape.meta.tag === "echo" 46 | }, 47 | responseDecorator: (tape, req) => { 48 | if (tape.meta.tag === "echo") { 49 | tape.res!.body = req.body 50 | } 51 | 52 | let location = tape.res!.headers["location"] 53 | if (location && location[0]) { 54 | location = location[0] 55 | tape.res!.headers["location"] = [location.replace(proxiedHost, talkbackHost)] 56 | } 57 | 58 | return tape 59 | }, 60 | ...opts 61 | } as Options 62 | } 63 | 64 | const startTalkback = async (opts?: Partial) => { 65 | const talkbackServer = talkback(fullOptions(opts)) 66 | await talkbackServer.start() 67 | 68 | return talkbackServer 69 | } 70 | 71 | const cleanupTapes = () => { 72 | // Delete all unnamed tapes 73 | let files = fs.readdirSync(tapesPath) 74 | for (let i = 0, len = files.length; i < len; i++) { 75 | const match = files[i].match(/unnamed-/) 76 | if (match !== null) { 77 | fs.unlinkSync(path.join(tapesPath, files[i])) 78 | } 79 | } 80 | const newTapesPath = path.join(tapesPath, "new-tapes") 81 | del.sync(newTapesPath) 82 | } 83 | 84 | describe("talkbackServer", () => { 85 | beforeEach(() => cleanupTapes()) 86 | 87 | before(async () => { 88 | proxiedServer = testServer() 89 | await proxiedServer.listen(proxiedPort) 90 | }) 91 | 92 | after(() => { 93 | if (proxiedServer) { 94 | proxiedServer.close() 95 | proxiedServer = null 96 | } 97 | }) 98 | 99 | afterEach(() => { 100 | if (talkbackServer) { 101 | talkbackServer.close() 102 | talkbackServer = null 103 | } 104 | }) 105 | 106 | describe("## record mode NEW", () => { 107 | it("proxies and creates a new tape when the POST request is unknown with human readable req and res", async () => { 108 | talkbackServer = await startTalkback() 109 | 110 | const reqBody = JSON.stringify({ foo: "bar" }) 111 | const headers = { "content-type": "application/json" } 112 | const res = await fetch(`${talkbackHost}/test/1`, { compress: false, method: "POST", headers, body: reqBody }) 113 | expect(res.status).to.eq(200) 114 | 115 | const expectedResBody = { ok: true, body: { foo: "bar" } } 116 | const body = await res.json() 117 | expect(body).to.eql(expectedResBody) 118 | 119 | const tape = JSON5.parse(fs.readFileSync(tapesPath + `/unnamed-${talkbackServer.tapeStore.lastTapeId}.json5`)) 120 | expect(tape.meta.reqHumanReadable).to.eq(true) 121 | expect(tape.meta.resHumanReadable).to.eq(true) 122 | expect(tape.req.url).to.eql("/test/1") 123 | expect(tape.res.body).to.eql(expectedResBody) 124 | }) 125 | 126 | it("proxies and creates a new tape when the GET request is unknown", async () => { 127 | talkbackServer = await startTalkback() 128 | 129 | const res = await fetch(`${talkbackHost}/test/1`, { compress: false, method: "GET" }) 130 | expect(res.status).to.eq(200) 131 | 132 | const expectedResBody = { ok: true, body: null } 133 | const body = await res.json() 134 | expect(body).to.eql(expectedResBody) 135 | 136 | const tape = JSON5.parse(fs.readFileSync(tapesPath + `/unnamed-${talkbackServer.tapeStore.lastTapeId}.json5`)) 137 | expect(tape.meta.reqHumanReadable).to.eq(undefined) 138 | expect(tape.meta.resHumanReadable).to.eq(true) 139 | expect(tape.req.url).to.eql("/test/1") 140 | expect(tape.res.body).to.eql(expectedResBody) 141 | }) 142 | 143 | it("proxies and creates a new tape when the POST request is unknown with human readable req and res", async () => { 144 | talkbackServer = await startTalkback() 145 | 146 | const reqBody = JSON.stringify({ foo: "bar" }) 147 | const headers = { "content-type": "application/json" } 148 | const res = await fetch(`${talkbackHost}/test/1`, { compress: false, method: "POST", headers, body: reqBody }) 149 | expect(res.status).to.eq(200) 150 | 151 | const expectedResBody = { ok: true, body: { foo: "bar" } } 152 | const body = await res.json() 153 | expect(body).to.eql(expectedResBody) 154 | 155 | const tape = JSON5.parse(fs.readFileSync(tapesPath + `/unnamed-${talkbackServer.tapeStore.lastTapeId}.json5`)) 156 | expect(tape.meta.reqHumanReadable).to.eq(true) 157 | expect(tape.meta.resHumanReadable).to.eq(true) 158 | expect(tape.req.url).to.eql("/test/1") 159 | expect(tape.res.body).to.eql(expectedResBody) 160 | }) 161 | 162 | it("proxies and creates a new tape when the HEAD request is unknown", async () => { 163 | talkbackServer = await startTalkback() 164 | 165 | const headers = { "content-type": "application/json" } 166 | const res = await fetch(`${talkbackHost}/test/head`, { method: "HEAD", headers }) 167 | expect(res.status).to.eq(200) 168 | 169 | const tape = JSON5.parse(fs.readFileSync(tapesPath + `/unnamed-${talkbackServer.tapeStore.lastTapeId}.json5`)) 170 | expect(tape.meta.reqHumanReadable).to.eq(undefined) 171 | expect(tape.meta.resHumanReadable).to.eq(undefined) 172 | expect(tape.req.url).to.eql("/test/head") 173 | expect(tape.req.body).to.eql("") 174 | expect(tape.res.body).to.eql("") 175 | }) 176 | 177 | it("proxies and creates a new tape with a custom tape name generator", async () => { 178 | talkbackServer = await startTalkback( 179 | { 180 | tapeNameGenerator: (tapeNumber, tape) => { 181 | return path.join("new-tapes", `${tape.req.method}`, `my-tape-${tapeNumber}`) 182 | } 183 | } 184 | ) 185 | 186 | const res = await fetch(`${talkbackHost}/test/1`, { compress: false, method: "GET" }) 187 | 188 | expect(res.status).to.eq(200) 189 | 190 | const tape = JSON5.parse(fs.readFileSync(tapesPath + `/new-tapes/GET/my-tape-${talkbackServer.tapeStore.lastTapeId}.json5`)) 191 | expect(tape.req.url).to.eql("/test/1") 192 | }) 193 | 194 | it("proxies and creates a new tape with a custom tape decorator that saves data on the request", async () => { 195 | const customMetaValue = "custom meta value" 196 | const requestDecorator = (req: Req, context: MatchingContext) => { 197 | req.headers["x-req-id"] = context.id 198 | req.headers["x-data"] = customMetaValue 199 | return req 200 | } 201 | 202 | const tapeDecorator = (tape: Tape, context: MatchingContext) => { 203 | expect(tape.req.headers["x-req-id"]).to.eql(context.id) 204 | tape.meta.myOwnData = tape.req.headers["x-data"] 205 | return tape 206 | } 207 | 208 | talkbackServer = await startTalkback( 209 | { 210 | requestDecorator, 211 | tapeDecorator: tapeDecorator 212 | } 213 | ) 214 | 215 | const res = await fetch(`${talkbackHost}/test/1`, { compress: false, method: "GET" }) 216 | 217 | expect(res.status).to.eq(200) 218 | 219 | const tape = JSON5.parse(fs.readFileSync(tapesPath + `/unnamed-${talkbackServer.tapeStore.lastTapeId}.json5`)) 220 | expect(tape.req.url).to.eql("/test/1") 221 | expect(tape.meta.myOwnData).to.eql(customMetaValue) 222 | }) 223 | 224 | it("proxies and creates a new tape with an invalid JSON response", async () => { 225 | talkbackServer = await startTalkback() 226 | const res = await fetch(`${talkbackHost}/test/invalid-json`, { compress: false, method: "GET" }) 227 | 228 | expect(res.status).to.eq(200) 229 | 230 | const tape = JSON5.parse(fs.readFileSync(tapesPath + `/unnamed-${talkbackServer.tapeStore.lastTapeId}.json5`)) 231 | expect(tape.req.url).to.eql("/test/invalid-json") 232 | expect(tape.meta.resHumanReadable).to.eq(true) 233 | expect(tape.res.body).to.eql('{"invalid: ') 234 | }) 235 | 236 | it("decorates proxied responses", async () => { 237 | talkbackServer = await startTalkback() 238 | 239 | const res = await fetch(`${talkbackHost}/test/redirect/1`, { compress: false, method: "GET", redirect: "manual" }) 240 | expect(res.status).to.eq(302) 241 | 242 | const location = res.headers.get("location") 243 | expect(location).to.eql(`${talkbackHost}/test/1`) 244 | }) 245 | 246 | it("handles when the proxied server returns a 500", async () => { 247 | talkbackServer = await startTalkback() 248 | 249 | const res = await fetch(`${talkbackHost}/test/3`) 250 | expect(res.status).to.eq(500) 251 | 252 | const tape = JSON5.parse(fs.readFileSync(tapesPath + `/unnamed-${talkbackServer.tapeStore.lastTapeId}.json5`)) 253 | expect(tape.req.url).to.eql("/test/3") 254 | expect(tape.res.status).to.eql(500) 255 | }) 256 | 257 | it("loads existing tapes and uses them if they match", async () => { 258 | talkbackServer = await startTalkback({ record: RecordMode.DISABLED }) 259 | 260 | const res = await fetch(`${talkbackHost}/test/3`, { compress: false }) 261 | expect(res.status).to.eq(200) 262 | 263 | const body = await res.json() 264 | expect(body).to.eql({ ok: true }) 265 | }) 266 | 267 | it("matches and returns pretty printed tapes", async () => { 268 | talkbackServer = await startTalkback({ record: RecordMode.DISABLED }) 269 | 270 | const headers = { "content-type": "application/json" } 271 | // Different key order 272 | const body = JSON.stringify({ param2: { subParam: 1 }, param1: 3 }) 273 | 274 | const res = await fetch(`${talkbackHost}/test/pretty`, { 275 | compress: false, 276 | method: "POST", 277 | headers, 278 | body 279 | }) 280 | expect(res.status).to.eq(200) 281 | 282 | const resClone = await res.clone() 283 | 284 | const resBody = await res.json() 285 | expect(resBody).to.eql({ ok: true, foo: { bar: 3 } }) 286 | 287 | const resBodyAsText = await resClone.text() 288 | expect(resBodyAsText).to.eql("{\n \"ok\": true,\n \"foo\": {\n \"bar\": 3\n }\n}") 289 | }) 290 | 291 | it("doesn't match pretty printed tapes with different body", async () => { 292 | const makeRequest = async (body: string) => { 293 | let res = await fetch(`${talkbackHost}/test/pretty`, { 294 | compress: false, 295 | method: "POST", 296 | headers, 297 | body 298 | }) 299 | expect(res.status).to.eq(404) 300 | } 301 | 302 | talkbackServer = await startTalkback({ record: RecordMode.DISABLED }) 303 | 304 | const headers = { "content-type": "application/json" } 305 | 306 | // Different nested object 307 | let body = JSON.stringify({ param1: 3, param2: { subParam: 2 } }) 308 | await makeRequest(body) 309 | 310 | // Extra key 311 | body = JSON.stringify({ param1: 3, param2: { subParam: 1 }, param3: false }) 312 | await makeRequest(body) 313 | }) 314 | 315 | it("decorates the response of an existing tape", async () => { 316 | talkbackServer = await startTalkback({ record: RecordMode.DISABLED }) 317 | 318 | const headers = { "content-type": "application/json" } 319 | const body = JSON.stringify({ text: "my-test" }) 320 | 321 | const res = await fetch(`${talkbackHost}/test/echo`, { 322 | compress: false, 323 | method: "POST", 324 | headers, 325 | body 326 | }) 327 | expect(res.status).to.eq(200) 328 | 329 | const resBody = await res.json() 330 | expect(resBody).to.eql({ text: "my-test" }) 331 | }) 332 | }) 333 | 334 | describe("## record mode OVERWRITE", () => { 335 | it("overwrites an existing tape", async () => { 336 | talkbackServer = await startTalkback({ 337 | record: RecordMode.OVERWRITE, 338 | ignoreHeaders: ["x-talkback-ping"], 339 | }) 340 | 341 | let headers = { "x-talkback-ping": "test1" } 342 | 343 | let res = await fetch(`${talkbackHost}/test/1`, { compress: false, headers }) 344 | expect(res.status).to.eq(200) 345 | let resBody = await res.json() 346 | let expectedBody = { ok: true, body: "test1" } 347 | expect(resBody).to.eql(expectedBody) 348 | 349 | let tape = JSON5.parse(fs.readFileSync(tapesPath + `/unnamed-${talkbackServer.tapeStore.lastTapeId}.json5`)) 350 | expect(tape.req.url).to.eql("/test/1") 351 | expect(tape.res.body).to.eql(expectedBody) 352 | 353 | headers = { "x-talkback-ping": "test2" } 354 | 355 | res = await fetch(`${talkbackHost}/test/1`, { compress: false, headers }) 356 | expect(res.status).to.eq(200) 357 | resBody = await res.json() 358 | expectedBody = { ok: true, body: "test2" } 359 | expect(resBody).to.eql(expectedBody) 360 | 361 | tape = JSON5.parse(fs.readFileSync(tapesPath + `/unnamed-${talkbackServer.tapeStore.lastTapeId}.json5`)) 362 | expect(tape.req.url).to.eql("/test/1") 363 | expect(tape.res.body).to.eql(expectedBody) 364 | }) 365 | }) 366 | 367 | describe("## record mode DISABLED", () => { 368 | it("returns a 404 on unknown request with fallbackMode NOT_FOUND (default)", async () => { 369 | talkbackServer = await startTalkback({ record: RecordMode.DISABLED }) 370 | 371 | const res = await fetch(`${talkbackHost}/test/1`, { compress: false }) 372 | expect(res.status).to.eq(404) 373 | }) 374 | 375 | it("proxies request to host on unknown request with fallbackMode PROXY", async () => { 376 | talkbackServer = await startTalkback({ record: RecordMode.DISABLED, fallbackMode: FallbackMode.PROXY }) 377 | 378 | const reqBody = JSON.stringify({ foo: "bar" }) 379 | const headers = { "content-type": "application/json" } 380 | const res = await fetch(`${talkbackHost}/test/1`, { compress: false, method: "POST", headers, body: reqBody }) 381 | expect(res.status).to.eq(200) 382 | 383 | const expectedResBody = { ok: true, body: { foo: "bar" } } 384 | const body = await res.json() 385 | expect(body).to.eql(expectedResBody) 386 | 387 | expect(fs.existsSync(tapesPath + "/unnamed-3.json5")).to.eq(false) 388 | }) 389 | }) 390 | 391 | describe("error handling", () => { 392 | afterEach(() => td.reset()) 393 | 394 | it("returns a 500 if anything goes wrong", async () => { 395 | talkbackServer = await startTalkback({ record: RecordMode.DISABLED }) 396 | td.replace(talkbackServer, "requestHandler", { 397 | handle: () => { 398 | throw "Test error" 399 | } 400 | }) 401 | 402 | const res = await fetch(`${talkbackHost}/test/1`, { compress: false }) 403 | expect(res.status).to.eq(500) 404 | }) 405 | }) 406 | 407 | describe("summary printing", () => { 408 | afterEach(() => td.reset()) 409 | 410 | it("prints the summary when enabled", async () => { 411 | talkbackServer = await startTalkback({ summary: true, silent: false }) 412 | const logInfo = td.replace(console, "log") 413 | talkbackServer.close() 414 | 415 | td.verify(logInfo(td.matchers.contains("SUMMARY"))) 416 | }) 417 | 418 | it("doesn't print the summary when disabled", async () => { 419 | talkbackServer = await startTalkback({ summary: false, silent: false }) 420 | const logInfo = td.replace(console, "log") 421 | talkbackServer.close() 422 | 423 | td.verify(logInfo(td.matchers.contains("SUMMARY")), { times: 0 }) 424 | }) 425 | }) 426 | 427 | describe("tape usage information", () => { 428 | it("should indicate that a tape has been used after usage", async () => { 429 | talkbackServer = await startTalkback({ record: RecordMode.DISABLED }) 430 | 431 | expect(talkbackServer.hasTapeBeenUsed("saved-request.json5")).to.eq(false) 432 | 433 | const res = await fetch(`${talkbackHost}/test/3`, { compress: false }) 434 | expect(res.status).to.eq(200) 435 | 436 | expect(talkbackServer.hasTapeBeenUsed("saved-request.json5")).to.eq(true) 437 | 438 | talkbackServer.resetTapeUsage() 439 | 440 | expect(talkbackServer.hasTapeBeenUsed("saved-request.json5")).to.eq(false) 441 | 442 | const body = await res.json() 443 | expect(body).to.eql({ ok: true }) 444 | }) 445 | }) 446 | 447 | describe("https", () => { 448 | it("should be able to run a https server", async () => { 449 | const options = { 450 | record: RecordMode.DISABLED, 451 | https: { 452 | enabled: true, 453 | keyPath: "./examples/server/httpsCert/localhost.key", 454 | certPath: "./examples/server/httpsCert/localhost.crt" 455 | } 456 | } 457 | talkbackServer = await startTalkback(options) 458 | 459 | // Disable self-signed certificate check 460 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" 461 | 462 | const res = await fetch(`https://localhost:${talkbackPort}/test/3`, { compress: false }) 463 | 464 | expect(res.status).to.eq(200) 465 | }) 466 | }) 467 | }) 468 | 469 | describe("talkback RequestHandler", () => { 470 | beforeEach(() => cleanupTapes()) 471 | 472 | it("matches existing tapes", async () => { 473 | const requestHandler = await talkback.requestHandler(fullOptions({ ignoreHeaders: ["user-agent", "connection", "accept"] })) 474 | 475 | const req = { 476 | url: "/test/3", 477 | method: "GET", 478 | body: Buffer.alloc(0), 479 | headers: [] 480 | } as HttpRequest 481 | 482 | const res = await requestHandler.handle(req) 483 | expect(res.status).to.eq(200) 484 | 485 | const body = JSON.parse(res.body.toString()) 486 | expect(body).to.eql({ ok: true }) 487 | }) 488 | }) 489 | -------------------------------------------------------------------------------- /test/logger.spec.ts: -------------------------------------------------------------------------------- 1 | import {Logger} from "../src/logger" 2 | import OptionsFactory, {Options} from "../src/options" 3 | import * as td from "testdouble" 4 | 5 | let log: Function, debug: Function, error: Function, options: Options 6 | describe("Logger", () => { 7 | beforeEach(() => { 8 | log = td.replace(console, "log") 9 | debug = td.replace(console, "debug") 10 | error = td.replace(console, "error") 11 | 12 | options = OptionsFactory.prepare({ 13 | name: "my-server" 14 | }) 15 | 16 | const mockedDate = new Date(Date.UTC(2021, 9, 23, 15, 23, 19)) 17 | 18 | td.replace(global, 'Date', () => mockedDate) 19 | }) 20 | 21 | afterEach(() => td.reset()) 22 | 23 | describe("#info", () => { 24 | it("does nothing if silent option is enabled", () => { 25 | const logger = Logger.for({...options, silent: true}) 26 | logger.info("Test") 27 | 28 | td.verify(log(td.matchers.anything()), {times: 0}) 29 | }) 30 | 31 | it("writes to log if silent option is enabled but debug is enabled", () => { 32 | const logger = Logger.for({...options, silent: true, debug: true}) 33 | logger.info("Test") 34 | 35 | td.verify(log("2021-10-23T15:23:19.000Z [my-server] [INFO] Test")) 36 | }) 37 | 38 | it("writes to log console if silent option is disabled", () => { 39 | const logger = Logger.for({...options, silent: false}) 40 | logger.info("Test") 41 | 42 | td.verify(log("2021-10-23T15:23:19.000Z [my-server] [INFO] Test")) 43 | }) 44 | 45 | it("serializes objects when logged", () => { 46 | const logger = Logger.for({...options}); 47 | logger.info({test: true}) 48 | 49 | td.verify(log("2021-10-23T15:23:19.000Z [my-server] [INFO] {\"test\":true}")) 50 | }) 51 | }) 52 | 53 | describe("#debug", () => { 54 | it("does nothing if debug option is disabled", () => { 55 | const logger = Logger.for({...options, debug: false}) 56 | logger.debug("Test") 57 | 58 | td.verify(debug(td.matchers.anything()), {times: 0}) 59 | }) 60 | 61 | it("writes to debug console if debug option is enabled", () => { 62 | const logger = Logger.for({...options, debug: true}) 63 | logger.debug("Test") 64 | 65 | td.verify(debug("2021-10-23T15:23:19.000Z [my-server] [DEBUG] Test")) 66 | }) 67 | }) 68 | 69 | describe("#error", () => { 70 | it("writes to error console if silent option is enabled", () => { 71 | const logger = Logger.for({...options, silent: true}) 72 | logger.error("Test") 73 | 74 | td.verify(error("2021-10-23T15:23:19.000Z [my-server] [ERROR] Test")) 75 | }) 76 | 77 | it("writes to error console if silent option is disabled", () => { 78 | const logger = Logger.for({...options, silent: false}) 79 | logger.error("Test") 80 | 81 | td.verify(error("2021-10-23T15:23:19.000Z [my-server] [ERROR] Test")) 82 | }) 83 | }) 84 | }) -------------------------------------------------------------------------------- /test/options.spec.ts: -------------------------------------------------------------------------------- 1 | import Options from "../src/options" 2 | import {expect} from "chai" 3 | 4 | describe("Options", () => { 5 | it("merges user options and default options", () => { 6 | const opts = Options.prepare({silent: true}) 7 | 8 | expect(opts.silent).to.eql(true) 9 | expect(opts.debug).to.eql(false) 10 | }) 11 | 12 | it("concats ignoreHeaders to default ones provided", () => { 13 | let opts = Options.prepare({ignoreHeaders: ["user-agent"]}) 14 | expect(opts.ignoreHeaders.length >= 1).to.eql(true) 15 | expect(opts.ignoreHeaders.includes("user-agent")).to.eql(true) 16 | 17 | // Check that it's there 18 | opts = Options.prepare() 19 | expect(opts.ignoreHeaders.length >= 0).to.eql(true) 20 | }) 21 | 22 | it("defaults name to the host", () => { 23 | const host = "https://my-api.com" 24 | let opts = Options.prepare({host}) 25 | expect(opts.name).to.eql(host) 26 | 27 | opts = Options.prepare({host, name: "My Server"}) 28 | expect(opts.name).to.eql("My Server") 29 | }) 30 | 31 | describe("options validation", () => { 32 | describe("#record", () => { 33 | it("throws an error when record is not a valid value", () => { 34 | expect(() => Options.prepare({record: "invalid"})) 35 | .to.throw("INVALID OPTION: record has an invalid value of 'invalid'") 36 | }) 37 | }) 38 | 39 | describe("#fallbackMode", () => { 40 | it("throws an error when fallbackMode is not a valid value", () => { 41 | expect(() => Options.prepare({fallbackMode: "invalid"})) 42 | .to.throw("INVALID OPTION: fallbackMode has an invalid value of 'invalid'") 43 | }) 44 | }) 45 | 46 | describe("#latency", () => { 47 | it("throws an error when latency is an array with other than 2 values", () => { 48 | expect(() => Options.prepare({latency: [1]})) 49 | .to.throw("Invalid LATENCY option. If using a range, the array should only have 2 values [min, max]. Current=[1]") 50 | expect(() => Options.prepare({latency: [1, 2, 3]})) 51 | .to.throw("Invalid LATENCY option. If using a range, the array should only have 2 values [min, max]. Current=[1,2,3]") 52 | }) 53 | }) 54 | 55 | describe("#errorRate", () => { 56 | it("throws an error when errorRate is a number outside the valid range", () => { 57 | expect(() => Options.prepare({errorRate: -3})) 58 | .to.throw("Invalid ERRORRATE option") 59 | expect(() => Options.prepare({errorRate: 140})) 60 | .to.throw("Invalid ERRORRATE option") 61 | }) 62 | 63 | it("doesn't throw an error when the value is within range", () => { 64 | expect(() => Options.prepare({errorRate: 10})) 65 | .to.not.throw() 66 | }) 67 | 68 | it("doesn't throw an error when the value is a function", () => { 69 | expect(() => Options.prepare({errorRate: () => 0})) 70 | .to.not.throw() 71 | }) 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /test/request-handler.spec.ts: -------------------------------------------------------------------------------- 1 | import RequestHandler from "../src/request-handler" 2 | import Tape from "../src/tape" 3 | import TapeStore from "../src/tape-store" 4 | import OptionsFactory, {FallbackMode, Options, RecordMode} from "../src/options" 5 | import {Res} from "../src/types" 6 | import * as td from "testdouble" 7 | import {expect} from "chai" 8 | 9 | let tapeStore: TapeStore, reqHandler: RequestHandler, opts: Options, savedTape: Tape, anotherRes: Res 10 | let setTimeoutTd: Function, setTimeoutCalled: boolean 11 | 12 | const rawTape = { 13 | meta: { 14 | createdAt: new Date(), 15 | reqHumanReadable: true, 16 | resHumanReadable: false 17 | }, 18 | req: { 19 | url: "/foo/bar/1?real=3", 20 | method: "GET", 21 | headers: { 22 | "accept": "application/json", 23 | "x-ignored": "1" 24 | }, 25 | body: "ABC" 26 | }, 27 | res: { 28 | status: 200, 29 | headers: { 30 | "accept": ["application/json"], 31 | "x-ignored": ["2"] 32 | }, 33 | body: Buffer.from("Hello").toString("base64") 34 | } 35 | } 36 | 37 | function prepareForExternalRequest() { 38 | const fakeMakeRealRequest = td.function() 39 | td.when(fakeMakeRealRequest(td.matchers.anything())).thenReturn(anotherRes) 40 | td.replace(reqHandler, "makeRealRequest", fakeMakeRealRequest) 41 | 42 | td.replace(tapeStore, "save") 43 | } 44 | 45 | describe("RequestHandler", () => { 46 | beforeEach(async () => { 47 | opts = OptionsFactory.prepare({silent: true, record: RecordMode.NEW}) 48 | tapeStore = new TapeStore(opts) 49 | reqHandler = new RequestHandler(tapeStore, opts) 50 | 51 | savedTape = await Tape.fromStore(rawTape, opts) 52 | anotherRes = { 53 | ...savedTape.res!, 54 | body: Buffer.from("Foobar") 55 | } 56 | }) 57 | 58 | afterEach(() => td.reset()) 59 | 60 | describe("#handle", () => { 61 | describe("record mode", () => { 62 | context("when record opt is 'NEW'", () => { 63 | context("when the request matches a tape", () => { 64 | beforeEach(() => { 65 | tapeStore.tapes = [savedTape] 66 | }) 67 | 68 | it("returns the matched tape response", async () => { 69 | const resObj = await reqHandler.handle(savedTape.req) 70 | expect(resObj.status).to.eql(200) 71 | expect(resObj.body).to.eql(Buffer.from("Hello")) 72 | }) 73 | 74 | context("when there's a responseDecorator", () => { 75 | beforeEach(() => { 76 | opts.responseDecorator = (tape, req, context) => { 77 | expect(context.id).to.exist 78 | 79 | tape.res!.body = req.body 80 | return tape 81 | } 82 | }) 83 | 84 | it("returns the decorated response", async () => { 85 | const resObj = await reqHandler.handle(savedTape.req) 86 | 87 | expect(resObj.status).to.eql(200) 88 | expect(resObj.body).to.eql(Buffer.from("ABC")) 89 | expect(savedTape.res!.body).to.eql(Buffer.from("Hello")) 90 | }) 91 | 92 | it("doesn't add a content-length header if it isn't present in the original response", async () => { 93 | const resObj = await reqHandler.handle(savedTape.req) 94 | 95 | expect(resObj.headers["content-length"]).to.be.undefined 96 | }) 97 | 98 | it("updates the content-length header if it is present in the original response", async () => { 99 | savedTape.res!.headers["content-length"] = [999] 100 | 101 | const resObj = await reqHandler.handle(savedTape.req) 102 | 103 | expect(resObj.headers["content-length"]).to.eq(3) 104 | }) 105 | 106 | it("throws an error if the responseDecorator returns null", async () => { 107 | opts.requestDecorator = () => null 108 | try { 109 | await reqHandler.handle(savedTape.req) 110 | expect.fail("Exception expected to be thrown") 111 | } catch (ex) { 112 | expect(ex.message).to.eql("requestDecorator didn't return a req object") 113 | } 114 | }) 115 | }) 116 | }) 117 | 118 | context("when the request doesn't match a tape", () => { 119 | beforeEach(() => { 120 | prepareForExternalRequest() 121 | }) 122 | 123 | it("makes the real request and returns the response, saving the tape", async () => { 124 | const resObj = await reqHandler.handle(savedTape.req) 125 | expect(resObj.status).to.eql(200) 126 | expect(resObj.body).to.eql(Buffer.from("Foobar")) 127 | 128 | td.verify(tapeStore.save(td.matchers.anything())) 129 | }) 130 | 131 | context("when there's a tapeDecorator", async () => { 132 | beforeEach(() => { 133 | opts.tapeDecorator = (tape, context) => { 134 | expect(context.id).to.exist 135 | tape.meta.myOwnMeta = "myOwnMeta_value" 136 | return tape 137 | } 138 | }) 139 | 140 | it("stores the decorated tape", async () => { 141 | await reqHandler.handle(savedTape.req) 142 | const tapeCaptor = td.matchers.captor() 143 | 144 | td.verify(tapeStore.save(tapeCaptor.capture())) 145 | 146 | const persistedTape = tapeCaptor.value 147 | expect(persistedTape.meta.myOwnMeta).to.eql("myOwnMeta_value") 148 | }) 149 | 150 | it("throws an error if the tapeDecorator returns null", async () => { 151 | opts.tapeDecorator = () => null 152 | try { 153 | await reqHandler.handle(savedTape.req) 154 | expect.fail("Exception expected to be thrown") 155 | } catch (ex) { 156 | expect(ex.message).to.eql("tapeDecorator didn't return a tape object") 157 | } 158 | }) 159 | }) 160 | 161 | context("when there's a responseDecorator", () => { 162 | beforeEach(() => { 163 | opts.responseDecorator = (tape, req, context) => { 164 | expect(context.id).to.exist 165 | tape.res!.body = req.body 166 | return tape 167 | } 168 | }) 169 | 170 | it("returns the decorated response", async () => { 171 | const resObj = await reqHandler.handle(savedTape.req) 172 | 173 | expect(resObj.status).to.eql(200) 174 | expect(resObj.body).to.eql(Buffer.from("ABC")) 175 | expect(savedTape.res!.body).to.eql(Buffer.from("Hello")) 176 | }) 177 | 178 | it("throws an error if the responseDecorator returns null", async () => { 179 | opts.responseDecorator = () => null 180 | try { 181 | await reqHandler.handle(savedTape.req) 182 | expect.fail("Exception expected to be thrown") 183 | } catch (ex) { 184 | expect(ex.message).to.eql("responseDecorator didn't return a tape object") 185 | } 186 | }) 187 | }) 188 | }) 189 | }) 190 | 191 | context("when record opt is 'OVERWRITE'", () => { 192 | beforeEach(() => { 193 | opts.record = RecordMode.OVERWRITE 194 | 195 | prepareForExternalRequest() 196 | }) 197 | 198 | afterEach(() => td.reset()) 199 | 200 | context("when the request matches a tape", () => { 201 | beforeEach(() => { 202 | tapeStore.tapes = [savedTape] 203 | }) 204 | 205 | it("makes the real request and returns the response, saving the tape", async () => { 206 | const resObj = await reqHandler.handle(savedTape.req) 207 | expect(resObj.status).to.eql(200) 208 | expect(resObj.body).to.eql(Buffer.from("Foobar")) 209 | 210 | td.verify(tapeStore.save(td.matchers.anything())) 211 | }) 212 | }) 213 | 214 | context("when the request doesn't match a tape", () => { 215 | it("makes the real request and returns the response, saving the tape", async () => { 216 | const resObj = await reqHandler.handle(savedTape.req) 217 | expect(resObj.status).to.eql(200) 218 | expect(resObj.body).to.eql(Buffer.from("Foobar")) 219 | 220 | td.verify(tapeStore.save(td.matchers.anything())) 221 | }) 222 | }) 223 | }) 224 | 225 | context("when record opt is 'DISABLED'", () => { 226 | beforeEach(() => { 227 | opts.record = RecordMode.DISABLED 228 | }) 229 | 230 | context("when the request matches a tape", () => { 231 | beforeEach(() => { 232 | tapeStore.tapes = [savedTape] 233 | }) 234 | 235 | it("returns the matched tape response", async () => { 236 | const resObj = await reqHandler.handle(savedTape.req) 237 | expect(resObj.status).to.eql(200) 238 | expect(resObj.body).to.eql(Buffer.from("Hello")) 239 | }) 240 | }) 241 | 242 | context("when the request doesn't match a tape", () => { 243 | context("when fallbackMode is 'NOT_FOUND'", () => { 244 | beforeEach(() => { 245 | opts.fallbackMode = FallbackMode.NOT_FOUND 246 | }) 247 | 248 | it("returns a 404", async () => { 249 | const resObj = await reqHandler.handle(savedTape.req) 250 | expect(resObj.status).to.eql(404) 251 | }) 252 | }) 253 | 254 | context("when fallbackMode is 'PROXY'", () => { 255 | beforeEach(() => { 256 | opts.fallbackMode = FallbackMode.PROXY 257 | prepareForExternalRequest() 258 | }) 259 | 260 | it("makes real request and returns the response but doesn't save it", async () => { 261 | const resObj = await reqHandler.handle(savedTape.req) 262 | 263 | expect(resObj.status).to.eql(200) 264 | expect(resObj.body).to.eql(Buffer.from("Foobar")) 265 | 266 | td.verify(tapeStore.save(td.matchers.anything()), {times: 0}) 267 | }) 268 | }) 269 | 270 | context("when fallbackMode is a function", () => { 271 | let fallbackModeToReturn: string 272 | 273 | beforeEach(() => { 274 | opts.fallbackMode = (req) => { 275 | expect(req).to.eql(savedTape.req) 276 | return fallbackModeToReturn 277 | } 278 | }) 279 | 280 | it("raises an error if the returned mode is not valid", async () => { 281 | fallbackModeToReturn = "INVALID" 282 | 283 | try { 284 | await reqHandler.handle(savedTape.req) 285 | throw "Exception expected to be thrown" 286 | } catch (ex) { 287 | expect(ex).to.eql("INVALID OPTION: fallbackMode has an invalid value of 'INVALID'") 288 | } 289 | }) 290 | 291 | it("does what the function returns", async () => { 292 | fallbackModeToReturn = FallbackMode.NOT_FOUND 293 | 294 | let resObj = await reqHandler.handle(savedTape.req) 295 | expect(resObj.status).to.eql(404) 296 | 297 | fallbackModeToReturn = FallbackMode.PROXY 298 | prepareForExternalRequest() 299 | 300 | resObj = await reqHandler.handle(savedTape.req) 301 | expect(resObj.status).to.eql(200) 302 | }) 303 | }) 304 | }) 305 | }) 306 | 307 | context("when record is a function", () => { 308 | let modeToReturn: string 309 | 310 | beforeEach(() => { 311 | opts.record = (req) => { 312 | expect(req).to.eql(savedTape.req) 313 | return modeToReturn 314 | } 315 | }) 316 | 317 | it("raises an error if the returned mode is not valid", async () => { 318 | modeToReturn = "INVALID" 319 | 320 | try { 321 | await reqHandler.handle(savedTape.req) 322 | throw "Exception expected to be thrown" 323 | } catch (ex) { 324 | expect(ex).to.eql("INVALID OPTION: record has an invalid value of 'INVALID'") 325 | } 326 | }) 327 | 328 | it("does what the function returns", async () => { 329 | modeToReturn = RecordMode.DISABLED 330 | 331 | let resObj = await reqHandler.handle(savedTape.req) 332 | expect(resObj.status).to.eql(404) 333 | 334 | modeToReturn = RecordMode.NEW 335 | prepareForExternalRequest() 336 | 337 | resObj = await reqHandler.handle(savedTape.req) 338 | expect(resObj.status).to.eql(200) 339 | expect(resObj.body).to.eql(Buffer.from("Foobar")) 340 | 341 | td.verify(tapeStore.save(td.matchers.anything())) 342 | }) 343 | }) 344 | }) 345 | 346 | describe("#requestDecorator", () => { 347 | it("updates the request before matching", async () => { 348 | tapeStore.tapes = [savedTape] 349 | 350 | const req = {...savedTape.req} 351 | const originalUrl = req.url 352 | 353 | req.url = "MODIFIED" 354 | req.headers["accept"] = "INVALID" 355 | 356 | opts.requestDecorator = (req, context) => { 357 | expect(context.id).to.exist 358 | req.url = originalUrl 359 | req.headers["accept"] = "application/json" 360 | return req 361 | } 362 | 363 | const resObj = await reqHandler.handle(req) 364 | expect(resObj.status).to.eql(200) 365 | expect(resObj.body).to.eql(Buffer.from("Hello")) 366 | }) 367 | }) 368 | 369 | describe("latency", () => { 370 | beforeEach(() => { 371 | setTimeoutTd = td.function() 372 | setTimeoutCalled = false 373 | td.replace(global, "setTimeout", setTimeoutTd) 374 | }) 375 | 376 | context("when the tape exists", () => { 377 | beforeEach(() => { 378 | tapeStore.tapes = [savedTape] 379 | }) 380 | 381 | it("does not wait to reply when no latency is set in Options", async () => { 382 | const resObj = await reqHandler.handle(savedTape.req) 383 | expect(resObj.status).to.eql(200) 384 | 385 | expect(setTimeoutCalled).to.eql(false) 386 | }) 387 | 388 | context("when latency is set in Options", () => { 389 | it("waits the fixed time when latency is a number", async () => { 390 | opts.latency = 1 391 | 392 | stubSetTimeoutWithValue(setTimeoutTd, 1) 393 | 394 | const resObj = await reqHandler.handle(savedTape.req) 395 | expect(resObj.status).to.eql(200) 396 | 397 | expect(setTimeoutCalled).to.eql(true) 398 | }) 399 | 400 | it("waits a number within the range when it is an array", async () => { 401 | opts.latency = [0, 10] 402 | 403 | const randomTd = td.function() 404 | td.replace(Math, "random", randomTd) 405 | td.when(randomTd()).thenReturn(0.6) 406 | 407 | stubSetTimeoutWithValue(setTimeoutTd, 6) 408 | 409 | const resObj = await reqHandler.handle(savedTape.req) 410 | expect(resObj.status).to.eql(200) 411 | 412 | expect(setTimeoutCalled).to.eql(true) 413 | }) 414 | 415 | it("waits the value returned by a function when it is a function", async () => { 416 | opts.latency = (req) => { 417 | expect(req).to.eql(savedTape.req) 418 | return 5 419 | } 420 | 421 | stubSetTimeoutWithValue(setTimeoutTd, 5) 422 | 423 | const resObj = await reqHandler.handle(savedTape.req) 424 | expect(resObj.status).to.eql(200) 425 | 426 | expect(setTimeoutCalled).to.eql(true) 427 | }) 428 | 429 | it("tape latency takes precendence when it is set at the tape level", async () => { 430 | savedTape.meta.latency = 5000 431 | 432 | stubSetTimeoutWithValue(setTimeoutTd, 5000) 433 | const resObj = await reqHandler.handle(savedTape.req) 434 | expect(resObj.status).to.eql(200) 435 | 436 | expect(setTimeoutCalled).to.eql(true) 437 | }) 438 | }) 439 | 440 | it("waits the tape latency when it is set at the tape level", async () => { 441 | savedTape.meta.latency = [1000, 5000] 442 | 443 | const randomTd = td.function() 444 | td.replace(Math, "random", randomTd) 445 | td.when(randomTd()).thenReturn(0.5) 446 | 447 | stubSetTimeoutWithValue(setTimeoutTd, 3000) 448 | const resObj = await reqHandler.handle(savedTape.req) 449 | expect(resObj.status).to.eql(200) 450 | 451 | expect(setTimeoutCalled).to.eql(true) 452 | }) 453 | }) 454 | 455 | context("when the tape does not exist", () => { 456 | beforeEach(() => { 457 | prepareForExternalRequest() 458 | }) 459 | 460 | context("when recording is enabled", () => { 461 | it("does not wait to reply", async () => { 462 | opts.latency = 1000 463 | 464 | const resObj = await reqHandler.handle(savedTape.req) 465 | expect(resObj.status).to.eql(200) 466 | 467 | expect(setTimeoutCalled).to.eql(false) 468 | }) 469 | }) 470 | 471 | context("when recording is disabled", () => { 472 | beforeEach(() => { 473 | opts.record = RecordMode.DISABLED 474 | }) 475 | 476 | it("adds latency when the fallback mode is PROXY", async () => { 477 | opts.latency = 1000 478 | opts.fallbackMode = FallbackMode.PROXY 479 | 480 | stubSetTimeoutWithValue(setTimeoutTd, 1000) 481 | const resObj = await reqHandler.handle(savedTape.req) 482 | expect(resObj.status).to.eql(200) 483 | 484 | expect(setTimeoutCalled).to.eql(true) 485 | }) 486 | 487 | it("doesn't add latency when the fallback mode is NOT_FOUND", async () => { 488 | opts.latency = 1000 489 | opts.fallbackMode = FallbackMode.NOT_FOUND 490 | 491 | const resObj = await reqHandler.handle(savedTape.req) 492 | expect(resObj.status).to.eql(404) 493 | 494 | expect(setTimeoutCalled).to.eql(false) 495 | }) 496 | }) 497 | }) 498 | }) 499 | 500 | describe("errorRate", () => { 501 | context("when the tape exists", () => { 502 | beforeEach(() => { 503 | tapeStore.tapes = [savedTape] 504 | }) 505 | 506 | it("returns an error response when falling inside errorRate", async () => { 507 | opts.errorRate = 100 508 | 509 | const resObj = await reqHandler.handle(savedTape.req) 510 | expect(resObj.status).to.eql(503) 511 | expect(resObj.body.includes("failure injection")).to.eql(true) 512 | }) 513 | 514 | it("doesn't return an error response when falling outside errorRate", async () => { 515 | opts.errorRate = 0 516 | 517 | const resObj = await reqHandler.handle(savedTape.req) 518 | expect(resObj.status).to.eql(200) 519 | expect(resObj.body.includes("failure injection")).to.eql(false) 520 | }) 521 | }) 522 | 523 | context("when recording is disabled", () => { 524 | beforeEach(() => { 525 | opts.record = RecordMode.DISABLED 526 | opts.errorRate = 100 527 | }) 528 | 529 | it("injects an error when the fallback mode is PROXY", async () => { 530 | opts.fallbackMode = FallbackMode.PROXY 531 | 532 | const resObj = await reqHandler.handle(savedTape.req) 533 | expect(resObj.status).to.eql(503) 534 | expect(resObj.body.includes("failure injection")).to.eql(true) 535 | }) 536 | 537 | it("doesn't inject an error when the fallback mode is NOT_FOUND", async () => { 538 | opts.fallbackMode = FallbackMode.NOT_FOUND 539 | 540 | const resObj = await reqHandler.handle(savedTape.req) 541 | expect(resObj.status).to.eql(404) 542 | }) 543 | }) 544 | }) 545 | }) 546 | }) 547 | 548 | function stubSetTimeoutWithValue(setTimeoutTd: Function, expectedValue: number) { 549 | td.when(setTimeoutTd(td.matchers.anything(), td.matchers.anything())).thenDo((cb: Function, value: number) => { 550 | setTimeoutCalled = true 551 | if (value !== expectedValue) { 552 | throw `Invalid timeout value. Expected ${expectedValue}. Got ${value}` 553 | } 554 | cb() 555 | }) 556 | } 557 | -------------------------------------------------------------------------------- /test/summary.spec.ts: -------------------------------------------------------------------------------- 1 | import Summary from "../src/summary" 2 | import * as td from "testdouble" 3 | import {DefaultOptions} from "../src/options" 4 | import Tape from "../src/tape" 5 | 6 | let logInfo: Function; 7 | 8 | const opts = { 9 | ...DefaultOptions, 10 | name: "My Server" 11 | } 12 | 13 | describe("Summary", () => { 14 | beforeEach(() => { 15 | logInfo = td.replace(console, "log") 16 | }) 17 | 18 | afterEach(() => { 19 | td.reset() 20 | }) 21 | 22 | describe("#print", () => { 23 | it("prints nothing when there are no new tapes and no unused tapes", () => { 24 | const summary = new Summary([], opts) 25 | 26 | summary.print() 27 | 28 | td.verify(logInfo(td.matchers.contains("New")), {times: 0}) 29 | td.verify(logInfo(td.matchers.contains("Unused")), {times: 0}) 30 | }) 31 | 32 | it("prints the path of new tapes", () => { 33 | const summary = new Summary([ 34 | {new: true, used: true, path: "path1"} as Tape, 35 | {used: true, path: "path2"} as Tape, 36 | {new: true, used: true, path: "path3"} as Tape 37 | ], opts) 38 | 39 | summary.print() 40 | 41 | td.verify(logInfo(td.matchers.contains("path1"))) 42 | td.verify(logInfo(td.matchers.contains("path2")), {times: 0}) 43 | td.verify(logInfo(td.matchers.contains("path3"))) 44 | }) 45 | 46 | it("prints the path of unused tapes", () => { 47 | const summary = new Summary([ 48 | {path: "path1"} as Tape, 49 | {used: true, path: "path2"} as Tape, 50 | {path: "path3"} as Tape 51 | ], opts) 52 | 53 | summary.print() 54 | 55 | td.verify(logInfo(td.matchers.contains("path1"))) 56 | td.verify(logInfo(td.matchers.contains("path2")), {times: 0}) 57 | td.verify(logInfo(td.matchers.contains("path3"))) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/support/factories.ts: -------------------------------------------------------------------------------- 1 | import {ReqRes} from "../../src/types" 2 | 3 | export default class Factories { 4 | static reqRes(reqRes: Partial) { 5 | const defaultReqRes: ReqRes = { 6 | body: Buffer.from("FOOBAR"), 7 | headers: {} 8 | } 9 | 10 | return {...defaultReqRes, ...reqRes} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/support/test-server.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http" 2 | 3 | const testServer = () => { 4 | return http.createServer(async (req, res) => { 5 | let rawReqBody = [] as Uint8Array[] 6 | req.on("error", (err) => { 7 | console.error(err) 8 | }).on("data", (chunk) => { 9 | rawReqBody.push(chunk) 10 | }).on("end", async () => { 11 | switch (req.url) { 12 | case "/test/1": { 13 | const reqBody = Buffer.concat(rawReqBody) 14 | const bodyAsString = reqBody.toString() 15 | 16 | const headers = { 17 | "content-type": "application/json" 18 | } 19 | res.writeHead(200, headers) 20 | 21 | let body: string | string[] | null = null 22 | if (bodyAsString) { 23 | body = JSON.parse(bodyAsString) 24 | } 25 | 26 | const pingHeader = req.headers["x-talkback-ping"] 27 | if (pingHeader) { 28 | body = pingHeader 29 | } 30 | 31 | res.end(JSON.stringify({ ok: true, body })) 32 | return 33 | } 34 | case "/test/2": { 35 | const reqBody = Buffer.concat(rawReqBody) 36 | res.writeHead(200, {}) 37 | const bodyAsJson = JSON.parse(reqBody.toString()) 38 | res.end(JSON.stringify({ ok: true, body: bodyAsJson })) 39 | return 40 | } 41 | case "/test/3": { 42 | res.writeHead(500) 43 | res.end() 44 | return 45 | } 46 | case "/test/head": { 47 | res.writeHead(200) 48 | res.end() 49 | return 50 | } 51 | case "/test/redirect/1": { 52 | res.writeHead(302, { 53 | "Location": "/test/1" 54 | }) 55 | res.end() 56 | return 57 | } 58 | case "/test/invalid-json": { 59 | res.writeHead(200, { "content-type": "application/json" }) 60 | res.end('{"invalid: ') 61 | } 62 | default: { 63 | res.writeHead(404) 64 | res.end() 65 | return 66 | } 67 | } 68 | }) 69 | }) 70 | } 71 | 72 | export default testServer 73 | -------------------------------------------------------------------------------- /test/tape-matcher.spec.ts: -------------------------------------------------------------------------------- 1 | import TapeMatcher from "../src/tape-matcher" 2 | import Tape from "../src/tape" 3 | import Options, {Options as OptionsType} from "../src/options" 4 | import ContentEncoding from "../src/utils/content-encoding" 5 | import {expect} from "chai" 6 | import {Req} from "../src/types" 7 | 8 | const raw = { 9 | meta: { 10 | createdAt: new Date(), 11 | reqHumanReadable: true, 12 | resHumanReadable: false 13 | }, 14 | req: { 15 | url: "/foo/bar/1?real=3", 16 | method: "GET", 17 | headers: { 18 | "accept": "application/json", 19 | "x-ignored": "1" 20 | }, 21 | body: "ABC" 22 | }, 23 | res: { 24 | headers: { 25 | "accept": ["application/json"], 26 | "x-ignored": ["2"] 27 | }, 28 | body: "SGVsbG8=" 29 | } 30 | } 31 | 32 | const opts = Options.prepare({ 33 | ignoreHeaders: ["x-ignored"], 34 | ignoreQueryParams: ["ignored1", "ignored2"], 35 | debug: false, 36 | silent: true, 37 | }) 38 | 39 | let tape: Tape 40 | 41 | describe("TapeMatcher", () => { 42 | beforeEach(async () => { 43 | tape = await Tape.fromStore(raw, opts) 44 | }) 45 | 46 | describe("#sameAs", () => { 47 | const req = { 48 | url: "/foo/bar/1?ignored1=foo&ignored2=bar&real=3", 49 | method: "GET", 50 | headers: { 51 | "accept": "application/json", 52 | "x-ignored": "1" 53 | }, 54 | body: Buffer.from("QUJD", "base64") 55 | } 56 | 57 | it("returns true when the request body is ignored", async () => { 58 | const newOpts = { 59 | ...opts, 60 | ignoreBody: true 61 | } 62 | 63 | const newTape = await Tape.fromStore(raw, newOpts) 64 | const tape2 = new Tape({...req, body: Buffer.from("XYZ")}, newOpts) 65 | expect(new TapeMatcher(newTape, newOpts).sameAs(tape2)).to.be.true 66 | }) 67 | 68 | it("returns true when everything is the same", () => { 69 | const tape2 = new Tape(req, opts) 70 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.true 71 | }) 72 | 73 | it("returns true when only ignored query params change", () => { 74 | const tape2 = new Tape({...req, url: "/foo/bar/1?ignored1=diff&real=3"}, opts) 75 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.true 76 | }) 77 | 78 | it("returns true when all query params are ignored", async () => { 79 | const newOpts = { 80 | ...opts, 81 | ignoreQueryParams: [ 82 | ...opts.ignoreQueryParams, 83 | "real" 84 | ] 85 | } 86 | const newTape = await Tape.fromStore(raw, newOpts) 87 | const tape2 = new Tape({...req, url: "/foo/bar/1?ignored1=diff&real=diff"}, newOpts) 88 | expect(new TapeMatcher(newTape, newOpts).sameAs(tape2)).to.be.true 89 | }) 90 | 91 | it("returns true when only ignored headers change", () => { 92 | const headers = { 93 | ...req.headers, 94 | "x-ignored": "diff" 95 | } 96 | const tape2 = new Tape({ 97 | ...req, 98 | headers 99 | }, opts) 100 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.true 101 | }) 102 | 103 | it("returns true when only headers outside of allowed headers are different", async () => { 104 | const headers = { 105 | ...req.headers, 106 | "x-invalid": "diff" 107 | } 108 | const newOpts: OptionsType = { 109 | ...opts, 110 | allowHeaders: ["accept", "not-present"] 111 | } 112 | 113 | tape = await Tape.fromStore(raw, newOpts) 114 | const tape2 = new Tape({ 115 | ...req, 116 | headers 117 | }, newOpts) 118 | 119 | expect(new TapeMatcher(tape, newOpts).sameAs(tape2)).to.be.true 120 | }) 121 | 122 | it("returns false when the urls are different", () => { 123 | const tape2 = new Tape({...req, url: "/bar"}, opts) 124 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.false 125 | }) 126 | 127 | it("returns false when the query params have different values", () => { 128 | const tape2 = new Tape({...req, url: "/foo/bar/1?real=different"}, opts) 129 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.false 130 | }) 131 | 132 | it("returns false when the query params are different", () => { 133 | const tape2 = new Tape({...req, url: "/foo/bar/1?real=3&newParam=1"}, opts) 134 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.false 135 | }) 136 | 137 | it("returns false when the methods are different", () => { 138 | const tape2 = new Tape({...req, method: "POST"}, opts) 139 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.false 140 | }) 141 | 142 | it("returns false when the bodies are different", () => { 143 | const tape2 = new Tape({...req, body: Buffer.from("")}, opts) 144 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.false 145 | }) 146 | 147 | it("returns true when both bodies are empty", async () => { 148 | const rawDup = { 149 | ...raw, 150 | req: { 151 | ...raw.req, 152 | method: "HEAD", 153 | headers: { 154 | ...raw.req.headers, 155 | "content-type": "application/json" 156 | }, 157 | body: "" 158 | } 159 | } 160 | 161 | const reqDup = { 162 | ...req, 163 | method: "HEAD", 164 | headers: { 165 | ...req.headers, 166 | "content-type": "application/json" 167 | }, 168 | body: Buffer.from("") 169 | } 170 | 171 | const newTape = await Tape.fromStore(rawDup, opts) 172 | const tape2 = new Tape(reqDup, opts) 173 | expect(new TapeMatcher(newTape, opts).sameAs(tape2)).to.be.true 174 | }) 175 | 176 | it("returns true when the request is compressed and are the same", async () => { 177 | const contentEncoding = new ContentEncoding({headers: {"content-encoding": "gzip"}, body: Buffer.from("ASD")}) 178 | const compressedBody = await contentEncoding.compressedBody(JSON.stringify({foo: "bar"})) 179 | 180 | const rawDup = { 181 | ...raw, 182 | req: { 183 | ...raw.req, 184 | headers: { 185 | ...raw.req.headers, 186 | "content-type": "application/json", 187 | "content-encoding": "gzip" 188 | }, 189 | body: Buffer.from(compressedBody) 190 | } 191 | } 192 | 193 | const newTape = await Tape.fromStore(rawDup, opts) 194 | 195 | const tape2 = new Tape({ 196 | ...req, 197 | headers: { 198 | ...req.headers, 199 | "content-type": "application/json", 200 | "content-encoding": "gzip" 201 | }, 202 | body: compressedBody 203 | }, opts) 204 | 205 | expect(new TapeMatcher(newTape, opts).sameAs(tape2)).to.be.true 206 | }) 207 | 208 | it("returns false when there are more headers", () => { 209 | const tape2 = new Tape({ 210 | ...req, 211 | headers: { 212 | ...req.headers, 213 | "foo": "bar" 214 | } 215 | }, opts) 216 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.false 217 | }) 218 | 219 | it("returns false when there are less headers", () => { 220 | const headers = {...req.headers} 221 | delete headers["accept"] 222 | const tape2 = new Tape({ 223 | ...req, 224 | headers 225 | }, opts) 226 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.false 227 | }) 228 | 229 | it("returns false when a header has a different value", () => { 230 | const headers = { 231 | ...req.headers, 232 | "accept": "x-form" 233 | } 234 | const tape2 = new Tape({ 235 | ...req, 236 | headers 237 | }, opts) 238 | expect(new TapeMatcher(tape, opts).sameAs(tape2)).to.be.false 239 | }) 240 | 241 | describe("bodyMatcher", () => { 242 | it("returns true when just the bodies are different but the bodyMatcher says they match", () => { 243 | const newOpts = { 244 | ...opts, 245 | bodyMatcher: (_tape: Tape, _otherReq: Req) => true 246 | } 247 | 248 | const tape2 = new Tape({...req, body: Buffer.from("XYZ")}, newOpts) 249 | expect(new TapeMatcher(tape, newOpts).sameAs(tape2)).to.be.true 250 | }) 251 | 252 | it("returns false when just the bodies are different and the bodyMatcher says they don't match", () => { 253 | const newOpts = { 254 | ...opts, 255 | bodyMatcher: (_tape: Tape, _otherReq: Req) => false 256 | } 257 | 258 | const tape2 = new Tape({...req, body: Buffer.from("XYZ")}, newOpts) 259 | expect(new TapeMatcher(tape, newOpts).sameAs(tape2)).to.be.false 260 | }) 261 | }) 262 | 263 | describe("urlMatcher", () => { 264 | it("returns true when urls are different but the urlMatcher says they match", () => { 265 | const newOpts = { 266 | ...opts, 267 | urlMatcher: (_tape: Tape, _otherReq: Req) => true 268 | } 269 | 270 | const tape2 = new Tape({...req, url: "/not-same"}, newOpts) 271 | expect(new TapeMatcher(tape, newOpts).sameAs(tape2)).to.be.true 272 | }) 273 | 274 | it("returns false when just the urls are different and the urlMatcher says they don't match", () => { 275 | const newOpts = { 276 | ...opts, 277 | urlMatcher: (_tape: Tape, _otherReq: Req) => false 278 | } 279 | 280 | const tape2 = new Tape({...req, url: "/not-same"}, newOpts) 281 | expect(new TapeMatcher(tape, newOpts).sameAs(tape2)).to.be.false 282 | }) 283 | }) 284 | }) 285 | }) 286 | -------------------------------------------------------------------------------- /test/tape-renderer.spec.ts: -------------------------------------------------------------------------------- 1 | const zlib = require("zlib") 2 | import {expect} from "chai" 3 | 4 | import TapeRenderer from "../src/tape-renderer" 5 | import Options from "../src/options" 6 | 7 | const raw = { 8 | meta: { 9 | createdAt: new Date(), 10 | reqHumanReadable: true, 11 | resHumanReadable: false 12 | }, 13 | req: { 14 | url: "/foo/bar/1?real=3", 15 | method: "GET", 16 | headers: { 17 | "accept": "text/unknown", 18 | "content-type": "text/plain", 19 | "x-ignored": "1" 20 | }, 21 | body: "ABC" 22 | }, 23 | res: { 24 | status: 200, 25 | headers: { 26 | "content-type": ["text/unknown"], 27 | "x-ignored": ["2"] 28 | }, 29 | body: Buffer.from("Hello").toString("base64") 30 | } 31 | } 32 | 33 | const opts = Options.prepare({ 34 | ignoreHeaders: ["x-ignored"], 35 | ignoreQueryParams: ["ignored1", "ignored2"], 36 | }) 37 | 38 | let tape 39 | 40 | describe("TapeRenderer", () => { 41 | beforeEach(async () => { 42 | tape = await TapeRenderer.fromStore(raw, opts) 43 | }) 44 | 45 | describe(".fromStore", () => { 46 | it("creates a tape from the raw file data with req and res human readable", () => { 47 | expect(tape.req.url).to.eq("/foo/bar/1?real=3") 48 | expect(tape.req.headers["accept"]).to.eq("text/unknown") 49 | expect(tape.req.headers["x-ignored"]).to.be.undefined 50 | expect(tape.req.body.equals(Buffer.from("ABC"))).to.be.true 51 | 52 | expect(tape.res.headers["content-type"]).to.eql(["text/unknown"]) 53 | expect(tape.res.headers["x-ignored"]).to.eql(["2"]) 54 | expect(tape.res.body.equals(Buffer.from("Hello"))).to.be.true 55 | }) 56 | 57 | it("creates a tape from the raw file data with req and res not human readable", async () => { 58 | const newRaw = { 59 | ...raw, 60 | meta: { 61 | ...raw.meta, 62 | reqHumanReadable: false, 63 | resHumanReadable: true 64 | }, 65 | req: { 66 | ...raw.req, 67 | body: "SGVsbG8=" 68 | }, 69 | res: { 70 | ...raw.res, 71 | body: "ABC" 72 | } 73 | } 74 | 75 | const tape = await TapeRenderer.fromStore(newRaw, opts) 76 | 77 | expect(tape.req.url).to.eq("/foo/bar/1?real=3") 78 | expect(tape.req.headers["accept"]).to.eq("text/unknown") 79 | expect(tape.req.headers["x-ignored"]).to.be.undefined 80 | expect(tape.req.body.equals(Buffer.from("Hello"))).to.be.true 81 | 82 | expect(tape.res.headers["content-type"]).to.eql(["text/unknown"]) 83 | expect(tape.res.headers["x-ignored"]).to.eql(["2"]) 84 | expect(tape.res.body.equals(Buffer.from("ABC"))).to.be.true 85 | }) 86 | 87 | it("can read pretty JSON", async () => { 88 | const newRaw = { 89 | ...raw, 90 | meta: { 91 | ...raw.meta, 92 | reqHumanReadable: true, 93 | resHumanReadable: true 94 | }, 95 | req: { 96 | ...raw.req, 97 | headers: { 98 | ...raw.req.headers, 99 | "content-type": "application/json", 100 | "content-length": 20 101 | }, 102 | body: { 103 | param: "value", 104 | nested: { 105 | param2: 3 106 | } 107 | } 108 | }, 109 | res: { 110 | ...raw.res, 111 | headers: { 112 | ...raw.res.headers, 113 | "content-type": ["application/json"], 114 | "content-length": [20] 115 | }, 116 | body: { 117 | foo: "bar", 118 | utf8: "🔤", 119 | nested: { 120 | fuu: 3 121 | } 122 | } 123 | } 124 | } 125 | 126 | let tape = await TapeRenderer.fromStore(newRaw, opts) 127 | expect(tape.req.body).to.eql(Buffer.from(JSON.stringify(newRaw.req.body, null, 2))) 128 | 129 | expect(tape.res.body).to.eql(Buffer.from(JSON.stringify(newRaw.res.body, null, 2))) 130 | expect(tape.res.headers["content-length"]).to.eql(["68"]) 131 | 132 | delete newRaw.res.headers["content-length"] 133 | tape = await TapeRenderer.fromStore(newRaw, opts) 134 | expect(tape.res.headers["content-length"]).to.eql(undefined) 135 | }) 136 | }) 137 | 138 | describe("#render", () => { 139 | it("renders a tape", async () => { 140 | const rawDup = { 141 | ...raw, 142 | req: { 143 | ...raw.req, 144 | headers: { 145 | ...raw.req.headers 146 | } 147 | } 148 | } 149 | delete rawDup.req.headers["x-ignored"] 150 | const tapeRenderer = await new TapeRenderer(tape) 151 | expect(await tapeRenderer.render()).to.eql(rawDup) 152 | }) 153 | 154 | it("renders json response as an object", async () => { 155 | const newRaw = { 156 | ...raw, 157 | meta: { 158 | ...raw.meta, 159 | resHumanReadable: true 160 | }, 161 | req: { 162 | ...raw.req, 163 | headers: { 164 | ...raw.req.headers 165 | } 166 | }, 167 | res: { 168 | ...raw.res, 169 | headers: { 170 | ...raw.res.headers, 171 | "content-type": ["application/json"], 172 | "content-length": [20] 173 | }, 174 | body: { 175 | foo: "bar", 176 | nested: { 177 | fuu: 3 178 | } 179 | } 180 | } 181 | } 182 | const newTape = await TapeRenderer.fromStore(newRaw, opts) 183 | 184 | delete newRaw.req.headers["x-ignored"] 185 | const tapeRenderer = new TapeRenderer(newTape) 186 | expect(await tapeRenderer.render()).to.eql(newRaw) 187 | }) 188 | 189 | it("renders invalid json response as text", async () => { 190 | const newRaw = { 191 | ...raw, 192 | meta: { 193 | ...raw.meta, 194 | resHumanReadable: true 195 | }, 196 | req: { 197 | ...raw.req, 198 | headers: { 199 | ...raw.req.headers 200 | } 201 | }, 202 | res: { 203 | ...raw.res, 204 | headers: { 205 | ...raw.res.headers, 206 | "content-type": ["application/json"], 207 | "content-length": [20] 208 | }, 209 | body: "I said I was going to send JSON, but actually I've changed my mind and here's some text" 210 | } 211 | } 212 | const newTape = await TapeRenderer.fromStore(newRaw, opts) 213 | 214 | delete newRaw.req.headers["x-ignored"] 215 | const tapeRenderer = new TapeRenderer(newTape) 216 | expect(await tapeRenderer.render()).to.eql(newRaw) 217 | }) 218 | 219 | it("renders tapes with empty bodies", async () => { 220 | const newRaw = { 221 | ...raw, 222 | req: { 223 | ...raw.req, 224 | body: "", 225 | method: "HEAD", 226 | headers: { 227 | ...raw.req.headers, 228 | "content-type": ["application/json"] 229 | } 230 | }, 231 | res: { 232 | ...raw.res, 233 | headers: { 234 | ...raw.res.headers, 235 | "content-type": ["application/json"] 236 | }, 237 | body: "" 238 | } 239 | } 240 | const newTape = await TapeRenderer.fromStore(newRaw, opts) 241 | 242 | delete newRaw.req.headers["x-ignored"] 243 | const tapeRenderer = new TapeRenderer(newTape) 244 | expect(await tapeRenderer.render()).to.eql(newRaw) 245 | }) 246 | 247 | it("renders pretty prints tapes with JSON gzip compressed bodies", async () => { 248 | const newRaw = { 249 | ...raw, 250 | meta: { 251 | ...raw.meta, 252 | resHumanReadable: true, 253 | resUncompressed: true 254 | }, 255 | req: { 256 | ...raw.req, 257 | headers: { 258 | ...raw.req.headers 259 | } 260 | }, 261 | res: { 262 | ...raw.res, 263 | headers: { 264 | ...raw.res.headers, 265 | "content-type": ["application/json"], 266 | "content-length": [20], 267 | "content-encoding": ["gzip"] 268 | }, 269 | body: { 270 | foo: "bar", 271 | nested: { 272 | fuu: 3 273 | } 274 | } 275 | } 276 | } 277 | const newTape = await TapeRenderer.fromStore(newRaw, opts) 278 | const bodyAsJson = JSON.stringify(newRaw.res.body, null, 2) 279 | const zipped = zlib.gzipSync(Buffer.from(bodyAsJson)) 280 | expect(newTape.res.body).to.eql(zipped) 281 | 282 | delete newRaw.req.headers["x-ignored"] 283 | const tapeRenderer = new TapeRenderer(newTape) 284 | expect(await tapeRenderer.render()).to.eql(newRaw) 285 | }) 286 | }) 287 | }) 288 | -------------------------------------------------------------------------------- /test/tapes/deep-directory/echo.json5: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "createdAt": "2018-08-10T23:19:27.010Z", 4 | "host": "http://localhost:8898", 5 | "reqHumanReadable": true, 6 | "resHumanReadable": true, 7 | "tag": "echo", 8 | "latency": 1 9 | }, 10 | "req": { 11 | "url": "/test/echo", 12 | "method": "POST", 13 | "headers": { 14 | "accept": "*/*", 15 | "content-type":"application/json" 16 | }, 17 | "body": "{\"text\":\"foobar\"}" 18 | }, 19 | "res": { 20 | "status": 200, 21 | "headers": { 22 | "content-type": [ 23 | "application/json" 24 | ], 25 | "date": [ 26 | "Sun, 10 Sep 2017 23:19:27 GMT" 27 | ], 28 | "connection": [ 29 | "close" 30 | ], 31 | "transfer-encoding": [ 32 | "chunked" 33 | ] 34 | }, 35 | "body": "{\"text\":\"foobar\"}" 36 | }, 37 | } -------------------------------------------------------------------------------- /test/tapes/malformed-tape.json: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "createdAt": "2017-09-10T23:19:27.010Z", 4 | 5 | } -------------------------------------------------------------------------------- /test/tapes/pretty-printed-json.json5: -------------------------------------------------------------------------------- 1 | // This file is in JSON5 format, so it can have comments 2 | { 3 | "meta": { 4 | "createdAt": "2017-09-10T23:19:27.010Z", 5 | "host": "http://localhost:8898", 6 | "reqHumanReadable": true, 7 | "resHumanReadable": true, 8 | "latency": [1, 10] 9 | }, 10 | "req": { 11 | "url": "/test/pretty", 12 | "method": "POST", 13 | "headers": { 14 | "content-type": "application/json", 15 | "accept": "*/*" 16 | }, 17 | "body": { 18 | param1: 3, 19 | param2: { 20 | subParam: 1 21 | } 22 | } 23 | }, 24 | "res": { 25 | "status": 200, 26 | "headers": { 27 | "content-type": [ 28 | "application/json" 29 | ], 30 | "date": [ 31 | "Sun, 10 Sep 2017 23:19:27 GMT" 32 | ], 33 | "connection": [ 34 | "close" 35 | ], 36 | "transfer-encoding": [ 37 | "chunked" 38 | ] 39 | }, 40 | "body": { 41 | "ok": true, 42 | "foo": { 43 | "bar": 3 44 | } 45 | } 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /test/tapes/saved-request.json5: -------------------------------------------------------------------------------- 1 | // This file is in JSON5 format, so it can have comments 2 | { 3 | "meta": { 4 | "createdAt": "2017-09-10T23:19:27.010Z", 5 | "host": "http://localhost:8898", 6 | "resHumanReadable": true 7 | }, 8 | "req": { 9 | "url": "/test/3", 10 | "method": "GET", 11 | "headers": { 12 | "accept": "*/*" 13 | }, 14 | "body": "" 15 | }, 16 | "res": { 17 | "status": 200, 18 | "headers": { 19 | "content-type": [ 20 | "application/json" 21 | ], 22 | "date": [ 23 | "Sun, 10 Sep 2017 23:19:27 GMT" 24 | ], 25 | "connection": [ 26 | "close" 27 | ], 28 | "transfer-encoding": [ 29 | "chunked" 30 | ] 31 | }, 32 | "body": "{\"ok\":true}" 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /test/utils/content-encoding.spec.ts: -------------------------------------------------------------------------------- 1 | import {Req, ReqRes} from "../../src/types" 2 | 3 | const zlib = require("zlib") 4 | import ContentEncoding from "../../src/utils/content-encoding" 5 | import {expect} from "chai" 6 | 7 | let reqRes: ReqRes, contentEncoding: ContentEncoding 8 | 9 | describe("ContentEncoding", () => { 10 | beforeEach(() => { 11 | reqRes = { 12 | headers: { 13 | "content-encoding": "gzip" 14 | }, 15 | body: Buffer.from("FOOBAR") 16 | } 17 | contentEncoding = new ContentEncoding(reqRes) 18 | }) 19 | 20 | describe("#isUncompressed", () => { 21 | it("returns true when there's no content-encoding header", () => { 22 | reqRes.headers = {} 23 | expect(contentEncoding.isUncompressed()).to.eql(true) 24 | }) 25 | 26 | it("returns true when content-encoding header is identity", () => { 27 | setEncoding("identity") 28 | expect(contentEncoding.isUncompressed()).to.eql(true) 29 | }) 30 | 31 | it("returns false when content-encoding is not identity", () => { 32 | expect(contentEncoding.isUncompressed()).to.eql(false) 33 | 34 | setEncoding("gzip, identity") 35 | expect(contentEncoding.isUncompressed()).to.eql(false) 36 | }) 37 | }) 38 | 39 | describe("#supportedAlgorithm", () => { 40 | it("returns true when content-encoding is a supported algorithm", () => { 41 | expect(contentEncoding.supportedAlgorithm()).to.eql(true) 42 | setEncoding("br") 43 | expect(contentEncoding.supportedAlgorithm()).to.eql(true) 44 | }) 45 | 46 | it("returns false when content-encoding is not a supported algorithm", () => { 47 | setEncoding("identity") 48 | expect(contentEncoding.supportedAlgorithm()).to.eql(false) 49 | }) 50 | }) 51 | 52 | describe("#uncompressedBody", () => { 53 | it("throws an error when the algorithm is not supported", (done) => { 54 | setEncoding("identity") 55 | contentEncoding.uncompressedBody(reqRes.body) 56 | .then(() => done("failed")) 57 | .catch(() => done()) 58 | }) 59 | 60 | it("returns uncompressed when algorithm is gzip", async () => { 61 | setEncoding("gzip") 62 | const uncompressedBody = Buffer.from("FOOBAR") 63 | const body = await zlib.gzipSync(uncompressedBody) 64 | 65 | expect(await contentEncoding.uncompressedBody(body)).to.eql(uncompressedBody) 66 | }) 67 | 68 | it("returns uncompressed when algorithm is deflate", async () => { 69 | setEncoding("deflate") 70 | const uncompressedBody = Buffer.from("FOOBAR") 71 | const body = await zlib.deflateSync(uncompressedBody) 72 | 73 | expect(await contentEncoding.uncompressedBody(body)).to.eql(uncompressedBody) 74 | }) 75 | 76 | it("returns uncompressed when algorithm is br", async () => { 77 | setEncoding("br") 78 | const uncompressedBody = Buffer.from("FOOBAR") 79 | const body = await zlib.brotliCompressSync(uncompressedBody) 80 | 81 | expect(await contentEncoding.uncompressedBody(body)).to.eql(uncompressedBody) 82 | }) 83 | }) 84 | 85 | describe("#compressedBody", () => { 86 | it("throws an error when the algorithm is not supported", (done) => { 87 | setEncoding("identity") 88 | contentEncoding.compressedBody("FOOBAR") 89 | .then(() => done("failed")) 90 | .catch(() => done()) 91 | }) 92 | 93 | it("returns compressed when algorithm is gzip", async () => { 94 | setEncoding("gzip") 95 | const compressed = await zlib.gzipSync("FOOBAR") 96 | 97 | expect(await contentEncoding.compressedBody("FOOBAR")).to.eql(compressed) 98 | }) 99 | 100 | it("returns compressed when algorithm is deflate", async () => { 101 | setEncoding("deflate") 102 | const compressed = await zlib.deflateSync("FOOBAR") 103 | 104 | expect(await contentEncoding.compressedBody("FOOBAR")).to.eql(compressed) 105 | }) 106 | 107 | it("returns compressed when algorithm is br", async () => { 108 | setEncoding("br") 109 | const compressed = await zlib.brotliCompressSync("FOOBAR") 110 | 111 | expect(await contentEncoding.compressedBody("FOOBAR")).to.eql(compressed) 112 | }) 113 | }) 114 | 115 | function setEncoding(encoding: string) { 116 | reqRes.headers["content-encoding"] = encoding 117 | } 118 | }) 119 | -------------------------------------------------------------------------------- /test/utils/headers.spec.ts: -------------------------------------------------------------------------------- 1 | import Headers from "../../src/utils/headers" 2 | import {expect} from "chai" 3 | 4 | describe("Headers", () => { 5 | describe(".write", () => { 6 | it("returns the value when it's an array", () => { 7 | const headers = { 8 | "content-type": ["application/json"] 9 | } 10 | const value = Headers.read(headers, "content-type") 11 | expect(value).to.eql("application/json") 12 | }) 13 | 14 | it("returns the value when it's just the value", () => { 15 | const headers = { 16 | "content-type": "application/json" 17 | } 18 | const value = Headers.read(headers, "content-type") 19 | expect(value).to.eql("application/json") 20 | }) 21 | }) 22 | 23 | describe(".write", () => { 24 | it("writes just the value as value when it's for req", () => { 25 | const headers = {"content-type": "foo"} 26 | Headers.write(headers, "content-type", "application/json", "req") 27 | expect(headers["content-type"]).to.eql("application/json") 28 | }) 29 | 30 | it("writes just the value as an array when it's for res", () => { 31 | const headers = {"content-type": "foo"} 32 | Headers.write(headers, "content-type", "application/json", "res") 33 | expect(headers["content-type"]).to.eql(["application/json"]) 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/utils/media-type.spec.ts: -------------------------------------------------------------------------------- 1 | import MediaType from "../../src/utils/media-type" 2 | import {expect} from "chai" 3 | import Factories from "../support/factories" 4 | 5 | describe("MediaType", () => { 6 | describe("#isHumanReadable", () => { 7 | it("returns true when the content-type is human readable and there's no content-encoding", () => { 8 | const res = Factories.reqRes({ 9 | headers: { 10 | "content-type": ["application/json"] 11 | } 12 | }) 13 | 14 | const mediaType = new MediaType(res) 15 | expect(mediaType.isHumanReadable()).to.be.true 16 | }) 17 | 18 | it("returns false when content-type is not present", () => { 19 | const res = Factories.reqRes({ 20 | headers: {} 21 | }) 22 | 23 | const mediaType = new MediaType(res) 24 | expect(mediaType.isHumanReadable()).to.be.false 25 | }) 26 | 27 | it("returns false when the content-type is not human readable", () => { 28 | const res = Factories.reqRes({ 29 | headers: { 30 | "content-type": ["img/png"] 31 | } 32 | }) 33 | 34 | const mediaType = new MediaType(res) 35 | expect(mediaType.isHumanReadable()).to.be.false 36 | }) 37 | 38 | it("returns true when content-type is JSON Schema", () => { 39 | const res = Factories.reqRes({ 40 | headers: { 41 | "content-type": ["application/some-schema+json"] 42 | } 43 | }) 44 | 45 | const mediaType = new MediaType(res) 46 | expect(mediaType.isHumanReadable()).to.be.true 47 | }) 48 | }) 49 | 50 | describe("#isJSON", () => { 51 | it("returns true when content-type is JSON", () => { 52 | const res = Factories.reqRes({ 53 | headers: { 54 | "content-type": ["application/json"] 55 | } 56 | }) 57 | 58 | const mediaType = new MediaType(res) 59 | expect(mediaType.isJSON()).to.be.true 60 | }) 61 | 62 | it("returns true when content-type is JSON Schema", () => { 63 | const res = Factories.reqRes({ 64 | headers: { 65 | "content-type": ["application/some-schema+json"] 66 | } 67 | }) 68 | 69 | const mediaType = new MediaType(res) 70 | expect(mediaType.isJSON()).to.be.true 71 | }) 72 | 73 | it("returns false when content-type is not JSON", () => { 74 | const res = Factories.reqRes({ 75 | headers: { 76 | "content-type": ["text/html"] 77 | } 78 | }) 79 | 80 | const mediaType = new MediaType(res) 81 | expect(mediaType.isJSON()).to.be.false 82 | }) 83 | 84 | it("returns false when content-type is not set", () => { 85 | const res = Factories.reqRes({ 86 | headers: {} 87 | }) 88 | 89 | const mediaType = new MediaType(res) 90 | expect(mediaType.isJSON()).to.be.false 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ "es5"], 4 | "target": "es5", 5 | "module": "commonjs", 6 | "moduleResolution": "Node", 7 | "outDir": "./dist", 8 | "declaration": true, 9 | "inlineSourceMap": true, 10 | "esModuleInterop": true 11 | }, 12 | "include": [ 13 | "src" 14 | ] 15 | } 16 | --------------------------------------------------------------------------------