diff --git a/package-lock.json b/package-lock.json index ace1722..09bcccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "snapapi", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "snapapi", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "compression": "^1.8.1", "express": "^4.21.0", diff --git a/src/routes/__tests__/screenshot.test.ts b/src/routes/__tests__/screenshot.test.ts index 6c4c78b..531eb00 100644 --- a/src/routes/__tests__/screenshot.test.ts +++ b/src/routes/__tests__/screenshot.test.ts @@ -132,7 +132,9 @@ describe('Screenshot Route', () => { deviceScale: undefined, delay: undefined, waitUntil: undefined, - cache: undefined + cache: undefined, + darkMode: false, + hideSelectors: undefined, }) expect(mockCache.put).toHaveBeenCalledWith(expect.any(Object), mockBuffer, 'image/png') @@ -286,7 +288,9 @@ describe('Screenshot Route', () => { deviceScale: undefined, delay: undefined, waitUntil: undefined, - cache: undefined + cache: undefined, + darkMode: false, + hideSelectors: undefined, }) }) @@ -396,6 +400,104 @@ describe('Screenshot Route', () => { }) }) + describe('darkMode parameter', () => { + it('should pass darkMode=true to takeScreenshot via POST', async () => { + const mockBuffer = Buffer.from('fake-screenshot-data') + mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' }) + + const req = createMockRequest({ url: "https://example.com", darkMode: 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({ darkMode: true })) + }) + + it('should parse darkMode="true" from GET query string', async () => { + const mockBuffer = Buffer.from('fake-screenshot-data') + mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' }) + + const req = createMockRequest({ url: "https://example.com", darkMode: "true" }, { 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(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({ darkMode: true })) + }) + + it('should parse darkMode="false" from GET query as false', async () => { + const mockBuffer = Buffer.from('fake-screenshot-data') + mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' }) + + const req = createMockRequest({ url: "https://example.com", darkMode: "false" }, { 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(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({ darkMode: false })) + }) + }) + + describe('hideSelectors parameter', () => { + it('should pass hideSelectors array via POST', async () => { + const mockBuffer = Buffer.from('fake-screenshot-data') + mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' }) + + const req = createMockRequest({ url: "https://example.com", hideSelectors: [".ad", "#banner"] }) + 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({ hideSelectors: [".ad", "#banner"] })) + }) + + it('should wrap single string hideSelectors in array via POST', async () => { + const mockBuffer = Buffer.from('fake-screenshot-data') + mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' }) + + const req = createMockRequest({ url: "https://example.com", hideSelectors: ".ad" }) + 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({ hideSelectors: [".ad"] })) + }) + + it('should parse comma-separated hideSelectors from GET query', async () => { + const mockBuffer = Buffer.from('fake-screenshot-data') + mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' }) + + const req = createMockRequest({ url: "https://example.com", hideSelectors: ".ad,#banner,.popup" }, { 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(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({ hideSelectors: [".ad", "#banner", ".popup"] })) + }) + + it('should reject more than 10 hideSelectors', async () => { + const selectors = Array.from({ length: 11 }, (_, i) => `.sel${i}`) + const req = createMockRequest({ url: "https://example.com", hideSelectors: selectors }) + 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(expect.objectContaining({ error: expect.stringContaining('hideSelectors') })) + }) + + it('should reject hideSelectors items longer than 200 chars', async () => { + const longSelector = '.a'.repeat(101) // 202 chars + const req = createMockRequest({ url: "https://example.com", hideSelectors: [longSelector] }) + 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(expect.objectContaining({ error: expect.stringContaining('hideSelectors') })) + }) + }) + describe('Parameter normalization', () => { it('should parse integer parameters correctly', async () => { const mockBuffer = Buffer.from('fake-screenshot-data') diff --git a/src/routes/screenshot.ts b/src/routes/screenshot.ts index 3d3aa06..ef246b6 100644 --- a/src/routes/screenshot.ts +++ b/src/routes/screenshot.ts @@ -71,6 +71,19 @@ export const screenshotRouter = Router(); * maximum: 5000 * default: 0 * description: Extra delay in ms after page load before capturing + * darkMode: + * type: boolean + * default: false + * description: Emulate prefers-color-scheme dark mode + * hideSelectors: + * oneOf: + * - type: string + * description: Single CSS selector or comma-separated list + * - type: array + * items: + * type: string + * maxItems: 10 + * description: CSS selectors to hide before capture (max 10 items, each max 200 chars) * waitUntil: * type: string * enum: [load, domcontentloaded, networkidle0, networkidle2] @@ -221,6 +234,18 @@ export const screenshotRouter = Router(); * enum: [load, domcontentloaded, networkidle0, networkidle2] * default: domcontentloaded * description: Page load event to wait for before capturing + * - name: darkMode + * in: query + * schema: + * type: boolean + * default: false + * description: Emulate prefers-color-scheme dark mode + * - name: hideSelectors + * in: query + * schema: + * type: string + * description: Comma-separated CSS selectors to hide before capture (max 10 items, each max 200 chars) + * example: ".ad,#cookie-banner,.popup" * - name: cache * in: query * schema: @@ -291,6 +316,8 @@ async function handleScreenshotRequest(req: any, res: any) { delay, waitUntil, cache, + darkMode, + hideSelectors, } = source; if (!url || typeof url !== "string") { @@ -298,6 +325,28 @@ async function handleScreenshotRequest(req: any, res: any) { return; } + // Normalize hideSelectors: string | string[] → string[] + let normalizedHideSelectors: string[] | undefined; + if (hideSelectors) { + if (typeof hideSelectors === 'string') { + normalizedHideSelectors = hideSelectors.split(',').map((s: string) => s.trim()).filter(Boolean); + } else if (Array.isArray(hideSelectors)) { + normalizedHideSelectors = hideSelectors; + } + } + + // Validate hideSelectors + if (normalizedHideSelectors) { + if (normalizedHideSelectors.length > 10) { + res.status(400).json({ error: "hideSelectors: maximum 10 selectors allowed" }); + return; + } + if (normalizedHideSelectors.some((s: string) => s.length > 200)) { + res.status(400).json({ error: "hideSelectors: each selector must be 200 characters or less" }); + return; + } + } + // Normalize parameters const params = { url, @@ -311,6 +360,8 @@ async function handleScreenshotRequest(req: any, res: any) { delay: delay ? parseInt(delay, 10) : undefined, waitUntil: ["load", "domcontentloaded", "networkidle0", "networkidle2"].includes(waitUntil) ? waitUntil : undefined, cache, + darkMode: darkMode === true || darkMode === "true", + hideSelectors: normalizedHideSelectors, }; try { diff --git a/src/services/__tests__/screenshot.test.ts b/src/services/__tests__/screenshot.test.ts index 815a8bc..d7c3b67 100644 --- a/src/services/__tests__/screenshot.test.ts +++ b/src/services/__tests__/screenshot.test.ts @@ -24,6 +24,8 @@ function createMockPage() { goto: vi.fn().mockResolvedValue(undefined), waitForSelector: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-screenshot')), + emulateMediaFeatures: vi.fn().mockResolvedValue(undefined), + addStyleTag: vi.fn().mockResolvedValue(undefined), } } @@ -177,6 +179,67 @@ describe('Screenshot Service', () => { }) }) + describe('darkMode', () => { + it('emulates prefers-color-scheme: dark when darkMode is true', async () => { + await takeScreenshot({ url: 'https://example.com', darkMode: true }) + expect(mockPage.emulateMediaFeatures).toHaveBeenCalledWith([ + { name: 'prefers-color-scheme', value: 'dark' } + ]) + }) + + it('calls emulateMediaFeatures before goto', async () => { + const callOrder: string[] = [] + mockPage.emulateMediaFeatures.mockImplementation(async () => { callOrder.push('emulate') }) + mockPage.goto.mockImplementation(async () => { callOrder.push('goto') }) + await takeScreenshot({ url: 'https://example.com', darkMode: true }) + expect(callOrder).toEqual(['emulate', 'goto']) + }) + + it('does not emulate media features when darkMode is false', async () => { + await takeScreenshot({ url: 'https://example.com', darkMode: false }) + expect(mockPage.emulateMediaFeatures).not.toHaveBeenCalled() + }) + + it('does not emulate media features when darkMode is not set', async () => { + await takeScreenshot({ url: 'https://example.com' }) + expect(mockPage.emulateMediaFeatures).not.toHaveBeenCalled() + }) + }) + + describe('hideSelectors', () => { + it('injects style tag to hide selectors after page load', async () => { + await takeScreenshot({ url: 'https://example.com', hideSelectors: ['.ad', '#banner'] }) + expect(mockPage.addStyleTag).toHaveBeenCalledWith({ + content: '.ad { display: none !important }\n#banner { display: none !important }' + }) + }) + + it('calls addStyleTag after goto', async () => { + const callOrder: string[] = [] + mockPage.goto.mockImplementation(async () => { callOrder.push('goto') }) + mockPage.addStyleTag.mockImplementation(async () => { callOrder.push('addStyleTag') }) + await takeScreenshot({ url: 'https://example.com', hideSelectors: ['.ad'] }) + expect(callOrder.indexOf('goto')).toBeLessThan(callOrder.indexOf('addStyleTag')) + }) + + it('does not inject style tag when hideSelectors is empty', async () => { + await takeScreenshot({ url: 'https://example.com', hideSelectors: [] }) + expect(mockPage.addStyleTag).not.toHaveBeenCalled() + }) + + it('does not inject style tag when hideSelectors is not set', async () => { + await takeScreenshot({ url: 'https://example.com' }) + expect(mockPage.addStyleTag).not.toHaveBeenCalled() + }) + + it('handles single selector', async () => { + await takeScreenshot({ url: 'https://example.com', hideSelectors: ['.cookie-banner'] }) + expect(mockPage.addStyleTag).toHaveBeenCalledWith({ + content: '.cookie-banner { display: none !important }' + }) + }) + }) + describe('Page lifecycle', () => { it('always releases page after successful screenshot', async () => { await takeScreenshot({ url: 'https://example.com' }) diff --git a/src/services/screenshot.ts b/src/services/screenshot.ts index 0e67020..abbb1e8 100644 --- a/src/services/screenshot.ts +++ b/src/services/screenshot.ts @@ -14,6 +14,8 @@ export interface ScreenshotOptions { deviceScale?: number; delay?: number; waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2"; + darkMode?: boolean; + hideSelectors?: string[]; } export interface ScreenshotResult { @@ -42,6 +44,10 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise { await page.goto(opts.url, { waitUntil, timeout: 20_000 }); @@ -53,6 +59,12 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise 0) { await new Promise(r => setTimeout(r, Math.min(opts.delay!, 5000))); } + + if (opts.hideSelectors && opts.hideSelectors.length > 0) { + await page.addStyleTag({ + content: opts.hideSelectors.map(s => s + ' { display: none !important }').join('\n') + }); + } })(), new Promise((_, reject) => setTimeout(() => reject(new Error("SCREENSHOT_TIMEOUT")), TIMEOUT_MS)), ]);