feat: PDF output — format=pdf with paper size, margins, scale options
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m26s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m26s
This commit is contained in:
parent
e7ef9d74c4
commit
af7637027e
5 changed files with 460 additions and 7 deletions
244
src/routes/__tests__/pdf.test.ts
Normal file
244
src/routes/__tests__/pdf.test.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { Request, Response } from 'express'
|
||||||
|
import { screenshotRouter } from '../screenshot.js'
|
||||||
|
import { playgroundRouter } from '../playground.js'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('../../services/screenshot.js', () => ({
|
||||||
|
takeScreenshot: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../services/cache.js', () => ({
|
||||||
|
screenshotCache: {
|
||||||
|
get: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
shouldBypass: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../services/watermark.js', () => ({
|
||||||
|
addWatermark: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../services/logger.js', () => ({
|
||||||
|
default: {
|
||||||
|
error: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../middleware/auth.js', () => ({
|
||||||
|
authMiddleware: vi.fn((req, res, next) => {
|
||||||
|
req.apiKeyInfo = { key: 'test_key', tier: 'pro', email: 'test@test.com' }
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../middleware/usage.js', () => ({
|
||||||
|
usageMiddleware: vi.fn((req, res, next) => next())
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('express-rate-limit', () => ({
|
||||||
|
default: vi.fn(() => (req: any, res: any, next: any) => next())
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { takeScreenshot } = await import('../../services/screenshot.js')
|
||||||
|
const { screenshotCache } = await import('../../services/cache.js')
|
||||||
|
const { addWatermark } = await import('../../services/watermark.js')
|
||||||
|
const mockTakeScreenshot = vi.mocked(takeScreenshot)
|
||||||
|
const mockCache = vi.mocked(screenshotCache)
|
||||||
|
const mockAddWatermark = vi.mocked(addWatermark)
|
||||||
|
|
||||||
|
function createMockRequest(params: any = {}, overrides: any = {}): Partial<Request> {
|
||||||
|
const method = overrides.method || 'POST'
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
body: method === 'POST' ? params : {},
|
||||||
|
query: method === 'GET' ? params : {},
|
||||||
|
headers: { authorization: 'Bearer test_key' },
|
||||||
|
apiKeyInfo: { key: 'test_key', tier: 'pro', email: 'test@test.com' },
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
socket: { remoteAddress: '127.0.0.1' } as any,
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockResponse(): Partial<Response> {
|
||||||
|
const res: any = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
send: vi.fn().mockReturnThis(),
|
||||||
|
setHeader: vi.fn().mockReturnThis()
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PDF Output', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockCache.shouldBypass.mockReturnValue(false)
|
||||||
|
mockCache.get.mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /v1/screenshot with format=pdf', () => {
|
||||||
|
it('should return PDF with correct Content-Type', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('%PDF-1.4 fake pdf content')
|
||||||
|
mockTakeScreenshot.mockResolvedValueOnce({
|
||||||
|
buffer: pdfBuffer,
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
retryCount: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf' })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf')
|
||||||
|
expect(res.send).toHaveBeenCalledWith(pdfBuffer)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set Content-Disposition for PDF', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('%PDF-1.4 fake pdf content')
|
||||||
|
mockTakeScreenshot.mockResolvedValueOnce({
|
||||||
|
buffer: pdfBuffer,
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
retryCount: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf' })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="screenshot.pdf"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass pdfFormat option to takeScreenshot', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||||
|
mockTakeScreenshot.mockResolvedValueOnce({ buffer: pdfBuffer, contentType: 'application/pdf', retryCount: 0 })
|
||||||
|
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfFormat: 'a4' })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
format: 'pdf',
|
||||||
|
pdfFormat: 'a4'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass pdfLandscape option to takeScreenshot', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||||
|
mockTakeScreenshot.mockResolvedValueOnce({ buffer: pdfBuffer, contentType: 'application/pdf', retryCount: 0 })
|
||||||
|
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfLandscape: true })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
format: 'pdf',
|
||||||
|
pdfLandscape: true
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400 when format=pdf with selector', async () => {
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', selector: '#content' })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'format "pdf" is mutually exclusive with selector and clip' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400 when format=pdf with clip', async () => {
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', clip: { x: 0, y: 0, width: 100, height: 100 } })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'format "pdf" is mutually exclusive with selector and clip' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400 for invalid pdfFormat', async () => {
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfFormat: 'b5' })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'pdfFormat must be one of: a4, letter, legal, a3' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400 when pdfScale is out of range (too low)', async () => {
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfScale: 0.05 })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'pdfScale must be between 0.1 and 2.0' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400 when pdfScale is out of range (too high)', async () => {
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfScale: 3.0 })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'pdfScale must be between 0.1 and 2.0' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /v1/screenshot with format=pdf', () => {
|
||||||
|
it('should handle PDF via GET request', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||||
|
mockTakeScreenshot.mockResolvedValueOnce({ buffer: pdfBuffer, contentType: 'application/pdf', retryCount: 0 })
|
||||||
|
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf' }, { method: 'GET' })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = screenshotRouter.stack.find(layer => layer.route?.methods.get)?.route.stack[0].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf')
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="screenshot.pdf"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Playground PDF', () => {
|
||||||
|
it('should return PDF without watermark in playground', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('%PDF-1.4 playground pdf')
|
||||||
|
mockTakeScreenshot.mockResolvedValueOnce({ buffer: pdfBuffer, contentType: 'application/pdf', retryCount: 0 })
|
||||||
|
|
||||||
|
const req = createMockRequest({ url: 'https://example.com', format: 'pdf' })
|
||||||
|
const res = createMockResponse()
|
||||||
|
|
||||||
|
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[1].handle
|
||||||
|
await handler(req, res, vi.fn())
|
||||||
|
|
||||||
|
// Should NOT call addWatermark for PDF
|
||||||
|
expect(mockAddWatermark).not.toHaveBeenCalled()
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf')
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="screenshot.pdf"')
|
||||||
|
expect(res.send).toHaveBeenCalledWith(pdfBuffer)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -84,7 +84,7 @@ const playgroundLimiter = rateLimit({
|
||||||
* schema: { $ref: "#/components/schemas/Error" }
|
* schema: { $ref: "#/components/schemas/Error" }
|
||||||
*/
|
*/
|
||||||
playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
|
playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
|
||||||
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, waitUntil } = req.body;
|
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, waitUntil, pdfFormat, pdfLandscape, pdfPrintBackground, pdfScale, pdfMargin } = req.body;
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
res.status(400).json({ error: "Missing required parameter: url" });
|
res.status(400).json({ error: "Missing required parameter: url" });
|
||||||
|
|
@ -94,7 +94,7 @@ playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
|
||||||
// Enforce reasonable limits for playground
|
// Enforce reasonable limits for playground
|
||||||
const safeWidth = Math.min(Math.max(parseInt(width, 10) || 1280, 320), 1920);
|
const safeWidth = Math.min(Math.max(parseInt(width, 10) || 1280, 320), 1920);
|
||||||
const safeHeight = Math.min(Math.max(parseInt(height, 10) || 800, 200), 1080);
|
const safeHeight = Math.min(Math.max(parseInt(height, 10) || 800, 200), 1080);
|
||||||
const safeFormat = ["png", "jpeg", "webp"].includes(format) ? format : "png";
|
const safeFormat = ["png", "jpeg", "webp", "pdf"].includes(format) ? format : "png";
|
||||||
const safeFullPage = fullPage === true;
|
const safeFullPage = fullPage === true;
|
||||||
const safeQuality = safeFormat === "png" ? undefined : Math.min(Math.max(parseInt(quality, 10) || 80, 1), 100);
|
const safeQuality = safeFormat === "png" ? undefined : Math.min(Math.max(parseInt(quality, 10) || 80, 1), 100);
|
||||||
const safeDeviceScale = Math.min(Math.max(parseInt(deviceScale, 10) || 1, 1), 3);
|
const safeDeviceScale = Math.min(Math.max(parseInt(deviceScale, 10) || 1, 1), 3);
|
||||||
|
|
@ -105,9 +105,9 @@ playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
|
||||||
? waitForSelector : undefined;
|
? waitForSelector : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await takeScreenshot({
|
const screenshotOpts: any = {
|
||||||
url,
|
url,
|
||||||
format: safeFormat as "png" | "jpeg" | "webp",
|
format: safeFormat as "png" | "jpeg" | "webp" | "pdf",
|
||||||
width: safeWidth,
|
width: safeWidth,
|
||||||
height: safeHeight,
|
height: safeHeight,
|
||||||
fullPage: safeFullPage,
|
fullPage: safeFullPage,
|
||||||
|
|
@ -115,9 +115,30 @@ playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
|
||||||
deviceScale: safeDeviceScale,
|
deviceScale: safeDeviceScale,
|
||||||
waitUntil: safeWaitUntil as any,
|
waitUntil: safeWaitUntil as any,
|
||||||
waitForSelector: safeWaitForSelector,
|
waitForSelector: safeWaitForSelector,
|
||||||
});
|
};
|
||||||
|
|
||||||
// Add watermark
|
if (safeFormat === "pdf") {
|
||||||
|
if (pdfFormat) screenshotOpts.pdfFormat = pdfFormat;
|
||||||
|
if (pdfLandscape !== undefined) screenshotOpts.pdfLandscape = pdfLandscape;
|
||||||
|
if (pdfPrintBackground !== undefined) screenshotOpts.pdfPrintBackground = pdfPrintBackground;
|
||||||
|
if (pdfScale !== undefined) screenshotOpts.pdfScale = pdfScale;
|
||||||
|
if (pdfMargin) screenshotOpts.pdfMargin = pdfMargin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await takeScreenshot(screenshotOpts);
|
||||||
|
|
||||||
|
// Skip watermark for PDF (can't watermark a PDF the same way)
|
||||||
|
if (safeFormat === "pdf") {
|
||||||
|
res.setHeader("Content-Type", result.contentType);
|
||||||
|
res.setHeader("Content-Length", result.buffer.length);
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.setHeader("X-Playground", "true");
|
||||||
|
res.setHeader("Content-Disposition", 'attachment; filename="screenshot.pdf"');
|
||||||
|
res.send(result.buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add watermark for image formats
|
||||||
const watermarked = await addWatermark(result.buffer, safeWidth, safeHeight);
|
const watermarked = await addWatermark(result.buffer, safeWidth, safeHeight);
|
||||||
|
|
||||||
res.setHeader("Content-Type", result.contentType);
|
res.setHeader("Content-Type", result.contentType);
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,11 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
clipY,
|
clipY,
|
||||||
clipW,
|
clipW,
|
||||||
clipH,
|
clipH,
|
||||||
|
pdfFormat,
|
||||||
|
pdfLandscape,
|
||||||
|
pdfPrintBackground,
|
||||||
|
pdfScale,
|
||||||
|
pdfMargin,
|
||||||
} = source;
|
} = source;
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
|
|
@ -449,6 +454,23 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PDF-specific validation
|
||||||
|
if (format === "pdf") {
|
||||||
|
if (selector || clip || (clipX || clipY || clipW || clipH)) {
|
||||||
|
res.status(400).json({ error: 'format "pdf" is mutually exclusive with selector and clip' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pdfFormat && !["a4", "letter", "legal", "a3"].includes(pdfFormat)) {
|
||||||
|
res.status(400).json({ error: "pdfFormat must be one of: a4, letter, legal, a3" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scale = pdfScale !== undefined ? parseFloat(pdfScale) : undefined;
|
||||||
|
if (scale !== undefined && (scale < 0.1 || scale > 2.0)) {
|
||||||
|
res.status(400).json({ error: "pdfScale must be between 0.1 and 2.0" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate userAgent parameter
|
// Validate userAgent parameter
|
||||||
if (userAgent && typeof userAgent === 'string') {
|
if (userAgent && typeof userAgent === 'string') {
|
||||||
if (userAgent.length > 500) {
|
if (userAgent.length > 500) {
|
||||||
|
|
@ -566,6 +588,13 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
selector: selector || undefined,
|
selector: selector || undefined,
|
||||||
userAgent: userAgent || undefined,
|
userAgent: userAgent || undefined,
|
||||||
clip: normalizedClip || undefined,
|
clip: normalizedClip || undefined,
|
||||||
|
...(format === "pdf" ? {
|
||||||
|
pdfFormat: pdfFormat || undefined,
|
||||||
|
pdfLandscape: pdfLandscape === true || pdfLandscape === "true" || undefined,
|
||||||
|
pdfPrintBackground: pdfPrintBackground === false || pdfPrintBackground === "false" ? false : undefined,
|
||||||
|
pdfScale: pdfScale ? parseFloat(pdfScale) : undefined,
|
||||||
|
pdfMargin: pdfMargin || undefined,
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -596,6 +625,9 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
res.setHeader("X-Cache", "MISS");
|
res.setHeader("X-Cache", "MISS");
|
||||||
res.setHeader("X-Retry-Count", String(result.retryCount ?? 0));
|
res.setHeader("X-Retry-Count", String(result.retryCount ?? 0));
|
||||||
|
if (format === "pdf") {
|
||||||
|
res.setHeader("Content-Disposition", 'attachment; filename="screenshot.pdf"');
|
||||||
|
}
|
||||||
res.send(result.buffer);
|
res.send(result.buffer);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error({ err: err.message, url }, "Screenshot failed");
|
logger.error({ err: err.message, url }, "Screenshot failed");
|
||||||
|
|
|
||||||
133
src/services/__tests__/pdf.test.ts
Normal file
133
src/services/__tests__/pdf.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { takeScreenshot } from '../screenshot.js'
|
||||||
|
|
||||||
|
// Mock browser
|
||||||
|
vi.mock('../browser.js', () => ({
|
||||||
|
acquirePage: vi.fn(),
|
||||||
|
releasePage: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../ssrf.js', () => ({
|
||||||
|
validateUrl: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../logger.js', () => ({
|
||||||
|
default: { warn: vi.fn(), error: vi.fn() }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { acquirePage, releasePage } = await import('../browser.js')
|
||||||
|
const mockAcquirePage = vi.mocked(acquirePage)
|
||||||
|
|
||||||
|
describe('takeScreenshot - PDF format', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call page.pdf() for format=pdf and return application/pdf', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('%PDF-1.4 test')
|
||||||
|
const mockPage = {
|
||||||
|
setViewport: vi.fn(),
|
||||||
|
goto: vi.fn(),
|
||||||
|
pdf: vi.fn().mockResolvedValue(pdfBuffer),
|
||||||
|
emulateMediaFeatures: vi.fn(),
|
||||||
|
setUserAgent: vi.fn(),
|
||||||
|
addStyleTag: vi.fn(),
|
||||||
|
waitForSelector: vi.fn(),
|
||||||
|
evaluate: vi.fn(),
|
||||||
|
$: vi.fn(),
|
||||||
|
screenshot: vi.fn()
|
||||||
|
}
|
||||||
|
mockAcquirePage.mockResolvedValue({ page: mockPage as any, instance: {} as any })
|
||||||
|
|
||||||
|
const result = await takeScreenshot({
|
||||||
|
url: 'https://example.com',
|
||||||
|
format: 'pdf' as any
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPage.pdf).toHaveBeenCalled()
|
||||||
|
expect(mockPage.screenshot).not.toHaveBeenCalled()
|
||||||
|
expect(result.contentType).toBe('application/pdf')
|
||||||
|
expect(result.buffer.toString().startsWith('%PDF')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass PDF options to page.pdf()', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||||
|
const mockPage = {
|
||||||
|
setViewport: vi.fn(),
|
||||||
|
goto: vi.fn(),
|
||||||
|
pdf: vi.fn().mockResolvedValue(pdfBuffer),
|
||||||
|
emulateMediaFeatures: vi.fn(),
|
||||||
|
setUserAgent: vi.fn(),
|
||||||
|
addStyleTag: vi.fn(),
|
||||||
|
waitForSelector: vi.fn(),
|
||||||
|
evaluate: vi.fn(),
|
||||||
|
$: vi.fn(),
|
||||||
|
screenshot: vi.fn()
|
||||||
|
}
|
||||||
|
mockAcquirePage.mockResolvedValue({ page: mockPage as any, instance: {} as any })
|
||||||
|
|
||||||
|
await takeScreenshot({
|
||||||
|
url: 'https://example.com',
|
||||||
|
format: 'pdf' as any,
|
||||||
|
pdfFormat: 'letter',
|
||||||
|
pdfLandscape: true,
|
||||||
|
pdfPrintBackground: false,
|
||||||
|
pdfScale: 1.5,
|
||||||
|
pdfMargin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' }
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(mockPage.pdf).toHaveBeenCalledWith({
|
||||||
|
format: 'letter',
|
||||||
|
landscape: true,
|
||||||
|
printBackground: false,
|
||||||
|
scale: 1.5,
|
||||||
|
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use default PDF options when none specified', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||||
|
const mockPage = {
|
||||||
|
setViewport: vi.fn(),
|
||||||
|
goto: vi.fn(),
|
||||||
|
pdf: vi.fn().mockResolvedValue(pdfBuffer),
|
||||||
|
emulateMediaFeatures: vi.fn(),
|
||||||
|
setUserAgent: vi.fn(),
|
||||||
|
addStyleTag: vi.fn(),
|
||||||
|
waitForSelector: vi.fn(),
|
||||||
|
evaluate: vi.fn(),
|
||||||
|
$: vi.fn(),
|
||||||
|
screenshot: vi.fn()
|
||||||
|
}
|
||||||
|
mockAcquirePage.mockResolvedValue({ page: mockPage as any, instance: {} as any })
|
||||||
|
|
||||||
|
await takeScreenshot({
|
||||||
|
url: 'https://example.com',
|
||||||
|
format: 'pdf' as any
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPage.pdf).toHaveBeenCalledWith({
|
||||||
|
format: 'a4',
|
||||||
|
landscape: false,
|
||||||
|
printBackground: true,
|
||||||
|
scale: 1.0,
|
||||||
|
margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject format=pdf with selector', async () => {
|
||||||
|
await expect(takeScreenshot({
|
||||||
|
url: 'https://example.com',
|
||||||
|
format: 'pdf' as any,
|
||||||
|
selector: '#content'
|
||||||
|
})).rejects.toThrow('format "pdf" is mutually exclusive with selector and clip')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject format=pdf with clip', async () => {
|
||||||
|
await expect(takeScreenshot({
|
||||||
|
url: 'https://example.com',
|
||||||
|
format: 'pdf' as any,
|
||||||
|
clip: { x: 0, y: 0, width: 100, height: 100 }
|
||||||
|
})).rejects.toThrow('format "pdf" is mutually exclusive with selector and clip')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -6,7 +6,7 @@ import logger from "./logger.js";
|
||||||
|
|
||||||
export interface ScreenshotOptions {
|
export interface ScreenshotOptions {
|
||||||
url: string;
|
url: string;
|
||||||
format?: "png" | "jpeg" | "webp";
|
format?: "png" | "jpeg" | "webp" | "pdf";
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
fullPage?: boolean;
|
fullPage?: boolean;
|
||||||
|
|
@ -22,6 +22,11 @@ export interface ScreenshotOptions {
|
||||||
selector?: string;
|
selector?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
clip?: { x: number; y: number; width: number; height: number };
|
clip?: { x: number; y: number; width: number; height: number };
|
||||||
|
pdfFormat?: string;
|
||||||
|
pdfLandscape?: boolean;
|
||||||
|
pdfPrintBackground?: boolean;
|
||||||
|
pdfScale?: number;
|
||||||
|
pdfMargin?: { top?: string; right?: string; bottom?: string; left?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScreenshotResult {
|
export interface ScreenshotResult {
|
||||||
|
|
@ -99,6 +104,11 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise<Screensho
|
||||||
validateSelector(opts.selector);
|
validateSelector(opts.selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check PDF mutual exclusivity with selector and clip
|
||||||
|
if (opts.format === "pdf" && (opts.selector || opts.clip)) {
|
||||||
|
throw new Error('format "pdf" is mutually exclusive with selector and clip');
|
||||||
|
}
|
||||||
|
|
||||||
// Check mutual exclusivity of selector and fullPage
|
// Check mutual exclusivity of selector and fullPage
|
||||||
if (opts.selector && opts.fullPage) {
|
if (opts.selector && opts.fullPage) {
|
||||||
throw new Error("selector and fullPage are mutually exclusive");
|
throw new Error("selector and fullPage are mutually exclusive");
|
||||||
|
|
@ -191,6 +201,19 @@ async function executeBrowserScreenshot(opts: ScreenshotOptions): Promise<Omit<S
|
||||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("SCREENSHOT_TIMEOUT")), TIMEOUT_MS)),
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("SCREENSHOT_TIMEOUT")), TIMEOUT_MS)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// PDF output branch
|
||||||
|
if (format === "pdf") {
|
||||||
|
const pdfResult = await page.pdf({
|
||||||
|
format: (opts.pdfFormat || 'a4') as any,
|
||||||
|
landscape: opts.pdfLandscape ?? false,
|
||||||
|
printBackground: opts.pdfPrintBackground ?? true,
|
||||||
|
scale: opts.pdfScale ?? 1.0,
|
||||||
|
margin: opts.pdfMargin || { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }
|
||||||
|
});
|
||||||
|
const buffer = Buffer.from(pdfResult as unknown as ArrayBuffer);
|
||||||
|
return { buffer, contentType: 'application/pdf' };
|
||||||
|
}
|
||||||
|
|
||||||
const screenshotOpts: any = {
|
const screenshotOpts: any = {
|
||||||
type: format === "webp" ? "webp" : format,
|
type: format === "webp" ? "webp" : format,
|
||||||
encoding: "binary",
|
encoding: "binary",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue