feat: add css parameter for custom CSS injection in screenshots
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m33s

This commit is contained in:
OpenClaw 2026-03-04 21:06:50 +01:00
parent 1b7251fbcb
commit 0999474fbd
8 changed files with 176 additions and 17 deletions

View file

@ -135,6 +135,7 @@ describe('Screenshot Route', () => {
cache: undefined,
darkMode: false,
hideSelectors: undefined,
css: undefined,
})
expect(mockCache.put).toHaveBeenCalledWith(expect.any(Object), mockBuffer, 'image/png')
@ -291,6 +292,7 @@ describe('Screenshot Route', () => {
cache: undefined,
darkMode: false,
hideSelectors: undefined,
css: undefined,
})
})
@ -498,6 +500,77 @@ describe('Screenshot Route', () => {
})
})
describe('css parameter', () => {
it('should pass css 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", css: "body { background: red !important }" })
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({ css: "body { background: red !important }" }))
})
it('should pass css to takeScreenshot via GET', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' })
const req = createMockRequest({ url: "https://example.com", css: "body { background: red !important }" }, { 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({ css: "body { background: red !important }" }))
})
it('should return 400 when css exceeds 5000 characters', async () => {
const longCss = 'a'.repeat(5001)
const req = createMockRequest({ url: "https://example.com", css: longCss })
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('css') }))
})
it('should accept css at exactly 5000 characters', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' })
const exactCss = 'a'.repeat(5000)
const req = createMockRequest({ url: "https://example.com", css: exactCss })
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({ css: exactCss }))
})
it('should work alongside darkMode and hideSelectors', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' })
const req = createMockRequest({
url: "https://example.com",
css: "body { font-family: Arial }",
darkMode: true,
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({
css: "body { font-family: Arial }",
darkMode: true,
hideSelectors: [".ad"]
}))
})
})
describe('Parameter normalization', () => {
it('should parse integer parameters correctly', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')

View file

@ -75,6 +75,11 @@ export const screenshotRouter = Router();
* type: boolean
* default: false
* description: Emulate prefers-color-scheme dark mode
* css:
* type: string
* maxLength: 5000
* description: Custom CSS to inject into the page before capture (max 5000 chars)
* example: "body { background: #1a1a2e !important; color: #eee !important }"
* hideSelectors:
* oneOf:
* - type: string
@ -234,6 +239,13 @@ export const screenshotRouter = Router();
* enum: [load, domcontentloaded, networkidle0, networkidle2]
* default: domcontentloaded
* description: Page load event to wait for before capturing
* - name: css
* in: query
* schema:
* type: string
* maxLength: 5000
* description: Custom CSS to inject into the page before capture (max 5000 chars)
* example: "body { background: #1a1a2e !important }"
* - name: darkMode
* in: query
* schema:
@ -318,6 +330,7 @@ async function handleScreenshotRequest(req: any, res: any) {
cache,
darkMode,
hideSelectors,
css,
} = source;
if (!url || typeof url !== "string") {
@ -325,6 +338,12 @@ async function handleScreenshotRequest(req: any, res: any) {
return;
}
// Validate css parameter
if (css && typeof css === 'string' && css.length > 5000) {
res.status(400).json({ error: "css: maximum 5000 characters allowed" });
return;
}
// Normalize hideSelectors: string | string[] → string[]
let normalizedHideSelectors: string[] | undefined;
if (hideSelectors) {
@ -362,6 +381,7 @@ async function handleScreenshotRequest(req: any, res: any) {
cache,
darkMode: darkMode === true || darkMode === "true",
hideSelectors: normalizedHideSelectors,
css: css || undefined,
};
try {

View file

@ -240,6 +240,48 @@ describe('Screenshot Service', () => {
})
})
describe('css parameter', () => {
it('injects custom CSS via addStyleTag', async () => {
await takeScreenshot({ url: 'https://example.com', css: 'body { background: red !important }' })
expect(mockPage.addStyleTag).toHaveBeenCalledWith({
content: 'body { background: red !important }'
})
})
it('injects css after goto and waitForSelector', async () => {
const callOrder: string[] = []
mockPage.goto.mockImplementation(async () => { callOrder.push('goto') })
mockPage.waitForSelector.mockImplementation(async () => { callOrder.push('waitForSelector') })
mockPage.addStyleTag.mockImplementation(async () => { callOrder.push('addStyleTag') })
await takeScreenshot({ url: 'https://example.com', css: 'body { color: blue }', waitForSelector: '#main' })
expect(callOrder.indexOf('goto')).toBeLessThan(callOrder.indexOf('addStyleTag'))
expect(callOrder.indexOf('waitForSelector')).toBeLessThan(callOrder.indexOf('addStyleTag'))
})
it('works alongside hideSelectors', async () => {
await takeScreenshot({ url: 'https://example.com', css: 'body { color: blue }', hideSelectors: ['.ad'] })
// Both should result in addStyleTag calls
expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: 'body { color: blue }' })
expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: '.ad { display: none !important }' })
})
it('works alongside darkMode', async () => {
await takeScreenshot({ url: 'https://example.com', css: 'h1 { font-size: 48px }', darkMode: true })
expect(mockPage.emulateMediaFeatures).toHaveBeenCalledWith([{ name: 'prefers-color-scheme', value: 'dark' }])
expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: 'h1 { font-size: 48px }' })
})
it('does not inject style tag when css is not set', async () => {
await takeScreenshot({ url: 'https://example.com' })
expect(mockPage.addStyleTag).not.toHaveBeenCalled()
})
it('does not inject style tag when css is empty string', async () => {
await takeScreenshot({ url: 'https://example.com', css: '' })
expect(mockPage.addStyleTag).not.toHaveBeenCalled()
})
})
describe('Page lifecycle', () => {
it('always releases page after successful screenshot', async () => {
await takeScreenshot({ url: 'https://example.com' })

View file

@ -16,6 +16,7 @@ export interface ScreenshotOptions {
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
darkMode?: boolean;
hideSelectors?: string[];
css?: string;
}
export interface ScreenshotResult {
@ -60,6 +61,10 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise<Screensho
await new Promise(r => setTimeout(r, Math.min(opts.delay!, 5000)));
}
if (opts.css) {
await page.addStyleTag({ content: opts.css });
}
if (opts.hideSelectors && opts.hideSelectors.length > 0) {
await page.addStyleTag({
content: opts.hideSelectors.map(s => s + ' { display: none !important }').join('\n')