feat: add darkMode and hideSelectors screenshot parameters
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m31s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m31s
- darkMode: emulates prefers-color-scheme: dark before navigation - hideSelectors: injects CSS to hide elements before capture - POST: accepts string or string array - GET: accepts comma-separated string - Validation: max 10 selectors, each max 200 chars - OpenAPI docs updated for both GET and POST endpoints - 13 new tests added (service + route)
This commit is contained in:
parent
9575d312fe
commit
96d21aa63b
5 changed files with 232 additions and 4 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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<Screensho
|
|||
try {
|
||||
await page.setViewport({ width, height, deviceScaleFactor: deviceScale });
|
||||
|
||||
if (opts.darkMode) {
|
||||
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await page.goto(opts.url, { waitUntil, timeout: 20_000 });
|
||||
|
|
@ -53,6 +59,12 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise<Screensho
|
|||
if (opts.delay && opts.delay > 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<never>((_, reject) => setTimeout(() => reject(new Error("SCREENSHOT_TIMEOUT")), TIMEOUT_MS)),
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue