Introduction #

This article is an introduction to using Ink v3.2.0 (with TypeScript and React) to create CLI apps. IDEA Ultimate / Webstorm project files are provided.

⚠️ This isn’t a reference for: React, Node.js, TypeScript, CSS #

Learn more about these topics on developerlife.com:

Resource Notes
Node.js handbook Learn Node.js w/ TypeScript
React Hooks handbook Learn React, Redux, and Testing w/ TypeScript
CSS handbook Learn CSS, grid, flexbox, to implement responsive design

What is Ink #

Ink is a React renderer written in TypeScript using the react-reconciler package. So instead of rendering React components to the DOM Ink actually renders them to a terminal based UI.

With it, you can make CLI apps that look like this:

Ink supplies its own host UI components (eg: <Text>) which has nothing to do w/ react-dom host components that come out of the box (eg: <img>) w/ React.

Ink supplied host UI components are rendered to a terminal based UI by using:

  1. console streams to manipulate the output to the console, and input from the console.
  2. Yoga to implement CSS flexbox layout and positioning. Yoga is written in C++ that allows flexbox layout itself to be implemented in various platforms (like Lithio on Android, Node.js, .Net, etc).
  3. You can take a look at the dependencies that Ink has in its package.json. It relies on packages like: chalk, cli-boxes, cli-cursor, yoga-layout-prebuilt, react-reconciler, react-devtools-core.

React core, renderer, reconciler #

The following is an overview of React to understand custom renderers like Ink.

React core only includes APIs that are needed to define components. They don’t have any platform specific code or diffing algorithm. Things like React.createElement(), React.createClass(), React.Component, React.Children, and React.PropTypes are defined here.

Renderers are needed to transform React component trees into DOM nodes in a browser (react-dom), or native platform views (React Native).

A React component tree is made up of host and composite components.

  1. host components are platform specific components that belong to the host environment, eg: browser / DOM, React Native, React Canvas, React PDF, React Docx, React Hardware etc.
  • For a browser / DOM host these could be things like div or img (aka regular DOM nodes). They typically begin with lower-case in the case of react-dom.
  • For a React Native app on Android these could be things like Text or View that map to the Android View hierarchy.
  1. composite components are user-defined components that you write in React. These are things that you write using React like <MyButton>, <MyContent>, etc.

The reconciler is a diffing algorithm that helps React figure out what host components to update on a state change. The Fiber reconciler is the default since React v16. Fiber reconciler can perform the reconciliation of the tree by splitting work into minor chunks and hence can prioritize, pause and resume work thus freeing up the main thread to perform work efficiently.

Ink provides its own custom renderer and host components that you can use.

💡 Resources to help you create your own renderer #

Resource Notes
Video Build your own renderer & learn how React works under the hood
Tutorial Build your own renderer & learn how React works under the hood
Video Understand React Fiber Reconciler

Create Ink project with TypeScript #

Ink provides the equivalent of create-react-app for its projects called create-ink-app. You can run it via npx to generate a TypeScript “scaffold” project using the following commands.

$ mkdir ink-cli-app
$ cd ink-cli-app
$ npx create-ink-app --typescript

This will create quite a few files and folders that form the scaffolding. You can also add a .gitignore file that contains the following.

node_modules
package-lock.json
dist

A lot of important things happen when the scaffolding is created by running create-ink-app. Here are some of highlights of the files that are generated and the npm global configuration that is changed.

⚠ If you don’t know what symlinks are, please read this before continuing.

1. 📄 package.json #

This file names the module, pulls in all the dependencies for the Ink app, and sets up the module to be executable.

name:

  • The name of the folder in which you run npx create-ink-app is used for the value of this property. In this case it is ink-cli-app.

devDependencies:

  • Notably it pulls in xo for linting, and ava for testing.
  • And TypeScript as expected.

dependencies:

  • Notably it pulls in meow which does command line argument parsing gets added.
  • And react and ink as expected.

bin:

  • This module is executable since the bin property value is dist/cli.js. You can run this module by executing this command in a terminal (in the folder where package.json resides).

    npm exec -c 'ink-cli-app --name=Grogu'
    

    In the command above, ink-cli-app refers to the value of the name property. Please refer to the npm link and bin section for more information on executable modules.

    ⚠ If you define your bin property value (in package.json) to be a JS file, eg: dist/cli.js then it is imperative that this file be marked executable on Linux or MacOS. Also this JS file needs to have a header that tells the OS that it can be run, eg: #!/usr/bin/env node.

