Repository: pmcelhaney/counterfact
Pull request #181 (2022-09-09 19:51:30)

go command
- Streamline the CLI. This is not working yet because I need to transform the TS files
- transpile Typescript on the fly
- more unit test concurrency shenanigans
- update generate scripts in package.json
- add changeset
- Bring back the generate, start, and init commmands. The one command to rule them all is now under 'go'
- update the changelog to mention the go command
- Update README to reflect current status and (in)stability
- Make sure Jest exits in CI even if there are open handles
- just trying to get CI to run



Patch:
diff --git a/.changeset/lucky-starfishes-fail.md b/.changeset/lucky-starfishes-fail.md
new file mode 100644
index 0000000..fd7c672
--- /dev/null
+++ b/.changeset/lucky-starfishes-fail.md
@@ -0,0 +1,5 @@
+---
+"counterfact": minor
+---
+
+adds a 'go' command that generates code and starts the server in one step
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 72bf829..3ab54ea 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -35,4 +35,4 @@ jobs:
       - name: ESlint
         run: yarn eslint -f github-annotations . 
       - name: Unit tests
-        run: yarn test --reporters=github-actions
+        run: yarn test --reporters=github-actions --forceExit
diff --git a/.github/workflows/coveralls.yaml b/.github/workflows/coveralls.yaml
index ba9fa36..d5bac0e 100644
--- a/.github/workflows/coveralls.yaml
+++ b/.github/workflows/coveralls.yaml
@@ -20,7 +20,7 @@ jobs:
     - name: install, run tests
       run: |
         yarn
-        yarn test
+        yarn test --forceExit
 
     - name: Coveralls
       uses: coverallsapp/github-action@1.1.3
diff --git a/.gitignore b/.gitignore
index acc4609..7d153c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ dist
 coverage
 .stryker-tmp
 reports
+out
 
 # In testing we may generate these files. We don't want to check them in.
 templates/typescript/counterfact/components
diff --git a/README.md b/README.md
index 5a8df93..184cb3f 100644
--- a/README.md
+++ b/README.md
@@ -4,107 +4,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)
 
