import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtempSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); function run(file, ...args) { return spawnSync(process.execPath, [CLI, ...args], { env: { ...process.env, TODO_FILE: file }, encoding: "utf8", }); } const tempFile = () => join(mkdtempSync(join(tmpdir(), "todo-")), "todos.json"); test("ls with no file prints the empty message and writes nothing", () => { const file = tempFile(); const res = run(file, "ls"); assert.equal(res.status, 0); assert.equal(res.stdout.trim(), "Nothing to do."); assert.equal(existsSync(file), false); }); test("no command lists the todos", () => { const file = tempFile(); run(file, "add", "a"); const res = run(file); assert.equal(res.status, 0); assert.equal(res.stdout.trim(), "[ ] 1. a"); }); test("add trims surrounding whitespace from the text", () => { const file = tempFile(); run(file, "add", " buy", "milk "); assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), [{ id: 1, text: "buy milk", done: false }]); }); test("add fails readably when no ids are left, without saving", () => { const file = tempFile(); const before = JSON.stringify([{ id: Number.MAX_SAFE_INTEGER - 1, text: "a", done: false }]); writeFileSync(file, before); const res = run(file, "add", "b"); assert.equal(res.status, 1); assert.match(res.stderr, /^No ids left/); assert.equal(readFileSync(file, "utf8"), before); }); test("rm on a file with duplicate ids removes only the first of them", () => { const file = tempFile(); writeFileSync(file, JSON.stringify([{ id: 2, text: "b", done: false }, { id: 2, text: "c", done: false }])); const res = run(file, "rm", "2"); assert.equal(res.status, 0, res.stderr); assert.equal(res.stdout.trim(), "[ ] 3. c"); }); test("add with no text fails without saving", () => { const file = tempFile(); const res = run(file, "add", " "); assert.equal(res.status, 1); assert.match(res.stderr, /Missing todo text/); assert.equal(existsSync(file), false); }); for (const [label, args, message] of [ ["missing id", [], /Invalid id: \(missing\)/], ["non-numeric id", ["abc"], /Invalid id: abc/], ["hex id", ["0x1"], /Invalid id: 0x1/], ["exponent id", ["1e0"], /Invalid id: 1e0/], ["decimal id", ["1.0"], /Invalid id: 1\.0/], ["padded id", [" 1 "], /Invalid id: 1 /], ["empty id", [""], /Invalid id: \n/], ["unsafe id", ["99999999999999999999"], /Invalid id: 99999999999999999999/], ["leading-zero id", ["01"], /Invalid id: 01/], ["zero id", ["0"], /Invalid id: 0/], ["unknown id", ["99"], /No todo with id 99/], ]) { for (const cmd of ["done", "rm"]) { test(`${cmd} with ${label} fails and leaves the file unchanged`, () => { const file = tempFile(); run(file, "add", "a"); const before = readFileSync(file, "utf8"); const res = run(file, cmd, ...args); assert.equal(res.status, 1); assert.match(res.stderr, message); assert.equal(readFileSync(file, "utf8"), before); }); } } test("unknown command prints usage even when the file is corrupt", () => { const file = tempFile(); writeFileSync(file, "{ not json"); const res = run(file, "--help"); assert.equal(res.status, 1); assert.match(res.stderr, /Unknown command: --help\nUsage:/); }); test("a failed save gives a readable error instead of a stack trace", { skip: process.getuid?.() === 0 && "root ignores file permissions" }, () => { const file = tempFile(); chmodSync(dirname(file), 0o500); try { const res = run(file, "add", "a"); assert.equal(res.status, 1); assert.match(res.stderr, /^Could not save /); assert.doesNotMatch(res.stderr, /at .*\.js:\d+/); } finally { chmodSync(dirname(file), 0o700); } }); test("a corrupt file gives a readable error instead of a stack trace", () => { const file = tempFile(); writeFileSync(file, "{ not json"); const res = run(file, "ls"); assert.equal(res.status, 1); assert.match(res.stderr, /^Could not read /); assert.doesNotMatch(res.stderr, /at .*\.js:\d+/); });