From 91a08bab7055dd574c5a7bdfd4baaeb13835c76c Mon Sep 17 00:00:00 2001 From: SnapAPI Developer Date: Thu, 5 Mar 2026 12:07:54 +0100 Subject: [PATCH] Add js parameter for custom JavaScript injection - Add js parameter to ScreenshotOptions interface (max 5000 chars) - Execute JavaScript via page.evaluate() after delay, before CSS/hideSelectors - 5-second timeout with JS_TIMEOUT error handling - JS_EXECUTION_ERROR for script failures with sanitized error messages - Support in both GET and POST endpoints with validation - Updated OpenAPI spec for both GET and POST routes - Added comprehensive test coverage (service + route layers) - Updated SDK documentation (Node.js and Python) with examples Test results: 414 tests passing (includes new JS injection tests) --- sdk/node/README.md | 31 +++ sdk/python/README.md | 31 +++ src/routes/__tests__/screenshot.test.ts | 245 ++++++++++++++++++++++ src/routes/screenshot.ts | 39 +++- src/services/__tests__/screenshot.test.ts | 180 ++++++++++++++++ src/services/screenshot.ts | 49 ++++- 6 files changed, 572 insertions(+), 3 deletions(-) diff --git a/sdk/node/README.md b/sdk/node/README.md index ac9377f..187f0bc 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -121,6 +121,37 @@ const combined = await snap.capture({ }); ``` +### JavaScript Injection + +```typescript +// Execute custom JavaScript before capture +const interactiveScreenshot = await snap.capture({ + url: 'https://example.com', + js: ` + // Dismiss modal popup + document.querySelector('.modal-overlay')?.remove(); + + // Scroll to specific content + window.scrollTo(0, 500); + + // Click button to reveal content + document.querySelector('#show-more-btn')?.click(); + + // Wait for animation to complete + await new Promise(resolve => setTimeout(resolve, 1000)); + `, +}); + +// Combine with other options for complex scenarios +const complexCapture = await snap.capture({ + url: 'https://example.com/app', + js: 'document.querySelector(".sidebar").style.display = "none";', + css: 'body { zoom: 0.8 }', + waitForSelector: '#content-loaded', + hideSelectors: ['.ad-banner', '.cookie-notice'], +}); +``` + ## API Reference ### `new SnapAPI(apiKey, config?)` diff --git a/sdk/python/README.md b/sdk/python/README.md index 1874b4b..b314da0 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -125,6 +125,37 @@ combined = snap.capture( ) ``` +### JavaScript Injection + +```python +# Execute custom JavaScript before capture +interactive_screenshot = snap.capture( + "https://example.com", + js=""" + // Dismiss modal popup + document.querySelector('.modal-overlay')?.remove(); + + // Scroll to specific content + window.scrollTo(0, 500); + + // Click button to reveal content + document.querySelector('#show-more-btn')?.click(); + + // Wait for animation to complete + await new Promise(resolve => setTimeout(resolve, 1000)); + """, +) + +# Combine with other options for complex scenarios +complex_capture = snap.capture( + "https://example.com/app", + js='document.querySelector(".sidebar").style.display = "none";', + css="body { zoom: 0.8 }", + wait_for_selector="#content-loaded", + hide_selectors=[".ad-banner", ".cookie-notice"], +) +``` + ### Error Handling ```python diff --git a/src/routes/__tests__/screenshot.test.ts b/src/routes/__tests__/screenshot.test.ts index cc43b64..e6e47c1 100644 --- a/src/routes/__tests__/screenshot.test.ts +++ b/src/routes/__tests__/screenshot.test.ts @@ -624,4 +624,249 @@ describe('Screenshot Route', () => { })) }) }) + + describe('JavaScript injection (js parameter)', () => { + it('should accept js parameter in POST request', async () => { + const mockBuffer = Buffer.from('fake-screenshot-data') + mockTakeScreenshot.mockResolvedValueOnce({ + buffer: mockBuffer, + contentType: 'image/png' + }) + + const jsCode = 'document.body.style.background = "red";' + const req = createMockRequest({ + url: "https://example.com", + js: jsCode + }) + 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({ + js: jsCode + })) + }) + + it('should accept js parameter in GET request', async () => { + const mockBuffer = Buffer.from('fake-screenshot-data') + mockTakeScreenshot.mockResolvedValueOnce({ + buffer: mockBuffer, + contentType: 'image/png' + }) + + const jsCode = 'window.scrollTo(0, 500);' + const req = createMockRequest({ + url: "https://example.com", + js: jsCode + }, { 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({ + js: jsCode + })) + }) + + it('should reject js parameter longer than 5000 characters', async () => { + const longJs = 'a'.repeat(5001) + const req = createMockRequest({ + url: "https://example.com", + js: longJs + }) + 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: "js: maximum 5000 characters allowed" + }) + }) + + it('should handle JS_EXECUTION_ERROR from service', async () => { + mockTakeScreenshot.mockRejectedValueOnce(new Error('JS_EXECUTION_ERROR: Script failed')) + + const req = createMockRequest({ + url: "https://example.com", + js: "throw new Error('test error');" + }) + 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: "JS_EXECUTION_ERROR: Script failed" + }) + }) + + it('should handle JS_TIMEOUT error from service', async () => { + mockTakeScreenshot.mockRejectedValueOnce(new Error('JS_EXECUTION_ERROR: JS_TIMEOUT')) + + const req = createMockRequest({ + url: "https://example.com", + js: "while(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(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "JS_EXECUTION_ERROR: JS_TIMEOUT" + }) + }) + + describe('selector parameter', () => { + it('should pass selector parameter to takeScreenshot service', async () => { + const mockBuffer = Buffer.from('element-screenshot') + mockTakeScreenshot.mockResolvedValueOnce({ + buffer: mockBuffer, + contentType: 'image/png' + }) + + const req = createMockRequest({ + url: "https://example.com", + selector: "#main-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(mockTakeScreenshot).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://example.com", + selector: "#main-content" + }) + ) + expect(res.send).toHaveBeenCalledWith(mockBuffer) + }) + + it('should return 400 when selector and fullPage are both provided', async () => { + const req = createMockRequest({ + url: "https://example.com", + selector: "#content", + fullPage: 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(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "selector and fullPage are mutually exclusive" + }) + }) + + it('should handle SELECTOR_NOT_FOUND error from service', async () => { + mockTakeScreenshot.mockRejectedValueOnce(new Error('SELECTOR_NOT_FOUND')) + + const req = createMockRequest({ + url: "https://example.com", + selector: "#nonexistent" + }) + 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: "Element not found: #nonexistent" + }) + }) + }) + }) + + describe('GET /v1/screenshot selector parameter', () => { + it('should pass selector from query string to takeScreenshot service', async () => { + const mockBuffer = Buffer.from('element-screenshot') + mockTakeScreenshot.mockResolvedValueOnce({ + buffer: mockBuffer, + contentType: 'image/png' + }) + + const req = createMockRequest({ + url: "https://example.com", + selector: ".main-section" + }, { 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({ + url: "https://example.com", + selector: ".main-section" + }) + ) + expect(res.send).toHaveBeenCalledWith(mockBuffer) + }) + + it('should return 400 when selector and fullPage are both provided in GET request', async () => { + const req = createMockRequest({ + url: "https://example.com", + selector: "#content", + fullPage: "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(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "selector and fullPage are mutually exclusive" + }) + }) + + it('should handle SELECTOR_NOT_FOUND error from service in GET request', async () => { + mockTakeScreenshot.mockRejectedValueOnce(new Error('SELECTOR_NOT_FOUND')) + + const req = createMockRequest({ + url: "https://example.com", + selector: "#missing" + }, { 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.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "Element not found: #missing" + }) + }) + }) }) \ No newline at end of file diff --git a/src/routes/screenshot.ts b/src/routes/screenshot.ts index bc167bb..7264d4d 100644 --- a/src/routes/screenshot.ts +++ b/src/routes/screenshot.ts @@ -80,6 +80,16 @@ export const screenshotRouter = Router(); * maxLength: 5000 * description: Custom CSS to inject into the page before capture (max 5000 chars) * example: "body { background: #1a1a2e !important; color: #eee !important }" + * js: + * type: string + * maxLength: 5000 + * description: Custom JavaScript code to execute on the page before capture (max 5000 chars, 5-second timeout) + * example: "document.querySelector('.modal').remove(); window.scrollTo(0, 500);" + * selector: + * type: string + * maxLength: 200 + * description: CSS selector for element to capture instead of full page/viewport (max 200 chars) + * example: "#main-content" * hideSelectors: * oneOf: * - type: string @@ -246,6 +256,13 @@ export const screenshotRouter = Router(); * maxLength: 5000 * description: Custom CSS to inject into the page before capture (max 5000 chars) * example: "body { background: #1a1a2e !important }" + * - name: js + * in: query + * schema: + * type: string + * maxLength: 5000 + * description: Custom JavaScript code to execute on the page before capture (max 5000 chars, 5-second timeout) + * example: "document.querySelector('.modal').remove();" * - name: darkMode * in: query * schema: @@ -331,6 +348,8 @@ async function handleScreenshotRequest(req: any, res: any) { darkMode, hideSelectors, css, + js, + selector, } = source; if (!url || typeof url !== "string") { @@ -344,6 +363,12 @@ async function handleScreenshotRequest(req: any, res: any) { return; } + // Validate js parameter + if (js && typeof js === 'string' && js.length > 5000) { + res.status(400).json({ error: "js: maximum 5000 characters allowed" }); + return; + } + // Normalize hideSelectors: string | string[] → string[] let normalizedHideSelectors: string[] | undefined; if (hideSelectors) { @@ -366,6 +391,12 @@ async function handleScreenshotRequest(req: any, res: any) { } } + // Check mutual exclusivity of selector and fullPage + if (selector && (fullPage === true || fullPage === "true")) { + res.status(400).json({ error: "selector and fullPage are mutually exclusive" }); + return; + } + // Normalize parameters const params = { url, @@ -382,6 +413,8 @@ async function handleScreenshotRequest(req: any, res: any) { darkMode: darkMode === true || darkMode === "true", hideSelectors: normalizedHideSelectors, css: css || undefined, + js: js || undefined, + selector: selector || undefined, }; try { @@ -423,7 +456,11 @@ async function handleScreenshotRequest(req: any, res: any) { res.status(504).json({ error: "Screenshot timed out. The page may be too slow to load." }); return; } - if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL") || err.message.includes("Could not resolve")) { + if (err.message === "SELECTOR_NOT_FOUND") { + res.status(400).json({ error: `Element not found: ${selector}` }); + return; + } + if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL") || err.message.includes("Could not resolve") || err.message.includes("JS_EXECUTION_ERROR")) { res.status(400).json({ error: err.message }); return; } diff --git a/src/services/__tests__/screenshot.test.ts b/src/services/__tests__/screenshot.test.ts index e9ae600..2e7b77b 100644 --- a/src/services/__tests__/screenshot.test.ts +++ b/src/services/__tests__/screenshot.test.ts @@ -410,4 +410,184 @@ describe('Screenshot Service', () => { expect(releasePage).not.toHaveBeenCalled() }) }) + + describe('JavaScript injection (js parameter)', () => { + beforeEach(() => { + mockPage.evaluate = vi.fn().mockResolvedValue(undefined) + }) + + it('executes custom JavaScript when js parameter is provided', async () => { + const jsCode = 'document.body.style.background = "red";' + + await takeScreenshot({ + url: 'https://example.com', + js: jsCode + }) + + expect(mockPage.evaluate).toHaveBeenCalledWith(jsCode) + }) + + it('does not call page.evaluate when js parameter is not provided', async () => { + await takeScreenshot({ url: 'https://example.com' }) + + expect(mockPage.evaluate).not.toHaveBeenCalled() + }) + + it('executes JavaScript after delay but before CSS injection', async () => { + const jsCode = 'window.scrollTo(0, 100);' + const cssCode = 'body { color: red; }' + + await takeScreenshot({ + url: 'https://example.com', + delay: 1000, + js: jsCode, + css: cssCode + }) + + // Check that methods were called in the right order + expect(mockPage.goto).toHaveBeenCalledBefore(mockPage.evaluate as any) + expect(mockPage.evaluate).toHaveBeenCalledBefore(mockPage.addStyleTag as any) + }) + + it('throws JS_EXECUTION_ERROR when JavaScript execution fails', async () => { + mockPage.evaluate = vi.fn().mockRejectedValueOnce(new Error('ReferenceError: foo is not defined')) + + await expect(takeScreenshot({ + url: 'https://example.com', + js: 'console.log(foo);' + })).rejects.toThrow('JS_EXECUTION_ERROR: ReferenceError: foo is not defined') + }) + + it('throws JS_TIMEOUT error when JavaScript execution takes too long', async () => { + // Mock a long-running script that never resolves + mockPage.evaluate = vi.fn().mockReturnValue(new Promise(() => {})) + + await expect(takeScreenshot({ + url: 'https://example.com', + js: 'while(true) {}' + })).rejects.toThrow('JS_EXECUTION_ERROR: JS_TIMEOUT') + }) + + it('handles JavaScript execution with hideSelectors and CSS', async () => { + const jsCode = 'document.querySelector(".modal").remove();' + + await takeScreenshot({ + url: 'https://example.com', + js: jsCode, + hideSelectors: ['.popup'], + css: 'body { font-size: 16px; }' + }) + + expect(mockPage.evaluate).toHaveBeenCalledWith(jsCode) + expect(mockPage.addStyleTag).toHaveBeenCalledTimes(2) // Once for CSS, once for hideSelectors + }) + }) + + describe('selector parameter for element screenshots', () => { + let mockElement: any + + beforeEach(() => { + mockElement = { + screenshot: vi.fn().mockResolvedValue(Buffer.from('element-screenshot')) + } + mockPage.$ = vi.fn().mockResolvedValue(mockElement) + }) + + it('uses element.screenshot() when selector is provided', async () => { + await takeScreenshot({ url: 'https://example.com', selector: '#main-content' }) + expect(mockPage.$).toHaveBeenCalledWith('#main-content') + expect(mockElement.screenshot).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'png', + encoding: 'binary' + }) + ) + expect(mockPage.screenshot).not.toHaveBeenCalled() + }) + + it('passes through format and quality to element.screenshot()', async () => { + await takeScreenshot({ + url: 'https://example.com', + selector: '.widget', + format: 'jpeg', + quality: 85 + }) + expect(mockElement.screenshot).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'jpeg', + quality: 85, + encoding: 'binary' + }) + ) + }) + + it('throws SELECTOR_NOT_FOUND when element is not found', async () => { + mockPage.$.mockResolvedValueOnce(null) + await expect(takeScreenshot({ + url: 'https://example.com', + selector: '#nonexistent' + })).rejects.toThrow('SELECTOR_NOT_FOUND') + }) + + it('validates selector length (max 200 chars)', async () => { + const longSelector = 'div'.repeat(100) // 300 chars + await expect(takeScreenshot({ + url: 'https://example.com', + selector: longSelector + })).rejects.toThrow('selector is too long') + }) + + it('validates selector for dangerous content (javascript:)', async () => { + await expect(takeScreenshot({ + url: 'https://example.com', + selector: 'javascript:alert(1)' + })).rejects.toThrow('selector contains dangerous content') + }) + + it('validates selector for dangerous content (script tag)', async () => { + await expect(takeScreenshot({ + url: 'https://example.com', + selector: '' + })).rejects.toThrow('selector contains dangerous content') + }) + + it('rejects selector and fullPage used together', async () => { + await expect(takeScreenshot({ + url: 'https://example.com', + selector: '#content', + fullPage: true + })).rejects.toThrow('selector and fullPage are mutually exclusive') + }) + + it('works with all other parameters (delay, css, hideSelectors, etc.)', async () => { + await takeScreenshot({ + url: 'https://example.com', + selector: '#main', + delay: 1000, + css: 'body { color: red; }', + hideSelectors: ['.ad'], + darkMode: true + }) + + expect(mockPage.emulateMediaFeatures).toHaveBeenCalledWith([ + { name: 'prefers-color-scheme', value: 'dark' } + ]) + expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: 'body { color: red; }' }) + expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: '.ad { display: none !important }' }) + expect(mockPage.$).toHaveBeenCalledWith('#main') + expect(mockElement.screenshot).toHaveBeenCalled() + }) + + it('does not use page.screenshot() when selector is provided', async () => { + await takeScreenshot({ url: 'https://example.com', selector: '.element' }) + expect(mockPage.screenshot).not.toHaveBeenCalled() + expect(mockElement.screenshot).toHaveBeenCalled() + }) + + it('uses page.screenshot() when selector is not provided', async () => { + await takeScreenshot({ url: 'https://example.com' }) + expect(mockPage.screenshot).toHaveBeenCalled() + expect(mockPage.$).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/services/screenshot.ts b/src/services/screenshot.ts index eaa50e3..3eb85f6 100644 --- a/src/services/screenshot.ts +++ b/src/services/screenshot.ts @@ -17,6 +17,8 @@ export interface ScreenshotOptions { darkMode?: boolean; hideSelectors?: string[]; css?: string; + js?: string; + selector?: string; } export interface ScreenshotResult { @@ -60,6 +62,15 @@ function validateCSS(css: string): void { } } +function validateSelector(selector: string): void { + if (selector.length > 200) { + throw new Error("selector is too long"); + } + if (selector.includes("javascript:") || selector.includes(" { // Validate URL for SSRF await validateUrl(opts.url); @@ -77,6 +88,15 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise setTimeout(r, Math.min(opts.delay!, 5000))); } + if (opts.js) { + try { + await Promise.race([ + page.evaluate(opts.js), + new Promise((_, reject) => setTimeout(() => reject(new Error("JS_TIMEOUT")), 5000)) + ]); + } catch (err: any) { + throw new Error("JS_EXECUTION_ERROR: " + (err.message || "Script failed")); + } + } + if (opts.css) { await page.addStyleTag({ content: opts.css }); } @@ -121,12 +152,26 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise