Node.js version: 22.1.0.
Part one - the basics - is here: https://dev.to/camptocamp-geo/cli-application-with-the-nodejs-readline-module-48ic
Part two - going further - is here: https://dev.to/camptocamp-geo/cli-application-with-the-nodejs-readline-module-23-2ekg
Testing
A program is never complete without tests. In addition to unit tests, which are generally context-free, I tried to do a more global test. More or less an acceptance test for my CLI program. I was faced with two problems:
- How to read my console.
- How to simulate writing.
Read
To read what comes out of console.log
, or its sub-layer stdout.write
, it is by definition not possible to use readline. Otherwise, every output would be an input, which would be problematic. So we have to find a roundabout way. Here are two possibilities:
- Add a “debug” mode and associated methods to store the logs somewhere for access in tests. For example, in an object, or in a file, and in this case why not with a second readline instance.
- Overload “console.log” or “stdin.write” to redirect written values before continuing execution of the standard function. No need for a “debug” mode.
In my case, I used the second method. With vitest
and its spyOn
, it was easy and quite elegant, here's an extract:
import { test, describe, vi, beforeAll, expect } from "vitest";
import { stdin } from "node:process";
import { CliLearningCards } from "./cli-learning-cards.js";
const consoleSpy = vi.spyOn(console, "log");
const getLastLog = () => `${consoleSpy.mock.lastCall}`;
/** @returns n previous log */
const getReverseLog = (reverseIndex: number) => {
const n = consoleSpy.mock.calls.length - reverseIndex;
return `${consoleSpy.mock.calls[n]}`;
};
...
describe("Cli-learning-card", () => {
beforeAll(() => {
consoleSpy.mockClear();
});
...
Simulate writing
In theory, it's simple. Here's a quick summary:
- stdin is unique per process.
- In my case, the test process is the same one running my program.
- I can give
stdin
as input to myReadline
interface. - And if I write
stdin.emit('keypress', null, {name: 'enter'})
, my program receives my “enter” event.
However, in my test, if I create a readline
interface, and write rl.write(“a sentence”)
, it never reaches my program. Worse still, if I add:
// Inside my program
getRl(): readline.Interface {
this.rl;
}
// Inside my test
const mySoft = new MySoft();
mySoft.getRl().write(“hello\n”); // Does nothing :-(
And if I use it to write my test, it doesn't work.
On the other hand, and even less elegantly, if I add a function to my program to write directly:
// Inside my program
write(line: string) {
this.rl.write(`${line}\n`);
}
// Inside my test
const mySoft = new MySoft();
mySoft.write(“hello”); // It works !!?
In this case, it works. It's not clean, but I can simulate writing from my test. And I currently have no idea why it works in this case, but not in the previous one. Comments welcome!
Debugging
It's easy to debug your application with node --inspect
and a Webkit browser. On the other hand, seeing the results can be complicated if your program regularly cleans the terminal. If you don't understand the results displayed, I advise you:
- Temporarily comment out your terminal-cleaning method.
- Or redirect your “console.log” to a file.
Multi stream
Events
As I was using several readline interfaces, I sometimes couldn't understand why my application wasn't reacting to my events. Always remember that each instance has its own listeners, including streams of course.
Close events and stdin/stdout
If several of your streams are linked to stdin, don't forget to handle the stream stop events in each stream. If only one is not managed, the stdin/stdout streams will be cut, and all your readline interfaces will stop. So, if your program is active as long as your interfaces are active, then your program will stop if you forget to handle a stop event for one of your readline interfaces.
CLI and the “I” problematics
In CLI , there's the I for interface. And making a beautiful, modular, responsive, clean interface can take a lot more time than coding the logic of your program. Here are some of the difficulties you'll have to face:
- It's all text, and only text. But the good news is that any character always takes the same space.
- It may take two characters in a string to form a visual character.
- Color is also some characters in a string, but does not affect the length of the printed text.
- Users can resize their terminal at any time (node.js can handle that with events and
stdout.columns
,stdout.lines
).
For a simple program, it's possible to manage these cases in a reasonable time. If you want to go further, I advise you to turn to external libraries. For example (this list is obviously not intended to be exhaustive):
- Colors and text management: https://github.com/chalk/chalk and all the sub-librairies they have
- Commands argument formatter and parser: https://github.com/tj/commander.js
- Nice prompts and more: https://github.com/terkelg/prompts
- Another prompt-and-more tool https://github.com/enquirer/enquirer
- Advance CLI modules: https://github.com/chjj/blessed
- Advanced CLI, but with React: https://github.com/vadimdemedes/ink
And many, many more. The aim here was to talk about Readline, which I think gives you a good basis for managing, understanding and using these CLI libraries, and creating your own program.
Top comments (0)