Skip to content
aviral gupta

// B5.5 · ~51 min · Beginner

Build: a tested to-do CLI

In this build you write a to-do CLI that reads commands typed line by line, keeps the list in a class that emits events, and proves with node:test that it works.

Lesson 5 of 5 in B5 Events, input and first tests

End of the module

You will be able to

  • Split a typed line into a command and its argument, and check the argument before using it
  • Write TodoList as an EventEmitter that emits added and done, and let listeners do the printing
  • Run the commands in a readline loop, and test the pieces and the session with node:test
  1. Warm-up · Activity 1 of 7

    Warm-up from B5.1: what does this program print?

    import {EventEmitter} from 'node:events';
    
    const list = new EventEmitter();
    list.on('added', (text) => console.log('added', text));
    console.log('before');
    list.emit('added', 'tea');
    console.log('after');
  2. Predict · Activity 2 of 7

    Predict before you read on. A first try at splitting a typed line into a command and its text. What does it print?

    const line = 'add buy milk';
    const [command, arg] = line.trim().split(' ');
    console.log(arg);
  3. Practice · Activity 3 of 7

    Fill in the class that TodoList extends, so that it has on and emit.

    export class TodoList extends ____ {
    export class TodoList extends {
  4. Practice · Activity 4 of 7

    The finished CLI reads these lines in turn, starting with an empty list. Match each typed line to what it prints after the prompt.

  5. Practice · Activity 5 of 7

    Here the two 'done' listeners are added the other way round. todo.js holds the finished TodoList. What does the program print?

    import {TodoList} from './todo.js';
    
    const list = new TodoList();
    list.on('done', () => {
      if (list.items.every((item) => item.done)) console.log('all done!');
    });
    list.on('done', (item, n) => console.log('done ' + n + ': ' + item.text));
    list.add('tea');
    list.done('1');
  6. Brain teaser · Activity 6 of 7

    Brain teaser. This loop adds its listener inside the loop. The input is add a, add b and add c, one per line. How many lines starting with added does it print?

    import {createInterface} from 'node:readline/promises';
    import {TodoList, parseCommand} from './todo.js';
    
    const list = new TodoList();
    const rl = createInterface({input: process.stdin});
    for await (const line of rl) {
      const {command, arg} = parseCommand(line);
      list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text));
      if (command === 'add') list.add(arg);
    }
  7. Apply · Activity 7 of 7

    Mini-task. Put steps 1 to 3 in todo.js and the session in main.js, with "type": "module" and a "test": "node --test" script in package.json. Write todo.test.js with at least three tests: one for parseCommand, one for findTodo with a number that is too big, and one that collects the done events. Run npm test. Then write the commands of a session into session.txt and run node main.js < session.txt.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The same shape, a different job: a scoreboard

Before you build the to-do CLI, see its shape in another program. Scoreboard extends EventEmitter and emits 'scored' for every point and 'lead' when someone takes the lead; the listeners print. A readline loop reads commands, catches errors and stops at quit. The commands are piped in, so they are not shown: each reply follows its prompt on the same line.

main.js

import {EventEmitter} from 'node:events';
import {createInterface} from 'node:readline/promises';

// The same shape as the to-do CLI, for another job: keeping score in a game.
class Scoreboard extends EventEmitter {
  constructor() {
    super();
    this.points = new Map(); // name -> points
    this.leader = '';
  }

  point(name) {
    if (name === '') throw new Error('point needs a name, such as point Ada');
    const total = (this.points.get(name) ?? 0) + 1;
    this.points.set(name, total);
    this.emit('scored', name, total);
    if (name !== this.leader && total > (this.points.get(this.leader) ?? 0)) {
      this.leader = name;
      this.emit('lead', name);
    }
  }
}

const board = new Scoreboard();
board.on('scored', (name, total) => console.log(name + ' has ' + total));
board.on('lead', (name) => console.log(name + ' takes the lead'));

const rl = createInterface({input: process.stdin, output: process.stdout});
let open = true;
rl.on('close', () => (open = false));
rl.setPrompt('score> ');
rl.prompt();
for await (const line of rl) {
  const [command = '', name = ''] = line.trim().split(/\s+/);
  if (command === 'quit') break;
  try {
    if (command === 'point') board.point(name);
    else if (command === 'show') console.log([...board.points].map(([who, n]) => who + ' ' + n).join(', '));
    else if (command !== '') console.log('unknown command: ' + command);
  } catch (error) {
    console.log(error.message);
  }
  if (open) rl.prompt();
}
console.log('final leader: ' + (board.leader || 'nobody'));

