Files
SnapOtter/packages/doc-engine/tests/ghostscript.test.ts
T
SnapOtterandGitHub 301e6eb01a test: coverage campaign and mutation testing across five packages (#628)
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
2026-07-24 17:36:57 +08:00

271 lines
9.4 KiB
TypeScript

import { spawn } from "node:child_process";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeSpawnHelpers } from "./helpers/spawn-capture.js";
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
const mockSpawn = vi.mocked(spawn);
const h = makeSpawnHelpers(mockSpawn);
/** Pull a `-dKey=value` flag's value out of an argv array. */
function flagValue(args: string[], key: string): string | undefined {
const prefix = `${key}=`;
const found = args.find((a) => a.startsWith(prefix));
return found?.slice(prefix.length);
}
beforeEach(() => {
vi.clearAllMocks();
process.env.GS_PATH = "/usr/bin/gs";
delete process.env.SUBPROCESS_MEMORY_LIMIT_MB;
});
afterEach(() => {
delete process.env.GS_PATH;
});
describe("runGs error handling (via gsGrayscalePdf)", () => {
it("resolves on exit 0", async () => {
h.nextClose({ code: 0 });
await expect(
import("../src/ghostscript.js").then((m) => m.gsGrayscalePdf("/in.pdf", "/out.pdf")),
).resolves.toBeUndefined();
});
it("rejects on non-zero exit with the stderr tail", async () => {
h.nextClose({ stderr: "Ghostscript could not open input", code: 1 });
await expect(
import("../src/ghostscript.js").then((m) => m.gsGrayscalePdf("/in.pdf", "/out.pdf")),
).rejects.toThrow("gs exited 1: Ghostscript could not open input");
});
it("reports the signal when code is null", async () => {
h.nextClose({ stderr: "boom", code: null, signal: "SIGSEGV" });
await expect(
import("../src/ghostscript.js").then((m) => m.gsGrayscalePdf("/in.pdf", "/out.pdf")),
).rejects.toThrow("gs exited SIGSEGV: boom");
});
it("rejects on an error event", async () => {
h.nextError(new Error("spawn gs ENOENT"));
await expect(
import("../src/ghostscript.js").then((m) => m.gsGrayscalePdf("/in.pdf", "/out.pdf")),
).rejects.toThrow("spawn gs ENOENT");
});
it("throws when the gs binary is unavailable", async () => {
vi.resetModules();
vi.doMock("../src/binaries.js", () => ({ resolveGs: () => null }));
const mod = await import("../src/ghostscript.js");
await expect(mod.gsGrayscalePdf("/in.pdf", "/out.pdf")).rejects.toThrow(
"gs binary not found (set GS_PATH or install ghostscript)",
);
vi.doUnmock("../src/binaries.js");
vi.resetModules();
});
it("kills with SIGKILL and rejects after the 120s deadline", async () => {
const mod = await import("../src/ghostscript.js");
vi.useFakeTimers();
try {
const child = h.nextManual();
const settled = expect(mod.gsGrayscalePdf("/in.pdf", "/out.pdf")).rejects.toThrow(
"ghostscript timed out after 120s",
);
await vi.advanceTimersByTimeAsync(120_000);
await settled;
expect(child.killSignals).toContain("SIGKILL");
} finally {
vi.useRealTimers();
}
});
});
describe("gsCompressPdf (preset re-distillation)", () => {
it("builds the exact argv for the ebook preset", async () => {
h.nextClose({ code: 0 });
await import("../src/ghostscript.js").then((m) =>
m.gsCompressPdf("/in.pdf", "/out.pdf", "ebook"),
);
expect(h.lastBin()).toBe("/usr/bin/gs");
expect(h.lastArgs()).toEqual([
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-dQUIET",
"-sDEVICE=pdfwrite",
"-dPDFSETTINGS=/ebook",
"-dCompatibilityLevel=1.6",
"-sOutputFile=/out.pdf",
"/in.pdf",
]);
});
it("interpolates the preset name into -dPDFSETTINGS for each preset", async () => {
for (const preset of ["screen", "ebook", "printer"] as const) {
h.nextClose({ code: 0 });
await import("../src/ghostscript.js").then((m) =>
m.gsCompressPdf("/in.pdf", "/out.pdf", preset),
);
expect(flagValue(h.lastArgs(), "-dPDFSETTINGS")).toBe(`/${preset}`);
}
});
it("places the input path last and the output path in -sOutputFile", async () => {
h.nextClose({ code: 0 });
await import("../src/ghostscript.js").then((m) =>
m.gsCompressPdf("/tmp/src.pdf", "/tmp/dst.pdf", "screen"),
);
const args = h.lastArgs();
expect(args[args.length - 1]).toBe("/tmp/src.pdf");
expect(flagValue(args, "-sOutputFile")).toBe("/tmp/dst.pdf");
});
});
describe("gsGrayscalePdf", () => {
it("builds the exact DeviceGray argv", async () => {
h.nextClose({ code: 0 });
await import("../src/ghostscript.js").then((m) => m.gsGrayscalePdf("/in.pdf", "/out.pdf"));
expect(h.lastArgs()).toEqual([
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-dQUIET",
"-sDEVICE=pdfwrite",
"-sColorConversionStrategy=Gray",
"-dProcessColorModel=/DeviceGray",
"-sOutputFile=/out.pdf",
"/in.pdf",
]);
});
});
describe("gsPdfaConvert", () => {
it("builds the exact PDF/A argv", async () => {
h.nextClose({ code: 0 });
await import("../src/ghostscript.js").then((m) => m.gsPdfaConvert("/in.pdf", "/out.pdf"));
expect(h.lastArgs()).toEqual([
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-dQUIET",
"-dPDFA=2",
"-dPDFACompatibilityPolicy=1",
"-sColorConversionStrategy=RGB",
"-sDEVICE=pdfwrite",
"-sOutputFile=/out.pdf",
"/in.pdf",
]);
});
it("uses RGB (not Gray) color conversion for PDF/A", async () => {
h.nextClose({ code: 0 });
await import("../src/ghostscript.js").then((m) => m.gsPdfaConvert("/in.pdf", "/out.pdf"));
expect(h.lastArgs()).toContain("-sColorConversionStrategy=RGB");
expect(h.lastArgs()).not.toContain("-sColorConversionStrategy=Gray");
});
});
describe("gsCompressPdfTuned (DPI + QFactor levers)", () => {
async function run(dpi: number, qf: number): Promise<string[]> {
h.nextClose({ code: 0 });
await import("../src/ghostscript.js").then((m) =>
m.gsCompressPdfTuned("/in.pdf", "/out.pdf", dpi, qf),
);
return h.lastArgs();
}
it("passes the rounded DPI to both color and gray resolution", async () => {
const args = await run(150, 1);
expect(flagValue(args, "-dColorImageResolution")).toBe("150");
expect(flagValue(args, "-dGrayImageResolution")).toBe("150");
});
it("sets mono resolution to res*4 when that is under 600", async () => {
const args = await run(100, 1);
expect(flagValue(args, "-dMonoImageResolution")).toBe("400");
});
it("caps mono resolution at 600 (res*4 would exceed it)", async () => {
const args = await run(150, 1);
expect(flagValue(args, "-dMonoImageResolution")).toBe("600");
});
it("mono resolution just below the cap is res*4, not 600", async () => {
const args = await run(149, 1);
expect(flagValue(args, "-dMonoImageResolution")).toBe("596");
});
it("clamps DPI up to the floor of 9", async () => {
const args = await run(5, 1);
expect(flagValue(args, "-dColorImageResolution")).toBe("9");
expect(flagValue(args, "-dMonoImageResolution")).toBe("36");
});
it("clamps DPI down to the ceiling of 600", async () => {
const args = await run(9000, 1);
expect(flagValue(args, "-dColorImageResolution")).toBe("600");
});
it("rounds a fractional DPI to the nearest integer (9.4 -> 9)", async () => {
const args = await run(9.4, 1);
expect(flagValue(args, "-dColorImageResolution")).toBe("9");
});
it("rounds a fractional DPI up when >= .5 (9.6 -> 10)", async () => {
const args = await run(9.6, 1);
expect(flagValue(args, "-dColorImageResolution")).toBe("10");
expect(flagValue(args, "-dMonoImageResolution")).toBe("40");
});
it("embeds the clamped QFactor in the distiller dict for color and gray", async () => {
const args = await run(100, 2);
const dict = args[args.indexOf("-c") + 1];
expect(dict).toContain(
"/ColorImageDict << /QFactor 2 /Blend 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >>",
);
expect(dict).toContain("/GrayImageDict << /QFactor 2 /Blend 1 >>");
expect(dict).toContain("setdistillerparams");
});
it("clamps QFactor up to the floor of 0.05", async () => {
const args = await run(100, 0.001);
const dict = args[args.indexOf("-c") + 1];
expect(dict).toContain("/QFactor 0.05 ");
});
it("clamps QFactor down to the ceiling of 4", async () => {
const args = await run(100, 99);
const dict = args[args.indexOf("-c") + 1];
expect(dict).toContain("/QFactor 4 ");
});
it("forces DCT re-encode and disables JPEG passthrough so QFactor applies", async () => {
const args = await run(100, 1);
expect(args).toContain("-dAutoFilterColorImages=false");
expect(args).toContain("-dColorImageFilter=/DCTEncode");
expect(args).toContain("-dAutoFilterGrayImages=false");
expect(args).toContain("-dGrayImageFilter=/DCTEncode");
expect(args).toContain("-dPassThroughJPEGImages=false");
});
it("passes the -c dict before -f and the input path", async () => {
const args = await run(100, 1);
const cIdx = args.indexOf("-c");
const fIdx = args.indexOf("-f");
expect(cIdx).toBeGreaterThan(-1);
expect(fIdx).toBe(cIdx + 2); // -c, <dict>, -f
expect(args[fIdx + 1]).toBe("/in.pdf");
});
it("enables downsampling with Average for color/gray and Subsample for mono", async () => {
const args = await run(100, 1);
expect(args).toContain("-dDownsampleColorImages=true");
expect(args).toContain("-dColorImageDownsampleType=/Average");
expect(args).toContain("-dDownsampleGrayImages=true");
expect(args).toContain("-dGrayImageDownsampleType=/Average");
expect(args).toContain("-dDownsampleMonoImages=true");
expect(args).toContain("-dMonoImageDownsampleType=/Subsample");
});
});