Files
elizaos--eliza/plugins/plugin-pdf/__tests__/pdf-service.test.ts
T
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

249 lines
7.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IAgentRuntime } from "@elizaos/core";
import { MAX_PDF_BUFFER_BYTES, PdfService } from "../services/pdf";
const getDocumentProxyMock = vi.hoisted(() => vi.fn());
vi.mock("unpdf", () => ({
getDocumentProxy: getDocumentProxyMock,
}));
interface MockPageInput {
items: unknown[];
width?: number;
height?: number;
}
function makePdf(
pages: MockPageInput[],
info: Record<string, string | undefined> = {}
) {
return {
numPages: pages.length,
getPage: vi.fn(async (pageNumber: number) => {
const page = pages[pageNumber - 1];
if (!page) throw new Error(`Missing page ${pageNumber}`);
return {
getTextContent: vi.fn(async () => ({ items: page.items })),
getViewport: vi.fn(() => ({
width: page.width ?? 612,
height: page.height ?? 792,
})),
};
}),
getMetadata: vi.fn(async () => ({ info })),
};
}
function service(): PdfService {
return new PdfService({} as IAgentRuntime);
}
function validPdfBuffer(body = "body"): Buffer {
return Buffer.from(`%PDF-1.7\n${body}`);
}
describe("PdfService", () => {
beforeEach(() => {
getDocumentProxyMock.mockReset();
});
it("extracts text from every page, ignores non-text items, and cleans control characters", async () => {
getDocumentProxyMock.mockResolvedValue(
makePdf([
{ items: [{ str: "Hello" }, { str: " world\u0000" }, { notText: true }] },
{ items: [{ str: "Second\t\tpage" }] },
])
);
await expect(service().convertPdfToText(validPdfBuffer())).resolves.toBe(
"Hello world\nSecond page"
);
expect(getDocumentProxyMock).toHaveBeenCalledWith(expect.any(Uint8Array));
});
it("honors start/end page bounds and returns page count for ranged extraction", async () => {
const pdf = makePdf([
{ items: [{ str: "one" }] },
{ items: [{ str: "two" }] },
{ items: [{ str: "three" }] },
]);
getDocumentProxyMock.mockResolvedValue(pdf);
await expect(
service().convertPdfToTextWithOptions(validPdfBuffer(), {
startPage: 2,
endPage: 99,
})
).resolves.toEqual({
success: true,
text: "two\nthree",
pageCount: 3,
});
expect(pdf.getPage).toHaveBeenCalledTimes(2);
expect(pdf.getPage).toHaveBeenNthCalledWith(1, 2);
expect(pdf.getPage).toHaveBeenNthCalledWith(2, 3);
});
it("can preserve item whitespace and skip cleanup when requested", async () => {
getDocumentProxyMock.mockResolvedValue(
makePdf([{ items: [{ str: "A " }, { str: "\u0000B" }] }])
);
await expect(
service().convertPdfToTextWithOptions(validPdfBuffer(), {
preserveWhitespace: true,
cleanContent: false,
})
).resolves.toEqual({
success: true,
text: "A \u0000B",
pageCount: 1,
});
});
it("returns structured errors for option-based extraction failures", async () => {
getDocumentProxyMock.mockRejectedValue(new Error("bad pdf"));
await expect(service().convertPdfToTextWithOptions(validPdfBuffer("bad"))).resolves.toEqual({
success: false,
error: "bad pdf",
});
});
it("returns metadata, dimensions, per-page text, and aggregate text", async () => {
getDocumentProxyMock.mockResolvedValue(
makePdf(
[
{ items: [{ str: " First page " }], width: 100, height: 200 },
{ items: [{ str: "Second" }, { str: " page" }], width: 300, height: 400 },
],
{
Title: "Spec",
Author: "Ada",
Subject: "Testing",
Keywords: "pdf,unit",
Creator: "suite",
Producer: "vitest",
CreationDate: "2024-01-02T03:04:05.000Z",
ModDate: "2024-02-03T04:05:06.000Z",
}
)
);
const info = await service().getDocumentInfo(validPdfBuffer());
expect(info).toEqual({
pageCount: 2,
metadata: {
title: "Spec",
author: "Ada",
subject: "Testing",
keywords: "pdf,unit",
creator: "suite",
producer: "vitest",
creationDate: new Date("2024-01-02T03:04:05.000Z"),
modificationDate: new Date("2024-02-03T04:05:06.000Z"),
},
text: "First page\nSecond page",
pages: [
{ pageNumber: 1, width: 100, height: 200, text: "First page" },
{ pageNumber: 2, width: 300, height: 400, text: "Second page" },
],
});
});
it("normalizes whitespace without removing newlines", () => {
expect(service().cleanUpContent(" a\t\tb \u0000\u0007\n c \r\n\t")).toBe(
"a b\n c"
);
});
it("rejects empty and non-PDF binary inputs before extraction", async () => {
await expect(service().convertPdfToText(Buffer.alloc(0))).rejects.toThrow(
"PDF input is empty"
);
await expect(service().convertPdfToText(Buffer.from("not a pdf"))).rejects.toThrow(
"PDF input is not a supported PDF document"
);
expect(getDocumentProxyMock).not.toHaveBeenCalled();
});
it("rejects path, URL, data URL, and MIME wrapper payloads before extraction", async () => {
const hostilePayloads = [
"/tmp/report.pdf",
"file:///etc/passwd",
"https://example.com/report.pdf",
"data:application/pdf;base64,JVBERi0xLjcK",
new URL("file:///tmp/report.pdf"),
{ contentType: "application/pdf", data: validPdfBuffer() },
{ mimeType: "application/json", buffer: Buffer.from("{}") },
];
for (const payload of hostilePayloads) {
await expect(service().convertPdfToText(payload as never)).rejects.toThrow(
"PDF input must be a Buffer or Uint8Array"
);
}
expect(getDocumentProxyMock).not.toHaveBeenCalled();
});
it("rejects oversized PDF inputs before extraction", async () => {
const oversizedPdf = Buffer.concat([
Buffer.from("%PDF-1.7\n"),
Buffer.alloc(MAX_PDF_BUFFER_BYTES),
]);
await expect(service().convertPdfToText(oversizedPdf)).rejects.toThrow(
`PDF input exceeds maximum size of ${MAX_PDF_BUFFER_BYTES} bytes`
);
expect(getDocumentProxyMock).not.toHaveBeenCalled();
});
it("returns structured validation errors for malformed option-based inputs", async () => {
await expect(service().convertPdfToTextWithOptions(Buffer.from("data"))).resolves.toEqual({
success: false,
error: "PDF input is not a supported PDF document",
});
expect(getDocumentProxyMock).not.toHaveBeenCalled();
});
it.each([
[{ startPage: 0 }, "startPage must be a positive finite integer"],
[{ startPage: 1.5 }, "startPage must be a positive finite integer"],
[{ startPage: Number.NaN }, "startPage must be a positive finite integer"],
[{ endPage: Number.POSITIVE_INFINITY }, "endPage must be a positive finite integer"],
[{ startPage: 3, endPage: 2 }, "endPage must be greater than or equal to startPage"],
])("returns structured errors for hostile extraction options %#", async (options, error) => {
getDocumentProxyMock.mockResolvedValue(makePdf([{ items: [{ str: "one" }] }]));
await expect(
service().convertPdfToTextWithOptions(validPdfBuffer(), options)
).resolves.toEqual({
success: false,
error,
});
});
it("omits invalid metadata dates instead of returning Invalid Date objects", async () => {
getDocumentProxyMock.mockResolvedValue(
makePdf([{ items: [{ str: "content" }] }], {
CreationDate: "not-a-date",
ModDate: "2024-02-03T04:05:06.000Z",
})
);
const info = await service().getDocumentInfo(validPdfBuffer());
expect(info.metadata.creationDate).toBeUndefined();
expect(info.metadata.modificationDate).toEqual(new Date("2024-02-03T04:05:06.000Z"));
});
it("accepts PDF headers after leading transport bytes within the scan window", async () => {
getDocumentProxyMock.mockResolvedValue(makePdf([{ items: [{ str: "offset header" }] }]));
const prefixedPdf = Buffer.concat([Buffer.from([0, 1, 2]), validPdfBuffer()]);
await expect(service().convertPdfToText(prefixedPdf)).resolves.toBe("offset header");
});
});