- CLI holds a lock file from load to save, so concurrent commands can't lose an update; stale locks from crashed runs are taken over - load() renumbers duplicate ids from the old add() bug instead of refusing the file - save() keeps owner, group and setgid on the temp file, and falls back to an in-place write for hard-linked files, files whose owner/group can't be kept, and directories that aren't writable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
5.3 KiB
JavaScript
147 lines
5.3 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync, spawn } from "node:child_process";
|
|
import { mkdtempSync, writeFileSync, readFileSync, existsSync, chmodSync, utimesSync } 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("concurrent adds don't lose updates or reuse ids", async () => {
|
|
const file = tempFile();
|
|
const runs = Array.from({ length: 12 }, (_, i) =>
|
|
new Promise((done) => {
|
|
const child = spawn(process.execPath, [CLI, "add", `t${i}`], { env: { ...process.env, TODO_FILE: file } });
|
|
child.on("exit", done);
|
|
}));
|
|
assert.deepEqual(await Promise.all(runs), Array(12).fill(0));
|
|
const todos = JSON.parse(readFileSync(file, "utf8"));
|
|
assert.equal(todos.length, 12);
|
|
assert.deepEqual(todos.map((t) => t.id).sort((a, b) => a - b), Array.from({ length: 12 }, (_, i) => i + 1));
|
|
assert.equal(existsSync(`${file}.lock`), false);
|
|
});
|
|
|
|
test("a stale lock from a crashed run is taken over", () => {
|
|
const file = tempFile();
|
|
writeFileSync(`${file}.lock`, "");
|
|
const old = new Date(Date.now() - 60_000);
|
|
utimesSync(`${file}.lock`, old, old);
|
|
const res = run(file, "add", "a");
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.equal(existsSync(`${file}.lock`), false);
|
|
});
|
|
|
|
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+/);
|
|
});
|