convertor/tests/converters/libreoffice.test.ts
Radhakrishnan Pachyappan 089105fd09 fix(libreoffice): use separate input/output filters and add Calc category
LibreOffice Writer has no CSV export filter.  The shared filter map used
Text for csv in both directions, which made any Writer-category conversion
to csv fail with:

  no export filter for <file>.csv found, aborting.

Root cause: getFilters() looked up the same filters map for the infilter and
the outfilter.  Text is a valid *input* filter for reading delimiter-
separated files in Writer but is not accepted as an *output* filter for the
.csv extension.

Fix:
- Split into separate inputFilters / outputFilters maps so csv can remain
  readable by the text (Writer) category without offering it as a write
  target there.
- Remove csv from properties.to.text so the UI/router no longer exposes
  the unsupported Writer → CSV path.
- Populate the previously-empty filters.calc with correct LibreOffice Calc
  filter names and add spreadsheet formats to properties.from.calc /
  properties.to.calc, enabling conversions such as xlsx → csv, csv → xlsx,
  ods → xlsx, etc. via the Calc module.

Fixes #561

Signed-off-by: Radhakrishnan Pachyappan <gingeekrishna@gmail.com>
2026-06-21 16:48:32 +05:30

224 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/libreoffice";
import type { ExecFileFn } from "../../src/converters/types";
function requireDefined<T>(value: T, msg: string): NonNullable<T> {
if (value === undefined || value === null) throw new Error(msg);
return value as NonNullable<T>;
}
// --- capture/inspect execFile calls -----------------------------------------
type Call = { cmd: string; args: string[] };
let calls: Call[] = [];
let behavior:
| { kind: "success"; stdout?: string; stderr?: string }
| { kind: "error"; message?: string; stderr?: string } = { kind: "success" };
const mockExecFile: ExecFileFn = (cmd, args, cb) => {
calls.push({ cmd, args });
if (behavior.kind === "error") {
cb(new Error(behavior.message ?? "mock failure"), "", behavior.stderr ?? "");
} else {
cb(null, behavior.stdout ?? "ok", behavior.stderr ?? "");
}
// We don't return a real ChildProcess in tests.
return undefined;
};
// --- capture console output (no terminal noise) ------------------------------
let logs: string[] = [];
let errors: string[] = [];
const originalLog = console.log;
const originalError = console.error;
// Use Console["log"] for typing; avoids explicit `any`
const makeSink =
(sink: string[]): Console["log"] =>
(...data) => {
sink.push(data.map(String).join(" "));
};
beforeEach(() => {
calls = [];
behavior = { kind: "success" };
logs = [];
errors = [];
console.log = makeSink(logs);
console.error = makeSink(errors);
});
afterEach(() => {
console.log = originalLog;
console.error = originalError;
});
// --- core behavior -----------------------------------------------------------
test("invokes soffice with --headless and outdir derived from targetPath", async () => {
await convert("in.docx", "docx", "odt", "out/out.odt", undefined, mockExecFile);
const { cmd, args } = requireDefined(calls[0], "Expected at least one execFile call");
expect(cmd).toBe("soffice");
expect(args).toEqual([
"--headless",
"--infilter=MS Word 2007 XML",
"--convert-to",
"odt:writer8",
"--outdir",
"out",
"in.docx",
]);
});
test("uses only outFilter when input has no filter (e.g., pdf -> txt)", async () => {
await convert("in.pdf", "pdf", "txt", "out/out.txt", undefined, mockExecFile);
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
expect(args).toEqual([
"--headless",
"--infilter=writer_pdf_import",
"--convert-to",
"txt:Text",
"--outdir",
"out",
"in.pdf",
]);
});
test("uses only infilter when convertTo has no out filter (e.g., docx -> pdf)", async () => {
await convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile);
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
// If docx has an infilter, it should be present
expect(args).toEqual(["--headless", "--convert-to", "pdf", "--outdir", "out", "in.docx"]);
const i = args.indexOf("--convert-to");
expect(i).toBeGreaterThanOrEqual(0);
expect(args[i + 1]).toBe("pdf");
expect(args.slice(-2)).toEqual(["out", "in.docx"]);
});
test("strips leading './' from outdir", async () => {
await convert("in.txt", "txt", "docx", "./out/out.docx", undefined, mockExecFile);
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
const outDirIdx = args.indexOf("--outdir");
expect(outDirIdx).toBeGreaterThanOrEqual(0);
expect(args[outDirIdx + 1]).toBe("out");
});
// --- promise settlement ------------------------------------------------------
test("resolves with 'Done' when execFile succeeds", async () => {
behavior = { kind: "success", stdout: "fine", stderr: "" };
await expect(
convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile),
).resolves.toBe("Done");
});
test("rejects when execFile returns an error", async () => {
behavior = { kind: "error", message: "convert failed", stderr: "oops" };
await expect(
convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile),
).rejects.toMatch(/error: Error: convert failed/);
});
// --- logging behavior --------------------------------------------------------
test("logs stdout when present", async () => {
behavior = { kind: "success", stdout: "hello", stderr: "" };
await convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile);
expect(logs).toContain("stdout: hello");
expect(errors).toHaveLength(0);
});
test("logs stderr when present", async () => {
behavior = { kind: "success", stdout: "", stderr: "uh-oh" };
await convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile);
expect(errors).toContain("stderr: uh-oh");
// When stdout is empty, no stdout log
expect(logs.find((l) => l.startsWith("stdout:"))).toBeUndefined();
});
test("logs both stdout and stderr when both are present", async () => {
behavior = { kind: "success", stdout: "alpha", stderr: "beta" };
await convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile);
expect(logs).toContain("stdout: alpha");
expect(errors).toContain("stderr: beta");
});
test("logs stderr on exec error as well", async () => {
behavior = { kind: "error", message: "boom", stderr: "EPIPE" };
expect(convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile)).rejects.toMatch(
/error: Error: boom/,
);
// The callback still provided stderr; your implementation logs it before settling
expect(errors).toContain("stderr: EPIPE");
});
// --- calc (spreadsheet) category --------------------------------------------
test("uses Calc infilter and outfilter for xlsx -> csv (regression for issue #561)", async () => {
await convert("in.xlsx", "xlsx", "csv", "out/out.csv", undefined, mockExecFile);
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
expect(args).toEqual([
"--headless",
"--infilter=Calc MS Excel 2007 XML",
"--convert-to",
"csv:Text - txt - csv (StarCalc)",
"--outdir",
"out",
"in.xlsx",
]);
});
test("uses Calc infilter and outfilter for csv -> xlsx", async () => {
await convert("in.csv", "csv", "xlsx", "out/out.xlsx", undefined, mockExecFile);
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
expect(args).toEqual([
"--headless",
"--infilter=Text - txt - csv (StarCalc)",
"--convert-to",
"xlsx:Calc MS Excel 2007 XML",
"--outdir",
"out",
"in.csv",
]);
});
test("uses Calc infilter and outfilter for ods -> xlsx", async () => {
await convert("in.ods", "ods", "xlsx", "out/out.xlsx", undefined, mockExecFile);
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
expect(args).toEqual([
"--headless",
"--infilter=calc8",
"--convert-to",
"xlsx:Calc MS Excel 2007 XML",
"--outdir",
"out",
"in.ods",
]);
});
test("pdf -> csv produces no filters (cross-category: not supported via LibreOffice Writer)", async () => {
await convert("in.pdf", "pdf", "csv", "out/out.csv", undefined, mockExecFile);
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
// pdf is Writer-category; csv output requires Calc — no valid cross-category
// filter exists, so soffice is invoked without explicit filters and will fail
// gracefully. The properties list no longer exposes this combination.
expect(args).toEqual(["--headless", "--convert-to", "csv", "--outdir", "out", "in.pdf"]);
});