input.txt

point Ada
point Lin
point Lin
point
show
jump
quit

Run it with

node main.js

Output

score> Ada has 1
Ada takes the lead
score> Lin has 1
score> Lin has 2
Lin takes the lead
score> point needs a name, such as point Ada
score> Ada 1, Lin 2
score> unknown command: jump
score> final leader: Lin
  • One point can print two lines: Lin's second point emitted 'scored', then 'lead'.
  • point without a name threw an Error; the loop caught it, printed its message and went on.
  • quit ended the loop with break; the line after the loop printed the final leader.
  • Scoreboard prints nothing itself, so a test can count its events without reading the screen.

Exercises

Exercise 1 of 5

Step 1: parse a typed line

Write parseCommand(line). Trim the line. The command is the text up to the first space, in small letters; arg is everything after that space, trimmed, with its inner spaces and its capital letters kept. A line with no space is all command, and arg is ''. An empty line gives {command: '', arg: ''}. So ' Add Call Ada ' gives {command: 'add', arg: 'Call Ada'}.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Start with const text = line.trim(); then const space = text.indexOf(' '); which is -1 when there is no space.

  2. Hint 2

    No space: return {command: text.toLowerCase(), arg: ''}.

  3. Hint 3

    Otherwise text.slice(0, space) is the command and text.slice(space + 1).trim() the arg; lower-case only the command.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

console.log(parseCommand('add buy milk'));
console.log(parseCommand('  DONE 2 '));
console.log(parseCommand('list'));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  return {command: line, arg: ''};
}

console.log(parseCommand('add buy milk'));
console.log(parseCommand('  DONE 2 '));
console.log(parseCommand('list'));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {parseCommand} from './main.js';

const check = (line, expected) => {
  const got = parseCommand(line);
  assert.deepEqual(got, expected, 'parseCommand(' + JSON.stringify(line) + ') gave ' + JSON.stringify(got));
};

test('a command and its text', () => {
  check('add buy milk', {command: 'add', arg: 'buy milk'});
  check('done 2', {command: 'done', arg: '2'});
});

test('outer spaces go, inner spaces stay', () => {
  check('  add  buy  milk  ', {command: 'add', arg: 'buy  milk'});
});

test('the command is lower-cased, the text is not', () => {
  check('Add Call Ada', {command: 'add', arg: 'Call Ada'});
  check('DONE 1', {command: 'done', arg: '1'});
});

test('a word alone has an empty arg', () => {
  check('list', {command: 'list', arg: ''});
  check(' QUIT ', {command: 'quit', arg: ''});
});

