counterfact 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  "commit": false,
5
5
  "fixed": [],
6
6
  "linked": [],
7
- "access": "restricted",
7
+ "access": "public",
8
8
  "baseBranch": "main",
9
9
  "updateInternalDependencies": "patch",
10
10
  "ignore": []
@@ -36,11 +36,3 @@ jobs:
36
36
  run: yarn eslint -f github-annotations .
37
37
  - name: Unit tests
38
38
  run: yarn test --reporters=github-actions
39
- - name: Create Release Pull Request or Publish to npm
40
- id: changesets
41
- uses: changesets/action@v1
42
- with:
43
- publish: yarn release
44
- env:
45
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,36 @@
1
+ name: Mutation Testing
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ branches:
7
+ - main
8
+
9
+ jobs:
10
+ ci-checks:
11
+ name: Mutation Testing
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Check out repo
15
+ uses: actions/checkout@v3
16
+ with:
17
+ fetch-depth: 0
18
+ - uses: actions/setup-node@v3
19
+ with:
20
+ node-version: 16.x
21
+ cache: yarn
22
+ id: setup-node
23
+ - name: Get Node Version
24
+ run: echo "::set-output name=version::$(node -v)"
25
+ id: node-version
26
+ - name: Cache node_modules
27
+ uses: actions/cache@v3
28
+ with:
29
+ path: "**/node_modules"
30
+ key: ${{ runner.os }}-${{ steps.node-version.outputs.version }}-${{ hashFiles('**/yarn.lock') }}
31
+ - name: Install Packages
32
+ run: yarn install --frozen-lockfile
33
+ - name: Run Stryker
34
+ run: yarn test:mutants
35
+ env:
36
+ STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }}
@@ -0,0 +1,42 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ concurrency: ${{ github.workflow }}-${{ github.ref }}
9
+
10
+ jobs:
11
+ release:
12
+ name: Release
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Checkout Repo
16
+ uses: actions/checkout@v3
17
+
18
+ - name: Setup Node.js 16.x
19
+ uses: actions/setup-node@v3
20
+ with:
21
+ node-version: 16.x
22
+ cache: yarn
23
+
24
+ - name: Get Node Version
25
+ run: echo "::set-output name=version::$(node -v)"
26
+ id: node-version
27
+ - name: Cache node_modules
28
+ uses: actions/cache@v3
29
+ with:
30
+ path: "**/node_modules"
31
+ key: ${{ runner.os }}-${{ steps.node-version.outputs.version }}-${{ hashFiles('**/yarn.lock') }}
32
+ - name: Install Dependencies
33
+ run: yarn
34
+
35
+ - name: Create Release Pull Request or Publish to npm
36
+ id: changesets
37
+ uses: changesets/action@v1
38
+ with:
39
+ publish: yarn release
40
+ env:
41
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # counterfact
2
+
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 3829a83: fix changeset access configuration (https://github.com/changesets/changesets/issues/503)
8
+
9
+ ## 0.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 4c2bad8: build: add npm token to the release workflow
14
+
15
+ ## 0.1.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 719d932: support for HTTP response code
20
+ - a5cd8d4: Filled out missing properties in package.json.
21
+ - Deleted the original index.js file that does nothing.
22
+ - This is the first release that's actually usable.
23
+
24
+ ### Patch Changes
25
+
26
+ - 6101623: Update the demo app to work with recent API changes
27
+ - 95f3ca2: no changes -- just trying to get a Github action to run
package/README.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # Counterfact
2
2
 
3
- This is a work in progress.
3
+ <!--
4
+ To make this work we had to trick Stryker into thinking it was running on Travis CI
5
+ As of this writing it's not getting updated automatically.
6
+
7
+ TRAVIS=true STRYKER_DASHBOARD_API_KEY=XXXXXXX yarn stryker run
8
+
9
+ https://github.com/stryker-mutator/stryker-js/issues/744
10
+ -->
11
+
12
+ [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fpmcelhaney%2Fcounterfact%2Fmain)](https://dashboard.stryker-mutator.io/reports/github.com/pmcelhaney/counterfact/main)
13
+
14
+ ## This is a work in progress.
4
15
 
5
16
  If you're nosy and don't mind a whole lot of incompleteness:
6
17
 
@@ -10,33 +21,197 @@ yarn test
10
21
  node demo/index.js
11
22
  ```
12
23
 
13
- Then open http://localhost:3100/hello/world and prepare to be underwhelmed. (See [./demo/README.md](./demo/README.md) for more.)
14
-
15
- ## Features
16
-
17
- - [x] Convention-over-configuration style API
18
- - [x] un-opinionated with respect how you set up fake data and APIs
19
- - [x] e.g. implement `GET /hello/world` by exporting a `GET()` function from `./hello/world.mjs`
20
- - [ ] easily read the request (method, headers, body, path, query string) and send a response (status, headers, body, content type, etc.)
21
- - [x] fundamental, most commonly used parts
22
- - [ ] details
23
- - [x] middleware to plug in to Koa
24
- - [ ] can also be run as a stand-alone HTTP server via CLI
25
- - [x] hot reload: add / remove / change a file and see results without restarting the server
26
- - [x] convenient access to a store which has local state
27
- - [x] pure API (using reducers)
28
- - [x] side effect API
29
- - [ ] set up test scenarios by populating a store using "migration scripts"
30
- - [ ] configure the store through an API or a GUI to select migration scripts
31
- - [ ] "record mode" to automatically generate migration scripts
32
-
33
- ## Non-functional requirements
34
-
35
- - [x] Written in Typescript
36
- - [x] Solid unit test coverage
37
- - [ ] CI/CD pipeline
38
- - [ ] Documentation
39
- - [x] Demo
40
- - [ ] Publish to npm
41
-
42
- As of Friday, April 15, 2022, all of the "hard" stuff is done (bleeding edge Node + Jest + TypeScript and most of hot reloading) and what remains is mostly drudgery. I'm aiming to finish hot reloading and put together a demo by EOD Monday (and hopefully more check some more boxes).
24
+ ## Why?
25
+
26
+ I was building an UI against back end code that was so cumbersome I spent almost as much time trying to get the latest version of the back end code running as I did writing the front end code. Also I needed to build UI code against features and bug fixes that would not be available for a long time. Eventually I determined in the long run it would be quicker to hack together a fake implementation of the API so that I could keep going. Over time that fake implementation evolved to the point that it was pretty nice to work with. And I loved that I could easily recreate the exact scenarios I needed to test against.
27
+
28
+ After leaving that job and leaving the code behind, I looked around for open source tools that do more or less the same thing, and couldn't find any that were as malleable and flexible as the code that I had hacked together. So I recreated it as an open source library. Not hacked together this time, but carefully constructed with test driven development.
29
+
30
+ With Counterfact, we can implement a server with quick-and-dirty JavaScript code and _that's totally okay_. We're not supposed to use Counterfact in production any more than we're supposed to build a skyscraper out of Play-Doh. The design is optimized for testing scenarios quickly and painlessly. Changes are picked up immediately, without compiling or restarting the server. It's great for manual testing, especially hard to reproduce bugs. It's also great for automated testing.
31
+
32
+ ## Installation
33
+
34
+ ```sh
35
+ npm install --save-dev counterfact
36
+ ```
37
+
38
+ or
39
+
40
+ ```sh
41
+ yarn add -D counterfact
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ### TL;DR
47
+
48
+ ```js
49
+ // ./path/to/some-route.js
50
+ export function REQUEST_METHOD({parts, of, the, request}) {
51
+ return {
52
+ parts,
53
+ of,
54
+ the,
55
+ response
56
+ };
57
+ }
58
+ ```
59
+
60
+ ### Using the Koa plugin
61
+
62
+ (Currently this is the only way to run Counterfact. A stand-alone CLI is coming soon.)
63
+
64
+ ```js
65
+ import { fileURLToPath } from "node:url";
66
+
67
+ import Koa from "koa";
68
+ import { counterfact } from "counterfact";
69
+
70
+ const PORT = 3100;
71
+
72
+ const app = new Koa();
73
+
74
+ const { koaMiddleware } = await counterfact(
75
+ fileURLToPath(new URL("routes/", import.meta.url))
76
+ );
77
+
78
+ app.use(koaMiddleware);
79
+
80
+ app.listen(PORT);
81
+ ```
82
+
83
+ ### Setting up routes
84
+
85
+ The first thing you need to do is create a directory where you will put your routes.
86
+
87
+ ```sh
88
+ mkdir routes
89
+ ```
90
+
91
+ To mock an API at `/hello/world`, create a file called `./routes/hello/world.js`.
92
+
93
+ Inside that file, output a function that handles a GET request.
94
+
95
+ ```js
96
+ export function GET() {
97
+ return {
98
+ status: 200, // optional HTTP status code (200 is the default)
99
+ body: "hello world" // HTTP response body
100
+ }
101
+ }
102
+ ```
103
+
104
+ Now when you run your server and call "GET /hello/world" you should get a 200 response with "hello world" in the body.
105
+
106
+ ### Reading the query string
107
+
108
+ The get function has one parameter, a context object that contains metadata about the request. We can use that to read a the query string from `/hello/world?greeting=Hi`
109
+
110
+ ```js
111
+ export function GET(context) {
112
+ return {
113
+ body: "${context.query.greeting} world"
114
+ }
115
+ }
116
+ ```
117
+
118
+ In practice, tend to use destructuring syntax, which is the closest thing we have in JavaScript to named arguments.
119
+
120
+ ```js
121
+ export function GET({ query }) {
122
+ return {
123
+ body: "${query.greeting} world"
124
+ }
125
+ }
126
+ ```
127
+
128
+ ### Dynamic routes
129
+
130
+ Create another file called `./routes/hello/[name].js`. This file will match `/hello/universe`, `/hello/friends`, and `/hello/whatever-you-want-here`.
131
+
132
+ ```js
133
+ export function GET({ greeting, path }) {
134
+ return {
135
+ body: "${query.greeting}, ${path.name}!"
136
+ }
137
+ }
138
+ ```
139
+
140
+ The `path` object is analogous to the `query` object, holding values in the dynamic parts of the path.
141
+
142
+ (Note that `/hello/world` is still handled by `/hello/world.js` -- static routes take precedence over dynamic ones.)
143
+
144
+ This feature was [inspired by Next.js](https://nextjs.org/docs/routing/dynamic-routes).
145
+
146
+ ### State
147
+
148
+ State management is handled through a plain old JavaScript object called `store`. The store is initialized as an empty object (`{}`). You can read and write its keys however you like. Changes will persist from one request to another as long as the server is running.
149
+
150
+ There are no rules around how you manipulate the store. Yes, you read that right.
151
+
152
+ ```js
153
+ export function GET({ greeting, path, store }) {
154
+
155
+ store.visits ??= {};
156
+ store.visits[path.name] ??= 0;
157
+ store.visits[path.name] += 1;
158
+
159
+ return {
160
+ body: "${query.greeting}, ${path.name}!"
161
+ }
162
+ }
163
+ ```
164
+
165
+ ### Request Methods
166
+
167
+ So far we've only covered `GET` requests. What about `POST`, `PUT`, `PATCH` and `DELETE`? All HTTP request methods are supported. It's a matter exporting functions with the corresponding names.
168
+
169
+ ```js
170
+ export function GET({ path, store }) {
171
+
172
+ store.friends ??= {};
173
+ store.friends[path.name] ??= {
174
+ appearance: "lovely"
175
+ };
176
+
177
+ return {
178
+ body: "Hello, ${path.name}. You look ${store.friends[name].appearance} today!"
179
+ }
180
+ }
181
+
182
+ export function POST(path, store, body) {
183
+
184
+ store.friends ??= {};
185
+ store.friends[path.name] ??= {};
186
+ store.friends[appearance] = body.appearance;
187
+
188
+ return {
189
+ body: {
190
+ "Okay, I'll remember that ${path.name} is ${body.appearance}!"
191
+ }
192
+ }
193
+ }
194
+ ```
195
+
196
+ ### Asynchronous Handlers
197
+
198
+ If you need to do work asynchronously, return a promise or use async / await.
199
+
200
+ ```js
201
+ export function PUT({body}) {
202
+ return doSomeStuffWith(body).then(() => {
203
+ body: "Successfully did an async PUT"
204
+ });
205
+ }
206
+
207
+ export async function DELETE() {
208
+ await deleteMe();
209
+ return {
210
+ body: "Took a while, but now it's deleted."
211
+ };
212
+ }
213
+ ```
214
+
215
+ ### Coming soon
216
+
217
+ Headers, content-type, etc. are not supported yet but coming soon. Hopefully by now you can guess what those APIs are going to look like.
package/demo/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  This demo illustrates some of the basic features of Counterfact.
4
4
 
5
- `index.js` starts an Apollo server and loads Counterfact's middleware pointed to route definitions at `./routes`.
5
+ `index.js` starts a Koa server and loads Counterfact's middleware pointed to route definitions at `./routes`.
6
6
 
7
7
  Under `./routes` you will find a few endpoints definitions:
8
8
 
9
- `hello.js` defines `/hello/:friend` and says hello to your friends.
9
+ `hello.js` defines `/hello/:name` and says hello to your friends.
10
10
 
11
11
  `hello/kitty.js` defines `/hello/kitty`, overriding the behavior defined in `hello.js`.
12
12
 
package/demo/index.js CHANGED
@@ -15,4 +15,9 @@ const { koaMiddleware } = await counterfact(
15
15
  app.use(koaMiddleware);
16
16
 
17
17
  app.listen(PORT);
18
- console.log(`Open http://localhost:${PORT}/hello/world`);
18
+ console.log("Try these URLs:");
19
+ console.log(`http://localhost:${PORT}/hello/world`);
20
+ console.log(`http://localhost:${PORT}/hello/friends`);
21
+ console.log(`http://localhost:${PORT}/hello/kitty`);
22
+ console.log(`http://localhost:${PORT}/hello/world?greeting=Hi`);
23
+ console.log(`http://localhost:${PORT}/count`);
@@ -0,0 +1,6 @@
1
+ export function GET() {
2
+ return {
3
+ status: 404,
4
+ body: "Not found",
5
+ };
6
+ }
@@ -1,13 +1,13 @@
1
1
  export function GET({ path, store, query }) {
2
2
  store.visits ??= {};
3
- store.visits[path] ??= 0;
4
- store.visits[path] += 1;
3
+ store.visits[path.name] ??= 0;
4
+ store.visits[path.name] += 1;
5
5
 
6
6
  if (!path) {
7
7
  return { body: "Hello, stranger!" };
8
8
  }
9
9
 
10
10
  return {
11
- body: `${query.greeting ?? "Hello"}, ${path}!`,
11
+ body: `${query.greeting ?? "Hello"}, ${path.name}!`,
12
12
  };
13
13
  }
@@ -0,0 +1,16 @@
1
+
2
+ {
3
+ "testEnvironment": "node",
4
+ "collectCoverage": true,
5
+
6
+ "collectCoverageFrom": ["src/**/*.{js,jsx,ts,tsx}", "!**/node_modules/**"],
7
+
8
+ "coverageThreshold": {
9
+ "global": {
10
+ "branches": 85,
11
+ "functions": 90,
12
+ "lines": 90,
13
+ "statements": -4
14
+ }
15
+ }
16
+ }
package/package.json CHANGED
@@ -1,29 +1,43 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "0.0.1",
3
+ "version": "0.1.2",
4
4
  "description": "a library for building a fake REST API for testing",
5
5
  "type": "module",
6
- "main": "index.js",
7
- "author": "Patrick McElhaney <pmcelhaney@gmail.com>",
6
+ "main": "./src/counterfact.js",
7
+ "exports": "./src/counterfact.js",
8
+ "author": "Patrick McElhaney <pmcelhaney@gmail.com> (https://patrickmcelhaney.com)",
8
9
  "license": "MIT",
10
+ "repository": "github:pmcelhaney/counterfact",
11
+ "bugs": "https://github.com/pmcelhaney/counterfact/issues",
12
+ "keywords": [
13
+ "counterfact",
14
+ "fake",
15
+ "rest",
16
+ "api",
17
+ "testing"
18
+ ],
9
19
  "engines": {
10
20
  "node": ">=16.9.0"
11
21
  },
12
22
  "scripts": {
13
- "test": "yarn node --experimental-vm-modules $(yarn bin jest)"
23
+ "test": "yarn node --experimental-vm-modules $(yarn bin jest)",
24
+ "test:mutants": "stryker run stryker.config.json",
25
+ "release": "npx changeset publish"
14
26
  },
15
27
  "devDependencies": {
28
+ "@changesets/cli": "2.22.0",
29
+ "@stryker-mutator/core": "6.0.2",
30
+ "@stryker-mutator/jest-runner": "6.0.2",
16
31
  "eslint": "8.17.0",
17
32
  "eslint-config-hardcore": "24.5.0",
18
33
  "eslint-formatter-github-annotations": "0.1.0",
19
- "jest": "28.1.0",
34
+ "jest": "28.1.1",
20
35
  "koa": "2.13.4",
21
36
  "nodemon": "2.0.16",
22
- "supertest": "6.2.3",
23
- "typescript": "4.7.3"
37
+ "stryker-cli": "1.0.2",
38
+ "supertest": "6.2.3"
24
39
  },
25
40
  "dependencies": {
26
- "@changesets/cli": "^2.22.0",
27
41
  "chokidar": "^3.5.3"
28
42
  }
29
43
  }
@@ -1,3 +1,5 @@
1
+ const HTTP_STATUS_CODE_OK = 200;
2
+
1
3
  export function koaMiddleware(dispatcher) {
2
4
  return async function middleware(ctx) {
3
5
  const { method, path, body, query } = ctx.request;
@@ -11,7 +13,7 @@ export function koaMiddleware(dispatcher) {
11
13
 
12
14
  /* eslint-disable require-atomic-updates */
13
15
  ctx.body = response.body;
14
- ctx.status = 200;
16
+ ctx.status = response.status ?? HTTP_STATUS_CODE_OK;
15
17
  /* eslint-enable require-atomic-updates */
16
18
  };
17
19
  }
package/src/registry.js CHANGED
@@ -11,10 +11,6 @@ export class Registry {
11
11
  this.store = store;
12
12
  }
13
13
 
14
- get modulesList() {
15
- return Object.keys(this.modules);
16
- }
17
-
18
14
  add(url, module) {
19
15
  let node = this.moduleTree;
20
16
 
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
3
+ "_comment": "This config was generated using 'stryker init'. Please take a look at: https://stryker-mutator.io/docs/stryker-js/configuration/ for more information",
4
+ "packageManager": "yarn",
5
+ "reporters": [
6
+ "html",
7
+ "clear-text",
8
+ "progress",
9
+ "dashboard"
10
+ ],
11
+ "testRunner": "jest",
12
+ "testRunnerNodeArgs": ["--experimental-vm-modules"],
13
+ "coverageAnalysis": "perTest"
14
+ }
@@ -20,6 +20,11 @@ describe("integration test", () => {
20
20
  return await Promise.resolve({ body: "POST /hello/world" });
21
21
  }
22
22
  `,
23
+ "teapot.mjs": `
24
+ export async function POST() {
25
+ return await Promise.resolve({ status: 418, body: "I am a teapot." });
26
+ }
27
+ `,
23
28
  };
24
29
 
25
30
  await withTemporaryFiles(files, async (basePath) => {
@@ -29,9 +34,11 @@ describe("integration test", () => {
29
34
 
30
35
  const getResponse = await request.get("/hello");
31
36
  const postResponse = await request.post("/hello/world");
37
+ const teapotResponse = await request.post("/teapot");
32
38
 
33
39
  expect(getResponse.text).toBe("GET /hello");
34
40
  expect(postResponse.text).toBe("POST /hello/world");
41
+ expect(teapotResponse.statusCode).toBe(418);
35
42
 
36
43
  await moduleLoader.stopWatching();
37
44
  });
@@ -72,6 +72,27 @@ describe("a dispatcher", () => {
72
72
  expect(response.body).toBe("Searching for stores near 90210!");
73
73
  });
74
74
 
75
+ it("passes status code in the response", async () => {
76
+ const registry = new Registry();
77
+
78
+ registry.add("/stuff", {
79
+ PUT() {
80
+ return {
81
+ status: 201,
82
+ body: "ok",
83
+ };
84
+ },
85
+ });
86
+
87
+ const dispatcher = new Dispatcher(registry);
88
+ const response = await dispatcher.request({
89
+ method: "PUT",
90
+ path: "/stuff",
91
+ });
92
+
93
+ expect(response.status).toBe(201);
94
+ });
95
+
75
96
  it("passes a reducer function that can be used to read / update the store", async () => {
76
97
  const registry = new Registry({ value: 0 });
77
98
 
@@ -7,7 +7,7 @@ describe("koa middleware", () => {
7
7
  const registry = new Registry();
8
8
 
9
9
  registry.add("/hello", {
10
- GET({ body }) {
10
+ POST({ body }) {
11
11
  return {
12
12
  body: `Hello, ${body.name}!`,
13
13
  };
@@ -17,7 +17,7 @@ describe("koa middleware", () => {
17
17
  const dispatcher = new Dispatcher(registry);
18
18
  const middleware = koaMiddleware(dispatcher);
19
19
  const ctx = {
20
- request: { path: "/hello", method: "GET", body: { name: "Homer" } },
20
+ request: { path: "/hello", method: "POST", body: { name: "Homer" } },
21
21
  };
22
22
 
23
23
  await middleware(ctx);
@@ -25,4 +25,26 @@ describe("koa middleware", () => {
25
25
  expect(ctx.status).toBe(200);
26
26
  expect(ctx.body).toBe("Hello, Homer!");
27
27
  });
28
+
29
+ it("passes the status code", async () => {
30
+ const registry = new Registry();
31
+
32
+ registry.add("/not-modified", {
33
+ GET() {
34
+ return {
35
+ status: 304,
36
+ };
37
+ },
38
+ });
39
+
40
+ const dispatcher = new Dispatcher(registry);
41
+ const middleware = koaMiddleware(dispatcher);
42
+ const ctx = {
43
+ request: { path: "/not-modified", method: "GET" },
44
+ };
45
+
46
+ await middleware(ctx);
47
+
48
+ expect(ctx.status).toBe(304);
49
+ });
28
50
  });
@@ -1,5 +0,0 @@
1
- export function GET({ id }) {
2
- return {
3
- body: id,
4
- };
5
- }
package/index.js DELETED
@@ -1,4 +0,0 @@
1
- const greeting = "hello";
2
- const name = "world";
3
-
4
- export { greeting, name };
package/jest.config.js DELETED
@@ -1,6 +0,0 @@
1
- /* eslint-disable import/no-anonymous-default-export */
2
-
3
- /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
4
- export default {
5
- testEnvironment: "node",
6
- };