Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
1cb5a9f
test(document-cli): cover the reducer's close/overlay/undo-cap/merge-…
Mearman Sep 14, 2026
1f1685e
refactor(document-cli): collapse pushSnapshot's undo-cap branch into …
Mearman Sep 14, 2026
d165ddd
fix(document-cli): allow doc/xls/ppt documents to undo through their …
Mearman Sep 14, 2026
6f85984
test(document-cli): cover PDF item actions and every reopenEditable f…
Mearman Sep 14, 2026
1a2bf32
test(document-cli): cover odb-to-xlsx, odb-to-csv, and odb-tables
Mearman Sep 14, 2026
b048246
test(document-cli): cover pdf-inspect's default and --json report modes
Mearman Sep 14, 2026
8af58e0
test(document-cli): cover from-package's output-conflict, csv/svg tar…
Mearman Sep 14, 2026
72854c4
test(document-cli): cover cli-main's bare/tui/command dispatch
Mearman Sep 14, 2026
067fa33
test(document-cli): cover ADD_LIST_ITEM's docx/markdown paragraph-cop…
Mearman Sep 14, 2026
eea0e3c
test(document-cli): cover odm-to-pdf's --out flag and font-substituti…
Mearman Sep 14, 2026
b52dad0
refactor(document-cli): rebuild formula presets per-call instead of a…
Mearman Sep 15, 2026
9ce7871
test(document-cli): cover legacy binary and flowable formats in the f…
Mearman Sep 15, 2026
27c9df6
test(document-cli): cover the ooxml-fixture test-support builders
Mearman Sep 15, 2026
6c8babe
build(document-cli): serialise mutation test runs and extend the dry-…
Mearman Sep 15, 2026
2ba7dee
refactor(document-cli): rebuild the format-to-extension table per call
Mearman Sep 15, 2026
83a03f1
fix(document-cli): let Esc dismiss the slide-table-detail screen when…
Mearman Sep 15, 2026
ab3cb69
test(document-cli): cover slide-table-detail's missing-table and edge…
Mearman Sep 15, 2026
9addc10
test(document-cli): cover paragraph-detail's own three guard fallbacks
Mearman Sep 15, 2026
76d7382
test(document-cli): cover UNDO's own per-format read-only warning
Mearman Sep 15, 2026
68acf40
test(document-cli): cover the odg page-vs-slide wording branch and re…
Mearman Sep 15, 2026
4eb74d0
ci: resolve mutation-testing's --affected against the PR base, not th…
Mearman Sep 15, 2026
ea12d44
test(document-cli): cover mergePptxTableCells boundaries and the doc-…
Mearman Sep 15, 2026
e56885f
test(document-cli): assert exact status text for four out-of-range an…
Mearman Sep 15, 2026
81a8550
test(document-cli): cover every remaining PDF field-edit wrong-kind b…
Mearman Sep 15, 2026
040aaa4
test(document-cli): cover INSERT_ODT_FORMULA's tree rebuild and wrong…
Mearman Sep 15, 2026
028e337
test(document-cli): cover the odg ellipse/line/path vector-add cases
Mearman Sep 15, 2026
a4a56e6
test(document-cli): cover CANCEL_QUIT and SAVE_ERROR
Mearman Sep 15, 2026
5f69eeb
test(document-cli): cover withShape's own odg page-vs-slide wording
Mearman Sep 15, 2026
64f79db
test(document-cli): cover the standalone status/stack/run actions
Mearman Sep 15, 2026
5d802fd
test(document-cli): cover SET_TABLE_CELL_TEXT's happy path and both g…
Mearman Sep 15, 2026
9a1757b
test(document-cli): cover ADD_PDF_TEXT and INSERT_DOCX_FORMULA
Mearman Sep 15, 2026
4c1aa22
test(document-cli): cover six previously untested slide/page/shape ac…
Mearman Sep 15, 2026
f38153f
refactor(document-cli): drop the redundant markdown branch from APPEN…
Mearman Sep 15, 2026
deb93d4
test(document-cli): close mutation-coverage gaps across reducer.ts's …
Mearman Sep 15, 2026
179f631
docs(document-cli): state combineSignals' no-await precondition preci…
Mearman Sep 20, 2026
6d86141
chore(document-cli): raise the mutation break threshold to 57
Mearman Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ permissions:
env:
# Mirrors ci.yml's own TURBO_FLAGS exactly: a PR scopes to the packages it actually touches (and their dependents, via turbo's own dependency-aware --affected), main runs the whole workspace.
TURBO_FLAGS: ${{ github.event_name == 'pull_request' && '--affected' || '' }}
# Mirrors ci.yml's own TURBO_SCM_BASE exactly, and is required for --affected to mean anything on a pull_request: actions/checkout's detached-HEAD checkout has no local branch named `main` (only `origin/main`), so without an explicit base turbo cannot resolve the literal `main` ref it falls back to, warns "unable to detect git range, assuming all files have changed", and silently treats every package as affected -- confirmed directly (2026-09-15: a pull_request run's own Plan job logged that exact fallback and planned all 23 workspace packages into 8 shards for a PR whose diff touched a single package).
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}

