├── .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 |
--------------------------------------------------------------------------------