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)
This commit is contained in:
parent
ba888bb580
commit
91a08bab70
6 changed files with 572 additions and 3 deletions
|
|
@ -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
|
## API Reference
|
||||||
|
|
||||||
### `new SnapAPI(apiKey, config?)`
|
### `new SnapAPI(apiKey, config?)`
|
||||||
|
|
|
||||||
|
|
@ -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
|
### Error Handling
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -80,6 +80,16 @@ export const screenshotRouter = Router();
|
||||||
* maxLength: 5000
|
* maxLength: 5000
|
||||||
* description: Custom CSS to inject into the page before capture (max 5000 chars)
|
* description: Custom CSS to inject into the page before capture (max 5000 chars)
|
||||||
* example: "body { background: #1a1a2e !important; color: #eee !important }"
|
* 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:
|
* hideSelectors:
|
||||||
* oneOf:
|
* oneOf:
|
||||||
* - type: string
|
* - type: string
|
||||||
|
|
@ -246,6 +256,13 @@ export const screenshotRouter = Router();
|
||||||
* maxLength: 5000
|
* maxLength: 5000
|
||||||
* description: Custom CSS to inject into the page before capture (max 5000 chars)
|
* description: Custom CSS to inject into the page before capture (max 5000 chars)
|
||||||
* example: "body { background: #1a1a2e !important }"
|
* 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
|
* - name: darkMode
|
||||||
* in: query
|
* in: query
|
||||||
* schema:
|
* schema:
|
||||||
|
|
@ -331,6 +348,8 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
darkMode,
|
darkMode,
|
||||||
hideSelectors,
|
hideSelectors,
|
||||||
css,
|
css,
|
||||||
|
js,
|
||||||
|
selector,
|
||||||
} = source;
|
} = source;
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
|
|
@ -344,6 +363,12 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
return;
|
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[]
|
// Normalize hideSelectors: string | string[] → string[]
|
||||||
let normalizedHideSelectors: string[] | undefined;
|
let normalizedHideSelectors: string[] | undefined;
|
||||||
if (hideSelectors) {
|
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
|
// Normalize parameters
|
||||||
const params = {
|
const params = {
|
||||||
url,
|
url,
|
||||||
|
|
@ -382,6 +413,8 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
darkMode: darkMode === true || darkMode === "true",
|
darkMode: darkMode === true || darkMode === "true",
|
||||||
hideSelectors: normalizedHideSelectors,
|
hideSelectors: normalizedHideSelectors,
|
||||||
css: css || undefined,
|
css: css || undefined,
|
||||||
|
js: js || undefined,
|
||||||
|
selector: selector || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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." });
|
res.status(504).json({ error: "Screenshot timed out. The page may be too slow to load." });
|
||||||
return;
|
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 });
|
res.status(400).json({ error: err.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -410,4 +410,184 @@ describe('Screenshot Service', () => {
|
||||||
expect(releasePage).not.toHaveBeenCalled()
|
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: '<script>alert(1)</script>'
|
||||||
|
})).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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ export interface ScreenshotOptions {
|
||||||
darkMode?: boolean;
|
darkMode?: boolean;
|
||||||
hideSelectors?: string[];
|
hideSelectors?: string[];
|
||||||
css?: string;
|
css?: string;
|
||||||
|
js?: string;
|
||||||
|
selector?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScreenshotResult {
|
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("<script")) {
|
||||||
|
throw new Error("selector contains dangerous content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function takeScreenshot(opts: ScreenshotOptions): Promise<ScreenshotResult> {
|
export async function takeScreenshot(opts: ScreenshotOptions): Promise<ScreenshotResult> {
|
||||||
// Validate URL for SSRF
|
// Validate URL for SSRF
|
||||||
await validateUrl(opts.url);
|
await validateUrl(opts.url);
|
||||||
|
|
@ -77,6 +88,15 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise<Screensho
|
||||||
validateCSS(opts.css);
|
validateCSS(opts.css);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.selector) {
|
||||||
|
validateSelector(opts.selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mutual exclusivity of selector and fullPage
|
||||||
|
if (opts.selector && opts.fullPage) {
|
||||||
|
throw new Error("selector and fullPage are mutually exclusive");
|
||||||
|
}
|
||||||
|
|
||||||
const format = opts.format || "png";
|
const format = opts.format || "png";
|
||||||
const width = Math.min(opts.width || 1280, MAX_WIDTH);
|
const width = Math.min(opts.width || 1280, MAX_WIDTH);
|
||||||
const height = Math.min(opts.height || 800, MAX_HEIGHT);
|
const height = Math.min(opts.height || 800, MAX_HEIGHT);
|
||||||
|
|
@ -106,6 +126,17 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise<Screensho
|
||||||
await new Promise(r => setTimeout(r, Math.min(opts.delay!, 5000)));
|
await new Promise(r => 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) {
|
if (opts.css) {
|
||||||
await page.addStyleTag({ content: opts.css });
|
await page.addStyleTag({ content: opts.css });
|
||||||
}
|
}
|
||||||
|
|
@ -121,12 +152,26 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise<Screensho
|
||||||
|
|
||||||
const screenshotOpts: any = {
|
const screenshotOpts: any = {
|
||||||
type: format === "webp" ? "webp" : format,
|
type: format === "webp" ? "webp" : format,
|
||||||
fullPage,
|
|
||||||
encoding: "binary",
|
encoding: "binary",
|
||||||
};
|
};
|
||||||
|
if (!opts.selector) {
|
||||||
|
screenshotOpts.fullPage = fullPage;
|
||||||
|
}
|
||||||
if (quality !== undefined) screenshotOpts.quality = quality;
|
if (quality !== undefined) screenshotOpts.quality = quality;
|
||||||
|
|
||||||
const result = await page.screenshot(screenshotOpts);
|
let result: Buffer;
|
||||||
|
if (opts.selector) {
|
||||||
|
// Take element screenshot
|
||||||
|
const element = await page.$(opts.selector);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error("SELECTOR_NOT_FOUND");
|
||||||
|
}
|
||||||
|
result = await element.screenshot(screenshotOpts) as Buffer;
|
||||||
|
} else {
|
||||||
|
// Take page screenshot
|
||||||
|
result = await page.screenshot(screenshotOpts) as Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(result as unknown as ArrayBuffer);
|
const buffer = Buffer.from(result as unknown as ArrayBuffer);
|
||||||
|
|
||||||
const contentType = format === "png" ? "image/png" : format === "jpeg" ? "image/jpeg" : "image/webp";
|
const contentType = format === "png" ? "image/png" : format === "jpeg" ? "image/jpeg" : "image/webp";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue