Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for txt fixtures #30

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# VCR.js [![CircleCI](https://circleci.com/gh/blueberryapps/vcr.js.svg?style=svg)](https://circleci.com/gh/blueberryapps/vcr.js) [![Dependency Status](https://dependencyci.com/github/blueberryapps/vcr.js/badge)](https://dependencyci.com/github/blueberryapps/vcr.js)
# VCR.js-next

## fork of [original blueberryapps/vcr.js](https://github.com/blueberryapps/vcr.js) with [cassettes](#cassettes) feature

Mock server with Proxy and Record support inspired by ruby VCR.

## tl;dr
```
yarn add vcr.js
yarn add vcr.js-next
mkdir -p fixtures/users
echo '{"users": ["Tim", "Tom"]}' > ./fixtures/users/GET.default.json
yarn vcr -- -f ./fixtures
Expand Down Expand Up @@ -95,6 +97,9 @@ response from `https://ap.io/users` is streamed to the client and is also saved
If you want to save fixtures from proxy under a custom variant, just set the `record_fixture_variant` cookie with any word you want as the value.
With the `record_fixture_variant=blacklistedUser` cookie the recorded fixtures will be saved as `{path}/GET.blacklistedUser.json`.

## Cassettes
If you need completely separated sets of fixtures, set `cassette` cookie with *absolute path* to folder containing the fixtures. The same cookie in proxy-record mode will create the folder and save fixtures there. To have colocated cassettes and tests in cypress, you can use `const cassette = path.resolve(path.dirname(Cypress.spec.absolute), './fixtures-folder')`

## Development

```console
Expand Down
7 changes: 0 additions & 7 deletions bin/vcr.js
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ var express = require('express');
var chalk = require('chalk');
var server = require('../lib/server').default;
var canUsePort = require('../lib/canUsePort').default;
var bodyParser = require('body-parser');

var DEFAULT_PORT = 8100;

Expand Down Expand Up @@ -40,12 +39,6 @@ var port = canUsePort(argv.port) ? argv.port : DEFAULT_PORT;
//this is a fix for azure systems
port = process.env.PORT || port;

//this part is to read the contents of form elements
app.use(bodyParser.json());
app.use(bodyParser.urlencoded());
// in latest body-parser use like below.
app.use(bodyParser.urlencoded({ extended: true }));

app.use(server([fixturesDir], argv.proxy, argv.record && fixturesDir))
app.listen(port, '0.0.0.0', function(err) {
if (err) {
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vcr.js",
"version": "0.8.3",
"name": "vcr.js-next",
"version": "0.10.3",
"license": "MIT",
"author": {
"email": "[email protected]",
Expand Down Expand Up @@ -75,4 +75,4 @@
"js"
]
}
}
}
3 changes: 3 additions & 0 deletions src/__tests__/customCassette/very-different/GET.default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "Forrest"
}
6 changes: 6 additions & 0 deletions src/__tests__/fixtures/express-handler/POST.default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

module.exports = (req, res) => {
const { parsed: { num } } = req.body;

res.json({ num });
};
64 changes: 64 additions & 0 deletions src/__tests__/pipeMiddlewares.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import pipeMiddlewares from '../pipeMiddlewares';
import * as express from 'express';

const delay = (ms: number) => new Promise(res => setTimeout(res, ms));
const middlewareMock = (req: any, res: any, nextFn: any) => nextFn();

describe('pipeMiddlewares', () => {
it('should do nothing when called without middlewares', () => {
const req = {} as express.Request;
const res = {} as express.Response;
const next = jest.fn() as express.NextFunction;
expect(() => pipeMiddlewares([])(req, res, next)).not.toThrow();
});

it('with one middleware should call it with provided handler arguments', () => {
const req = {} as express.Request;
const res = {} as express.Response;
const next = jest.fn() as express.NextFunction;
const m1 = jest.fn();
pipeMiddlewares([m1])(req, res, next);

expect(m1).toBeCalledWith(req, res, next);
});

it('should call last middleware with provided handler arguments', () => {
const req = {} as express.Request;
const res = {} as express.Response;
const next = jest.fn() as express.NextFunction;
const m1 = jest.fn(middlewareMock);
const m2 = jest.fn(middlewareMock);
pipeMiddlewares([m1, m2])(req, res, next);

expect(m2).toBeCalledWith(req, res, next);
});

it('should call middlewares sequentially', () => new Promise((resolve) => {
const stack: number[] = [];
const createStubMiddleware = (ms: number) => jest.fn(async (rq, rs, nextFn) => {
await delay(ms);
stack.push(ms);
nextFn();
});
const m1 = createStubMiddleware(150);
const m2 = createStubMiddleware(100);
const m3 = createStubMiddleware(50);

pipeMiddlewares([m1, m2, m3])({} as express.Request, {} as express.Response, () => {
expect(stack).toEqual([150, 100, 50]);
resolve();
});
}));

it('should call next with error when occured', () => {
const error = new Error('First middleware errored');
const next = jest.fn() as express.NextFunction;
const m1 = jest.fn((_, __, m1Next) => m1Next(error));
const m2 = jest.fn(middlewareMock);
pipeMiddlewares([m1, m2])({} as express.Request, {} as express.Response, next);

expect(m2).not.toBeCalled();
expect(next).toBeCalledWith(error);
});
});

66 changes: 56 additions & 10 deletions src/__tests__/server.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as request from 'supertest';
import server from '../server';
import * as BluebirdPromise from 'bluebird';
import { ChildProcess, spawn } from 'child_process';
import { emptyDirSync, removeSync } from 'fs-extra';
import * as path from 'path';
import * as request from 'supertest';
import listAllFixtures from '../listAllFixtures';
import { emptyDirSync } from 'fs-extra';
import { spawn, ChildProcess } from 'child_process';
import * as BluebirdPromise from 'bluebird';
import server from '../server';
import kill from './helpers/killProcessTree';

let mockServer: ChildProcess;
Expand Down Expand Up @@ -67,8 +67,14 @@ describe('Stub server', () => {
variants: {
default: path.join(__dirname, 'fixtures/cnx-gbl-org-quality/qa/v1/dtm/events/GET.default.json')
}
}

},
{
endpoint: '/express-handler',
method: 'POST',
variants: {
default: path.join(__dirname, 'fixtures/express-handler/POST.default.js')
}
},
]);
})
);
Expand Down Expand Up @@ -155,7 +161,8 @@ describe('Stub server', () => {
'/cnx-gbl-org-quality/qa/v1/dm/jobsites/1/GET.default',
'/cnx-gbl-org-quality/qa/v1/dm/jobsites/GET.page=5&size=10',
'/cnx-gbl-org-quality/qa/v1/dm/jobsites/{id}/GET.default',
'/cnx-gbl-org-quality/qa/v1/dtm/events/GET.default'
'/cnx-gbl-org-quality/qa/v1/dtm/events/GET.default',
'/express-handler/POST.default',
])
);
});
Expand Down Expand Up @@ -210,11 +217,33 @@ describe('Stub server', () => {
.get('/users')
.expect(200)
.then((res: request.Response) => {
expect(res.body.length).toEqual(10); // should return 10 users
// expect(res.body.length).toEqual(10); // should return 10 users
expect(res.body[0].id).toEqual(1); // should return id 1 for first user
expect(res.body[0].username).toEqual('Bret'); // should return id 1 for first user
});
});

it('should use fixturesDir specified in cassette cookie', async () => {
const cassetteDir = path.join(__dirname, 'customCassette');
await request.agent(app)
.get('/very-different')
.set('Cookie', `cassette=${cassetteDir}`)
.expect(200)
.then((res: request.Response) => {
expect(res.body).toEqual({name: 'Forrest'});
});
});

it('should parse body for POST js fixtures', async () => {
await request.agent(app)
.post('/express-handler')
.send({ parsed: {num: 42} })
.set('Accept', 'application/json')
.expect(200)
.then((res: request.Response) => {
expect(res.body).toEqual({num: 42});
});
});
});

describe('Stub server in proxy mode', async () => {
Expand Down Expand Up @@ -329,6 +358,24 @@ describe('Stub server in proxy mode', async () => {
});
});

it('should proxy and save fixture to custom cassette', async () => {
const cassetteDir = path.join(__dirname, 'empty-vhs');
const appserver = server([cassetteDir], 'http://localhost:5000', 'overwritten-by-cassette');

await request.agent(appserver)
.get('/mocked-vhs')
.set('Cookie', `cassette=${cassetteDir}`)
.expect(200)
.then((res: request.Response) => {
const fixture = require(path.join(cassetteDir, 'mocked-vhs', 'GET.default.json'));

expect(res.body.answer).toBe(42);
expect(fixture.answer).toBe(42);
});

removeSync(cassetteDir);
});

it('should proxy requests and keep query params', async () => {
const appserver = server(fixtureDirs, 'http://localhost:5000', outputFixturesDir);
await request.agent(appserver)
Expand Down Expand Up @@ -357,5 +404,4 @@ describe('Stub server in proxy mode', async () => {
expect(fixture.bodyProp).toBe(42);
});
});

});
3 changes: 3 additions & 0 deletions src/getFixturesDirs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Request } from 'express';

export default ({ cookies = {} }: Request, defaultDirs: string[]) => cookies.cassette ? [cookies.cassette] : defaultDirs;
18 changes: 17 additions & 1 deletion src/listAllFixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,24 @@ export interface FixturesMap {[key: string]: string; };

const SUPPORTED_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']);

const isDir = (dir: string): boolean => {
try {
if (fs.statSync(dir).isDirectory()) {
return true;
} else {
return false;
}
} catch (e) {
return false;
}
};

// List all files in a directory in Node.js recursively in a synchronous fashion
const walkSync = function(dir: string, filelist: string[] = []): string[] {
if (!isDir(dir)) {
return [];
}

const files = fs.readdirSync(dir);
files.forEach((file: string) => {
const filePath = path.join(dir, file);
Expand All @@ -20,7 +36,7 @@ const walkSync = function(dir: string, filelist: string[] = []): string[] {
};

const isFixture = (absolutePath: string): boolean => {
const extensionSupported = /\.(js|json)$/.test(absolutePath);
const extensionSupported = /\.(js|json|txt)$/.test(absolutePath);

const fixtureMethod = path.basename(absolutePath).split('.')[0].toUpperCase();
const methodSupported = SUPPORTED_METHODS.has(fixtureMethod);
Expand Down
4 changes: 4 additions & 0 deletions src/loadFixture.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as chalk from 'chalk';
import * as fs from 'fs';

function requireUncached(mod: string): any {
delete require.cache[require.resolve(mod)];
Expand All @@ -8,6 +9,9 @@ function requireUncached(mod: string): any {
export default function loadFixture(filePath: string): any {
let fixture;
try {
if (filePath.split('.').pop() === 'txt')
return fs.readFileSync(filePath, 'utf8');

fixture = requireUncached(filePath);
if (fixture.default && typeof fixture.default === 'function') return fixture.default;
return fixture;
Expand Down
9 changes: 7 additions & 2 deletions src/middlewares/proxy/getFixturePath.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import * as path from 'path';
import getFixtureVariant from './getFixtureVariant';
import {Request} from 'express';
import { IncomingMessage } from 'http';

export default function getFixturePath(req: Request, outputDir: string): string {
export default function getFixturePath(req: Request, outputDir: string, proxyRes?: IncomingMessage): string {
const variant = getFixtureVariant(req);
const dirName = req.path.replace(/^\//, '');
const fileName = `${req.method.toUpperCase()}.${variant}.json`;
let ext = 'json';
if ((proxyRes && proxyRes.headers && proxyRes.headers['content-type'] && proxyRes.headers['content-type'].includes('text/plain')) || proxyRes && proxyRes.statusCode === 204)
ext = 'txt';

const fileName = `${req.method.toUpperCase()}.${variant}.${ext}`;

return path.join(outputDir, dirName, fileName);
}
16 changes: 11 additions & 5 deletions src/middlewares/proxy/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import * as chalk from 'chalk';
import { NextFunction, Request, RequestHandler, Response } from 'express';
import { IncomingMessage } from 'http';
import * as request from 'request';
import getFixturesDirs from '../../getFixturesDirs';
import createProxyRequestOptions from './createProxyRequestOptions';
import getFixturePath from './getFixturePath';
import getProxyResponseHeaders from './getProxyResponseHeaders';
import writeFixture from './writeFixture';
import {IncomingMessage} from 'http';
import {Request, Response, NextFunction, RequestHandler} from 'express';

export default (realApiBaseUrl: string, outputDir?: string): RequestHandler =>
(req: Request, res: Response, next: NextFunction): void => {
if (req.path === '/') return next();

const apiReqURL = `${realApiBaseUrl}${req.originalUrl}`;
const outputCassette = getFixturesDirs(req, outputDir ? [outputDir] : [])[0];

// pipe request from stub server to real API
req
Expand All @@ -21,12 +23,16 @@ export default (realApiBaseUrl: string, outputDir?: string): RequestHandler =>
// response from real API, if not OK, pass control to next
if (!proxyRes.statusCode || proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) {
console.log(`${chalk.magenta('[Stub server]')} proxy request to ${chalk.yellow(realApiBaseUrl + req.originalUrl)} ended up with ${chalk.red(`${proxyRes.statusCode}`)}`);
// console.log(`${chalk.magenta('[Stub server]')} request headers: ${JSON.stringify(req.headers, null, 2)}`);
// console.log(`${chalk.magenta('[Stub server]')} response headers: ${JSON.stringify(proxyRes.headers, null, 2)}`);
return next();
}
// console.log(`${chalk.blue('[Stub server]')} request headers: ${JSON.stringify(req.headers, null, 2)}`);
// console.log(`${chalk.blue('[Stub server]')} response status: ${proxyRes.statusCode} headers: ${JSON.stringify(proxyRes.headers, null, 2)}`);

// response from API is OK
console.log(`${chalk.magenta('[Stub server]')} proxy request to ${chalk.yellow(realApiBaseUrl + req.originalUrl)} ended up with ${chalk.green(`${proxyRes.statusCode}`)} returning its response`);
const headers = {...proxyRes.headers, ...getProxyResponseHeaders(req, apiReqURL, outputDir)};
const headers = {...proxyRes.headers, ...getProxyResponseHeaders(req, apiReqURL, outputCassette)};
res.writeHead(proxyRes.statusCode || 500, headers);

// pipe API response to client till the 'end'
Expand All @@ -35,8 +41,8 @@ export default (realApiBaseUrl: string, outputDir?: string): RequestHandler =>
proxyRes.on('end', () => { res.end(); });

// write response as fixture on the disc
if (outputDir) {
const fullPath = getFixturePath(req, outputDir);
if (outputCassette) {
const fullPath = getFixturePath(req, outputCassette, proxyRes);
writeFixture(fullPath, proxyRes, next);
}
});
Expand Down
16 changes: 16 additions & 0 deletions src/pipeMiddlewares.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as express from 'express';

const pipeMiddlewares = ([head, ...tail]: express.Handler[]) =>
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!head) {
// empty middlewares array
return;
}

const nextMiddleware = (err?: Error) =>
err ? next(err) : pipeMiddlewares(tail)(req, res, next);

head(req, res, !tail.length ? next : nextMiddleware);
};

export default pipeMiddlewares;
Loading