jobs:
plan:
Expand Down
162 changes: 162 additions & 0 deletions packages/document-cli/src/cli-main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
EXIT_INPUT_ERROR,
EXIT_SUCCESS,
EXIT_USAGE_ERROR,
} from "./runtime/exit-codes";
import * as programModule from "./program";
import { main } from "./cli-main";

// runTui itself (a real Ink render against a real terminal) is exercised by src/tui/*.test.tsx -- this file's own subject is cli-main.ts's dispatch logic around it: which of the three paths (bare invocation, an explicit 'tui' token, or an ordinary registered command) main() takes, how each computes the TUI's own startPath, TTY-gating, and how launchTui's own success/failure maps to an exit code. runTui is mocked throughout so no real Ink instance is ever rendered here.
const runTuiMock =
vi.fn<(options: { readonly startPath?: string }) => Promise<void>>();
vi.mock("./tui/index.js", () => ({
runTui: (options: { readonly startPath?: string }) => runTuiMock(options),
}));

describe("main", () => {
const originalArgv = process.argv;
const originalExitCode = process.exitCode;
const originalIsTTY = process.stdout.isTTY;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
let stderrSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
runTuiMock.mockReset();
runTuiMock.mockResolvedValue(undefined);
stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
});

afterEach(() => {
process.argv = originalArgv;
process.exitCode = originalExitCode;
process.stdout.isTTY = originalIsTTY;
process.removeAllListeners("SIGINT");
vi.restoreAllMocks();
});

it("launches the TUI on a bare invocation when stdout is a TTY, with no start path", async () => {
process.stdout.isTTY = true;
process.argv = ["node", "document-cli"];

await main();

expect(runTuiMock).toHaveBeenCalledTimes(1);
expect(runTuiMock).toHaveBeenCalledWith(
expect.objectContaining({ startPath: undefined }),
);
expect(process.exitCode).toBe(EXIT_SUCCESS);
});

it("shows help and exits successfully on a bare invocation when stdout is not a TTY, without launching the TUI", async () => {
process.stdout.isTTY = false;
process.argv = ["node", "document-cli"];

await main();

expect(runTuiMock).not.toHaveBeenCalled();
expect(process.exitCode).toBe(EXIT_SUCCESS);
expect(stdoutSpy).toHaveBeenCalledWith(
expect.stringContaining("document-cli"),
);
});

it("refuses an explicit 'tui' invocation with a usage error when stdout is not a TTY", async () => {
process.stdout.isTTY = false;
process.argv = ["node", "document-cli", "tui"];

await main();

expect(runTuiMock).not.toHaveBeenCalled();
expect(process.exitCode).toBe(EXIT_USAGE_ERROR);
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining("requires an interactive terminal"),
);
});

it("launches the TUI for an explicit 'tui' invocation with a TTY, resolving the start path from the first non-flag argument", async () => {
process.stdout.isTTY = true;
process.argv = ["node", "document-cli", "tui", "--foo", "somefile.docx"];

await main();

expect(runTuiMock).toHaveBeenCalledWith(
expect.objectContaining({ startPath: "somefile.docx" }),
);
expect(process.exitCode).toBe(EXIT_SUCCESS);
});

it("launches the TUI for an explicit 'tui' invocation with no file argument, leaving the start path undefined", async () => {
process.stdout.isTTY = true;
process.argv = ["node", "document-cli", "tui"];

await main();

expect(runTuiMock).toHaveBeenCalledWith(
expect.objectContaining({ startPath: undefined }),
);
});

it("reports EXIT_INPUT_ERROR and the formatted error when runTui itself rejects with a framework-level failure", async () => {
process.stdout.isTTY = true;
process.argv = ["node", "document-cli"];
runTuiMock.mockRejectedValue(new Error("ink blew up"));

await main();

expect(process.exitCode).toBe(EXIT_INPUT_ERROR);
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining("ink blew up"),
);
});