scripts:

  • You can run the start script in order to run the module. This script builds the module and then runs the same file path that’s specified in bin property (dist/cli.js). Here’s how you can pass command line arguments to this script.

    $ npm run start -- --name=Grogu
    
  • You can run the test script in order to run the tests included in this module using xo and ava.
  • You can run the build script in order to run tsc and compile the TypeScript files to JS and dump them in the dist folder (as specified in tsconfig.json).

files:

  • This property acts as a whitelist (and we don’t have an .npmignore file, which acts as a blacklist) which tells npm which classes to included when publishing this module. The value is the same as bin property, which just means that any dependent classes should be included as required source for this module to operate.

engines:

  • This just says that any Node.js v10.x or later runtime is acceptable.

The create-ink-app runs npm link in the folder that has package.json file, which allows you to run ink-cli-app from your terminal (in any folder). This does two “things”.

  1. “Thing” #1 🧙 - List of globally installed modules updated

    If you run npm list -g you will find a new entry for your module has been added to the list of modules that are globally installed via npm on your machine.

     $ npm list -g | lolcat
    

    On my machine it looks like this.

    /home/linuxbrew/.linuxbrew/lib
     all-the-package-names@1.3905.0
     doctoc@2.0.1
     ink-cli-app@0.0.0 -> ./../../../nazmul/github/ts-scratch/ink-cli-app
     npm@8.1.0
     prettier@2.4.1
     ts-node-dev@1.1.8
     ts-node@10.2.1
     typescript@4.4.3
    

    As you can see an entry has been created for ink-cli-app and added to the list of all the globally installed npm modules.

    Here are some notes on this.

    1. To remove this entry run npm uninstall -g ink-cli-app (which we aren’t going to do).

    2. Each entry in this list has a corresponding folder in the global node_modules folder which we get into next.

  2. “Thing” #2 🧙 - Two symlinks created for your module

    The first symlink has been created in the global node_modules folder pointing to the folder in which our module resides.

    We can find where this global node_modules folder is located by running npm root -g.

    This is what it looks like on my machine.

    $ npm root -g
    /home/linuxbrew/.linuxbrew/lib/node_modules
    

    I run the following command to see what the contents of this folder are.

    lsd -1 (npm root -g) | lolcat
    

    The ink-cli-app symlink is pointing to the actual folder (/home/nazmul/github/ts-scratch/ink-cli-app) where the node module resides.

    all-the-package-names
    doctoc
    ink-cli-app  ../../../../nazmul/github/ts-scratch/ink-cli-app
    npm
    prettier
    ts-node
    ts-node-dev
    typescript

    One more symlink is created and this one has everything to do with being able to run ink-cli-app from any folder in a terminal (or a GUI app).

    How does Node.js know to execute ink-cli-app when run from a terminal or GUI app? 🤔 After all this global node_modules folder is not in my $PATH.

    The answer is a little tricky and before we begin our journey to answering this question, here’s what some Node.js and npm related folders look like on my machine.

    Type Path
    Node.js location in $PATH /home/linuxbrew/.linuxbrew/bin/
    Symlinks created by npm link /home/linuxbrew/.linuxbrew/bin/
    Global node_modules /home/linuxbrew/.linuxbrew/lib/node_modules/
    Actual ink-cli-app folder /home/nazmul/github/ts-scratch/ink-cli-app/

    Here are two things that happen to allow ink-cli-app to be run from anywhere.

    1. When we install Node.js (via brew or nvm or apt or whatever) we have to add the folder containing the node and npm binary into the $PATH environment variable.

      • For me this is /home/linuxbrew/.linuxbrew/bin/ since I’m using brew.

      • Since I’m using GNOME, I added this folder to my $PATH in my $HOME/.profile file, making it available to terminal and GUI apps after I log into my desktop GNOME session.

    2. npm link cleverly creates a symlink in this folder (which is accessible by $PATH) that points to the file specified by the bin property of the package.json for the ink-cli-app node module. Thus allowing us to be able to run this CLI app from any folder in a terminal, or any GUI app! 🎉

    When I look in my /home/linuxbrew/.linuxbrew/bin, I find this ink-cli-app symlink.

    $ cd /home/linuxbrew/.linuxbrew/bin
    $ lsd -1 | grep ink | lolcat
    

    Which produces this output.

    ink-cli-app  ../lib/node_modules/ink-cli-app/dist/cli.js

    This is how we read this: the 🔗 ink-cli-app symlink in this folder (📂 .linuxbrew/bin) is 👉 pointing to the symlink 🔗 ink-cli-app in the global 📂 node_modules folder, which itself is 👉 pointing to the actual module folder (📂 github/ts-scratch/ink-cli-app) which contains a 📄 dist/cli.js file (which is specified in the bin property in package.json).

    Whew! 😅

    💡 For more details on how npm link works along w/ executable modules in npm, please read this section.

