Skip to content

Commit 00541b2

Browse files
committed
feat: add screenshot
1 parent f8218b4 commit 00541b2

5 files changed

Lines changed: 372 additions & 0 deletions

File tree

src/__tests__/browser/daemon.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class Mock_page extends EventEmitter {
4545
private current_title = 'Blank';
4646
private current_url = 'about:blank';
4747
private mock_context: Mock_context|null = null;
48+
private screenshot_opts: {fullPage?: boolean}|null = null;
4849

4950
constructor(mock_context?: Mock_context){
5051
super();
@@ -80,6 +81,13 @@ class Mock_page extends EventEmitter {
8081
return {status: ()=>200};
8182
}
8283

84+
async screenshot(opts?: {fullPage?: boolean}){
85+
this.screenshot_opts = opts ?? {};
86+
return Buffer.from(
87+
opts?.fullPage ? 'full-page-image' : 'viewport-image'
88+
);
89+
}
90+
8391
async evaluate(_fn: unknown, arg: {attr_name: string; selector?: string}){
8492
return {
8593
nodes: [
@@ -143,6 +151,10 @@ class Mock_page extends EventEmitter {
143151
this.closed = true;
144152
this.emit('close');
145153
}
154+
155+
last_screenshot_opts(){
156+
return this.screenshot_opts;
157+
}
146158
}
147159

148160
class Mock_context extends EventEmitter {
@@ -536,6 +548,44 @@ describe('browser/daemon', ()=>{
536548
await daemon.stop();
537549
});
538550

551+
it('captures screenshots and returns the saved file path', async()=>{
552+
const mock_browser = new Mock_browser();
553+
const connect_over_cdp = vi.fn(async()=>mock_browser as unknown as Browser);
554+
const daemon = new BrowserDaemon({
555+
cdp_endpoint: 'wss://example.test',
556+
daemon_dir: tmp_dir,
557+
idle_timeout_ms: 0,
558+
session_name: 'screenshot-actions',
559+
}, {connect_over_cdp});
560+
561+
const output_path = path.join(tmp_dir, 'captures', 'page.png');
562+
const screenshot = await daemon.handle_request({
563+
id: 'screenshot-1',
564+
action: 'screenshot',
565+
params: {
566+
base64: true,
567+
full_page: true,
568+
path: output_path,
569+
},
570+
});
571+
expect(screenshot).toMatchObject({
572+
id: 'screenshot-1',
573+
success: true,
574+
data: {
575+
base64: Buffer.from('full-page-image').toString('base64'),
576+
full_page: true,
577+
mime_type: 'image/png',
578+
path: output_path,
579+
},
580+
});
581+
582+
const page = mock_browser.first_context().first_page();
583+
expect(page.last_screenshot_opts()).toEqual({fullPage: true});
584+
expect(fs.readFileSync(output_path, 'utf8')).toBe('full-page-image');
585+
586+
await daemon.stop();
587+
});
588+
539589
it('starts and clears the keepalive interval with the daemon lifecycle', async()=>{
540590
vi.useFakeTimers();
541591

src/__tests__/commands/browser.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
handle_browser_cookies,
7474
handle_browser_open,
7575
handle_browser_reload,
76+
handle_browser_screenshot,
7677
handle_browser_sessions,
7778
handle_browser_snapshot,
7879
handle_browser_status,
@@ -216,6 +217,39 @@ describe('commands/browser', ()=>{
216217
);
217218
});
218219

220+
it('captures a screenshot for an active browser session', async()=>{
221+
mocks.send_command.mockResolvedValue({
222+
success: true,
223+
data: {
224+
full_page: true,
225+
mime_type: 'image/png',
226+
path: '/tmp/browser-shot.png',
227+
},
228+
});
229+
230+
await handle_browser_screenshot('/tmp/browser-shot.png', {
231+
fullPage: true,
232+
session: 'shop',
233+
});
234+
235+
expect(mocks.send_command).toHaveBeenCalledWith(
236+
'shop',
237+
expect.objectContaining({
238+
action: 'screenshot',
239+
params: {
240+
base64: false,
241+
full_page: true,
242+
path: '/tmp/browser-shot.png',
243+
},
244+
}),
245+
{daemon_dir: undefined, timeout_ms: undefined}
246+
);
247+
expect(mocks.print).toHaveBeenCalledWith(
248+
'/tmp/browser-shot.png',
249+
{output: undefined}
250+
);
251+
});
252+
219253
it('rejects invalid snapshot depth before sending the command', async()=>{
220254
await expect(handle_browser_snapshot({depth: 'abc', session: 'shop'})).rejects.toThrow(
221255
'fail:Snapshot depth must be a non-negative integer.'
@@ -454,6 +488,52 @@ describe('commands/browser', ()=>{
454488
);
455489
});
456490

491+
it('parses screenshot flags and forwards the screenshot params', async()=>{
492+
mocks.send_command.mockResolvedValue({
493+
success: true,
494+
data: {
495+
base64: 'aW1hZ2U=',
496+
full_page: true,
497+
mime_type: 'image/png',
498+
path: '/tmp/browser-shot.png',
499+
},
500+
});
501+
const command = create_browser_command();
502+
command.exitOverride();
503+
504+
await command.parseAsync([
505+
'screenshot',
506+
'/tmp/browser-shot.png',
507+
'--session',
508+
'shop',
509+
'--full-page',
510+
'--base64',
511+
'--json',
512+
], {from: 'user'});
513+
514+
expect(mocks.send_command).toHaveBeenCalledWith(
515+
'shop',
516+
expect.objectContaining({
517+
action: 'screenshot',
518+
params: {
519+
base64: true,
520+
full_page: true,
521+
path: '/tmp/browser-shot.png',
522+
},
523+
}),
524+
{daemon_dir: undefined, timeout_ms: undefined}
525+
);
526+
expect(mocks.print).toHaveBeenCalledWith(
527+
{
528+
base64: 'aW1hZ2U=',
529+
full_page: true,
530+
mime_type: 'image/png',
531+
path: '/tmp/browser-shot.png',
532+
},
533+
{json: true, output: undefined, pretty: undefined}
534+
);
535+
});
536+
457537
it('rejects open-only flags on status through browser-group parsing', async()=>{
458538
const command = create_browser_command();
459539
command.exitOverride();
@@ -498,6 +578,20 @@ describe('commands/browser', ()=>{
498578
expect(mocks.send_command).not.toHaveBeenCalled();
499579
});
500580

581+
it('rejects screenshot-only flags outside the screenshot command', async()=>{
582+
const command = create_browser_command();
583+
command.exitOverride();
584+
585+
await expect(command.parseAsync([
586+
'status',
587+
'--full-page',
588+
], {from: 'user'})).rejects.toThrow(
589+
'fail:--full-page is not supported by "brightdata browser status".'
590+
);
591+
592+
expect(mocks.send_command).not.toHaveBeenCalled();
593+
});
594+
501595
it('rejects not-yet-implemented global flags on open', async()=>{
502596
const command = create_browser_command();
503597
command.exitOverride();

src/browser/daemon.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from 'path';
55
import {chromium} from 'playwright-core';
66
import {clear_connection_state, ensure_connected as ensure_browser_connected} from './connection';
77
import {parse_daemon_request} from './ipc';
8+
import {take_screenshot} from './screenshot';
89
import {capture_snapshot} from './snapshot';
910
import type {
1011
Browser,
@@ -505,6 +506,8 @@ class BrowserDaemon {
505506
return {alive: true, connected: this.state.connected};
506507
case 'reload':
507508
return this.handle_reload();
509+
case 'screenshot':
510+
return this.handle_screenshot(request.params);
508511
case 'snapshot':
509512
return this.handle_snapshot(request.params);
510513
case 'status':
@@ -620,6 +623,39 @@ class BrowserDaemon {
620623
};
621624
}
622625

626+
private async handle_screenshot(params: Json_object|undefined){
627+
const base64 = params?.['base64'];
628+
const full_page = params?.['full_page'];
629+
const file_path = params?.['path'];
630+
631+
if (base64 !== undefined && typeof base64 != 'boolean')
632+
{
633+
throw new Error(
634+
'Screenshot "base64" parameter must be a boolean when provided.'
635+
);
636+
}
637+
if (full_page !== undefined && typeof full_page != 'boolean')
638+
{
639+
throw new Error(
640+
'Screenshot "full_page" parameter must be a boolean when provided.'
641+
);
642+
}
643+
if (file_path !== undefined
644+
&& (typeof file_path != 'string' || !file_path.trim()))
645+
{
646+
throw new Error(
647+
'Screenshot "path" parameter must be a non-empty string when provided.'
648+
);
649+
}
650+
651+
const page = await this.ensure_connected();
652+
return await take_screenshot(page, {
653+
base64: base64 === true,
654+
full_page: full_page === true,
655+
path: typeof file_path == 'string' ? file_path.trim() : undefined,
656+
});
657+
}
658+
623659
private handle_network(){
624660
return {
625661
requests: Array.from(this.state.requests.entries()).map(

src/browser/screenshot.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import crypto from 'crypto';
2+
import fs from 'fs';
3+
import os from 'os';
4+
import path from 'path';
5+
import type {Page} from 'playwright-core';
6+
7+
const SCREENSHOT_MIME_TYPE = 'image/png';
8+
const SCREENSHOT_TMP_DIR = 'brightdata-cli';
9+
const SCREENSHOT_TMP_PREFIX = 'browser-screenshot-';
10+
11+
type Screenshot_capture_opts = {
12+
base64?: boolean;
13+
full_page?: boolean;
14+
path?: string;
15+
};
16+
17+
type Screenshot_capture_result = {
18+
base64?: string;
19+
full_page: boolean;
20+
mime_type: string;
21+
path: string;
22+
};
23+
24+
const normalize_screenshot_path = (file_path: string|undefined): string|undefined=>{
25+
if (file_path === undefined)
26+
return undefined;
27+
const normalized = file_path.trim();
28+
if (!normalized)
29+
throw new Error('Screenshot path cannot be empty.');
30+
return path.resolve(normalized);
31+
};
32+
33+
const create_temp_screenshot_path = (): string=>{
34+
const token = crypto.randomBytes(6).toString('hex');
35+
return path.join(
36+
os.tmpdir(),
37+
SCREENSHOT_TMP_DIR,
38+
`${SCREENSHOT_TMP_PREFIX}${Date.now()}-${token}.png`
39+
);
40+
};
41+
42+
const take_screenshot = async(
43+
page: Page,
44+
opts: Screenshot_capture_opts = {},
45+
): Promise<Screenshot_capture_result>=>{
46+
const full_page = opts.full_page === true;
47+
const output_path = normalize_screenshot_path(opts.path)
48+
?? create_temp_screenshot_path();
49+
const buffer = await page.screenshot({fullPage: full_page});
50+
51+
fs.mkdirSync(path.dirname(output_path), {recursive: true});
52+
fs.writeFileSync(output_path, buffer);
53+
54+
return {
55+
base64: opts.base64 === true ? buffer.toString('base64') : undefined,
56+
full_page,
57+
mime_type: SCREENSHOT_MIME_TYPE,
58+
path: output_path,
59+
};
60+
};
61+
62+
export {
63+
create_temp_screenshot_path,
64+
normalize_screenshot_path,
65+
SCREENSHOT_MIME_TYPE,
66+
take_screenshot,
67+
};
68+
export type {
69+
Screenshot_capture_opts,
70+
Screenshot_capture_result,
71+
};

0 commit comments

Comments
 (0)