it("dispatches an ordinary registered command through the assembled program rather than the TUI", async () => {
process.argv = ["node", "document-cli", "--help"];

await main();

expect(runTuiMock).not.toHaveBeenCalled();
expect(stdoutSpy).toHaveBeenCalledWith(
expect.stringContaining("Commands:"),
);
});

it("registers a 'tui [file]' subcommand on the assembled program that also launches the TUI", async () => {
process.argv = ["node", "document-cli", "tui-registration-probe"];
// dispatchToken is neither undefined nor "tui", so main() takes the else branch that registers 'tui [file]' on a fresh createProgram() result before parsing -- calling createProgram() directly afterwards, as this test does below, would build a SEPARATE program without that registration. Spy on it instead so this test observes the exact program instance main() itself builds and registers against.
const createProgramSpy = vi.spyOn(programModule, "createProgram");
await main();
const registeredProgram = createProgramSpy.mock.results[0]?.value as
ReturnType<typeof programModule.createProgram> | undefined;
if (registeredProgram === undefined) {
throw new Error("expected main() to have called createProgram()");
}

await registeredProgram.parseAsync([
"node",
"document-cli",
"tui",
"registered-file.docx",
]);

expect(runTuiMock).toHaveBeenCalledWith(
expect.objectContaining({ startPath: "registered-file.docx" }),
);
});

it("propagates a non-CommanderError bug from a registered action instead of swallowing it", async () => {
const brokenProgram = programModule.createProgram();
brokenProgram.command("boom").action(() => {
throw new Error("boom");
});
vi.spyOn(programModule, "createProgram").mockReturnValue(brokenProgram);
process.argv = ["node", "document-cli", "boom"];

await expect(main()).rejects.toThrow("boom");
});
});
198 changes: 198 additions & 0 deletions packages/document-cli/src/commands/from-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createDocx,
createOdg,
createOds,
decodeDocumentPackage,
openDocx,
Expand Down Expand Up @@ -258,6 +259,203 @@ describe("from-package", () => {
});
});

it("fails with a usage error when the positional output and --out disagree, and succeeds when they agree", async () => {
const packagePath = join(workspace, "dumped-for-conflict.package.json");
await runCli([
"docx-to-pdf",
join(workspace, "source.docx"),
join(workspace, "unused-conflict.pdf"),
"--dump-package",
packagePath,
]);

const disagreeing = await runCli([
"from-package",
packagePath,
join(workspace, "one.docx"),
"--out",
join(workspace, "other.docx"),
]);
expect(disagreeing.exitCode).not.toBe(EXIT_SUCCESS);
expect(disagreeing.stderr).toContain("[from-package]");
expect(disagreeing.stderr).toContain("conflicting output destinations");

const agreedPath = join(workspace, "agreed.docx");
const agreeing = await runCli([
"from-package",
packagePath,
agreedPath,
"--out",
agreedPath,
]);
expect(agreeing.exitCode).toBe(EXIT_SUCCESS);
expect(agreeing.stderr).not.toContain("conflicting output destinations");
});

it("writes to the path named by --out when no positional output is given", async () => {
const packagePath = join(workspace, "dumped-for-out-flag.package.json");
await runCli([
"docx-to-pdf",
join(workspace, "source.docx"),
join(workspace, "unused-out-flag.pdf"),
"--dump-package",
packagePath,
]);

const output = join(workspace, "via-out-flag.docx");
const { exitCode } = await runCli([
"from-package",
packagePath,
"--out",
output,
]);
expect(exitCode).toBe(EXIT_SUCCESS);
const rebuilt = openDocx(new Uint8Array(await readFile(output)));
expect(
rebuilt
.paragraphs()
.some((paragraph) => paragraph.text === PARAGRAPH_TEXT),
).toBe(true);
});

it("builds real csv output from a spreadsheet-kind DocumentTree, threading --delimiter through", async () => {
const sheetPath = join(workspace, "source-for-csv.ods");
const editor = createOds();
const sheet = editor.sheets()[0];
if (sheet === undefined) {
throw new Error("createOds() did not produce a default sheet");
}
sheet.cell(0, 0).value = { kind: "string", value: "A" };
sheet.cell(0, 1).value = { kind: "string", value: "B" };
sheet.setColumnWidth(0, 72);
sheet.setColumnWidth(1, 72);
sheet.setRowHeight(0, 14);
await writeFile(sheetPath, editor.toBytes());

const packagePath = join(workspace, "dumped-for-csv.package.json");
await runCli([
"ods-to-pdf",
sheetPath,
join(workspace, "unused-csv.pdf"),
"--dump-package",
packagePath,
]);

const csvPath = join(workspace, "rebuilt.csv");
const csvRun = await runCli([
"from-package",
packagePath,
csvPath,
"--delimiter",
";",
]);
expect(csvRun.exitCode).toBe(EXIT_SUCCESS);
const csvText = await readFile(csvPath, "utf-8");
expect(csvText).toContain("A;B");
});