3. 📄 dist/cli.js #

The 📄 dist/cli.js file is marked as executable. This is a really important step because this file is actually used in the bin entry in package.json and tells Node.js that this is a binary executable file that will launch this module when run from the command line. Also this JS file needs to have a header that tells the OS that it can be run, eg: #!/usr/bin/env node.

⚠ Note that the dist/cli.js file is compiled from the TypeScript file source/cli.tsx. The build script generates this file and its not checked into git.

4. 📄 tsconfig.json #

The 📄 tsconfig.json tells tsc to dump the compiled JS files to the dist/ folder.

  • This is why we ignore dist/ in our .gitignore file above.
  • You can run tsc directly or run npm run build to generate the JS files.

5. 📂 source/ #

The 📂 source/ folder has three files, the main one being cli.tsx.

  • 📄 cli.tsx - This is the main entry point that launches our app from the command line using Node.js. If you run npm run start then this file is executed (after the build script runs).

  • 📄 ui.tsx - This is where you write your React code. App is defined here.

  • 📄 test.tsx - This is what gets run when npm run test is executed (which runs xo and ava).

6. 📄 readme.md #

The 📄 readme.md file is auto generated and has some information on how to launch your newly minted CLI app. It basically says that you run the following commands.

ink-cli-app --help
ink-cli-app --name=Grogu

The following UML diagram that pulls all this together, and illustrates everything we have covered in the following sections: What is Ink, React core, renderer, reconciler, and Install Ink and create a project scaffold in TypeScript.

  1. You can see the dependencies that source/cli.tsx (which is compiled into cli.js) pulls in. This is the main entry point of the module (the bin property in package.json).
  2. The render function from ink is used, since we are using Ink to render the JSX and not react-dom.
  3. meow is used to parse the command line arguments.

7. 📄 .editorconfig #

This file sets the default indentation character to tab, and width to 4. I replace it w/ spaces and 2. Then I had to reformat the code in each of the files in source.

root = true