test('an empty line gives an empty command', () => {
  check('   ', {command: '', arg: ''});
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 5

Step 2: check a number, show the list

Write findTodo(items, arg): it returns the number arg names when it is digits only and between 1 and items.length; otherwise it throws a RangeError with the message no to-do "5", the arg in double quotes. Write formatTodos(items): one line per to-do, 1. [ ] buy milk or 2. [x] call Ada for a finished one, joined with newlines; nothing to do yet for an empty list.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Test the form first: /^[0-9]+$/.test(arg) is false for '', 'two', '1.5' and '-1'. Number('') is 0, and Number alone would accept '1.5'.

  2. Hint 2

    Then compare n = Number(arg) with 1 and items.length, and throw new RangeError('no to-do "' + arg + '"').

  3. Hint 3

    In formatTodos, map each item with its index: (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text, then join('\n').

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

const items = [{text: 'buy milk', done: true}, {text: 'call Ada', done: false}];
console.log(formatTodos(items));
console.log(findTodo(items, '2'));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  return Number(arg);
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  return items.map((item) => item.text).join('\n');
}

const items = [{text: 'buy milk', done: true}, {text: 'call Ada', done: false}];
console.log(formatTodos(items));
console.log(findTodo(items, '2'));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {findTodo, formatTodos} from './main.js';

const items = [{text: 'buy milk', done: true}, {text: 'call Ada', done: false}];

test('findTodo gives the number of an existing to-do', () => {
  assert.equal(findTodo(items, '1'), 1, "findTodo(items, '1') should be the number 1");
  assert.equal(findTodo(items, '2'), 2, "findTodo(items, '2') should be the number 2");
});

test('findTodo throws a RangeError for any other text', () => {
  for (const arg of ['0', '3', 'two', '', '1.5', '-1']) {
    assert.throws(() => findTodo(items, arg), {name: 'RangeError', message: 'no to-do "' + arg + '"'}, 'findTodo(items, ' + JSON.stringify(arg) + ') should throw RangeError: no to-do "' + arg + '"');
  }
});

test('formatTodos numbers the to-dos and marks the finished ones', () => {
  const got = formatTodos(items);
  assert.equal(got, '1. [x] buy milk\n2. [ ] call Ada', 'formatTodos gave ' + JSON.stringify(got));
});

test('an empty list says so', () => {
  assert.equal(formatTodos([]), 'nothing to do yet', 'formatTodos([]) gave ' + JSON.stringify(formatTodos([])));
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 3 of 5

Step 3: a list that emits events

Finish TodoList. add(text) throws an Error with the message add needs a text, such as add buy milk for an empty text; otherwise it pushes {text, done: false} and emits 'added' with the item and its number. done(arg) finds the number with findTodo, which throws for a wrong one, marks the item and emits 'done' with the item and its number, but emits nothing for a to-do that is done already. Print nothing in the class.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    In add: if (text === '') throw new Error('add needs a text, such as add buy milk'); then push the item and this.emit('added', item, this.items.length).

  2. Hint 2

    In done: const n = findTodo(this.items, arg); throws by itself for a wrong number, before anything changes. The item is this.items[n - 1].

  3. Hint 3

    If item.done is already true, return before emitting. Otherwise set it and this.emit('done', item, n).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import {EventEmitter} from 'node:events';

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

// The list itself. It prints nothing: it reports every change as an event.
export class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  // Adds {text, done: false}, then emits 'added' with the item and its number.
  add(text) {
    if (text === '') throw new Error('add needs a text, such as add buy milk');
    const item = {text, done: false};
    this.items.push(item);
    this.emit('added', item, this.items.length);
  }

  // Marks to-do number arg as done, then emits 'done' with the item and its number;
  // a to-do that is done already gives no second event.
  done(arg) {
    const n = findTodo(this.items, arg);
    const item = this.items[n - 1];
    if (item.done) return;
    item.done = true;
    this.emit('done', item, n);
  }
}

const list = new TodoList();
list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text));
list.on('done', (item, n) => console.log('done ' + n + ': ' + item.text));
list.add('buy milk');
list.done('1');
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {EventEmitter} from 'node:events';

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

// The list itself. It prints nothing: it reports every change as an event.
export class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  // Adds {text, done: false}, then emits 'added' with the item and its number.
  add(text) {
    this.items.push({text, done: false});
  }

  // Marks to-do number arg as done, then emits 'done' with the item and its number;
  // a to-do that is done already gives no second event.
  done(arg) {
    this.items[Number(arg) - 1].done = true;
  }
}

const list = new TodoList();
list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text));
list.on('done', (item, n) => console.log('done ' + n + ': ' + item.text));
list.add('buy milk');
list.done('1');

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {TodoList} from './main.js';

// Collects every event of a list as [event, number, text, done].
const record = (list) => {
  const events = [];
  list.on('added', (item, n) => events.push(['added', n, item.text, item.done]));
  list.on('done', (item, n) => events.push(['done', n, item.text, item.done]));
  return events;
};

test('add stores the to-do and emits added with the item and its number', () => {
  const list = new TodoList();
  const events = record(list);
  list.add('tea');
  list.add('milk');
  assert.deepEqual(list.items, [{text: 'tea', done: false}, {text: 'milk', done: false}], 'items is ' + JSON.stringify(list.items));
  assert.deepEqual(events, [['added', 1, 'tea', false], ['added', 2, 'milk', false]], 'the events were ' + JSON.stringify(events));
});

test('add with an empty text throws and changes nothing', () => {
  const list = new TodoList();
  const events = record(list);
  assert.throws(() => list.add(''), {message: 'add needs a text, such as add buy milk'}, "add('') should throw: add needs a text, such as add buy milk");
  assert.equal(list.items.length + events.length, 0, "add('') should neither store a to-do nor emit an event");
});

test('done marks the to-do and emits done with the item and its number', () => {
  const list = new TodoList();
  const events = record(list);
  list.add('tea');
  list.add('milk');
  list.done('2');
  assert.deepEqual(events.slice(2), [['done', 2, 'milk', true]], 'after done 2 the events were ' + JSON.stringify(events.slice(2)));
  assert.equal(list.items[0].done, false, 'done 2 should leave tea unfinished');
});

