k-skill/packages/k-skill-rhwp/test/cli.test.js
Jeffrey (Dongkyu) Kim dadc5f4ffa Add rhwp-edit and rhwp-advanced skills with k-skill-rhwp CLI
Splits HWP handling into three focused skills per issue #155:

- hwp (kept): kordoc-based read/convert (Markdown, JSON, diffing, form
  fields, Markdown->HWPX). Description narrowed to 'read-only' to make
  the routing policy explicit.
- rhwp-edit (new): HWP binary editing via new k-skill-rhwp npm package
  that wraps the @rhwp/core WASM bindings as CLI subcommands: info,
  list-paragraphs, search, insert-text, delete-text, replace-all,
  create-table, set-cell-text, create-blank, and render.
- rhwp-advanced (new): guidance for the upstream Rust rhwp CLI
  (export-svg --debug-overlay, dump, dump-pages, ir-diff, thumbnail,
  convert) for layout debugging, IR inspection, version comparison,
  and read-only-document unlocking.

The new k-skill-rhwp package under packages/ ships a Node.js 18+ CLI
and library that round-trips HWP 5.x documents entirely in-process; no
Rust toolchain is required. It auto-installs the WASM-required
globalThis.measureTextWidth shim for headless Node, and all editing
subcommands always write to a distinct output path so the source file
is never mutated. HWPX save remains disabled per the upstream rhwp
#196 data-safety gate; HWPX input is accepted but output is written as
HWP 5.x.

Includes 24 node:test cases covering init, round-trip insertText,
replaceAll, createTable + setCellText, deleteText, searchText,
listParagraphs, renderPage (SVG/HTML), and full CLI arg-parse +
end-to-end round-trip through the CLI layer.

Wires README feature table (3 rows for hwp / rhwp-edit / rhwp-advanced),
docs/install.md optional-install list, docs/roadmap.md (marks HWP
advanced editing as shipped while keeping Windows/security-module
automation out of scope), docs/sources.md (adds rhwp upstream, CLI
source, @rhwp/core, @rhwp/editor, and rhwp #196 references), and the
root pack:dry-run script. Adds a Changesets entry for k-skill-rhwp
minor.

Closes #155.
2026-04-22 12:45:13 +09:00

159 lines
4.9 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Writable } = require("node:stream");
const { parseArgs, main, USAGE } = require("../src/cli");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-rhwp-cli-"));
test.after(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
function collectStream() {
const chunks = [];
const stream = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk);
cb();
}
});
Object.defineProperty(stream, "buffer", {
get: () => Buffer.concat(chunks).toString("utf8")
});
return stream;
}
test("parseArgs handles positional args, --flag value, --flag=value, and boolean flags", () => {
const a = parseArgs([
"insert-text",
"in.hwp",
"out.hwp",
"--section",
"0",
"--paragraph=1",
"--text",
"hi",
"--case-sensitive"
]);
assert.deepEqual(a._, ["insert-text", "in.hwp", "out.hwp"]);
assert.equal(a.flags.section, "0");
assert.equal(a.flags.paragraph, "1");
assert.equal(a.flags.text, "hi");
assert.equal(a.flags["case-sensitive"], true);
});
test("parseArgs supports -- to pass remaining tokens positionally", () => {
const a = parseArgs(["cmd", "--", "--weird-text", "with --dashes"]);
assert.deepEqual(a._, ["cmd", "--weird-text", "with --dashes"]);
});
test("main prints usage with --help", async () => {
const stdout = collectStream();
const stderr = collectStream();
const code = await main(["--help"], { stdout, stderr });
assert.equal(code, 0);
assert.ok(stdout.buffer.includes("k-skill-rhwp"));
assert.ok(stdout.buffer.includes("info <input>"));
assert.equal(stderr.buffer, "");
assert.ok(USAGE.length > 100);
});
test("main prints version with -v", async () => {
const stdout = collectStream();
const stderr = collectStream();
const code = await main(["-v"], { stdout, stderr });
assert.equal(code, 0);
assert.match(stdout.buffer, /k-skill-rhwp \d+\.\d+\.\d+/);
});
test("main returns exit code 1 for unknown command with a helpful message", async () => {
const stdout = collectStream();
const stderr = collectStream();
const code = await main(["totally-fake-command"], { stdout, stderr });
assert.equal(code, 1);
assert.match(stderr.buffer, /unknown command: totally-fake-command/);
});
test("main info on a generated blank document prints valid JSON with sectionCount >= 1", async () => {
const blank = path.join(tmpRoot, "cli-blank.hwp");
const createOut = collectStream();
const createErr = collectStream();
const createCode = await main(["create-blank", blank], {
stdout: createOut,
stderr: createErr
});
assert.equal(createCode, 0, `create-blank failed: ${createErr.buffer}`);
assert.ok(fs.existsSync(blank));
const infoOut = collectStream();
const infoErr = collectStream();
const infoCode = await main(["info", blank], { stdout: infoOut, stderr: infoErr });
assert.equal(infoCode, 0, `info failed: ${infoErr.buffer}`);
const parsed = JSON.parse(infoOut.buffer);
assert.equal(parsed.sourceFormat, "hwp");
assert.equal(parsed.sectionCount, 1);
assert.ok(Array.isArray(parsed.sections));
});
test("main insert-text + info end-to-end round-trip through the CLI layer", async () => {
const blank = path.join(tmpRoot, "cli-rt-blank.hwp");
const edited = path.join(tmpRoot, "cli-rt-edited.hwp");
const nul = collectStream();
await main(["create-blank", blank], { stdout: nul, stderr: collectStream() });
const insertOut = collectStream();
const insertErr = collectStream();
const insertCode = await main(
[
"insert-text",
blank,
edited,
"--section",
"0",
"--paragraph",
"0",
"--offset",
"0",
"--text",
"안녕 CLI"
],
{ stdout: insertOut, stderr: insertErr }
);
assert.equal(insertCode, 0, `insert-text failed: ${insertErr.buffer}`);
const insertResult = JSON.parse(insertOut.buffer);
assert.equal(insertResult.ok, true);
const infoOut = collectStream();
await main(["info", edited], { stdout: infoOut, stderr: collectStream() });
const infoResult = JSON.parse(infoOut.buffer);
assert.equal(infoResult.sections[0].paragraphs[0].length, "안녕 CLI".length);
});
test("main reports missing required --section flag to stderr and exits 1", async () => {
const blank = path.join(tmpRoot, "cli-missing-flag.hwp");
await main(["create-blank", blank], { stdout: collectStream(), stderr: collectStream() });
const stdout = collectStream();
const stderr = collectStream();
const code = await main(
[
"insert-text",
blank,
path.join(tmpRoot, "cli-missing-out.hwp"),
"--paragraph",
"0",
"--offset",
"0",
"--text",
"hi"
],
{ stdout, stderr }
);
assert.equal(code, 1);
assert.match(stderr.buffer, /missing required --section/);
});