[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 2

Resolving permissions problems on Linux #

When using create-ink-app on Pop_OS! 21.04 I experienced some very strange issues when trying to run the CLI app using npm run start, or even ink-cli-app. I got a sh: 1: node: Permission denied error when running either of these commands 🤔.

This led to some wild goose chasing which resulted in a deeper understanding of executable Node.js modules, npm config, execution permissions, and such. So the next section is dedicated to background knowledge of all these things and might be useful for deeper understanding or debugging when you run into issues.

Ultimately I solved it by re-running the npx create-ink-app command, which did the following things.

  • It regenerated the dist.
  • It marked the cli.js as executable (the original problem was that this wasn’t marked as executable).
  • Then it linked the module to npm via npm link, so that the ink-cli-app command would run on my terminal.

This section goes into the details of how npm global modules work, what npm bin entry means in package.json, and what executable node modules actually are (you’re actually making one in this handbook).

💡 To learn more about installing and configuring Node.js itself, check out this section in the Node.js handbook. This will walk you thru where Node.js stores its files and modules, etc.

Let’s consider the high level uses cases that you’ve already used npm for.

  1. Install dependencies (eg: npm i react).
  2. Install applications and run them (eg npm i -g prettier; prettier --help) or just run them w/out installing first (eg: npx create-react-app).

npm is powerful (and can be confusing) because it serves 2 roles:

  1. Allowing you to manage (create, publish, export, and import) dependencies (modules) for code that you write (eg: npm i react). To learn more about publishing npm packages, check out this section in the Node.js handbook.
  2. Allowing you to create and use command line apps that are run from your terminal (eg: npx create-react-app or prettier --write *md). 👈 We will focus on this use case and how it works.

What is an executable module? #

To make command line apps and distribute them via npm, so that our users can install them and run them via npm, we have to create not just any node module, but an executable one.

⚠ We are going to use the sample app we are working on started here called ink-cli-app. Before reading further:

  1. Please read this section to understand what package.json file does and what npm link does.
  2. In order to understand how Node.js executes programs, please read this section in the Node.js handbook.

So how does a module become executable?

In package.json you can define a bin property. When someone tries to “run” your node module from the terminal or GUI app, Node.js will attempt to execute the the file path in bin property. Here more details on this.

  • You can simply define a string value for bin, which is a file path where the executable of your node module resides (eg: dist/cli.js).
  • This file can be a JS file or some other kind of binary executable file. Think of marking a file executable in your terminal and then running it, whether it’s a JS file, .fish file, etc.
    • This file will also need to be marked as executable.
    • It must have a shebang header like #!/usr/bin/env node, so that your OS knows how to execute this file.

Next, we are going to look at how to run this executable module! Here is a brief overview of what we will see next.

  1. How npm link works to allow this module to be executed from anywhere on our machine.
  2. How npm exec -c works to allow this module to only be executed from the folder in which it resides.

My Node.js configuration #

Here are all the interesting folders on my machine that are related to the npm things we will dive into next. I used brew to get Node.js on Pop_OS! 21.04 (based on Ubuntu).

Type Path
Node.js location in $PATH /home/linuxbrew/.linuxbrew/bin/
Symlinks created by npm link /home/linuxbrew/.linuxbrew/bin/
Global node_modules /home/linuxbrew/.linuxbrew/lib/node_modules/
Actual ink-cli-app folder /home/nazmul/github/ts-scratch/ink-cli-app/

Node.js & $PATH

  • I installed Node.js via brew.
    1. I manually added /home/linuxbrew/.linuxbrew/bin to my $PATH
    2. I put this in ~/.profile (for GNOME, so I can run npm modules from any terminal or GUI app).
  • node and npm binaries themselves are a symlinked in this folder which is how node is found along w/ npm binaries when I use them in the terminal.

Symlinks created by npm link

  • All the symlinks npm link creates show up in this folder /home/linuxbrew/.linuxbrew/bin/ (which is also in the $PATH).
  • This is where symlinks for prettier and even ink-cli-app are stored.

Global node_modules

  • Node.js stores globally installed modules in this this folder like prettier.
  • For me this is /home/linuxbrew/.linuxbrew/lib/node_modules.

Installing a module globally and then executing it #

Before going over our executable module (ink-cli-app), let’s use an existing example of prettier.

To install it, you have to run npm i -g prettier. It downloads the module and creates a symlink prettier which you can use in the terminal and GUI apps.

💡 You can find what modules are installed globally by running npm list -g. You can uninstall the module by running npm uninstall -g <MODULE_NAME>.

  1. prettier is downloaded to the global node_modules folder. So in /home/linuxbrew/.linuxbrew/lib/node_modules/prettier/ the files are downloaded from npm.

    $ cd /home/linuxbrew/.linuxbrew/lib/node_modules
    $ realpath prettier | lolcat
    

    Which produces the following output.

    /home/linuxbrew/.linuxbrew/lib/node_modules/prettier
  2. A symlink is created for prettier in the /home/linuxbrew/.linuxbrew/bin/ folder which points to the file specified in the bin property in the global node_modules/prettier/package.json folder.

    $ cd /home/linuxbrew/.linuxbrew/bin
    $ lsd -1 prettier | lolcat
    

    This produces the following output.

    prettier  ../lib/node_modules/prettier/bin-prettier.js
    

Creating your own executable module #

We are going to use the sample app we are working on started here called ink-cli-app.

⚠ Please read this section before moving on to understand what package.json file does and what npm link does as well.

To recap, npm create-ink-app does the following:

  • A package.json file is created here w/ a bin property defined, which is the dist/cli.js file. This JS file is also marked executable.
  • npm link is run in this module’s folder which creates a symlink ink-cli-app.

Let’s take a closer look at the ink-cli-app symlink itself (which is generated by npm link) using the following commands. This symlink is in my $PATH.

$ cd /home/linuxbrew/.linuxbrew/bin
$ lsd -1 ink-cli-app | lolcat

This produces the following output.

ink-cli-app  ../lib/node_modules/ink-cli-app/dist/cli.js

We can simply run the CLI app by running ink-cli-app from the terminal as shown here.

$ ink-cli-app --name=Grogu

Which results in the following output.

Hello, Grogu

⚡ This tutorial goes into the details of how Node.js symlinks work for globally installed modules and local ones using npm link.

Running this module using npm exec -c in the module’s folder #

However, we might also want to run it w/out using the symlink above. To do this we can use npm exec -c in the module folder itself.

$ cd /home/nazmul/github/ts-scratch/ink-cli-app/
$ npm exec -c 'ink-cli-app --name=Grogu'

Which results in the following output.

Hello, Grogu

⚡ Here’s more information on npm exec -c.

💡 We can also run npm link in this module’s folder in order to generate a symlink to the bin property value.

Specify more than one executable in bin property #

If you want to specify more than a single executable for your module, you can provide an object rather a string value for bin. Here’s an example.

{
  "bin": {
    "myClientApp": "./cli.js",
    "myServerApp": "./server.js"
  }
}

So if you wanted to run myServerApp you can call.

npm exec -c 'myServerApp --port=1234'

💡 When you run npm link it will create multiple symlinks one for each key-value pair in bin. These symlinks are stored in /home/linuxbrew/.linuxbrew/bin/ in my brew install of Node.js.

Meta example #

A “meta” example of all of the concepts above is the create-ink-app npm module itself.

  1. You can run it via npx without installing it locally.
  2. After you run it, it will globally install your CLI app via npm and mark the dist/cli.js file executable. This JS file is the main entry point for your newly minted CLI app.

npm install -g #

When you tell npm to install a module globally, it will actually install it in the global folder (which is different depending on how you installed Node.js in the first place). It will also create a symlink for the bin entry in package.json using the value of name. This will allow you to run this module from the terminal (for eg: prettier or doctoc).

To find out where npm actually installs global modules, run the following command.

npm root -g

To list all the globally installed modules, you can run the following command.

npm list -g

Conversely to uninstall a module globally, you can use the following command.

npm uninstall -g <PACKAGE_NAME>

npm files #

This isn’t really related to the bin property in package.json. The files property tells Node.js that all the files listed in this array should be whitelisted and included in the module. So provide at least one entry point into your code, typically, the same entry as the bin property. You can bypass using this property by using a .npmignore blacklist file.

npm config #

npm stores global configuration as well as user specific configuration settings. These are stored as key-value pairs using JSON. In order to show the list of npm config key-value pairs use the following.

npm config ls -l

The user specific settings are stored in $HOME/.npmrc.

Executing the following command will change a user default config key’s value and will update the $HOME/.npmrc file w/ this value (and create the file if it didn’t exist before).

npm config set unsafe-perm true

You can then check to see the value of this key using.

npm config get unsafe-perm

If you delete the $HOME/.npmrc file, then this will remove all the key-value pairs that you have set using npm config set <KEY> <VALUE>.

The unsafe-perm setting actually affects which user is actually used by Node.js when running modules. You can read more about it in these links.


Make major changes to create-ink-app v2.1.1 scaffolding #

We are now going to majorly deviate from code that was generated by the scaffolding.

  1. Notably, we are going to drop ava v4.x for testing and use RTL and Jest instead.
  2. We are going to drop xo (no need for this linter package).
  3. We are going to drop meow and use commander instead.
  4. We are also going to change some folder names.

The following are the details.

⚡ Use this template repo instead of create-ink-app #

I’ve created a GitHub template repo that we are going use in future articles instead of create-ink-app. Here’s a link to ts-ink-template repo; all the changes shown in this section are reflected in this repo.

Rename source to src #

The create-ink-app generates a source folder, but I prefer to use src. To make this change:

  1. Rename the folder (mv source src).
  2. Update references to source in package.json and tsconfig.json (sed -i s/source/src/g *json).

Update a few dependencies #

In package.json we are going to update the following dependency meow to ^10.0.0.

💡 You can run npm outdated to see a what package in your package.json can be updated. Then you can update them by typing npm i <PACKAGE>@latest.

Here’s the new package.json.

{
  "name": "ink-cli-app",
  "version": "0.0.1",
  "license": "Apache 2.0",
  "bin": "dist/cli.js",
  "engines": {
    "node": ">=10"
  },
  "scripts": {
    "build": "tsc",
    "build-watch": "tsc --watch",
    "start": "dist/cli.js",
    "build-and-start": "npm run build && dist/cli.js",
    "pretest": "npm run build",
    "test": "jest",
    "start-watch": "nodemon --exitcrash -e ts,tsx,js,jsx --exec 'node dist/cli.js || exit 1'",
    "dev": "npm-run-all -p -r build-watch start-watch"
  },
  "files": ["dist/cli.js"],
  "dependencies": {
    "chalk": "^4.1.2",
    "commander": "^8.3.0",
    "ink": "^3.2.0",
    "r3bl-ts-utils": "^1.0.5",
    "react": "^17.0.2",
    "tslib": "^2.3.1"
  },
  "devDependencies": {
    "@sindresorhus/tsconfig": "^2.0.0",
    "@types/jest": "^27.0.2",
    "@types/react": "^17.0.34",
    "eslint-plugin-react": "^7.27.0",
    "eslint-plugin-react-hooks": "^4.3.0",
    "ink-testing-library": "^2.1.0",
    "jest": "^27.3.1",
    "nodemon": "^2.0.15",
    "npm-run-all": "^4.1.5",
    "prettier": "^2.4.1",
    "ts-jest": "^27.0.7",
    "ts-node": "^10.4.0",
    "typescript": "^4.4.4"
  }
}

A lot of scripts have been added, to make it easy to work w/ building, running, testing and watching. Here’s more information on this from the template repo which you should be using instead of create-ink-app.

Break all the things 💣 - Switch TS strict mode & commander #

This is going to be a huge divergence to the configuration and code that’s generated by the scaffolding. We will use CommonJS as the module format for maximum backward compatibility (ESM isn’t there yet).

  1. Here are the changes that need to be made to tsconfig.json.

    {
      "extends": "@sindresorhus/tsconfig",
      "compilerOptions": {
        "esModuleInterop": true,
        "outDir": "dist",
        "sourceMap": true,
        "target": "ESNext",
        "//ESNext": "https://stackoverflow.com/a/62837086/2085356",
        "module": "CommonJS"
      },
      "include": ["src"]
    }
    
  2. Here are the changes that need to be made the src/cli.tsx file.

    In cli.tsx the call to meow() has to be changed to use commander.

    #!/usr/bin/env node
    
    import React from "react"
    import { render } from "ink"
    import App from "./ui"
    import { _let } from "r3bl-ts-utils"
    import { Command } from "commander"
    
    const name: string = _let(new Command(), (command) => {
      command.option("-n, --name <name>", "name to display")
      command.parse(process.argv)
      const options = command.opts()
      return options["name"]
    })
    
    render(<App name={name} />)
    
  3. Here is a change that need to be made to the src/ui.tsx file.

    There’s a module.exports = App line that needs to be deleted. We are now only using export default App.

Drop support for xo #

By running npm uninstall -D xo we drop the linter dependency.

Drop support for ava #

By running npm uninstall -D ava @ava/typescript we drop support for ava test runner. Delete thesrc/test.tsx file as well, since it will no longer compile.

Use Jest for testing #

We will use Jest to add tests to our node module. To start here are the packages that need to be installed.

$ npm i -D jest ts-jest ts-node @types/jest

Then we need a new jestconfig.json file.

{
  "#comments": [
    "https://jestjs.io/docs/configuration#projects-arraystring--projectconfig",
    "https://jestjs.io/docs/configuration#testenvironment-string",
    "https://gist.github.com/thebuilder/15a084f74b1c6a1f163fc6254ad5a5ba"
  ],
  "projects": [
    {
      "displayName": "node  (default project)",
      "transform": {
        "^.+\\.(t|j)sx?$": "ts-jest"
      },
      "testEnvironment": "node",
      "testMatch": ["**/__tests__/**/*.test.ts?(x)"]
    },
    {
      "displayName": "jsdom (browser project)",
      "transform": {
        "^.+\\.(t|j)sx?$": "ts-jest"
      },
      "testEnvironment": "jsdom",
      "testMatch": ["**/__tests__/**/*.test.jsdom.ts?(x)"]
    }
  ]
}

💡 We have setup two projects in the jestconfig.json file above. Depending on the code we want to test, it might expect to run in a browser environment or in node. By default tests will run in the node test environment. If a test is named *.test.jsdom.ts(x) then it will be run in a jsdom test environment (which emulates a browser environment w/ a pure JS implementation of DOM and BOM that is headless).

Finally, here’s a simple test ui.test.ts that uses ink-testing-library to create the UI test.

import React from "react"
import chalk from "chalk"
import { render } from "ink-testing-library"
import App from "../ui"

describe("ink test suite", () => {
  test("greet unknown user", () => {
    const { lastFrame } = render(React.createElement(App, null))
    expect(lastFrame()).toEqual("Hello, \u001b[32mStranger\u001b[39m")
  })

  test("greet user with a name", () => {
    const { lastFrame } = render(React.createElement(App, { name: "Jane" }))
    expect(lastFrame()).toEqual(chalk`Hello, {green Jane}`)
  })
})

⚡ Here’s a commit that adds all the testing stuff.

Related Posts