test('a second done for the same to-do emits nothing', () => {
  const list = new TodoList();
  const events = record(list);
  list.add('tea');
  list.done('1');
  list.done('1');
  assert.equal(events.filter((e) => e[0] === 'done').length, 1, 'done 1 twice should emit done once');
});

test('done with a wrong number throws a RangeError and changes nothing', () => {
  const list = new TodoList();
  list.add('tea');
  for (const arg of ['0', '2', 'x', '']) {
    assert.throws(() => list.done(arg), {name: 'RangeError', message: 'no to-do "' + arg + '"'}, 'done(' + JSON.stringify(arg) + ') should throw RangeError: no to-do "' + arg + '"');
  }
  assert.equal(list.items[0].done, false, 'tea should still be unfinished');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 4 of 5

Step 4: the session

main.js runs the session; todo.js holds steps 1 to 3. Add the listeners: added 1: tea, done 1: tea, then all done! once every to-do is done. In the loop: quit ends it with break; add, done and list run (list prints formatTodos); a mistake prints its message; an unknown word prints unknown command: fly; an empty line does nothing. After the loop print bye: 1 of 2 done. Try printf 'add tea\nquit\n' | node main.js.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Before the loop: list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text)); the same for 'done', then a second 'done' listener with list.items.every((item) => item.done).

  2. Hint 2

    In the loop: if (command === 'quit') break; then a try block with if/else if for add, done, list and command !== '', and catch (error) { console.log(error.message); }.

  3. Hint 3

    After the loop, count the finished to-dos with list.items.filter((item) => item.done).length.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import {createInterface} from 'node:readline/promises';
import {TodoList, parseCommand, formatTodos} from './todo.js';

const list = new TodoList();
list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text));
list.on('done', (item, n) => console.log('done ' + n + ': ' + item.text));
list.on('done', () => {
  if (list.items.every((item) => item.done)) console.log('all done!');
});

const rl = createInterface({input: process.stdin, output: process.stdout});
let open = true; // false once the input has ended: then no more prompts
rl.on('close', () => (open = false));
rl.setPrompt('todo> ');
rl.prompt();
for await (const line of rl) {
  const {command, arg} = parseCommand(line);
  if (command === 'quit') break;
  try {
    if (command === 'add') list.add(arg);
    else if (command === 'done') list.done(arg);
    else if (command === 'list') console.log(formatTodos(list.items));
    else if (command !== '') console.log('unknown command: ' + command);
  } catch (error) {
    console.log(error.message);
  }
  if (open) rl.prompt();
}
const finished = list.items.filter((item) => item.done).length;
console.log('bye: ' + finished + ' of ' + list.items.length + ' done');
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {createInterface} from 'node:readline/promises';
import {TodoList, parseCommand, formatTodos} from './todo.js';

const list = new TodoList();
// Listen for 'added' and 'done' here, and print what happened.

const rl = createInterface({input: process.stdin, output: process.stdout});
let open = true; // false once the input has ended: then no more prompts
rl.on('close', () => (open = false));
rl.setPrompt('todo> ');
rl.prompt();
for await (const line of rl) {
  const {command, arg} = parseCommand(line);
  // Run the command here: quit, add, done, list, or an unknown one.
  if (open) rl.prompt();
}
// Print the bye line here.

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';

const session = async (stdin, expected) => {
  const printed = await runMain({stdin});
  assert.equal(printed, expected, 'for the input ' + JSON.stringify(stdin) + ' the program printed:\n' + printed);
};

test('add and list print the numbered to-dos', async () => {
  await session('add buy milk\nadd call Ada\nlist\nquit\n', 'todo> added 1: buy milk\ntodo> added 2: call Ada\ntodo> 1. [ ] buy milk\n2. [ ] call Ada\ntodo> bye: 0 of 2 done\n');
});

test('done prints the to-do, and all done! after the last one', async () => {
  await session('add tea\nadd milk\ndone 2\ndone 1\nquit\n', 'todo> added 1: tea\ntodo> added 2: milk\ntodo> done 2: milk\ntodo> done 1: tea\nall done!\ntodo> bye: 2 of 2 done\n');
});

test('mistakes print a message, and the session goes on', async () => {
  await session('done 1\nadd\nfly away\n\nadd tea\nquit\n', 'todo> no to-do "1"\ntodo> add needs a text, such as add buy milk\ntodo> unknown command: fly\ntodo> todo> added 1: tea\ntodo> bye: 0 of 1 done\n');
});

