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
249 lines
7.5 KiB
TypeScript
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");
|
|
});
|
|
});
|