test: improve converter tests

This commit is contained in:
Jörg Krzeslak
2026-10-02 14:20:22 +02:00
committed by Laertes87
parent 6a2bc87135
commit 411ffabd8c
24 changed files with 1445 additions and 1204 deletions
+20 -2
View File
@@ -1,7 +1,25 @@
import { test } from "bun:test";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/assimp";
import { runCommonTests } from "./helpers/commonTests";
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
test("invokes assimp export with the target format id", async () => {
const { execFile, calls } = createMockExecFile();
await convert("in/scene.obj", "obj", "stl", "out/scene.stl", undefined, execFile);
expect(calls).toEqual([
{ cmd: "assimp", args: ["export", "in/scene.obj", "out/scene.stl", "-fstl"] },
]);
});
test("passes format ids that are not file extensions through unchanged", async () => {
// e.g. glb2 is written to a .glb file, but assimp still needs -fglb2
const { execFile, calls } = createMockExecFile();
await convert("in/scene.obj", "obj", "glb2", "out/scene.glb", undefined, execFile);
expect(calls[0]?.args).toEqual(["export", "in/scene.obj", "out/scene.glb", "-fglb2"]);
});
+33 -2
View File
@@ -1,7 +1,38 @@
import { test } from "bun:test";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/calibre";
import { runCommonTests } from "./helpers/commonTests";
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
test("invokes ebook-convert with input and target path", async () => {
const { execFile, calls } = createMockExecFile();
await convert("in/book.epub", "epub", "mobi", "out/book.mobi", undefined, execFile);
expect(calls).toEqual([{ cmd: "ebook-convert", args: ["in/book.epub", "out/book.mobi"] }]);
});
test.each([
["recipe file type", "in/news.recipe", "recipe"],
["downloaded_recipe file type", "in/news.downloaded_recipe", "downloaded_recipe"],
["upper-case file type", "in/news.RECIPE", "RECIPE"],
["recipe extension with another file type", "in/news.recipe", "txt"],
["upper-case downloaded_recipe extension", "in/news.DOWNLOADED_RECIPE", "txt"],
])("rejects recipe input (%s) without invoking ebook-convert", async (_, filePath, fileType) => {
const { execFile, calls } = createMockExecFile();
await expect(
convert(filePath, fileType, "epub", "out/news.epub", undefined, execFile),
).rejects.toThrow("Recipe files are not supported");
expect(calls).toHaveLength(0);
});
test("accepts files without an extension", async () => {
const { execFile, calls } = createMockExecFile();
await expect(
convert("in/README", "txt", "epub", "out/README.epub", undefined, execFile),
).resolves.toBe("Done");
expect(calls).toHaveLength(1);
});
+74 -30
View File
@@ -2,25 +2,11 @@ import fs from "fs";
import { beforeEach, afterEach, expect, test, describe } from "bun:test";
import { buildDaselArgs, convert } from "../../src/converters/dasel";
import type { ExecFileFn } from "../../src/converters/types";
import { captureConsole } from "./helpers/converters";
const originalWriteFile = fs.writeFile;
describe("convert", () => {
let mockExecFile: ExecFileFn;
beforeEach(() => {
// mock fs.writeFile
// @ts-expect-error: property __promisify__ is missing
fs.writeFile = (path, data, cb) => cb(null);
// mock execFile
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "");
});
afterEach(() => {
// reset fs.writeFile
fs.writeFile = originalWriteFile;
});
describe("buildDaselArgs", () => {
test("should build dasel v3 arguments", () => {
expect(buildDaselArgs("input.yaml", "yaml", "json")).toEqual([
"--var",
@@ -31,6 +17,47 @@ describe("convert", () => {
]);
});
test.each([
["toml", "yaml"],
["json", "toml"],
["xml", "json"],
["csv", "yaml"],
])("should read %s and write %s", (fileType, convertTo) => {
expect(buildDaselArgs(`in/data.${fileType}`, fileType, convertTo)).toEqual([
"--var",
`data=${fileType}:file:in/data.${fileType}`,
"--out",
convertTo,
"$data",
]);
});
});
describe("convert", () => {
let mockExecFile: ExecFileFn;
let writes: { path: unknown; data: unknown }[];
let output: ReturnType<typeof captureConsole>;
beforeEach(() => {
writes = [];
output = captureConsole();
// mock fs.writeFile
// @ts-expect-error: property __promisify__ is missing
fs.writeFile = (path, data, cb) => {
writes.push({ path, data });
// @ts-expect-error: could not be callable with null
cb(null);
};
// mock execFile
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "");
});
afterEach(() => {
// reset fs.writeFile
fs.writeFile = originalWriteFile;
output.restore();
});
test("should call dasel with correct arguments and write output", async () => {
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
mockExecFile = (cmd, args, callback) => {
@@ -38,16 +65,6 @@ describe("convert", () => {
callback(null, "output-data", "");
};
let writeFileCalled = false;
// @ts-expect-error: property __promisify__ is missing
fs.writeFile = (path, data, cb) => {
writeFileCalled = true;
expect(path).toBe("output.json");
expect(data).toBe("output-data");
// @ts-expect-error: could not be callable with null
cb(null);
};
const result = await convert(
"input.yaml",
"yaml",
@@ -59,7 +76,7 @@ describe("convert", () => {
expect(calledArgs[0]).toBe("dasel");
expect(calledArgs[1]).toEqual(["--var", "data=yaml:file:input.yaml", "--out", "json", "$data"]);
expect(writeFileCalled).toBe(true);
expect(writes).toEqual([{ path: "output.json", data: "output-data" }]);
expect(result).toBe("Done");
});
@@ -67,7 +84,7 @@ describe("convert", () => {
let stdinEnded = false;
mockExecFile = (cmd, args, callback) => {
callback(null, "output-data", "");
return { stdin: { end: () => (stdinEnded = true) } } as ReturnType<ExecFileFn>;
return { stdin: { end: () => (stdinEnded = true) } } as unknown as ReturnType<ExecFileFn>;
};
await convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile);
@@ -75,11 +92,38 @@ describe("convert", () => {
expect(stdinEnded).toBe(true);
});
test("should reject if execFile returns an error", async () => {
test("should not fail when execFile returns no child process", async () => {
mockExecFile = (cmd, args, callback) => {
callback(null, "output-data", "");
return undefined;
};
await expect(
convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile),
).resolves.toBe("Done");
});
test("should log stderr and still write the output", async () => {
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "deprecation warning");
await convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile);
expect(output.errors).toEqual(["stderr: deprecation warning"]);
expect(writes).toHaveLength(1);
});
test("should not log stdout, since it is the converted document", async () => {
await convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile);
expect(output.logs).toEqual([]);
});
test("should reject and not write anything if execFile returns an error", async () => {
mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", "");
await expect(
convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile),
).rejects.toMatch(/error: Error: fail/);
expect(writes).toEqual([]);
});
test("should reject if writeFile fails", async () => {
@@ -89,6 +133,6 @@ describe("convert", () => {
convert("input.yaml", "yaml", "json", "output.json", undefined, (cmd, args, cb) =>
cb(null, "output-data", ""),
),
).rejects.toMatch(/Failed to write output/);
).rejects.toMatch(/Failed to write output: Error: write fail/);
});
});
+26 -82
View File
@@ -1,91 +1,35 @@
import type { ExecFileException } from "node:child_process";
import { beforeEach, expect, test } from "bun:test";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/dvisvgm";
import { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
let calls: string[][] = [];
beforeEach(() => {
calls = [];
});
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test("convert respects eps filetype", async () => {
const originalConsoleLog = console.log;
test.each([
{ fileType: "dvi", convertTo: "svg", flags: [] },
{ fileType: "xdv", convertTo: "svg", flags: [] },
{ fileType: "eps", convertTo: "svg", flags: ["--eps"] },
{ fileType: "pdf", convertTo: "svg", flags: ["--pdf"] },
{ fileType: "dvi", convertTo: "svgz", flags: ["-z"] },
{ fileType: "eps", convertTo: "svgz", flags: ["--eps", "-z"] },
{ fileType: "pdf", convertTo: "svgz", flags: ["--pdf", "-z"] },
])("converts $fileType to $convertTo", async ({ fileType, convertTo, flags }) => {
const { execFile, calls } = createMockExecFile();
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.eps", "eps", "stl", "output.stl", undefined, mockExecFile);
console.log = originalConsoleLog;
const result = await convert(
`in/figure.${fileType}`,
fileType,
convertTo,
`out/figure.${convertTo}`,
undefined,
execFile,
);
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["--eps", "input.eps", "output.stl"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects pdf filetype", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.pdf", "pdf", "stl", "output.stl", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["--pdf", "input.pdf", "output.stl"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects svgz conversion target type", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.obj", "eps", "svgz", "output.svgz", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["-z", "input.obj", "output.svgz"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls).toEqual([
{
cmd: "dvisvgm",
args: [...flags, `in/figure.${fileType}`, "-o", `out/figure.${convertTo}`],
},
]);
});
+112 -115
View File
@@ -1,16 +1,19 @@
import { beforeEach, expect, test } from "bun:test";
import { afterAll, afterEach, beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/ffmpeg";
import type { ExecFileOptions } from "node:child_process";
import { captureConsole } from "./helpers/converters";
let calls: string[][] = [];
let commands: string[] = [];
let lastOptions: ExecFileOptions | undefined;
function mockExecFile(
_cmd: string,
cmd: string,
args: string[],
options: ExecFileOptions,
callback: (err: Error | null, stdout: string, stderr: string) => void,
) {
commands.push(cmd);
calls.push(args);
lastOptions = options;
if (args.includes("fail.mov")) {
@@ -20,155 +23,150 @@ function mockExecFile(
}
}
// Both variables are read on every call and may be set in the developer's shell
// (mise.toml sets FFMPEG_OUTPUT_ARGS), so isolate them and restore them afterwards.
const originalEnv = {
FFMPEG_ARGS: process.env.FFMPEG_ARGS,
FFMPEG_OUTPUT_ARGS: process.env.FFMPEG_OUTPUT_ARGS,
};
let output: ReturnType<typeof captureConsole>;
beforeEach(() => {
calls = [];
commands = [];
lastOptions = undefined;
delete process.env.FFMPEG_ARGS;
delete process.env.FFMPEG_OUTPUT_ARGS;
output = captureConsole();
});
afterEach(() => {
output.restore();
});
afterAll(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
const ICO_FILTER = "scale='min(256,iw)':min'(256,ih)':force_original_aspect_ratio=decrease";
test("converts a normal file", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const result = await convert("in.mp4", "mp4", "avi", "out.avi", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["-i", "in.mp4", "out.avi"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(commands).toEqual(["ffmpeg"]);
expect(calls).toEqual([["-i", "in.mp4", "out.avi"]]);
expect(output.logs).toEqual(["stdout: Fake stdout"]);
});
test("adds resize for ico output", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const result = await convert("in.png", "png", "ico", "out.ico", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done: resized to 256x256");
expect(calls[0]).toEqual(
expect.arrayContaining(["-filter:v", expect.stringContaining("scale=")]),
);
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls[0]).toEqual(["-i", "in.png", "-filter:v", ICO_FILTER, "out.ico"]);
});
test("uses libaom-av1 for av1.mp4", async () => {
const originalConsoleLog = console.log;
test.each([
["av1.mp4", "libaom-av1"],
["av1.mkv", "libaom-av1"],
["h264.mp4", "libx264"],
["h264.mkv", "libx264"],
["h265.mp4", "libx265"],
["h265.mkv", "libx265"],
["h266.mp4", "libx266"],
["h266.mkv", "libx266"],
])("uses the right video codec for %s", async (convertTo, codec) => {
const result = await convert("in.mkv", "mkv", convertTo, "out.video", undefined, mockExecFile);
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in.mkv", "mkv", "av1.mp4", "out.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libaom-av1"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(result).toBe("Done");
expect(calls[0]).toEqual(["-i", "in.mkv", "-c:v", codec, "out.video"]);
});
test("uses libx264 for h264.mp4", async () => {
const originalConsoleLog = console.log;
test("does not force a codec for an unknown codec prefix", async () => {
await convert("in.mkv", "mkv", "vp9.webm", "out.webm", undefined, mockExecFile);
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx264"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("uses libx265 for h265.mp4", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in.mkv", "mkv", "h265.mp4", "out.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx265"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("uses libx266 for h266.mp4", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in.mkv", "mkv", "h266.mp4", "out.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx266"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls[0]).toEqual(["-i", "in.mkv", "out.webm"]);
});
test("respects FFMPEG_ARGS", async () => {
process.env.FFMPEG_ARGS = "-hide_banner -y";
const originalConsoleLog = console.log;
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
expect(calls[0]).toEqual(["-hide_banner", "-y", "-i", "input.mov", "output.mp4"]);
});
test("places FFMPEG_OUTPUT_ARGS after the input", async () => {
process.env.FFMPEG_OUTPUT_ARGS = "-preset veryfast -threads 2";
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual([
"-i",
"input.mov",
"-preset",
"veryfast",
"-threads",
"2",
"output.mp4",
]);
});
expect(calls[0]?.slice(0, 2)).toEqual(["-hide_banner", "-y"]);
expect(loggedMessage).toBe("stdout: Fake stdout");
test("orders global args, input, output args, codec args and target", async () => {
process.env.FFMPEG_ARGS = "-hwaccel vaapi";
process.env.FFMPEG_OUTPUT_ARGS = "-preset veryfast";
await convert("in.mkv", "mkv", "h265.mp4", "out.mp4", undefined, mockExecFile);
expect(calls[0]).toEqual([
"-hwaccel",
"vaapi",
"-i",
"in.mkv",
"-preset",
"veryfast",
"-c:v",
"libx265",
"out.mp4",
]);
});
test("splits FFMPEG_ARGS on any whitespace", async () => {
process.env.FFMPEG_ARGS = "-hide_banner\t-y -nostdin";
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
expect(calls[0]?.slice(0, 3)).toEqual(["-hide_banner", "-y", "-nostdin"]);
});
// BUG: split(/\s+/) keeps empty strings for leading/trailing whitespace
// (" -y ".split(/\s+/) is ["", "-y", ""]), so ffmpeg receives "" as an argument
// and fails. Remove `.failing` once src/converters/ffmpeg.ts drops empty entries.
test.failing("ignores leading and trailing whitespace in FFMPEG_ARGS", async () => {
process.env.FFMPEG_ARGS = " -y ";
process.env.FFMPEG_OUTPUT_ARGS = " -preset veryfast ";
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
expect(calls[0]).toEqual(["-y", "-i", "input.mov", "-preset", "veryfast", "output.mp4"]);
});
test("fails on exec error", async () => {
const originalConsoleError = console.error;
await expect(
convert("fail.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile),
).rejects.toBe("error: Error: mock failure");
let loggedMessage = "";
console.error = (msg) => {
loggedMessage = msg;
};
expect(convert("fail.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile)).rejects.toThrow(
"mock failure",
);
console.error = originalConsoleError;
expect(loggedMessage).toBe("stderr: Fake stderr: fail");
expect(output.errors).toEqual(["stderr: Fake stderr: fail"]);
});
test("logs stderr when execFile returns only stderr and no error", async () => {
const originalConsoleError = console.error;
let loggedMessage = "";
console.error = (msg) => {
loggedMessage = msg;
};
// Mock execFile to call back with no error, no stdout, but with stderr
const mockExecFileStderrOnly = (
_cmd: string,
@@ -181,9 +179,8 @@ test("logs stderr when execFile returns only stderr and no error", async () => {
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFileStderrOnly);
console.error = originalConsoleError;
expect(loggedMessage).toBe("stderr: Only stderr output");
expect(output.errors).toEqual(["stderr: Only stderr output"]);
expect(output.logs).toEqual([]);
});
test("passes a maxBuffer above the 1 MB default so long conversions don't overflow stderr (#565)", async () => {
+7 -22
View File
@@ -1,32 +1,17 @@
import { beforeEach, expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/graphicsmagick";
import { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
let calls: string[][] = [];
beforeEach(() => {
calls = [];
});
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test("convert applies EXIF auto-orient", async () => {
let command = "";
const mockExecFile: ExecFileFn = (
cmd: string,
args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = cmd;
calls.push(args);
callback(null, "", "");
};
const { execFile, calls } = createMockExecFile();
const result = await convert("input.jpg", "jpg", "pdf", "output.pdf", undefined, mockExecFile);
const result = await convert("input.jpg", "jpg", "pdf", "output.pdf", undefined, execFile);
expect(result).toBe("Done");
expect(command).toBe("gm");
expect(calls[0]).toEqual(["convert", "input.jpg", "-auto-orient", "output.pdf"]);
expect(calls).toEqual([
{ cmd: "gm", args: ["convert", "input.jpg", "-auto-orient", "output.pdf"] },
]);
});
+5
View File
@@ -2,6 +2,7 @@ import { test } from "bun:test";
import { ConvertFnWithExecFile } from "../../../src/converters/types";
import {
runConvertFailTest,
runConvertLogsNothingWithoutOutput,
runConvertLogsStderror,
runConvertLogsStderrorAndStdout,
runConvertSuccessTest,
@@ -23,4 +24,8 @@ export function runCommonTests(convert: ConvertFnWithExecFile) {
test("convert logs both stderr and stdout when present", async () => {
await runConvertLogsStderrorAndStdout(convert);
});
test("convert logs nothing when stdout and stderr are empty", async () => {
await runConvertLogsNothingWithoutOutput(convert);
});
}
+108 -100
View File
@@ -1,121 +1,129 @@
import { expect } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { ConvertFnWithExecFile, ExecFileFn } from "../../../src/converters/types";
import type { ConvertFnWithExecFile, ExecFileFn } from "../../../src/converters/types";
type ExecFileCall = { cmd: string; args: string[] };
/**
* Creates an execFile replacement that records every invocation and answers with the
* given error/stdout/stderr, so converter tests never spawn real binaries.
*/
export function createMockExecFile({
error = null,
stdout = "",
stderr = "",
}: { error?: Error | null; stdout?: string; stderr?: string } = {}) {
const calls: ExecFileCall[] = [];
const execFile: ExecFileFn = (cmd, args, callback) => {
calls.push({ cmd, args });
callback(error, stdout, stderr);
};
return { execFile, calls };
}
/**
* Redirects console.log/error/warn into arrays so tests can assert on them without
* printing to the terminal. Always call restore() afterwards (e.g. in afterEach).
*/
export function captureConsole() {
const original = { log: console.log, error: console.error, warn: console.warn };
const logs: string[] = [];
const errors: string[] = [];
const warnings: string[] = [];
const sink =
(target: string[]): Console["log"] =>
(...data) => {
target.push(data.map(String).join(" "));
};
console.log = sink(logs);
console.error = sink(errors);
console.warn = sink(warnings);
return {
logs,
errors,
warnings,
restore: () => {
console.log = original.log;
console.error = original.error;
console.warn = original.warn;
},
};
}
export async function runConvertSuccessTest(convertFn: ConvertFnWithExecFile) {
const originalConsoleLog = console.log;
const output = captureConsole();
const { execFile, calls } = createMockExecFile({ stdout: "Fake stdout" });
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
try {
const result = await convertFn("input.obj", "obj", "stl", "output.stl", undefined, execFile);
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback(null, "Fake stdout", "");
};
const result = await convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(result).toBe("Done");
expect(calls).toHaveLength(1);
expect(output.logs).toEqual(["stdout: Fake stdout"]);
expect(output.errors).toEqual([]);
} finally {
output.restore();
}
}
export async function runConvertFailTest(convertFn: ConvertFnWithExecFile) {
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback(new Error("Test error"), "", "");
};
const failures: [error: unknown, expected: RegExp][] = [
[new Error("Test error"), /error: Error: Test error/],
// non-standard error object lacking a 'message' property
[{ notMessage: true }, /error:/i],
// non-object error (e.g., a string)
["string error", /error:/i],
];
expect(
convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile),
).rejects.toMatch(/error: Error: Test error/);
for (const [error, expected] of failures) {
const { execFile } = createMockExecFile({ error: error as Error });
// Test with error object lacking 'message' property
const mockExecFileNoMessage: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
// Simulate a non-standard error object
callback({ notMessage: true } as unknown as ExecFileException, "", "");
};
expect(
convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFileNoMessage),
).rejects.toMatch(/error:/i);
// Test with a non-object error (e.g., a string)
const mockExecFileStringError: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback("string error" as unknown as ExecFileException, "", "");
};
expect(
convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFileStringError),
).rejects.toMatch(/error:/i);
await expect(
convertFn("input.obj", "obj", "stl", "output.stl", undefined, execFile),
).rejects.toMatch(expected);
}
}
export async function runConvertLogsStderror(convertFn: ConvertFnWithExecFile) {
const originalConsoleError = console.error;
const output = captureConsole();
const { execFile } = createMockExecFile({ stderr: "Fake stderr" });
let loggedMessage = "";
console.error = (msg) => {
loggedMessage = msg;
};
try {
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, execFile);
const mockExecFile = (
_cmd: string,
_args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Fake stderr");
};
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, mockExecFile);
console.error = originalConsoleError;
expect(loggedMessage).toBe("stderr: Fake stderr");
expect(output.errors).toEqual(["stderr: Fake stderr"]);
expect(output.logs).toEqual([]);
} finally {
output.restore();
}
}
export async function runConvertLogsStderrorAndStdout(convertFn: ConvertFnWithExecFile) {
const originalConsoleError = console.error;
const originalConsoleLog = console.log;
const output = captureConsole();
const { execFile } = createMockExecFile({ stdout: "Fake stdout", stderr: "Fake stderr" });
let loggedError = "";
let loggedMessage = "";
console.error = (msg) => {
loggedError = msg;
};
console.log = (msg) => {
loggedMessage = msg;
};
try {
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, execFile);
const mockExecFile = (
_cmd: string,
_args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "Fake stdout", "Fake stderr");
};
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, mockExecFile);
console.error = originalConsoleError;
console.log = originalConsoleLog;
expect(loggedError).toBe("stderr: Fake stderr");
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(output.errors).toEqual(["stderr: Fake stderr"]);
expect(output.logs).toEqual(["stdout: Fake stdout"]);
} finally {
output.restore();
}
}
export async function runConvertLogsNothingWithoutOutput(convertFn: ConvertFnWithExecFile) {
const output = captureConsole();
const { execFile } = createMockExecFile();
try {
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, execFile);
expect(output.logs).toEqual([]);
expect(output.errors).toEqual([]);
} finally {
output.restore();
}
}
+75 -170
View File
@@ -1,181 +1,86 @@
import { beforeEach, expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/imagemagick";
import { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
let calls: string[][] = [];
beforeEach(() => {
calls = [];
});
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test("convert respects ico conversion target type", async () => {
const originalConsoleLog = console.log;
const ICO_ARGS = ["-define", "icon:auto-resize=256,128,64,48,32,16", "-background", "none"];
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
test.each([
{
name: "applies only EXIF auto-orient for a plain conversion",
fileType: "jpg",
convertTo: "png",
inputArgs: [],
outputArgs: ["-auto-orient"],
},
{
name: "does not rasterize svg at high density unless the target is ico",
fileType: "svg",
convertTo: "png",
inputArgs: [],
outputArgs: ["-auto-orient"],
},
{
name: "auto-resizes ico output with a transparent background",
fileType: "eps",
convertTo: "ico",
inputArgs: [],
outputArgs: [...ICO_ARGS, "-auto-orient"],
},
{
name: "rasterizes svg at high density for ico output",
fileType: "svg",
convertTo: "ico",
inputArgs: ["-background", "none", "-density", "512"],
outputArgs: [...ICO_ARGS, "-auto-orient"],
},
{
name: "rasterizes pdf input at 300 dpi",
fileType: "pdf",
convertTo: "png",
inputArgs: ["-density", "300"],
outputArgs: ["-auto-orient"],
},
{
name: "combines pdf density with ico options",
fileType: "pdf",
convertTo: "ico",
inputArgs: ["-density", "300"],
outputArgs: [...ICO_ARGS, "-auto-orient"],
},
{
name: "disables the emf delegate and flattens onto white",
fileType: "emf",
convertTo: "png",
inputArgs: ["-define", "emf:delegate=false", "-density", "300"],
outputArgs: ["-background", "white", "-alpha", "remove", "-auto-orient"],
},
{
name: "combines emf handling with ico options",
fileType: "emf",
convertTo: "ico",
inputArgs: ["-define", "emf:delegate=false", "-density", "300"],
outputArgs: [...ICO_ARGS, "-background", "white", "-alpha", "remove", "-auto-orient"],
},
])("$name ($fileType -> $convertTo)", async ({ fileType, convertTo, inputArgs, outputArgs }) => {
const { execFile, calls } = createMockExecFile();
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.obj", "eps", "ico", "output.ico", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(
expect.arrayContaining([
"-define",
"icon:auto-resize=256,128,64,48,32,16",
"-background",
"none",
"input.obj",
"output.ico",
]),
const result = await convert(
`in/image.${fileType}`,
fileType,
convertTo,
`out/image.${convertTo}`,
undefined,
execFile,
);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects ico conversion target type with svg as input filetype", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.svg", "svg", "ico", "output.ico", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(
expect.arrayContaining([
"-define",
"icon:auto-resize=256,128,64,48,32,16",
"-background",
"none",
"-density",
"512",
"input.svg",
"output.ico",
]),
);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects ico conversion target type with emf as input filetype", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.emf", "emf", "ico", "output.ico", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(
expect.arrayContaining([
"-define",
"icon:auto-resize=256,128,64,48,32,16",
"-background",
"none",
"emf:delegate=false",
"-density",
"300",
"white",
"-alpha",
"remove",
"input.emf",
"output.ico",
]),
);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects emf as input filetype", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.emf", "emf", "obj", "output.obj", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(
expect.arrayContaining([
"-define",
"emf:delegate=false",
"-density",
"300",
"-background",
"white",
"-alpha",
"remove",
"input.emf",
"output.obj",
]),
);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert applies EXIF auto-orient", async () => {
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "", "");
};
const result = await convert("input.jpg", "jpg", "png", "output.png", undefined, mockExecFile);
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["input.jpg", "-auto-orient", "output.png"]));
expect(calls).toEqual([
{
cmd: "magick",
args: [...inputArgs, `in/image.${fileType}`, ...outputArgs, `out/image.${convertTo}`],
},
]);
});
+9 -2
View File
@@ -1,7 +1,14 @@
import { test } from "bun:test";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/inkscape";
import { runCommonTests } from "./helpers/commonTests";
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
test("invokes inkscape with input and -o target path", async () => {
const { execFile, calls } = createMockExecFile();
await convert("in/drawing.svg", "svg", "png", "out/drawing.png", undefined, execFile);
expect(calls).toEqual([{ cmd: "inkscape", args: ["in/drawing.svg", "-o", "out/drawing.png"] }]);
});
+9 -2
View File
@@ -1,7 +1,14 @@
import { test } from "bun:test";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/libheif";
import { runCommonTests } from "./helpers/commonTests";
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
test("invokes heif-convert with input and target path", async () => {
const { execFile, calls } = createMockExecFile();
await convert("in/photo.heic", "heic", "jpeg", "out/photo.jpg", undefined, execFile);
expect(calls).toEqual([{ cmd: "heif-convert", args: ["in/photo.heic", "out/photo.jpg"] }]);
});
+19 -70
View File
@@ -1,91 +1,40 @@
import { beforeEach, expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/libjxl";
import { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
let command: string = "";
beforeEach(() => {
command = "";
});
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test("convert uses djxl with input filetype being jxl", async () => {
const originalConsoleLog = console.log;
const { execFile, calls } = createMockExecFile();
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = _cmd;
callback(null, "Fake stdout", "");
};
const result = await convert("input.jxl", "jxl", "png", "output.png", undefined, mockExecFile);
console.log = originalConsoleLog;
const result = await convert("input.jxl", "jxl", "png", "output.png", undefined, execFile);
expect(result).toBe("Done");
expect(command).toEqual("djxl");
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls).toEqual([{ cmd: "djxl", args: ["input.jxl", "output.png"] }]);
});
test("convert uses cjxl with output filetype being jxl", async () => {
const originalConsoleLog = console.log;
const { execFile, calls } = createMockExecFile();
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = _cmd;
callback(null, "Fake stdout", "");
};
const result = await convert("input.png", "png", "jxl", "output.jxl", undefined, mockExecFile);
console.log = originalConsoleLog;
const result = await convert("input.png", "png", "jxl", "output.jxl", undefined, execFile);
expect(result).toBe("Done");
expect(command).toEqual("cjxl");
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls).toEqual([{ cmd: "cjxl", args: ["input.png", "output.jxl"] }]);
});
test("convert prefers cjxl when both input and output filetype are jxl", async () => {
const { execFile, calls } = createMockExecFile();
await convert("input.jxl", "jxl", "jxl", "output.jxl", undefined, execFile);
expect(calls[0]?.cmd).toBe("cjxl");
});
test("convert uses empty string as command with neither input nor output filetype being jxl", async () => {
const originalConsoleLog = console.log;
const { execFile, calls } = createMockExecFile();
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("input.png", "png", "jpg", "output.jpg", undefined, execFile);
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = _cmd;
callback(null, "Fake stdout", "");
};
const result = await convert("input.png", "png", "jpg", "output.jpg", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(command).toEqual("");
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls[0]?.cmd).toBe("");
});
+86 -24
View File
@@ -73,7 +73,7 @@ test("invokes soffice with --headless and outdir derived from targetPath", async
]);
});
test("uses only outFilter when input has no filter (e.g., pdf -> txt)", async () => {
test("uses writer_pdf_import and a text outFilter for 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");
@@ -89,18 +89,12 @@ test("uses only outFilter when input has no filter (e.g., pdf -> txt)", async ()
]);
});
test("uses only infilter when convertTo has no out filter (e.g., docx -> pdf)", async () => {
test("uses no filters at all when converting to pdf (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("does not force an infilter for wps (Microsoft Works, not MS Word 97)", async () => {
@@ -203,26 +197,94 @@ test("logs both stdout and stderr when both are present", async () => {
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/,
);
await 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 filter branch (test-only exports) ---------------------------------
test("getFilters returns calc mapping when present", () => {
// temporarily add entries to calc mapping
filters.calc["testfoo"] = "TestFooFilter";
filters.calc["testbar"] = "TestBarFilter";
// --- spreadsheet (calc) conversions ------------------------------------------
test.each([
{
fileType: "csv",
convertTo: "xlsx",
infilter: "Text - txt - csv (StarCalc)",
convertToArg: "xlsx:Calc MS Excel 2007 XML",
},
{
fileType: "ods",
convertTo: "xlsx",
infilter: "calc8",
convertToArg: "xlsx:Calc MS Excel 2007 XML",
},
{
fileType: "xlsx",
convertTo: "ods",
infilter: "Calc MS Excel 2007 XML",
convertToArg: "ods:calc8",
},
{
fileType: "xls",
convertTo: "xlsm",
infilter: "MS Excel 97",
convertToArg: "xlsm:Calc MS Excel 2007 XML VBA",
},
])(
"uses calc filters for $fileType -> $convertTo",
async ({ fileType, convertTo, infilter, convertToArg }) => {
await convert(
`in.${fileType}`,
fileType,
convertTo,
`out/out.${convertTo}`,
undefined,
mockExecFile,
);
try {
const res = getFilters("testfoo", "testbar");
expect(res).toEqual(["TestFooFilter", "TestBarFilter"]);
} finally {
// cleanup
delete filters.calc["testfoo"];
delete filters.calc["testbar"];
}
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
expect(args).toEqual([
"--headless",
`--infilter=${infilter}`,
"--convert-to",
convertToArg,
"--outdir",
"out",
`in.${fileType}`,
]);
},
);
test("uses no filters for a spreadsheet converted to pdf", async () => {
await convert("in.xlsx", "xlsx", "pdf", "out/out.pdf", undefined, mockExecFile);
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
expect(args).toEqual(["--headless", "--convert-to", "pdf", "--outdir", "out", "in.xlsx"]);
});
// --- getFilters (test-only export) -------------------------------------------
test("getFilters returns text filters when both formats are text formats", () => {
expect(getFilters("doc", "odt")).toEqual(["MS Word 97", "writer8"]);
});
test("getFilters falls back to calc filters when the target is not a text format", () => {
expect(getFilters("xls", "csv")).toEqual(["MS Excel 97", "Text - txt - csv (StarCalc)"]);
});
test.each([
["docx", "xlsx"],
["xlsx", "docx"],
["unknown", "odt"],
["docx", "unknown"],
])("getFilters returns no filters for unrelated formats (%s -> %s)", (fileType, convertTo) => {
expect(getFilters(fileType, convertTo)).toEqual([null, null]);
});
test("getFilters keeps wps deliberately unmapped instead of falling through to calc", () => {
expect("wps" in filters.text).toBe(true);
expect(filters.text.wps).toBeNull();
expect(getFilters("wps", "docx")).toEqual([null, "MS Word 2007 XML"]);
});
+363 -349
View File
@@ -1,393 +1,407 @@
// Set isolated DB path before import to protect production data
import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { mkdirSync, rmSync, existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
const testDbDir = join(tmpdir(), "converter-test-db");
rmSync(testDbDir, { recursive: true, force: true });
mkdirSync(testDbDir, { recursive: true });
process.env.DB_PATH = join(testDbDir, "test.sqlite");
import { test, expect, afterAll, afterEach } from "bun:test";
import { join } from "node:path";
import type { Cookie } from "elysia";
import { writeFile, mkdir, rm, readFile } from "fs/promises";
import { Database } from "bun:sqlite";
import { properties as libreofficeProperties } from "../../src/converters/libreoffice";
import {
chunks,
getAllInputs,
getAllTargets,
getPossibleTargets,
handleConvert,
mainConverter,
} from "../../src/converters/main";
// tests/preload.ts points DB_PATH at a temporary database, so production data is never used
import db from "../../src/db/db";
import { captureConsole } from "./helpers/converters";
// dynamic import ensures that the module is loaded after the environment variable is set
const converterModule = await import("../../src/converters/main");
const { getPossibleTargets, getAllTargets, getAllInputs, handleConvert, mainConverter, chunks } =
converterModule;
const testRoot = mkdtempSync(join(tmpdir(), "convertx-main-test-"));
// Isolated test database: avoids mutation of ./data/mydb.sqlite
const dbPath = process.env.DB_PATH ?? join(testDbDir, "test.sqlite");
const dbDir = dirname(resolve(dbPath));
// Every row written by these tests uses this prefix, so cleanup never touches other rows.
const JOB_PREFIX = "main-test-";
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
type FileRow = { file_name: string; output_file_name: string; status: string };
function jobCookie(name?: string): Cookie<string | undefined> {
return { value: name === undefined ? undefined : `${JOB_PREFIX}${name}` } as Cookie<
string | undefined
>;
}
const testDb = new Database(dbPath, { create: true });
function rowsFor(name: string, orderBy: "id" | "file_name" = "id"): FileRow[] {
return db
.query(
`SELECT file_name, output_file_name, status FROM file_names WHERE job_id = ? ORDER BY ${orderBy}`,
)
.all(`${JOB_PREFIX}${name}`) as FileRow[];
}
function countAllRows(): number {
return (db.query("SELECT COUNT(*) AS count FROM file_names").get() as { count: number }).count;
}
const vcard = (fullName: string) => `BEGIN:VCARD\nFN:${fullName}\nEND:VCARD\n`;
let caseCounter = 0;
let uploadsDir: string;
let outputDir: string;
let output: ReturnType<typeof captureConsole>;
beforeEach(() => {
// handleConvert concatenates dir and file name, so both need a trailing slash
const caseDir = join(testRoot, `case-${caseCounter++}`);
uploadsDir = `${join(caseDir, "uploads")}/`;
outputDir = `${join(caseDir, "output")}/`;
mkdirSync(uploadsDir, { recursive: true });
mkdirSync(outputDir, { recursive: true });
output = captureConsole();
});
// cleans up the table before each test for real isolation (even with parallel tests)
afterEach(() => {
try {
testDb.query("DELETE FROM file_names");
} catch (err) {
if (err instanceof Error) {
// ignore only the expected error for missing tables; rethrow real issues
if (!err.message.includes("no such table")) {
throw err;
}
}
}
output.restore();
db.query("DELETE FROM file_names WHERE job_id LIKE ?").run(`${JOB_PREFIX}%`);
});
// closes the DB and removes the temp directory after all tests finish
// removes the temp directory with the uploads and outputs after all tests finish
afterAll(() => {
testDb.close();
rmSync(testDbDir, { recursive: true, force: true });
rmSync(testRoot, { recursive: true, force: true });
});
// Mock factory for jobId Cookie to avoid repeated `as Cookie` casts
function createMockJobId(value: string): Cookie<string | undefined> {
return { value } as Cookie<string | undefined>;
}
describe("converter registry", () => {
test("getPossibleTargets lists the targets of every converter for an input type", () => {
expect(getPossibleTargets("vcf")).toEqual({ vcf: ["csv"] });
});
test("getPossibleTargets, getAllTargets and getAllInputs include vcf/csv mapping", () => {
const possible = getPossibleTargets("vcf");
// should have an entry for the vcf converter
expect(Object.keys(possible).length).toBeGreaterThan(0);
// getAllTargets should include 'vcf' converter target csv
const allTargets = getAllTargets();
// Be defensive: allTargets.vcf may be undefined in some builds
expect(allTargets).toHaveProperty("vcf");
expect(Array.isArray(allTargets.vcf)).toBe(true);
expect((allTargets.vcf ?? []).includes("csv")).toBe(true);
test("getPossibleTargets normalizes the input type", () => {
expect(getPossibleTargets("VCF")).toEqual({ vcf: ["csv"] });
expect(Object.keys(getPossibleTargets("jpeg")).length).toBeGreaterThan(0);
expect(getPossibleTargets("JPG")).toEqual(getPossibleTargets("jpeg"));
expect(getPossibleTargets("htm")).toEqual(getPossibleTargets("html"));
});
const allInputs = getAllInputs("vcf");
expect(allInputs.includes("vcf")).toBe(true);
test("getPossibleTargets returns an empty object for unknown input types", () => {
expect(getPossibleTargets("xyz123")).toEqual({});
});
test("getAllTargets has an entry for every registered converter", () => {
expect(Object.keys(getAllTargets()).sort()).toEqual([
"assimp",
"calibre",
"dasel",
"dvisvgm",
"ffmpeg",
"graphicsmagick",
"imagemagick",
"inkscape",
"libheif",
"libjxl",
"libreoffice",
"markitDown",
"msgconvert",
"pandoc",
"pdftops",
"potrace",
"resvg",
"vcf",
"vips",
"vtracer",
"xelatex",
]);
});
test("getAllTargets merges the targets of all categories of a converter", () => {
const allTargets = getAllTargets();
expect(allTargets.vcf).toEqual(["csv"]);
expect(allTargets.libjxl).toEqual([
"apng",
"exr",
"jpeg",
"pam",
"pfm",
"pgm",
"pgx",
"png",
"ppm",
"jxl",
]);
});
test("getAllInputs merges the inputs of all categories of a converter", () => {
expect(getAllInputs("vcf")).toEqual(["vcf"]);
expect(getAllInputs("libjxl")).toEqual([
"jxl",
"apng",
"exr",
"gif",
"jpeg",
"pam",
"pfm",
"pgm",
"pgx",
"png",
"ppm",
]);
});
test("getAllInputs returns an empty list for unknown converters", () => {
expect(getAllInputs("doesnotexist")).toEqual([]);
});
// BUG: building allTargets/allInputs stores the first category's array by
// reference and then pushes the other categories into it. That mutates the
// converters' own properties, so e.g. LibreOffice offers docx -> xlsx and
// libjxl offers jxl -> jxl. Remove `.failing` once main.ts copies the arrays.
test.failing("listing all targets and inputs does not leak formats across categories", () => {
expect(getPossibleTargets("docx").libreoffice).not.toContain("xlsx");
expect(getPossibleTargets("jxl").libjxl).not.toContain("jxl");
expect(libreofficeProperties.from.text).not.toContain("xlsx");
expect(libreofficeProperties.to.text).not.toContain("xlsm");
});
});
test("handleConvert uses vcf converter to transform .vcf to .csv and records DB entry", async () => {
const uploadsDir = "./data/uploads/test-main/";
const outputDir = "./data/output/test-main/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
describe("chunks", () => {
test("chunks with size 0 returns entire array as single chunk", () => {
expect(chunks([1, 2, 3, 4, 5], 0)).toEqual([[1, 2, 3, 4, 5]]);
});
const fileName = "contact.vcf";
const inputPath = `${uploadsDir}${fileName}`;
const sampleVcf = `BEGIN:VCARD
FN:John Doe
N:Doe;John;;;
TEL;TYPE=CELL:123456789
EMAIL:john@example.com
ORG:Example Inc;
END:VCARD
`;
await writeFile(inputPath, sampleVcf, "utf-8");
test("chunks with negative size returns entire array as single chunk", () => {
expect(chunks(["a", "b", "c"], -1)).toEqual([["a", "b", "c"]]);
});
const jobId = createMockJobId("4242");
test("chunks with size larger than array returns single chunk", () => {
expect(chunks([1, 2], 10)).toEqual([[1, 2]]);
});
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId);
test("chunks with exact division returns equal-sized chunks", () => {
expect(chunks([1, 2, 3, 4, 5, 6], 2)).toEqual([
[1, 2],
[3, 4],
[5, 6],
]);
});
const outPath = `${outputDir}contact.csv`;
const out = await readFile(outPath, "utf-8");
test("chunks puts the remainder into a smaller last chunk", () => {
expect(chunks([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
});
// CSV should contain headers and the name
expect(out.includes("Full Name")).toBe(true);
expect(out.includes("John Doe")).toBe(true);
// cleanup
await rm(inputPath);
await rm(outPath);
test("chunks of an empty array is empty", () => {
expect(chunks([], 3)).toEqual([]);
});
});
test("handleConvert with unsupported format does not throw", async () => {
const uploadsDir = "./data/uploads/test-unsupported/";
const outputDir = "./data/output/test-unsupported/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
describe("mainConverter", () => {
test("returns 'File type not supported' for unsupported combination", async () => {
expect(await mainConverter("test.xyz", "xyz", "abc", "out.abc")).toBe(
"File type not supported",
);
expect(output.logs).toContain("No available converter supports converting from xyz to abc.");
});
// Create a dummy file with unsupported extension
const fileName = "dummy.xyz123";
const inputPath = `${uploadsDir}${fileName}`;
await writeFile(inputPath, "dummy content", "utf-8");
test("returns 'File type not supported' for an unknown converter name", async () => {
const inputPath = `${uploadsDir}contact.vcf`;
await writeFile(inputPath, vcard("Jane Roe"));
// Try to convert unsupported format
const jobId = createMockJobId("unsupported-test");
// This should not throw, just log that no converter is available
await expect(
handleConvert([fileName], uploadsDir, outputDir, "pdf", "xyz123", jobId),
).resolves.toBeUndefined();
expect(
await mainConverter(inputPath, "vcf", "csv", `${outputDir}contact.csv`, {}, "doesnotexist"),
).toBe("File type not supported");
});
await rm(inputPath);
});
test("auto-discovers converter when not specified", async () => {
const inputPath = `${uploadsDir}test.vcf`;
const outPath = `${outputDir}test.csv`;
await writeFile(inputPath, vcard("Discover Test"));
test("handleConvert with multiple files processes them", async () => {
const uploadsDir = "./data/uploads/test-multi/";
const outputDir = "./data/output/test-multi/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
expect(await mainConverter(inputPath, "vcf", "csv", outPath)).toBe("Done");
expect(await readFile(outPath, "utf-8")).toContain("Discover Test");
});
// Create multiple vcf files
const files = ["contact1.vcf", "contact2.vcf", "contact3.vcf"];
const baseVcf = `BEGIN:VCARD
FN:Test Contact
N:Contact;Test;;;
END:VCARD
`;
test("normalizes the input type before discovering a converter", async () => {
const inputPath = `${uploadsDir}test.VCF`;
const outPath = `${outputDir}test.csv`;
await writeFile(inputPath, vcard("Upper Case"));
for (const fileName of files) {
await writeFile(`${uploadsDir}${fileName}`, baseVcf, "utf-8");
}
expect(await mainConverter(inputPath, "VCF", "csv", outPath)).toBe("Done");
});
const jobId = createMockJobId("multi-test");
await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobId);
test("returns 'Failed, check logs' when converter throws", async () => {
expect(
await mainConverter(`${uploadsDir}missing.vcf`, "vcf", "csv", "out.csv", undefined, "vcf"),
).toBe("Failed, check logs");
expect(output.errors.join("\n")).toContain("using vcf");
});
// Verify all output files were created
for (const fileName of files) {
const outputFileName = fileName.replace(".vcf", ".csv");
const outPath = `${outputDir}${outputFileName}`;
expect(await readFile(outPath, "utf-8")).toBeTruthy();
await rm(outPath);
}
// Cleanup
for (const fileName of files) {
await rm(`${uploadsDir}${fileName}`);
}
});
test("handleConvert with explicit converter skips discovery", async () => {
const uploadsDir = "./data/uploads/test-explicit/";
const outputDir = "./data/output/test-explicit/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
const fileName = "test.vcf";
const inputPath = `${uploadsDir}${fileName}`;
const sampleVcf = `BEGIN:VCARD
FN:Explicit Test
N:Test;Explicit;;;
END:VCARD
`;
await writeFile(inputPath, sampleVcf, "utf-8");
// Use explicit vcf converter (avoids discovery loop)
const jobId = createMockJobId("explicit-test");
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId);
const outPath = `${outputDir}test.csv`;
expect(await readFile(outPath, "utf-8")).toBeTruthy();
await rm(inputPath);
await rm(outPath);
});
test("handleConvert with dvisvgm discovers converter from category keys", async () => {
const uploadsDir = "./data/uploads/test-dvisvgm/";
const outputDir = "./data/output/test-dvisvgm/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
// Create a simple DVI-like file (dvisvgm would normally handle .dvi files)
// For testing we'll use a latex file and ask for svg output, which should fail gracefully
const fileName = "test.tex";
const inputPath = `${uploadsDir}${fileName}`;
await writeFile(
inputPath,
"\\documentclass{article}\\begin{document}test\\end{document}",
"utf-8",
test.each([
["msgconvert", "mail.eml", "eml", "msg"],
["calibre", "news.recipe", "recipe", "epub"],
])(
"returns 'Failed, check logs' when %s refuses the conversion",
async (converterName, fileName, fileType, convertTo) => {
expect(
await mainConverter(
`${uploadsDir}${fileName}`,
fileType,
convertTo,
`${outputDir}out.${convertTo}`,
{},
converterName,
),
).toBe("Failed, check logs");
expect(output.errors.join("\n")).toContain(`using ${converterName}`);
},
);
// This tests that converter discovery iterates through all converters
// and tries to find one matching tex -> svg
const jobId = createMockJobId("dvi-test");
await handleConvert([fileName], uploadsDir, outputDir, "svg", "tex", jobId);
// BUG: discovery loops with `for (converterName in properties)` and only breaks
// the inner loop, so the variable always ends on the last registered converter.
// Remove `.failing` once mainConverter stops at the first match.
test.failing("logs the name of the auto-discovered converter", async () => {
const inputPath = `${uploadsDir}named.vcf`;
await writeFile(inputPath, vcard("Named"));
await rm(inputPath);
await mainConverter(inputPath, "vcf", "csv", `${outputDir}named.csv`);
expect(output.logs.join("\n")).toContain("successfully using vcf");
});
// BUG: same root cause - later matches overwrite earlier ones, so emf -> png
// uses ImageMagick even though Inkscape is registered first on purpose
// ("Prioritize Inkscape for EMF files"). The input does not exist, so whichever
// converter is picked fails fast; only the logged converter name matters.
test.failing("auto-discovery prefers the first registered converter", async () => {
await mainConverter(`${uploadsDir}missing.emf`, "emf", "png", `${outputDir}missing.png`);
expect(output.errors.join("\n")).toContain("using inkscape");
});
});
test("handleConvert processes multiple files with vcf converter across categories", async () => {
const uploadsDir = "./data/uploads/test-vcf-multi/";
const outputDir = "./data/output/test-vcf-multi/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
describe("handleConvert", () => {
test("converts every file and records one row per file", async () => {
const files = ["a.vcf", "b.vcf", "c.vcf"];
for (const fileName of files) {
await writeFile(`${uploadsDir}${fileName}`, vcard(`Contact ${fileName}`));
}
const baseVcf = `BEGIN:VCARD
FN:Multi Test
N:Test;Multi;;;
TEL:9999
EMAIL:multi@test.com
END:VCARD
`;
await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobCookie("rows"));
// Create 2 vcf files to test looping through fileNames
const files = ["a.vcf", "b.vcf"];
for (const f of files) {
await writeFile(`${uploadsDir}${f}`, baseVcf, "utf-8");
}
expect(rowsFor("rows", "file_name")).toEqual([
{ file_name: "a.vcf", output_file_name: "a.csv", status: "Done" },
{ file_name: "b.vcf", output_file_name: "b.csv", status: "Done" },
{ file_name: "c.vcf", output_file_name: "c.csv", status: "Done" },
]);
for (const fileName of files) {
const csv = await readFile(`${outputDir}${fileName.replace(".vcf", ".csv")}`, "utf-8");
expect(csv).toBe(`Full Name\n"Contact ${fileName}"`);
}
});
// Explicit converter to hit the properties access code path
const jobId = createMockJobId("vcf-multi-test");
await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobId);
test("converts but does not record rows when the job id cookie has no value", async () => {
await writeFile(`${uploadsDir}contact.vcf`, vcard("No Job"));
const rowsBefore = countAllRows();
for (const f of files) {
const csvName = f.replace(".vcf", ".csv");
expect(await readFile(`${outputDir}${csvName}`, "utf-8")).toBeTruthy();
await rm(`${outputDir}${csvName}`);
}
await handleConvert(["contact.vcf"], uploadsDir, outputDir, "csv", "vcf", jobCookie());
for (const f of files) {
await rm(`${uploadsDir}${f}`);
}
});
test("chunks with size 0 returns entire array as single chunk", () => {
const arr = [1, 2, 3, 4, 5];
const result = chunks(arr, 0);
expect(result).toEqual([[1, 2, 3, 4, 5]]);
});
test("chunks with negative size returns entire array as single chunk", () => {
const arr = ["a", "b", "c"];
const result = chunks(arr, -1);
expect(result).toEqual([["a", "b", "c"]]);
});
test("chunks with size larger than array returns single chunk", () => {
const arr = [1, 2];
const result = chunks(arr, 10);
expect(result).toEqual([[1, 2]]);
});
test("chunks with exact division returns equal-sized chunks", () => {
const arr = [1, 2, 3, 4, 5, 6];
const result = chunks(arr, 2);
expect(result).toEqual([
[1, 2],
[3, 4],
[5, 6],
]);
});
test("mainConverter returns 'File type not supported' for unsupported combination", async () => {
const result = await mainConverter("test.xyz", "xyz", "abc", "out.abc");
expect(result).toBe("File type not supported");
});
test("mainConverter auto-discovers converter when not specified", async () => {
const uploadsDir = "./data/uploads/test-discover/";
const outputDir = "./data/output/test-discover/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
const fileName = "test.vcf";
const inputPath = `${uploadsDir}${fileName}`;
const outPath = `${outputDir}test.csv`;
const sampleVcf = `BEGIN:VCARD
FN:Discover Test
N:Test;Discover;;;
END:VCARD
`;
await writeFile(inputPath, sampleVcf, "utf-8");
// Call mainConverter without explicit converterName to trigger discovery
const result = await mainConverter(inputPath, "vcf", "csv", outPath, undefined, undefined);
expect(result).toBe("Done");
expect(await readFile(outPath, "utf-8")).toBeTruthy();
await rm(inputPath);
await rm(outPath);
});
test("mainConverter returns 'Failed, check logs' when converter throws", async () => {
// Try with a file that might cause issues (non-existent input)
// This should trigger the catch block
const result = await mainConverter(
"/nonexistent/path.vcf",
"vcf",
"csv",
"out.csv",
undefined,
"vcf",
);
expect(result).toBe("Failed, check logs");
});
test("handleConvert with normalization covers fileTypeOrig variations", async () => {
const uploadsDir = "./data/uploads/test-normalize/";
const outputDir = "./data/output/test-normalize/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
// Use a file extension that gets normalized (e.g., .htm -> .html)
const fileName = "index.htm";
const inputPath = `${uploadsDir}${fileName}`;
const sampleHtml = `<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>Test Content</body>
</html>`;
await writeFile(inputPath, sampleHtml, "utf-8");
// htm normalizes to html; libreoffice can handle html -> pdf
const jobId = createMockJobId("normalize-test");
await handleConvert([fileName], uploadsDir, outputDir, "pdf", "htm", jobId);
await rm(inputPath);
// PDF output may or may not exist depending on soffice availability, so we don't check it
});
test("handleConvert with explicit converter processes VCF to CSV", async () => {
const uploadsDir = "./data/uploads/test-main-extra/";
const outputDir = "./data/output/test-main-extra/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
const fileName = "contact.vcf";
const inputPath = `${uploadsDir}${fileName}`;
const outPath = `${outputDir}contact.csv`;
const sampleVcf = `BEGIN:VCARD
FN:Jane Roe
N:Roe;Jane;;;
TEL;TYPE=CELL:555
EMAIL:jane@example.com
END:VCARD
`;
await writeFile(inputPath, sampleVcf, "utf-8");
// Call handleConvert with an explicit converter name ("vcf") to transform VCF to CSV
const jobId = createMockJobId("discovery-test");
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId);
const out = await readFile(outPath, "utf-8");
expect(out.includes("Jane Roe")).toBe(true);
await rm(inputPath);
await rm(outPath);
});
test("handleConvert handles files without extension by appending output extension", async () => {
const uploadsDir = "./data/uploads/test-main-noext/";
const outputDir = "./data/output/test-main-noext/";
await mkdir(uploadsDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
const fileName = "noextfile"; // no extension
const inputPath = `${uploadsDir}${fileName}`;
const sampleVcf = `BEGIN:VCARD
FN:No Ext
N:Ext;No;;;
END:VCARD
`;
await writeFile(inputPath, sampleVcf, "utf-8");
const outPath = `${outputDir}${fileName}.csv`;
// Call handleConvert with explicit vcf converter (no extension on input)
const jobId = createMockJobId("noext-test");
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId);
await rm(inputPath);
await rm(outPath);
expect(countAllRows()).toBe(rowsBefore);
expect(await readFile(`${outputDir}contact.csv`, "utf-8")).toContain("No Job");
});
test("replaces only the last occurrence of the extension", async () => {
await writeFile(`${uploadsDir}vcf.backup.vcf`, vcard("Backup"));
await handleConvert(["vcf.backup.vcf"], uploadsDir, outputDir, "csv", "vcf", jobCookie("ext"));
expect(rowsFor("ext")).toEqual([
{ file_name: "vcf.backup.vcf", output_file_name: "vcf.backup.csv", status: "Done" },
]);
expect(await readFile(`${outputDir}vcf.backup.csv`, "utf-8")).toContain("Backup");
});
test("keeps the base name of files with an upper-case extension", async () => {
await writeFile(`${uploadsDir}CONTACT.VCF`, vcard("Upper"));
await handleConvert(["CONTACT.VCF"], uploadsDir, outputDir, "csv", "vcf", jobCookie("upper"));
expect(rowsFor("upper")).toEqual([
{ file_name: "CONTACT.VCF", output_file_name: "CONTACT.csv", status: "Done" },
]);
});
test("handles files without extension by appending output extension", async () => {
await writeFile(`${uploadsDir}noextfile`, vcard("No Ext"));
await handleConvert(["noextfile"], uploadsDir, outputDir, "csv", "vcf", jobCookie("noext"));
expect(rowsFor("noext")).toEqual([
{ file_name: "noextfile", output_file_name: "noextfile.csv", status: "Done" },
]);
expect(await readFile(`${outputDir}noextfile.csv`, "utf-8")).toContain("No Ext");
});
test("resolves and records the status when no converter supports the conversion", async () => {
await writeFile(`${uploadsDir}dummy.xyz123`, "dummy content");
await expect(
handleConvert(["dummy.xyz123"], uploadsDir, outputDir, "pdf", "xyz123", jobCookie("none")),
).resolves.toBeUndefined();
expect(rowsFor("none")).toEqual([
{
file_name: "dummy.xyz123",
output_file_name: "dummy.pdf",
status: "File type not supported",
},
]);
});
test("rejects when the output directory is missing, so the caller can log it", async () => {
await writeFile(`${uploadsDir}contact.vcf`, vcard("Lost"));
const missingDir = `${join(testRoot, "does-not-exist")}/`;
await expect(
handleConvert(["contact.vcf"], uploadsDir, missingDir, "csv", "vcf", jobCookie("lost")),
).rejects.toThrow(/ENOENT/);
expect(rowsFor("lost")).toEqual([]);
});
test.each([
["jpeg", "photo.jpg"],
["markdown_strict", "photo.md"],
["glb2", "photo.glb"],
["stlb", "photo.stl"],
])("names the output for target %s with its canonical extension", async (convertTo, expected) => {
// the conversion itself is unsupported, but the output name is still derived and recorded
await handleConvert(["photo.JPG"], uploadsDir, outputDir, convertTo, "none", jobCookie("name"));
expect(rowsFor("name")).toEqual([
{ file_name: "photo.JPG", output_file_name: expected, status: "File type not supported" },
]);
});
test("records numbered sibling outputs (e.g. one file per page) in numeric order", async () => {
// converters such as ImageMagick write doc-1.csv, doc-2.csv, ... instead of doc.csv
for (const fileName of [
"doc-1.csv",
"doc-2.csv",
"doc-10.csv",
// none of these belong to doc.csv
"doc-.csv",
"doc-a.csv",
"doc-1.txt",
"mydoc-1.csv",
"other-1.csv",
]) {
await writeFile(`${outputDir}${fileName}`, "");
}
await writeFile(`${uploadsDir}doc.vcf`, vcard("Pages"));
await handleConvert(["doc.vcf"], uploadsDir, outputDir, "csv", "vcf", jobCookie("pages"));
expect(rowsFor("pages").map((row) => row.output_file_name)).toEqual([
"doc-1.csv",
"doc-2.csv",
"doc-10.csv",
"doc.csv",
]);
});
});
+41 -2
View File
@@ -1,7 +1,46 @@
import { test } from "bun:test";
import { afterEach, beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/markitdown";
import { runCommonTests } from "./helpers/commonTests";
import { captureConsole, createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
let output: ReturnType<typeof captureConsole>;
beforeEach(() => {
output = captureConsole();
});
afterEach(() => {
output.restore();
});
test("invokes markitdown with input and -o target path", async () => {
const { execFile, calls } = createMockExecFile();
await convert("in/report.docx", "docx", "md", "out/report.md", undefined, execFile);
expect(calls).toEqual([{ cmd: "markitdown", args: ["in/report.docx", "-o", "out/report.md"] }]);
});
test("prefixes rejections with the converter name", async () => {
const { execFile } = createMockExecFile({ error: new Error("boom") });
await expect(
convert("in/report.docx", "docx", "md", "out/report.md", undefined, execFile),
).rejects.toBe("markitdown error: Error: boom");
});
test("does not log output of a failed run", async () => {
const { execFile } = createMockExecFile({
error: new Error("boom"),
stdout: "partial",
stderr: "trace",
});
await expect(
convert("in/report.docx", "docx", "md", "out/report.md", undefined, execFile),
).rejects.toMatch(/boom/);
expect(output.logs).toEqual([]);
expect(output.errors).toEqual([]);
});
+76 -47
View File
@@ -1,61 +1,90 @@
import { expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { afterEach, beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/msgconvert";
import { ExecFileFn } from "../../src/converters/types";
import { captureConsole, createMockExecFile } from "./helpers/converters";
test("convert rejects conversion if input filetype is not msg and output type is not eml", async () => {
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback(null, "Fake stdout", "");
};
let output: ReturnType<typeof captureConsole>;
const expectedError = new Error(
"Unsupported conversion from obj to stl. Only MSG to EML conversion is currently supported.",
);
expect(convert("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile)).rejects.toEqual(
expectedError,
beforeEach(() => {
output = captureConsole();
});
afterEach(() => {
output.restore();
});
test("convert invokes msgconvert with --outfile and resolves with the target path", async () => {
const { execFile, calls } = createMockExecFile();
const result = await convert("in/mail.msg", "msg", "eml", "out/mail.eml", undefined, execFile);
expect(result).toBe("out/mail.eml");
expect(calls).toEqual([
{ cmd: "msgconvert", args: ["--outfile", "out/mail.eml", "in/mail.msg"] },
]);
});
test.each([
["obj", "stl"],
["msg", "pdf"],
["eml", "eml"],
["eml", "msg"],
])("convert rejects %s to %s without invoking msgconvert", async (fileType, convertTo) => {
const { execFile, calls } = createMockExecFile();
await expect(
convert(`input.${fileType}`, fileType, convertTo, `output.${convertTo}`, undefined, execFile),
).rejects.toThrow(
`Unsupported conversion from ${fileType} to ${convertTo}. Only MSG to EML conversion is currently supported.`,
);
expect(calls).toHaveLength(0);
});
test("convert rejects conversion on error", async () => {
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback(new Error("Test error"), "", "");
};
const { execFile } = createMockExecFile({ error: new Error("Test error") });
const expectedError = new Error("msgconvert failed: Test error");
expect(convert("input.msg", "msg", "eml", "output.eml", undefined, mockExecFile)).rejects.toEqual(
expectedError,
);
await expect(
convert("input.msg", "msg", "eml", "output.eml", undefined, execFile),
).rejects.toThrow("msgconvert failed: Test error");
});
test("convert logs stderr as warning", async () => {
const originalConsoleWarn = console.warn;
const { execFile } = createMockExecFile({ stderr: "Fake stderr" });
let loggedMessage = "";
console.warn = (msg) => {
loggedMessage = msg;
};
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
const mockExecFile = (
_cmd: string,
_args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Fake stderr");
};
await convert("file.msg", "msg", "eml", "out.eml", undefined, mockExecFile);
console.error = originalConsoleWarn;
expect(loggedMessage).toBe("msgconvert stderr: Fake stderr");
expect(output.warnings).toEqual(["msgconvert stderr: Fake stderr"]);
});
test("convert redacts absolute paths in logged stderr", async () => {
const { execFile } = createMockExecFile({
stderr: "cannot read /home/user/uploads/secret.msg: bad header",
});
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
expect(output.warnings).toEqual(["msgconvert stderr: cannot read [REDACTED_PATH] bad header"]);
});
test("convert truncates logged stderr longer than 200 characters", async () => {
const { execFile } = createMockExecFile({ stderr: "x".repeat(201) });
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
expect(output.warnings).toEqual([`msgconvert stderr: ${"x".repeat(200)}...`]);
});
test("convert does not truncate stderr of exactly 200 characters", async () => {
const { execFile } = createMockExecFile({ stderr: "x".repeat(200) });
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
expect(output.warnings).toEqual([`msgconvert stderr: ${"x".repeat(200)}`]);
});
test("convert does not warn when stderr is empty", async () => {
const { execFile } = createMockExecFile({ stdout: "converted" });
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
expect(output.warnings).toEqual([]);
});
+36 -40
View File
@@ -1,20 +1,13 @@
import { beforeEach, expect, test, describe } from "bun:test";
import { expect, test, describe } from "bun:test";
import { convert } from "../../src/converters/pandoc";
import type { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
describe("convert", () => {
let mockExecFile: ExecFileFn;
beforeEach(() => {
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "");
});
test("should call pandoc with correct arguments (normal)", async () => {
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
mockExecFile = (cmd, args, callback) => {
calledArgs = [cmd, args, callback];
callback(null, "output-data", "");
};
const { execFile, calls } = createMockExecFile();
const result = await convert(
"input.md",
@@ -22,45 +15,48 @@ describe("convert", () => {
"html",
"output.html",
undefined,
mockExecFile,
execFile,
);
expect(calledArgs[0]).toBe("pandoc");
expect(calledArgs[1]).toEqual([
"input.md",
"-f",
"markdown",
"-t",
"html",
"-o",
"output.html",
expect(calls).toEqual([
{
cmd: "pandoc",
args: ["input.md", "-f", "markdown", "-t", "html", "-o", "output.html"],
},
]);
expect(result).toBe("Done");
});
test("should add xelatex argument for pdf/latex", async () => {
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
mockExecFile = (cmd, args, callback) => {
calledArgs = [cmd, args, callback];
callback(null, "output-data", "");
};
test.each(["pdf", "latex"])("should use the xelatex pdf engine for %s", async (convertTo) => {
const { execFile, calls } = createMockExecFile();
await convert("input.md", "markdown", "pdf", "output.pdf", undefined, mockExecFile);
await convert("input.md", "markdown", convertTo, `output.${convertTo}`, undefined, execFile);
expect(calledArgs[1][0]).toBe("--pdf-engine=xelatex");
expect(calledArgs[1]).toContain("input.md");
expect(calledArgs[1]).toContain("-f");
expect(calledArgs[1]).toContain("markdown");
expect(calledArgs[1]).toContain("-t");
expect(calledArgs[1]).toContain("pdf");
expect(calledArgs[1]).toContain("-o");
expect(calledArgs[1]).toContain("output.pdf");
expect(calls[0]?.args).toEqual([
"--pdf-engine=xelatex",
"input.md",
"-f",
"markdown",
"-t",
convertTo,
"-o",
`output.${convertTo}`,
]);
});
test("should not set a pdf engine for other targets", async () => {
const { execFile, calls } = createMockExecFile();
await convert("input.md", "markdown", "docx", "output.docx", undefined, execFile);
expect(calls[0]?.args.some((arg) => arg.startsWith("--pdf-engine"))).toBe(false);
});
test("should reject if execFile returns an error", async () => {
mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", "");
const { execFile } = createMockExecFile({ error: new Error("fail") });
await expect(
convert("input.md", "markdown", "html", "output.html", undefined, mockExecFile),
convert("input.md", "markdown", "html", "output.html", undefined, execFile),
).rejects.toMatch(/error: Error: fail/);
});
});
+28 -50
View File
@@ -1,77 +1,55 @@
import { beforeEach, expect, test } from "bun:test";
import { afterEach, beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/pdftops";
import { runCommonTests } from "./helpers/commonTests";
import { captureConsole, createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
let calls: string[][] = [];
function mockExecFile(
_cmd: string,
args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) {
calls.push(args);
if (args.includes("fail.pdf")) {
callback(new Error("mock failure"), "", "Fake stderr: fail");
} else {
callback(null, "Fake stdout", "");
}
}
let output: ReturnType<typeof captureConsole>;
beforeEach(() => {
calls = [];
output = captureConsole();
});
afterEach(() => {
output.restore();
});
test("converts a normal file to ps", async () => {
const originalConsoleLog = console.log;
const { execFile, calls } = createMockExecFile({ stdout: "Fake stdout" });
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const result = await convert("in.pdf", "pdf", "ps", "out.ps", undefined, mockExecFile);
console.log = originalConsoleLog;
const result = await convert("in.pdf", "pdf", "ps", "out.ps", undefined, execFile);
expect(result).toBe("Done");
expect(calls[0]).toEqual(["in.pdf", "out.ps"]);
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls).toEqual([{ cmd: "pdftops", args: ["in.pdf", "out.ps"] }]);
expect(output.logs).toEqual(["stdout: Fake stdout"]);
});
test("adds -eps flag for eps output", async () => {
const result = await convert("in.pdf", "pdf", "eps", "out.eps", undefined, mockExecFile);
const { execFile, calls } = createMockExecFile();
const result = await convert("in.pdf", "pdf", "eps", "out.eps", undefined, execFile);
expect(result).toBe("Done");
expect(calls[0]).toEqual(["-eps", "in.pdf", "out.eps"]);
expect(calls).toEqual([{ cmd: "pdftops", args: ["-eps", "in.pdf", "out.eps"] }]);
});
test("fails on exec error", async () => {
expect(convert("fail.pdf", "pdf", "ps", "output.ps", undefined, mockExecFile)).rejects.toMatch(
/error: Error: mock failure/,
test("fails on exec error without logging the output of the failed run", async () => {
const { execFile } = createMockExecFile({
error: new Error("mock failure"),
stderr: "Fake stderr: fail",
});
await expect(convert("fail.pdf", "pdf", "ps", "output.ps", undefined, execFile)).rejects.toBe(
"error: Error: mock failure",
);
expect(output.errors).toEqual([]);
});
test("logs stderr when execFile returns only stderr and no error", async () => {
const originalConsoleError = console.error;
const { execFile } = createMockExecFile({ stderr: "Only stderr output" });
let loggedMessage = "";
console.error = (msg) => {
loggedMessage = msg;
};
await convert("input.pdf", "pdf", "ps", "output.ps", undefined, execFile);
const mockExecFileStderrOnly = (
_cmd: string,
_args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Only stderr output");
};
await convert("input.pdf", "pdf", "ps", "output.ps", undefined, mockExecFileStderrOnly);
console.error = originalConsoleError;
expect(loggedMessage).toBe("stderr: Only stderr output");
expect(output.errors).toEqual(["stderr: Only stderr output"]);
});
+14 -2
View File
@@ -1,7 +1,19 @@
import { test } from "bun:test";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/potrace";
import { runCommonTests } from "./helpers/commonTests";
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
test.each(["svg", "pdf", "eps", "dxf"])("selects the %s backend via -b", async (convertTo) => {
const { execFile, calls } = createMockExecFile();
await convert("in/bitmap.pbm", "pbm", convertTo, `out/bitmap.${convertTo}`, undefined, execFile);
expect(calls).toEqual([
{
cmd: "potrace",
args: ["in/bitmap.pbm", "-o", `out/bitmap.${convertTo}`, "-b", convertTo],
},
]);
});
+9 -2
View File
@@ -1,7 +1,14 @@
import { test } from "bun:test";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/resvg";
import { runCommonTests } from "./helpers/commonTests";
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
test("invokes resvg with input and target path", async () => {
const { execFile, calls } = createMockExecFile();
await convert("in/icon.svg", "svg", "png", "out/icon.png", undefined, execFile);
expect(calls).toEqual([{ cmd: "resvg", args: ["in/icon.svg", "out/icon.png"] }]);
});
+153 -3
View File
@@ -1,4 +1,8 @@
import { expect, test, describe } from "bun:test";
import { afterAll, beforeAll, expect, test, describe } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { convert, parseVCF, toCSV } from "../../src/converters/vcf";
describe("parseVCF", () => {
@@ -60,6 +64,83 @@ END:VCARD`;
},
]);
});
test("should handle CRLF line endings", () => {
const vcfData = "BEGIN:VCARD\r\nFN:John Doe\r\nTEL:123\r\nEND:VCARD\r\n";
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe", Phone: "123" }]);
});
test("should keep colons that are part of the value", () => {
const vcfData = `BEGIN:VCARD
FN:Dr: Who
END:VCARD`;
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "Dr: Who" }]);
});
test("should default the first name to an empty string when N has only a last name", () => {
const vcfData = `BEGIN:VCARD
N:Doe
END:VCARD`;
expect(parseVCF(vcfData)).toEqual([{ "Last Name": "Doe", "First Name": "" }]);
});
test("should only use the organization name, not its units", () => {
const vcfData = `BEGIN:VCARD
ORG:Example Corp;Marketing;Berlin
END:VCARD`;
expect(parseVCF(vcfData)).toEqual([{ Organization: "Example Corp" }]);
});
test("should not mistake properties starting with N for the name", () => {
const vcfData = `BEGIN:VCARD
FN:John Doe
NOTE:likes cats
NICKNAME:Johnny
END:VCARD`;
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe" }]);
});
test("should skip lines without a colon", () => {
const vcfData = `BEGIN:VCARD
FN:John Doe
this line is garbage
END:VCARD`;
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe" }]);
});
test("should ignore text outside of BEGIN/END blocks", () => {
const vcfData = `exported by some tool
FN:Not A Contact
BEGIN:VCARD
FN:John Doe
END:VCARD
FN:Also Not A Contact`;
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe" }]);
});
test("should drop cards that contain no known properties", () => {
const vcfData = `BEGIN:VCARD
END:VCARD
BEGIN:VCARD
VERSION:3.0
URL:https://example.com
END:VCARD
BEGIN:VCARDEND:VCARD`;
expect(parseVCF(vcfData)).toEqual([]);
});
test("should return an empty list for input without cards", () => {
expect(parseVCF("")).toEqual([]);
expect(parseVCF("just some text")).toEqual([]);
});
});
describe("toCSV", () => {
@@ -85,6 +166,30 @@ describe("toCSV", () => {
expect(result).toBe('Full Name\n"John ""Johnny"" Doe"');
});
test("should keep commas and newlines inside quoted values", () => {
const contacts = [{ "Full Name": "Doe, John", Organization: "Line 1\nLine 2" }];
expect(toCSV(contacts)).toBe('Full Name,Organization\n"Doe, John","Line 1\nLine 2"');
});
test("should write an empty cell for values missing in later contacts", () => {
const contacts = [
{ "Full Name": "John Doe", Email: "john@example.com" },
{ "Full Name": "Jane Smith" },
];
expect(toCSV(contacts)).toBe('Full Name,Email\n"John Doe","john@example.com"\n"Jane Smith",""');
});
// BUG: the header is built from the first contact only, so fields that only
// later contacts have are silently dropped from the export. Remove `.failing`
// once toCSV builds the header from all contacts.
test.failing("should not drop fields that only later contacts have", () => {
const contacts = [{ "Full Name": "John Doe" }, { "Full Name": "Jane Smith", Email: "j@x.com" }];
expect(toCSV(contacts)).toContain("j@x.com");
});
test("should handle empty data", () => {
const result = toCSV([]);
expect(result).toBe("");
@@ -92,7 +197,52 @@ describe("toCSV", () => {
});
describe("convert", () => {
test("should be a function", () => {
expect(typeof convert).toBe("function");
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "convertx-vcf-"));
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
test("should read the vcf file and write the contacts as csv", async () => {
const inputPath = join(dir, "contacts.vcf");
const targetPath = join(dir, "contacts.csv");
await writeFile(
inputPath,
`BEGIN:VCARD
FN:John Doe
EMAIL:john@example.com
END:VCARD
BEGIN:VCARD
FN:Jane Smith
EMAIL:jane@example.com
END:VCARD
`,
);
const result = await convert(inputPath, "vcf", "csv", targetPath);
expect(result).toBe("Done");
expect(await readFile(targetPath, "utf-8")).toBe(
'Full Name,Email\n"John Doe","john@example.com"\n"Jane Smith","jane@example.com"',
);
});
test("should write an empty file when the input has no contacts", async () => {
const inputPath = join(dir, "empty.vcf");
const targetPath = join(dir, "empty.csv");
await writeFile(inputPath, "");
await expect(convert(inputPath, "vcf", "csv", targetPath)).resolves.toBe("Done");
expect(await readFile(targetPath, "utf-8")).toBe("");
});
test("should reject when the input file does not exist", async () => {
await expect(
convert(join(dir, "missing.vcf"), "vcf", "csv", join(dir, "missing.csv")),
).rejects.toThrow(/ENOENT/);
});
});
+12 -53
View File
@@ -1,65 +1,24 @@
import { beforeEach, expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { ExecFileFn } from "../../src/converters/types";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/vips";
import { runCommonTests } from "./helpers/commonTests";
let calls: string[][] = [];
beforeEach(() => {
calls = [];
});
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test("convert uses action pdfload with filetype being pdf", async () => {
const originalConsoleLog = console.log;
test("uses the pdfload action for pdf input", async () => {
const { execFile, calls } = createMockExecFile();
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in/doc.pdf", "pdf", "png", "out/doc.png", undefined, execFile);
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.pdf", "pdf", "obj", "output.obj", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["pdfload"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls).toEqual([{ cmd: "vips", args: ["pdfload", "in/doc.pdf", "out/doc.png"] }]);
});
test("convert uses action copy with filetype being anything but pdf", async () => {
const originalConsoleLog = console.log;
test.each(["jpeg", "png", "svg", "tiff"])("uses the copy action for %s input", async (fileType) => {
const { execFile, calls } = createMockExecFile();
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert(`in/image.${fileType}`, fileType, "webp", "out/image.webp", undefined, execFile);
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.jpg", "jpg", "obj", "output.obj", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["copy"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
expect(calls).toEqual([
{ cmd: "vips", args: ["copy", `in/image.${fileType}`, "out/image.webp"] },
]);
});
+108 -33
View File
@@ -1,58 +1,133 @@
import { beforeEach, expect, test, describe } from "bun:test";
import { afterEach, beforeEach, expect, test, describe } from "bun:test";
import { convert } from "../../src/converters/vtracer";
import type { ExecFileFn } from "../../src/converters/types";
import { captureConsole, createMockExecFile } from "./helpers/converters";
describe("convert", () => {
let mockExecFile: ExecFileFn;
let output: ReturnType<typeof captureConsole>;
beforeEach(() => {
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "");
output = captureConsole();
});
afterEach(() => {
output.restore();
});
test("should call vtracer with correct arguments (minimal)", async () => {
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
mockExecFile = (cmd, args, callback) => {
calledArgs = [cmd, args, callback];
callback(null, "output-data", "");
};
const { execFile, calls } = createMockExecFile();
const result = await convert("input.png", "png", "svg", "output.svg", undefined, mockExecFile);
const result = await convert("input.png", "png", "svg", "output.svg", undefined, execFile);
expect(calledArgs[0]).toBe("vtracer");
expect(calledArgs[1]).toEqual(["--input", "input.png", "--output", "output.svg"]);
expect(calls).toEqual([
{ cmd: "vtracer", args: ["--input", "input.png", "--output", "output.svg"] },
]);
expect(result).toBe("Done");
});
test("should add options as arguments", async () => {
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
mockExecFile = (cmd, args, callback) => {
calledArgs = [cmd, args, callback];
callback(null, "output-data", "");
};
test("should pass every supported option as a flag in a fixed order", async () => {
const { execFile, calls } = createMockExecFile();
// deliberately not in the converter's internal order
const options = {
path_precision: 8,
splice_threshold: 45,
max_iterations: 10,
length_threshold: 4,
corner_threshold: 60,
layer_difference: 16,
color_precision: 6,
filter_speckle: 4,
mode: "spline",
hierarchical: "stacked",
colormode: "color",
hierarchical: "true",
filter_speckle: 5,
path_precision: 0.8,
};
await convert("input.png", "png", "svg", "output.svg", options, mockExecFile);
await convert("input.png", "png", "svg", "output.svg", options, execFile);
expect(calledArgs[1]).toContain("--colormode");
expect(calledArgs[1]).toContain("color");
expect(calledArgs[1]).toContain("--hierarchical");
expect(calledArgs[1]).toContain("true");
expect(calledArgs[1]).toContain("--filter_speckle");
expect(calledArgs[1]).toContain("5");
expect(calledArgs[1]).toContain("--path_precision");
expect(calledArgs[1]).toContain("0.8");
expect(calls[0]?.args).toEqual([
"--input",
"input.png",
"--output",
"output.svg",
"--colormode",
"color",
"--hierarchical",
"stacked",
"--mode",
"spline",
"--filter_speckle",
"4",
"--color_precision",
"6",
"--layer_difference",
"16",
"--corner_threshold",
"60",
"--length_threshold",
"4",
"--max_iterations",
"10",
"--splice_threshold",
"45",
"--path_precision",
"8",
]);
});
test("should keep falsy option values such as 0", async () => {
const { execFile, calls } = createMockExecFile();
await convert("input.png", "png", "svg", "output.svg", { filter_speckle: 0 }, execFile);
expect(calls[0]?.args.slice(4)).toEqual(["--filter_speckle", "0"]);
});
test("should ignore unknown and undefined options", async () => {
const { execFile, calls } = createMockExecFile();
const options = { mode: undefined, preset: "photo", "--output": "/etc/passwd" };
await convert("input.png", "png", "svg", "output.svg", options, execFile);
expect(calls[0]?.args).toEqual(["--input", "input.png", "--output", "output.svg"]);
});
test.each([null, "colormode=color", 42])(
"should ignore non-object options (%p)",
async (options) => {
const { execFile, calls } = createMockExecFile();
await convert("input.png", "png", "svg", "output.svg", options, execFile);
expect(calls[0]?.args).toEqual(["--input", "input.png", "--output", "output.svg"]);
},
);
test("should reject if execFile returns an error", async () => {
mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", "stderr output");
const { execFile } = createMockExecFile({
error: new Error("fail"),
stderr: "stderr output",
});
await expect(
convert("input.png", "png", "svg", "output.svg", undefined, mockExecFile),
).rejects.toMatch(/error: Error: fail\nstderr: stderr output/);
convert("input.png", "png", "svg", "output.svg", undefined, execFile),
).rejects.toBe("error: Error: fail\nstderr: stderr output");
});
test("should omit the stderr suffix from the rejection when stderr is empty", async () => {
const { execFile } = createMockExecFile({ error: new Error("fail") });
await expect(
convert("input.png", "png", "svg", "output.svg", undefined, execFile),
).rejects.toBe("error: Error: fail");
});
test("should log stdout and stderr of a successful run", async () => {
const { execFile } = createMockExecFile({ stdout: "traced", stderr: "warning" });
await convert("input.png", "png", "svg", "output.svg", undefined, execFile);
// vtracer reports progress on stderr, so it is logged as regular output
expect(output.logs).toEqual(["stdout: traced", "stderr: warning"]);
expect(output.errors).toEqual([]);
});
});
+22 -2
View File
@@ -1,7 +1,27 @@
import { test } from "bun:test";
import { expect, test } from "bun:test";
import { convert } from "../../src/converters/xelatex";
import { runCommonTests } from "./helpers/commonTests";
import { createMockExecFile } from "./helpers/converters";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
test("invokes latexmk with xelatex in non-interactive mode", async () => {
const { execFile, calls } = createMockExecFile();
await convert("in/paper.tex", "latex", "pdf", "out/job/paper.pdf", undefined, execFile);
expect(calls).toEqual([
{
cmd: "latexmk",
args: ["-xelatex", "-interaction=nonstopmode", "-output-directory=out/job", "in/paper.tex"],
},
]);
});
test("strips a leading './' from the output directory", async () => {
const { execFile, calls } = createMockExecFile();
await convert("in/paper.tex", "latex", "pdf", "./data/output/paper.pdf", undefined, execFile);
expect(calls[0]?.args).toContain("-output-directory=data/output");
});