it("builds real svg output from a drawing-kind DocumentTree, threading --page through", async () => {
const drawingPath = join(workspace, "source-for-svg.odg");
const editor = createOdg();
editor.addPage();
editor.pages()[0]?.addRect({
frame: { xPt: 5, yPt: 5, widthPt: 40, heightPt: 30 },
fill: { r: 1, g: 0, b: 0 },
});
await writeFile(drawingPath, editor.toBytes());

const packagePath = join(workspace, "dumped-for-svg.package.json");
await runCli([
"odg-to-pdf",
drawingPath,
join(workspace, "unused-svg.pdf"),
"--dump-package",
packagePath,
]);

const svgPath = join(workspace, "rebuilt.svg");
const svgRun = await runCli([
"from-package",
packagePath,
svgPath,
"--page",
"0",
]);
expect(svgRun.exitCode).toBe(EXIT_SUCCESS);
const svgText = await readFile(svgPath, "utf-8");
expect(svgText).toContain("<svg");
});

it("emits a JSON summary under --json and stays silent under --quiet", async () => {
const packagePath = join(workspace, "dumped-for-json-quiet.package.json");
await runCli([
"docx-to-pdf",
join(workspace, "source.docx"),
join(workspace, "unused-json-quiet.pdf"),
"--dump-package",
packagePath,
]);

const jsonOutput = join(workspace, "via-json.docx");
const jsonRun = await runCli([
"from-package",
packagePath,
jsonOutput,
"--json",
]);
expect(jsonRun.exitCode).toBe(EXIT_SUCCESS);
const summary: unknown = JSON.parse(jsonRun.stderr);
expect(summary).toMatchObject({ output: jsonOutput });

const quietOutput = join(workspace, "via-quiet.docx");
const quietRun = await runCli([
"from-package",
packagePath,
quietOutput,
"--quiet",
]);
expect(quietRun.exitCode).toBe(EXIT_SUCCESS);
expect(quietRun.stderr).toBe("");
});

it("rejects input bytes that are not valid UTF-8", async () => {
const invalidUtf8Path = join(workspace, "invalid-utf8.package.json");
// A lone continuation byte (0x80) is never valid at the start of a UTF-8 sequence -- TextDecoder("utf-8", { fatal: true }) throws on it rather than silently substituting U+FFFD.
await writeFile(invalidUtf8Path, new Uint8Array([0x7b, 0x80, 0x7d]));

const { exitCode, stderr } = await runCli([
"from-package",
invalidUtf8Path,
join(workspace, "never-written-utf8.docx"),
]);

expect(exitCode).not.toBe(EXIT_SUCCESS);
expect(stderr).toContain("not valid");
});

it("rejects a DocumentTree dump whose $schema pins a document-schema.js major other than the installed one", async () => {
const mismatchPath = join(workspace, "version-mismatch.package.json");
// A real document-tree.schema.json $schema URI (so it clears the rename/demotion tombstones and reaches the version gate) pinned to major 6 -- a major this workspace's installed document-schema.js (7.x) never was, so it can never accidentally stop mismatching the way a hardcoded "installed - 1" could coincide with a real future install.
const mismatchDump = {
$schema:
"https://cdn.jsdelivr.net/npm/document-schema.js@6.0.0/schemas/document-tree.schema.json",
children: [],
};
await writeFile(mismatchPath, JSON.stringify(mismatchDump, undefined, 2));

const { exitCode, stderr } = await runCli([
"from-package",
mismatchPath,
join(workspace, "never-written-mismatch.docx"),
]);

expect(exitCode).not.toBe(EXIT_SUCCESS);
expect(stderr).toContain("document-schema.js@6.0.0");
expect(stderr).toContain("reads only @");
expect(stderr).toContain("-major dumps");
expect(stderr).toContain("--dump-package");
});

it("rejects a plain JSON file with no recognised $schema", async () => {
const plainPath = join(workspace, "plain.json");
await writeFile(plainPath, JSON.stringify({ hello: "world" }));
Expand Down
Loading
Loading