-Counterfact is (will be) a tool that helps front end developers and back end developers collaborate by quickly building reference implementations of [OpenAPI](https://www.openapis.org/) specs.
+> This project is a work in progress. As of September 9, 2022, most of the plumbing is in place but the APIs are still solidifying and it's not quite ready for day-to-day use. See the [Version 1.0](https://github.com/pmcelhaney/counterfact/milestone/3) and [Future](https://github.com/pmcelhaney/counterfact/milestone/5) milestones.
 
-Counterfact is **not** for **production**.
+Counterfact is (will be) a tool that helps front end developers and back end developers collaborate by quickly building reference implementations of [OpenAPI](https://www.openapis.org/) documents.
 
-Counterfact is **great** for **testing the UI**, both manual and automated testing. UI testing can be quite tedious and brittle in part because the _arrange_ part of Arrange, Act, Assert -- getting the server into a particular state -- is often slow, unpredictable, and complex. Counterfact bypasses the accidental complexity associated with arranging a real server's state, making tests that isolate the UI fast, repeatable, and easy to maintain.
+Counterfact makes it easy to build a **fake RESTful back end** to use when **testing the UI**, both manual and automated testing. UI testing can be quite tedious and brittle in part because the _arrange_ part of Arrange, Act, Assert -- getting the server into a particular state -- is often slow, unpredictable, and complex. Counterfact bypasses the accidental complexity associated with arranging a real server's state, making tests that isolate the UI fast, repeatable, and easy to maintain.
 
 It accomplishes that without over-isolating. You can run a full scenario: log in, make changes, log in with a different user, and see the effects of those changes. The server you're testing against is a _real_ server from the UI's perspective: it implements business logic and maintains state. It's only not real in the sense that it doesn't have to support 10,000 concurrent users, protect real data, go through a deployment pipeline, or maintain 99.99% uptime.
 
-Counterfact is **great** for **communication between front end and back end teams**. OpenAPI does an excellent job defining the inputs and outputs of a RESTful service, but it can't tell us how a server is supposed to behave. Counterfact fills that gap. The feedback cycle is orders of magnitude faster than making changes to a real, scalable back end and then seeing how those changes affect the UI/UX.
-
-## Road to 1.0
-
-This project is a work in progress. As of July 15, 2022, most of the plumbing is in place. This project will reach 1.0 when mutation test coverage is > 90% and:
-
-The following command
-
-```sh
-npx counterfact init ./my-petstore https://petstore3.swagger.io/api/v3/openapi.yaml
-```
-
-creates or augments an npm package, and after changing to the directory
-
-```sh
-cd my-petstore
-```
-
-the following command
-
-```
-yarn start
-```
-
-opens a browser with Swagger-UI pointing to a server that implements the pet store API.
-
-Not a mock server that returns canned or randomly generated responses, a [_real_ implementation](./demo-ts/), written in TypeScript.
-
-Or at least, the _potential_ for a real implementation. Out of the box, it will return example or random responses. But the code is easy to edit so, e.g., we can change `PUT /pet` to store a `Pet` in memory / a database / a microservice / etc., and `GET /pet` to retrieve that same `Pet`.
-
-We can see the effects of the code changes _without restarting the server_.
-
-And then after running
-
-```sh
-npm run counterfact generate /path/to/copy/of/petstore/with/some/changes.yaml
-```
-
-it regenerates the code except for the files that we changed. Of the files we changed, the IDE (via TypeScript and ESLint) tells us if the code needs to be modified to conform to the updated spec.
-
-_Again, without restarting the server._
-
-## Beyond 1.0
-
-The scenario above describes a zero-configuration happy path, adhering to the philosophy of convention over configuration.
-
-The next phase is to add configuration options that define things like what port the server runs on, when generated files are overwritten, where those files live, whether to add `start` and `generate` scripts and what they should be named, whether to build a soup-to-nuts server or only make Express / Koa / Connect middleware available, etc. These configurations may be set in a `.counterfactrc.json` file, environment variables, and or command line options, whatever makes sense.
-
-(By the way, generating code is optional. When this project started, that wasn't even part of the scope. If you don't have an OpenAPI spec, or don't want to use it, Counterfact is still an ergonomic platform on which to build quick and dirty prototypes of RESTful services in TypeScript or JavaScript.)
-
-After that, more features that improve developer experience. For example, say you're demoing an iOS music player app and someone asks about edge cases. You'll be able answer questions immediately by going into a REPL and typing things like:
-
-```ts
-context.addABunchOfSongsToCatalog(1_000_000);
-
-context.setAlbumTitle(
-  123,
-  "This Is is a Very Long Name That is Definitely Not Going to Fit in the Allotted Space and it has Emoji 🦧"
-);
-
-network.setLatency("1-5s");
-```
-
-## Installation
-
-```sh
-npm install --save-dev counterfact
-```
-
-or
-
-```sh
-yarn add -D counterfact
-```
-
-## Usage
-
-See [./docs/usage.md](./docs/usage.md)
-
-## Development Setup
-
-Looking to get involved? You can begin by cloning the project and using one of the following setup options.
-
-### Installation (devcontainer)
-
-> **BETA**: We are working through using a devcontainer for development. If you have [Docker](https://docker.com) on your machine and would like to give it a go and give us some feedback, please clone the repo, and then follow the instructions on the following page in place of the Installation instructions below.
->
-> - [Quick start: Open an existing folder in a container.](https://code.visualstudio.com/docs/remote/containers#_quick-start-open-an-existing-folder-in-a-container)
-
-### Installation (Original Method)
-
-Installation will set up Git hooks and install all of the Node packages.
-
-1. Make sure you have [Node 16](https://nodejs.org/en/) installed.
-2. Install yarn by running `npm install -g yarn`.
-3. Install the dependencies by running `yarn install`.
+Counterfact is **great** for **communication between front end and back end teams** and **prototyping workflows**. OpenAPI does an excellent job defining the inputs and outputs of a RESTful service, but it can't tell us how a server is supposed to behave. Counterfact fills that gap. The feedback cycle is orders of magnitude faster than making changes to a real, scalable back end and then seeing how those changes affect the UI/UX.
diff --git a/bin/counterfact.js b/bin/counterfact.js
index 4bf919d..76757e5 100755
--- a/bin/counterfact.js
+++ b/bin/counterfact.js
@@ -7,7 +7,7 @@ import { generate } from "../src/typescript-generator/generate.js";
 import { init } from "../src/typescript-generator/init.js";
 import { start } from "../src/start.js";
 
-// eslint-disable-next-line max-statements, sonarjs/cognitive-complexity
+// eslint-disable-next-line max-statements, sonarjs/cognitive-complexity, complexity
 async function main() {
   // eslint-disable-next-line prefer-destructuring
   const command = process.argv[2];
@@ -71,6 +71,20 @@ async function main() {
     return;
   }
 
+  if (command === "go" && process.argv.length === 5) {
+    const [, source, destination = "."] = process.argv
+      .slice(2)
+      .map((pathString) => nodePath.join(process.cwd(), pathString));
+
+    await generate(source, destination);
+
+    const basePath = nodePath.resolve(destination);
+
+    await start(basePath, 3100);
+
+    return;
+  }
+
   process.stdout.write("Usage:\n");
   process.stdout.write("  counterfact start [basePath] [port]\n");
   process.stdout.write("  counterfact generate [source] [destination]\n");
diff --git a/package.json b/package.json
index def56c4..c3ff507 100644
--- a/package.json
+++ b/package.json
@@ -30,9 +30,10 @@
     "prepare": "husky install",
     "lint": "eslint . --plugin file-progress --rule 'file-progress/activate: 1'",
     "lint:quickfix": "eslint --fix . eslint --fix demo-ts --rule=\"import/namespace: 0,etc/no-deprecated:0,import/no-cycle:0,no-explicit-type-exports/no-explicit-type-exports:0,import/no-deprecated:0,import/no-self-import:0,import/default:0,import/no-named-as-default:0\"",
-    "generate": "./bin/counterfact.js generate",
-    "generate:petstore": "yarn generate ./petstore.yaml demo-ts",
-    "generate:example": "yarn generate ./openapi-example.yaml demo-ts",
+    "generate:petstore": "yarn counterfact generate ./petstore.yaml out",
+    "generate:example": "yarn counterfact generate ./openapi-example.yaml out",
+    "go:petstore": "yarn counterfact go ./petstore.yaml out",
+    "go:example": "yarn counterfact go ./openapi-example.yaml out",
     "counterfact": "./bin/counterfact.js"
   },
   "devDependencies": {
diff --git a/src/counterfact.js b/src/counterfact.js
index 622da70..1bcb2b7 100644
--- a/src/counterfact.js
+++ b/src/counterfact.js
@@ -1,5 +1,6 @@
 import fs from "node:fs/promises";
 import nodePath from "node:path";
+import os from "node:os";
 
 import yaml from "js-yaml";
 
@@ -7,6 +8,7 @@ import { Registry } from "./registry.js";
 import { Dispatcher } from "./dispatcher.js";
 import { koaMiddleware } from "./koa-middleware.js";
 import { ModuleLoader } from "./module-loader.js";
+import { Transpiler } from "./transpiler.js";
 
 async function loadOpenApiDocument(source) {
   try {
@@ -16,6 +18,7 @@ async function loadOpenApiDocument(source) {
   }
 }
 
+// eslint-disable-next-line max-statements
 export async function counterfact(
   basePath,
   context = {},
@@ -25,10 +28,31 @@ export async function counterfact(
 
   const registry = new Registry(context);
 
+  const modulesPath = `${await fs.mkdtemp(
+    nodePath.join(os.tmpdir(), "counterfact-")
+  )}/`;
+
+  try {
+    await fs.writeFile(
+      nodePath.join(modulesPath, "package.json"),
+      '{"type": "module"}'
+    );
+  } catch {
+    throw new Error("could not write package.json");
+  }
+
+  const transpiler = new Transpiler(basePath, modulesPath);
+
+  await transpiler.watch();
+
   const dispatcher = new Dispatcher(registry, openApiDocument);
-  const moduleLoader = new ModuleLoader(basePath, registry);
+  const moduleLoader = new ModuleLoader(
+    nodePath.join(modulesPath, "paths"),
+    registry
+  );
 
   await moduleLoader.load();
+
   await moduleLoader.watch();
 
   return { koaMiddleware: koaMiddleware(dispatcher), registry, moduleLoader };
diff --git a/src/module-loader.js b/src/module-loader.js
index 8e4beeb..afbfec9 100644
--- a/src/module-loader.js
+++ b/src/module-loader.js
@@ -1,4 +1,5 @@
 import fs from "node:fs/promises";
+import { existsSync } from "node:fs";
 import nodePath from "node:path";
 import { once } from "node:events";
 
@@ -61,9 +62,14 @@ export class ModuleLoader extends EventTarget {
   }
 
   async load(directory = "") {
+    if (!existsSync(nodePath.join(this.basePath, directory))) {
+      throw new Error(`Directory does not exist ${this.basePath}`);
+    }
+
     const files = await fs.readdir(nodePath.join(this.basePath, directory), {
       withFileTypes: true,
     });
+
     const imports = files.flatMap(async (file) => {
       if (file.name.includes("#")) {
         return;
diff --git a/src/start.js b/src/start.js
index 50831ef..71bc11f 100644
--- a/src/start.js
+++ b/src/start.js
@@ -1,5 +1,3 @@
-import nodePath from "node:path";
-
 import Koa from "koa";
 import bodyParser from "koa-bodyparser";
 
@@ -15,10 +13,7 @@ export async function start(basePath = process.cwd(), port = DEFAULT_PORT) {
 
   app.use(bodyParser());
 
-  const { koaMiddleware } = await counterfact(
-    nodePath.join(basePath, "paths"),
-    context
-  );
+  const { koaMiddleware } = await counterfact(basePath, context);
 
   app.use(koaMiddleware);
 
diff --git a/src/transpiler.js b/src/transpiler.js
index e6a9771..d2e7cd0 100644
--- a/src/transpiler.js
+++ b/src/transpiler.js
@@ -26,13 +26,17 @@ export class Transpiler extends EventTarget {
   async watch() {
     this.watcher = chokidar.watch(`${this.sourcePath}/**/*.{js,mjs,ts,mts}`);
 
+    const transpiles = [];
+
     this.watcher.on("all", async (eventName, sourcePath) => {
       const destinationPath = sourcePath
         .replace(this.sourcePath, this.destinationPath)
         .replace(".ts", ".js");
 
       if (["add", "change"].includes(eventName)) {
-        this.transpileFile(eventName, sourcePath, destinationPath);
+        transpiles.push(
+          this.transpileFile(eventName, sourcePath, destinationPath)
+        );
       }
 
       if (eventName === "unlink") {
@@ -48,6 +52,7 @@ export class Transpiler extends EventTarget {
       }
     });
     await once(this.watcher, "ready");
+    await Promise.all(transpiles);
   }
 
   async stopWatching() {
@@ -63,14 +68,18 @@ export class Transpiler extends EventTarget {
       compilerOptions: { module: ts.ModuleKind.ES2022 },
     }).outputText;
 
-    await fs.writeFile(
-      nodePath.join(
-        sourcePath
-          .replace(this.sourcePath, this.destinationPath)
-          .replace(".ts", ".js")
-      ),
-      result
-    );
+    try {
+      await fs.writeFile(
+        nodePath.join(
+          sourcePath
+            .replace(this.sourcePath, this.destinationPath)
+            .replace(".ts", ".js")
+        ),
+        result
+      );
+    } catch {
+      throw new Error("could not transpile");
+    }
 
     this.dispatchEvent(new Event("write"));
   }
diff --git a/src/typescript-generator/init.js b/src/typescript-generator/init.js
index a540c4f..723110f 100644
--- a/src/typescript-generator/init.js
+++ b/src/typescript-generator/init.js
@@ -21,8 +21,12 @@ export async function init(source, destination) {
 
   specification.servers.unshift({ url: "/" });
 
-  await fs.writeFile(
-    nodePath.join(destination, "counterfact", "public", "openapi.yaml"),
-    yaml.dump(specification)
-  );
+  try {
+    await fs.writeFile(
+      nodePath.join(destination, "counterfact", "public", "openapi.yaml"),
+      yaml.dump(specification)
+    );
+  } catch {
+    throw new Error("Could not write openapi.yaml");
+  }
 }


Test patch:
diff --git a/test/counterfact.test.js b/test/counterfact.test.js
index 9305aa7..a4b0ae0 100644
--- a/test/counterfact.test.js
+++ b/test/counterfact.test.js
@@ -10,17 +10,17 @@ describe("integration test", () => {
     const app = new Koa();
     const request = supertest(app.callback());
     const files = {
-      "hello.mjs": `
+      "paths/hello.mjs": `
         export async function GET() {
           return await Promise.resolve({ body: "GET /hello" });
         }
       `,
-      "hello/world.mjs": `
+      "paths/hello/world.mjs": `
         export async function POST() {
           return await Promise.resolve({ body: "POST /hello/world" });
         }
       `,
-      "teapot.mjs": `
+      "paths/teapot.mjs": `
       export async function POST() {
         return await Promise.resolve({ status: 418, body: "I am a teapot." });
       }
@@ -56,10 +56,9 @@ describe("integration test", () => {
     };
 
     await withTemporaryFiles(files, async (basePath) => {
-      const { koaMiddleware, moduleLoader } = await counterfact(
-        `${basePath}/paths`,
-        { name: "World" }
-      );
+      const { koaMiddleware, moduleLoader } = await counterfact(basePath, {
+        name: "World",
+      });
 
       app.use(koaMiddleware);
 
@@ -102,7 +101,7 @@ describe("integration test", () => {
 
     await withTemporaryFiles(files, async (basePath) => {
       const { koaMiddleware, moduleLoader } = await counterfact(
-        `${basePath}/paths`,
+        basePath,
         { name: "World" },
         `${basePath}/openapi.yaml`
       );
diff --git a/test/transpiler.test.js b/test/transpiler.test.js
index e6a9937..8b811df 100644
--- a/test/transpiler.test.js
+++ b/test/transpiler.test.js
@@ -28,8 +28,6 @@ describe("a Transpiler", () => {
 
       await transpiler.watch();
 
-      await once(transpiler, "write");
-
       await expect(fs.readFile(path("dist/found.js"), "utf8")).resolves.toBe(
         JAVASCRIPT_SOURCE
       );
@@ -46,10 +44,7 @@ describe("a Transpiler", () => {
       async (basePath, { path, add }) => {
         transpiler = new Transpiler(path("src"), path("dist"));
 
-        const writeTheFirstFile = once(transpiler, "write");
-
         await transpiler.watch();
-        await writeTheFirstFile;
 
         const write = once(transpiler, "write");
 
@@ -100,7 +95,6 @@ describe("a Transpiler", () => {
       transpiler = new Transpiler(path("src"), path("dist"));
 
       await transpiler.watch();
-      await once(transpiler, "write");
       await remove("src/delete-me.ts");
       await once(transpiler, "delete");