feat: add darkMode and hideSelectors screenshot parameters
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:
Hoid 2026-03-04 12:06:26 +01:00
parent 9575d312fe
commit 96d21aa63b
5 changed files with 232 additions and 4 deletions

View file

@ -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' })

View file

@ -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)),
]);