test('quit stops the reading, and so does the end of the input', async () => {
  await session('add tea\nquit\nadd milk\n', 'todo> added 1: tea\ntodo> bye: 0 of 1 done\n');
  await session('add tea\ndone 1', 'todo> added 1: tea\ntodo> done 1: tea\nall done!\nbye: 1 of 1 done\n');
});

todo.js

import {EventEmitter} from 'node:events';

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

// The list itself. It prints nothing: it reports every change as an event.
export class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  // Adds {text, done: false}, then emits 'added' with the item and its number.
  add(text) {
    if (text === '') throw new Error('add needs a text, such as add buy milk');
    const item = {text, done: false};
    this.items.push(item);
    this.emit('added', item, this.items.length);
  }

  // Marks to-do number arg as done, then emits 'done' with the item and its number;
  // a to-do that is done already gives no second event.
  done(arg) {
    const n = findTodo(this.items, arg);
    const item = this.items[n - 1];
    if (item.done) return;
    item.done = true;
    this.emit('done', item, n);
  }
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 5 of 5

Step 5: checks that catch a broken list

Write the assertions of a test as a function, as in B5.3: checkTodoList(List) gets a TodoList class, makes a list, collects its events, and must throw an AssertionError when the class breaks a rule. The rules: add('tea'), add('milk') emit added with the numbers 1 and 2; done('2') twice emits done once, with 2 and milk; done('5') throws a RangeError. The tests run your check on the real class and on broken ones.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Collect before you act: const events = []; list.on('added', (item, n) => events.push('added ' + n + ' ' + item.text)); and the same for 'done'.

  2. Hint 2

    After the two adds: assert.deepEqual(events, ['added 1 tea', 'added 2 milk'], '…'). After done('2') twice, events.slice(2) must be ['done 2 milk'].

  3. Hint 3

    Last: assert.throws(() => list.done('5'), RangeError, '…'). Pass a function, so throws can catch the error.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import assert from 'node:assert/strict';
import {TodoList} from './todo.js';

// Throws an AssertionError when the TodoList class List breaks one of its rules.
export function checkTodoList(List) {
  const list = new List();
  const events = [];
  list.on('added', (item, n) => events.push('added ' + n + ' ' + item.text));
  list.on('done', (item, n) => events.push('done ' + n + ' ' + item.text));

  list.add('tea');
  list.add('milk');
  assert.deepEqual(events, ['added 1 tea', 'added 2 milk'], 'add should emit added with the number and the item');
  list.done('2');
  list.done('2');
  assert.deepEqual(events.slice(2), ['done 2 milk'], 'done should emit done once, with the number and the item');
  assert.throws(() => list.done('5'), RangeError, "done('5') should throw a RangeError");
}

checkTodoList(TodoList);
console.log('the real TodoList passed');
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import assert from 'node:assert/strict';
import {TodoList} from './todo.js';

// Throws an AssertionError when the TodoList class List breaks one of its rules.
export function checkTodoList(List) {
  const list = new List();
  list.add('tea');
  assert.equal(list.items.length, 1, 'add should store the to-do');
  // Add the rules: the added events, one done event, a RangeError for a wrong number.
}

checkTodoList(TodoList);
console.log('the real TodoList passed');

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {TodoList, findTodo} from './todo.js';
import {checkTodoList} from './main.js';

// Broken lists, one bug each.
class SilentAdd extends TodoList {
  add(text) {
    this.items.push({text, done: false});
  }
}
class FromZero extends TodoList {
  add(text) {
    const item = {text, done: false};
    this.items.push(item);
    this.emit('added', item, this.items.length - 1);
  }
}
class DoneTwice extends TodoList {
  done(arg) {
    const n = findTodo(this.items, arg);
    this.items[n - 1].done = true;
    this.emit('done', this.items[n - 1], n);
  }
}
class NoRangeError extends TodoList {
  done(arg) {
    try {
      super.done(arg);
    } catch {}
  }
}
const catches = (List) => {
  try {
    checkTodoList(List);
  } catch (error) {
    return error.name;
  }
  return 'nothing';
};

test('your check accepts the real TodoList', () => {
  checkTodoList(TodoList);
});

test('your check catches an add that emits nothing', () => {
  assert.equal(catches(SilentAdd), 'AssertionError', 'checkTodoList(SilentAdd) should throw an AssertionError');
});

test('your check catches numbers that start at 0', () => {
  assert.equal(catches(FromZero), 'AssertionError', 'checkTodoList(FromZero) should throw an AssertionError');
});

test('your check catches done emitted twice', () => {
  assert.equal(catches(DoneTwice), 'AssertionError', 'checkTodoList(DoneTwice) should throw an AssertionError');
});

test('your check catches a wrong number that does not throw', () => {
  assert.equal(catches(NoRangeError), 'AssertionError', 'checkTodoList(NoRangeError) should throw an AssertionError');
});

todo.js

import {EventEmitter} from 'node:events';

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

// The list itself. It prints nothing: it reports every change as an event.
export class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  // Adds {text, done: false}, then emits 'added' with the item and its number.
  add(text) {
    if (text === '') throw new Error('add needs a text, such as add buy milk');
    const item = {text, done: false};
    this.items.push(item);
    this.emit('added', item, this.items.length);
  }

  // Marks to-do number arg as done, then emits 'done' with the item and its number;
  // a to-do that is done already gives no second event.
  done(arg) {
    const n = findTodo(this.items, arg);
    const item = this.items[n - 1];
    if (item.done) return;
    item.done = true;
    this.emit('done', item, n);
  }
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Reading the lines with for instead of for await

import {createInterface} from 'node:readline/promises';

const rl = createInterface({input: process.stdin, output: process.stdout});
for (const line of rl) {
  console.log('you typed', line);
}

What Node.js prints

TypeError: rl is not iterable

Why, and the fix

A readline interface hands out its lines as they arrive, so it can be read only with for await, which waits for each line. A plain for … of needs an array or another synchronous iterable and stops at once. Write for await (const line of rl), at the top level of a module or inside an async function.

Using the typed number as an index

const items = [{text: 'buy milk', done: false}];

// 'done 1' gives arg '1'
function markDone(arg) {
  const item = items[arg];
  item.done = true;
  return item;
}

console.log(markDone('1'));

What Node.js prints

TypeError: Cannot set properties of undefined (setting 'done')

Why, and the fix

People count from 1, arrays from 0. done 1 gives the text '1', and items['1'] is the second item, here none, so item is undefined. Turn the text into a checked number first, then subtract 1: const n = findTodo(items, arg); const item = items[n - 1];. With two to-dos, the bug would not crash at all: it would quietly mark the wrong one.

Emitting 'error' with no one listening

import {EventEmitter} from 'node:events';

class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  done(arg) {
    const n = Number(arg);
    if (!(n >= 1 && n <= this.items.length)) {
      this.emit('error', new RangeError('no to-do "' + arg + '"'));
      return;
    }
    this.items[n - 1].done = true;
  }
}

const list = new TodoList();
list.done('5');
console.log('the session goes on');

What Node.js prints

RangeError: no to-do "5"

Why, and the fix

An 'error' event is special (B5.1): with no 'error' listener, emit throws the error, and since nothing catches it here, the program ends. Either add list.on('error', (error) => console.log(error.message)) before the loop, or throw the RangeError from done, as the CLI does, so that the try/catch in the loop handles it like any other mistake.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Parse, check, then act

The CLI has three parts. parseCommand turns a typed line into {command, arg}: 'add buy milk' gives {command: 'add', arg: 'buy milk'}. It cuts at the first space only, so the text keeps its inner spaces, and it lower-cases the command, not the text. findTodo checks the argument of done: digits only, from 1 to the number of to-dos, otherwise a RangeError whose message the person typing can read. These are pure functions: no input, no output, easy to test, and they run in the browser too. The TodoList class lives with them in todo.js; main.js only connects them to the terminal, so a test can import todo.js without starting a session.

The list reports, listeners print

TodoList extends EventEmitter and calls super() first. add(text) pushes {text, done: false} and emits 'added' with the item and its number; done(arg) marks the item and emits 'done', but only the first time. The class prints nothing. main.js adds the listeners: one prints added 1: buy milk, and a second 'done' listener prints all done! when every to-do is finished. Listeners run in the order they were added, before add or done returns, so all done! comes after the done line. Add each listener once, before the loop: a listener added inside the loop is added again for every line typed.

A loop that reads commands, and tests

rl.setPrompt('todo> ') and rl.prompt() write the prompt; for await reads each line; quit ends the loop with break, which closes the interface. An error from add or done is caught inside the loop and printed, so one wrong command does not end the session. If the input ends without quit, the loop ends too, and the program prints its last line. Measured: when that last line has no newline, the interface is already closed while the loop handles it, and rl.prompt() would throw, so a 'close' listener clears a flag. Tests check the pure functions directly, the events by collecting them in an array, and the whole session through runMain({stdin}) or a pipe.

Sources

Last reviewed October 4, 2026