agent-browser 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -9
- package/bin/agent-browser-linux-x64 +0 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +97 -13
- package/dist/actions.js.map +1 -1
- package/dist/browser.d.ts +62 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +423 -24
- package/dist/browser.js.map +1 -1
- package/dist/daemon.d.ts +5 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +52 -6
- package/dist/daemon.js.map +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +47 -1
- package/dist/protocol.js.map +1 -1
- package/dist/types.d.ts +69 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +26 -23
- package/scripts/build-all-platforms.sh +0 -0
- package/scripts/copy-native.js +2 -1
- package/scripts/postinstall.js +1 -1
- package/skills/agent-browser/SKILL.md +221 -17
- package/skills/agent-browser/references/authentication.md +188 -0
- package/skills/agent-browser/references/proxy-support.md +175 -0
- package/skills/agent-browser/references/session-management.md +181 -0
- package/skills/agent-browser/references/snapshot-refs.md +186 -0
- package/skills/agent-browser/references/video-recording.md +162 -0
- package/skills/agent-browser/templates/authenticated-session.sh +91 -0
- package/skills/agent-browser/templates/capture-workflow.sh +68 -0
- package/skills/agent-browser/templates/form-automation.sh +64 -0
- package/bin/agent-browser-darwin-arm64 +0 -0
- package/bin/agent-browser-darwin-x64 +0 -0
- package/bin/agent-browser-linux-arm64 +0 -0
- package/bin/agent-browser-win32-x64.exe +0 -0
- package/dist/browser.test.d.ts +0 -2
- package/dist/browser.test.d.ts.map +0 -1
- package/dist/browser.test.js +0 -136
- package/dist/browser.test.js.map +0 -1
- package/dist/protocol.test.d.ts +0 -2
- package/dist/protocol.test.d.ts.map +0 -1
- package/dist/protocol.test.js +0 -176
- package/dist/protocol.test.js.map +0 -1
package/dist/browser.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { chromium, firefox, webkit, devices, } from 'playwright-core';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
4
5
|
import { getEnhancedSnapshot, parseRef } from './snapshot.js';
|
|
5
6
|
/**
|
|
6
7
|
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
|
7
8
|
*/
|
|
8
9
|
export class BrowserManager {
|
|
9
10
|
browser = null;
|
|
10
|
-
|
|
11
|
+
cdpEndpoint = null; // stores port number or full URL
|
|
11
12
|
isPersistentContext = false;
|
|
13
|
+
browserbaseSessionId = null;
|
|
14
|
+
browserbaseApiKey = null;
|
|
15
|
+
browserUseSessionId = null;
|
|
16
|
+
browserUseApiKey = null;
|
|
12
17
|
contexts = [];
|
|
13
18
|
pages = [];
|
|
14
19
|
activePageIndex = 0;
|
|
@@ -28,6 +33,11 @@ export class BrowserManager {
|
|
|
28
33
|
screencastSessionId = 0;
|
|
29
34
|
frameCallback = null;
|
|
30
35
|
screencastFrameHandler = null;
|
|
36
|
+
// Video recording (Playwright native)
|
|
37
|
+
recordingContext = null;
|
|
38
|
+
recordingPage = null;
|
|
39
|
+
recordingOutputPath = '';
|
|
40
|
+
recordingTempDir = '';
|
|
31
41
|
/**
|
|
32
42
|
* Check if browser is launched
|
|
33
43
|
*/
|
|
@@ -507,27 +517,174 @@ export class BrowserManager {
|
|
|
507
517
|
/**
|
|
508
518
|
* Check if CDP connection needs to be re-established
|
|
509
519
|
*/
|
|
510
|
-
needsCdpReconnect(
|
|
520
|
+
needsCdpReconnect(cdpEndpoint) {
|
|
511
521
|
if (!this.browser?.isConnected())
|
|
512
522
|
return true;
|
|
513
|
-
if (this.
|
|
523
|
+
if (this.cdpEndpoint !== cdpEndpoint)
|
|
514
524
|
return true;
|
|
515
525
|
if (!this.isCdpConnectionAlive())
|
|
516
526
|
return true;
|
|
517
527
|
return false;
|
|
518
528
|
}
|
|
529
|
+
/**
|
|
530
|
+
* Close a Browserbase session via API
|
|
531
|
+
*/
|
|
532
|
+
async closeBrowserbaseSession(sessionId, apiKey) {
|
|
533
|
+
await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, {
|
|
534
|
+
method: 'DELETE',
|
|
535
|
+
headers: {
|
|
536
|
+
'X-BB-API-Key': apiKey,
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Close a Browser Use session via API
|
|
542
|
+
*/
|
|
543
|
+
async closeBrowserUseSession(sessionId, apiKey) {
|
|
544
|
+
const response = await fetch(`https://api.browser-use.com/api/v2/browsers/${sessionId}`, {
|
|
545
|
+
method: 'PATCH',
|
|
546
|
+
headers: {
|
|
547
|
+
'Content-Type': 'application/json',
|
|
548
|
+
'X-Browser-Use-API-Key': apiKey,
|
|
549
|
+
},
|
|
550
|
+
body: JSON.stringify({ action: 'stop' }),
|
|
551
|
+
});
|
|
552
|
+
if (!response.ok) {
|
|
553
|
+
throw new Error(`Failed to close Browser Use session: ${response.statusText}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Connect to Browserbase remote browser via CDP.
|
|
558
|
+
* Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
|
|
559
|
+
*/
|
|
560
|
+
async connectToBrowserbase() {
|
|
561
|
+
const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
|
|
562
|
+
const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
|
|
563
|
+
if (!browserbaseApiKey || !browserbaseProjectId) {
|
|
564
|
+
throw new Error('BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are required when using browserbase as a provider');
|
|
565
|
+
}
|
|
566
|
+
const response = await fetch('https://api.browserbase.com/v1/sessions', {
|
|
567
|
+
method: 'POST',
|
|
568
|
+
headers: {
|
|
569
|
+
'Content-Type': 'application/json',
|
|
570
|
+
'X-BB-API-Key': browserbaseApiKey,
|
|
571
|
+
},
|
|
572
|
+
body: JSON.stringify({
|
|
573
|
+
projectId: browserbaseProjectId,
|
|
574
|
+
}),
|
|
575
|
+
});
|
|
576
|
+
if (!response.ok) {
|
|
577
|
+
throw new Error(`Failed to create Browserbase session: ${response.statusText}`);
|
|
578
|
+
}
|
|
579
|
+
const session = (await response.json());
|
|
580
|
+
const browser = await chromium.connectOverCDP(session.connectUrl).catch(() => {
|
|
581
|
+
throw new Error('Failed to connect to Browserbase session via CDP');
|
|
582
|
+
});
|
|
583
|
+
try {
|
|
584
|
+
const contexts = browser.contexts();
|
|
585
|
+
if (contexts.length === 0) {
|
|
586
|
+
throw new Error('No browser context found in Browserbase session');
|
|
587
|
+
}
|
|
588
|
+
const context = contexts[0];
|
|
589
|
+
const pages = context.pages();
|
|
590
|
+
const page = pages[0] ?? (await context.newPage());
|
|
591
|
+
this.browserbaseSessionId = session.id;
|
|
592
|
+
this.browserbaseApiKey = browserbaseApiKey;
|
|
593
|
+
this.browser = browser;
|
|
594
|
+
context.setDefaultTimeout(10000);
|
|
595
|
+
this.contexts.push(context);
|
|
596
|
+
this.pages.push(page);
|
|
597
|
+
this.activePageIndex = 0;
|
|
598
|
+
this.setupPageTracking(page);
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
await this.closeBrowserbaseSession(session.id, browserbaseApiKey).catch((sessionError) => {
|
|
602
|
+
console.error('Failed to close Browserbase session during cleanup:', sessionError);
|
|
603
|
+
});
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Connect to Browser Use remote browser via CDP.
|
|
609
|
+
* Requires BROWSER_USE_API_KEY environment variable.
|
|
610
|
+
*/
|
|
611
|
+
async connectToBrowserUse() {
|
|
612
|
+
const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
|
|
613
|
+
if (!browserUseApiKey) {
|
|
614
|
+
throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
|
|
615
|
+
}
|
|
616
|
+
const response = await fetch('https://api.browser-use.com/api/v2/browsers', {
|
|
617
|
+
method: 'POST',
|
|
618
|
+
headers: {
|
|
619
|
+
'Content-Type': 'application/json',
|
|
620
|
+
'X-Browser-Use-API-Key': browserUseApiKey,
|
|
621
|
+
},
|
|
622
|
+
body: JSON.stringify({}),
|
|
623
|
+
});
|
|
624
|
+
if (!response.ok) {
|
|
625
|
+
throw new Error(`Failed to create Browser Use session: ${response.statusText}`);
|
|
626
|
+
}
|
|
627
|
+
let session;
|
|
628
|
+
try {
|
|
629
|
+
session = (await response.json());
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
throw new Error(`Failed to parse Browser Use session response: ${error instanceof Error ? error.message : String(error)}`);
|
|
633
|
+
}
|
|
634
|
+
if (!session.id || !session.cdpUrl) {
|
|
635
|
+
throw new Error(`Invalid Browser Use session response: missing ${!session.id ? 'id' : 'cdpUrl'}`);
|
|
636
|
+
}
|
|
637
|
+
const browser = await chromium.connectOverCDP(session.cdpUrl).catch(() => {
|
|
638
|
+
throw new Error('Failed to connect to Browser Use session via CDP');
|
|
639
|
+
});
|
|
640
|
+
try {
|
|
641
|
+
const contexts = browser.contexts();
|
|
642
|
+
let context;
|
|
643
|
+
let page;
|
|
644
|
+
if (contexts.length === 0) {
|
|
645
|
+
context = await browser.newContext();
|
|
646
|
+
page = await context.newPage();
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
context = contexts[0];
|
|
650
|
+
const pages = context.pages();
|
|
651
|
+
page = pages[0] ?? (await context.newPage());
|
|
652
|
+
}
|
|
653
|
+
this.browserUseSessionId = session.id;
|
|
654
|
+
this.browserUseApiKey = browserUseApiKey;
|
|
655
|
+
this.browser = browser;
|
|
656
|
+
context.setDefaultTimeout(60000);
|
|
657
|
+
this.contexts.push(context);
|
|
658
|
+
this.pages.push(page);
|
|
659
|
+
this.activePageIndex = 0;
|
|
660
|
+
this.setupPageTracking(page);
|
|
661
|
+
this.setupContextTracking(context);
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
|
|
665
|
+
console.error('Failed to close Browser Use session during cleanup:', sessionError);
|
|
666
|
+
});
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
519
670
|
/**
|
|
520
671
|
* Launch the browser with the specified options
|
|
521
672
|
* If already launched, this is a no-op (browser stays open)
|
|
522
673
|
*/
|
|
523
674
|
async launch(options) {
|
|
524
|
-
|
|
675
|
+
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
|
|
676
|
+
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
|
|
525
677
|
const hasExtensions = !!options.extensions?.length;
|
|
526
|
-
|
|
678
|
+
const hasProfile = !!options.profile;
|
|
679
|
+
if (hasExtensions && cdpEndpoint) {
|
|
527
680
|
throw new Error('Extensions cannot be used with CDP connection');
|
|
528
681
|
}
|
|
682
|
+
if (hasProfile && cdpEndpoint) {
|
|
683
|
+
throw new Error('Profile cannot be used with CDP connection');
|
|
684
|
+
}
|
|
529
685
|
if (this.isLaunched()) {
|
|
530
|
-
const needsRelaunch = (!
|
|
686
|
+
const needsRelaunch = (!cdpEndpoint && this.cdpEndpoint !== null) ||
|
|
687
|
+
(!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint));
|
|
531
688
|
if (needsRelaunch) {
|
|
532
689
|
await this.close();
|
|
533
690
|
}
|
|
@@ -535,8 +692,19 @@ export class BrowserManager {
|
|
|
535
692
|
return;
|
|
536
693
|
}
|
|
537
694
|
}
|
|
538
|
-
if (
|
|
539
|
-
await this.connectViaCDP(
|
|
695
|
+
if (cdpEndpoint) {
|
|
696
|
+
await this.connectViaCDP(cdpEndpoint);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
// Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
|
|
700
|
+
// -p flag takes precedence over env var
|
|
701
|
+
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
|
702
|
+
if (provider === 'browserbase') {
|
|
703
|
+
await this.connectToBrowserbase();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (provider === 'browseruse') {
|
|
707
|
+
await this.connectToBrowserUse();
|
|
540
708
|
return;
|
|
541
709
|
}
|
|
542
710
|
const browserType = options.browser ?? 'chromium';
|
|
@@ -547,26 +715,51 @@ export class BrowserManager {
|
|
|
547
715
|
const viewport = options.viewport ?? { width: 1280, height: 720 };
|
|
548
716
|
let context;
|
|
549
717
|
if (hasExtensions) {
|
|
718
|
+
// Extensions require persistent context in a temp directory
|
|
550
719
|
const extPaths = options.extensions.join(',');
|
|
551
720
|
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
|
721
|
+
// Combine extension args with custom args
|
|
722
|
+
const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
|
|
723
|
+
const allArgs = options.args ? [...extArgs, ...options.args] : extArgs;
|
|
552
724
|
context = await launcher.launchPersistentContext(path.join(os.tmpdir(), `agent-browser-ext-${session}`), {
|
|
553
725
|
headless: false,
|
|
554
726
|
executablePath: options.executablePath,
|
|
555
|
-
args:
|
|
727
|
+
args: allArgs,
|
|
728
|
+
viewport,
|
|
729
|
+
extraHTTPHeaders: options.headers,
|
|
730
|
+
userAgent: options.userAgent,
|
|
731
|
+
...(options.proxy && { proxy: options.proxy }),
|
|
732
|
+
});
|
|
733
|
+
this.isPersistentContext = true;
|
|
734
|
+
}
|
|
735
|
+
else if (hasProfile) {
|
|
736
|
+
// Profile uses persistent context for durable cookies/storage
|
|
737
|
+
// Expand ~ to home directory since it won't be shell-expanded
|
|
738
|
+
const profilePath = options.profile.replace(/^~\//, os.homedir() + '/');
|
|
739
|
+
context = await launcher.launchPersistentContext(profilePath, {
|
|
740
|
+
headless: options.headless ?? true,
|
|
741
|
+
executablePath: options.executablePath,
|
|
556
742
|
viewport,
|
|
557
743
|
extraHTTPHeaders: options.headers,
|
|
558
744
|
});
|
|
559
745
|
this.isPersistentContext = true;
|
|
560
746
|
}
|
|
561
747
|
else {
|
|
748
|
+
// Regular ephemeral browser
|
|
562
749
|
this.browser = await launcher.launch({
|
|
563
750
|
headless: options.headless ?? true,
|
|
564
751
|
executablePath: options.executablePath,
|
|
752
|
+
args: options.args,
|
|
753
|
+
});
|
|
754
|
+
this.cdpEndpoint = null;
|
|
755
|
+
context = await this.browser.newContext({
|
|
756
|
+
viewport,
|
|
757
|
+
extraHTTPHeaders: options.headers,
|
|
758
|
+
userAgent: options.userAgent,
|
|
759
|
+
...(options.proxy && { proxy: options.proxy }),
|
|
565
760
|
});
|
|
566
|
-
this.cdpPort = null;
|
|
567
|
-
context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers });
|
|
568
761
|
}
|
|
569
|
-
context.setDefaultTimeout(
|
|
762
|
+
context.setDefaultTimeout(60000);
|
|
570
763
|
this.contexts.push(context);
|
|
571
764
|
const page = context.pages()[0] ?? (await context.newPage());
|
|
572
765
|
this.pages.push(page);
|
|
@@ -575,14 +768,36 @@ export class BrowserManager {
|
|
|
575
768
|
}
|
|
576
769
|
/**
|
|
577
770
|
* Connect to a running browser via CDP (Chrome DevTools Protocol)
|
|
771
|
+
* @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
|
|
578
772
|
*/
|
|
579
|
-
async connectViaCDP(
|
|
580
|
-
if (!
|
|
581
|
-
throw new Error('
|
|
773
|
+
async connectViaCDP(cdpEndpoint) {
|
|
774
|
+
if (!cdpEndpoint) {
|
|
775
|
+
throw new Error('CDP endpoint is required for CDP connection');
|
|
582
776
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
777
|
+
// Determine the connection URL:
|
|
778
|
+
// - If it starts with ws://, wss://, http://, or https://, use it directly
|
|
779
|
+
// - If it's a numeric string (e.g., "9222"), treat as port for localhost
|
|
780
|
+
// - Otherwise, treat it as a port number for localhost
|
|
781
|
+
let cdpUrl;
|
|
782
|
+
if (cdpEndpoint.startsWith('ws://') ||
|
|
783
|
+
cdpEndpoint.startsWith('wss://') ||
|
|
784
|
+
cdpEndpoint.startsWith('http://') ||
|
|
785
|
+
cdpEndpoint.startsWith('https://')) {
|
|
786
|
+
cdpUrl = cdpEndpoint;
|
|
787
|
+
}
|
|
788
|
+
else if (/^\d+$/.test(cdpEndpoint)) {
|
|
789
|
+
// Numeric string - treat as port number (handles JSON serialization quirks)
|
|
790
|
+
cdpUrl = `http://localhost:${cdpEndpoint}`;
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
// Unknown format - still try as port for backward compatibility
|
|
794
|
+
cdpUrl = `http://localhost:${cdpEndpoint}`;
|
|
795
|
+
}
|
|
796
|
+
const browser = await chromium.connectOverCDP(cdpUrl).catch(() => {
|
|
797
|
+
throw new Error(`Failed to connect via CDP to ${cdpUrl}. ` +
|
|
798
|
+
(cdpUrl.includes('localhost')
|
|
799
|
+
? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
|
|
800
|
+
: 'Make sure the remote browser is accessible and the URL is correct.'));
|
|
586
801
|
});
|
|
587
802
|
// Validate and set up state, cleaning up browser connection if anything fails
|
|
588
803
|
try {
|
|
@@ -590,13 +805,14 @@ export class BrowserManager {
|
|
|
590
805
|
if (contexts.length === 0) {
|
|
591
806
|
throw new Error('No browser context found. Make sure the app has an open window.');
|
|
592
807
|
}
|
|
593
|
-
|
|
808
|
+
// Filter out pages with empty URLs, which can cause Playwright to hang
|
|
809
|
+
const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
|
|
594
810
|
if (allPages.length === 0) {
|
|
595
811
|
throw new Error('No page found. Make sure the app has loaded content.');
|
|
596
812
|
}
|
|
597
813
|
// All validation passed - commit state
|
|
598
814
|
this.browser = browser;
|
|
599
|
-
this.
|
|
815
|
+
this.cdpEndpoint = cdpEndpoint;
|
|
600
816
|
for (const context of contexts) {
|
|
601
817
|
this.contexts.push(context);
|
|
602
818
|
this.setupContextTracking(context);
|
|
@@ -676,7 +892,7 @@ export class BrowserManager {
|
|
|
676
892
|
const context = await this.browser.newContext({
|
|
677
893
|
viewport: viewport ?? { width: 1280, height: 720 },
|
|
678
894
|
});
|
|
679
|
-
context.setDefaultTimeout(
|
|
895
|
+
context.setDefaultTimeout(60000);
|
|
680
896
|
this.contexts.push(context);
|
|
681
897
|
const page = await context.newPage();
|
|
682
898
|
this.pages.push(page);
|
|
@@ -888,10 +1104,177 @@ export class BrowserManager {
|
|
|
888
1104
|
modifiers: params.modifiers ?? 0,
|
|
889
1105
|
});
|
|
890
1106
|
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Check if video recording is currently active
|
|
1109
|
+
*/
|
|
1110
|
+
isRecording() {
|
|
1111
|
+
return this.recordingContext !== null;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Start recording to a video file using Playwright's native video recording.
|
|
1115
|
+
* Creates a fresh browser context with video recording enabled.
|
|
1116
|
+
* Automatically captures current URL and transfers cookies/storage if no URL provided.
|
|
1117
|
+
*
|
|
1118
|
+
* @param outputPath - Path to the output video file (will be .webm)
|
|
1119
|
+
* @param url - Optional URL to navigate to (defaults to current page URL)
|
|
1120
|
+
*/
|
|
1121
|
+
async startRecording(outputPath, url) {
|
|
1122
|
+
if (this.recordingContext) {
|
|
1123
|
+
throw new Error("Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording.");
|
|
1124
|
+
}
|
|
1125
|
+
if (!this.browser) {
|
|
1126
|
+
throw new Error('Browser not launched. Call launch first.');
|
|
1127
|
+
}
|
|
1128
|
+
// Check if output file already exists
|
|
1129
|
+
if (existsSync(outputPath)) {
|
|
1130
|
+
throw new Error(`Output file already exists: ${outputPath}`);
|
|
1131
|
+
}
|
|
1132
|
+
// Validate output path is .webm (Playwright native format)
|
|
1133
|
+
if (!outputPath.endsWith('.webm')) {
|
|
1134
|
+
throw new Error('Playwright native recording only supports WebM format. Please use a .webm extension.');
|
|
1135
|
+
}
|
|
1136
|
+
// Auto-capture current URL if none provided
|
|
1137
|
+
const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
|
|
1138
|
+
const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
|
|
1139
|
+
if (!url && currentPage) {
|
|
1140
|
+
const currentUrl = currentPage.url();
|
|
1141
|
+
if (currentUrl && currentUrl !== 'about:blank') {
|
|
1142
|
+
url = currentUrl;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
// Capture state from current context (cookies + storage)
|
|
1146
|
+
let storageState;
|
|
1147
|
+
if (currentContext) {
|
|
1148
|
+
try {
|
|
1149
|
+
storageState = await currentContext.storageState();
|
|
1150
|
+
}
|
|
1151
|
+
catch {
|
|
1152
|
+
// Ignore errors - context might be closed or invalid
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// Create a temp directory for video recording
|
|
1156
|
+
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
|
1157
|
+
this.recordingTempDir = path.join(os.tmpdir(), `agent-browser-recording-${session}-${Date.now()}`);
|
|
1158
|
+
mkdirSync(this.recordingTempDir, { recursive: true });
|
|
1159
|
+
this.recordingOutputPath = outputPath;
|
|
1160
|
+
// Create a new context with video recording enabled and restored state
|
|
1161
|
+
const viewport = { width: 1280, height: 720 };
|
|
1162
|
+
this.recordingContext = await this.browser.newContext({
|
|
1163
|
+
viewport,
|
|
1164
|
+
recordVideo: {
|
|
1165
|
+
dir: this.recordingTempDir,
|
|
1166
|
+
size: viewport,
|
|
1167
|
+
},
|
|
1168
|
+
storageState,
|
|
1169
|
+
});
|
|
1170
|
+
this.recordingContext.setDefaultTimeout(10000);
|
|
1171
|
+
// Create a page in the recording context
|
|
1172
|
+
this.recordingPage = await this.recordingContext.newPage();
|
|
1173
|
+
// Add the recording context and page to our managed lists
|
|
1174
|
+
this.contexts.push(this.recordingContext);
|
|
1175
|
+
this.pages.push(this.recordingPage);
|
|
1176
|
+
this.activePageIndex = this.pages.length - 1;
|
|
1177
|
+
// Set up page tracking
|
|
1178
|
+
this.setupPageTracking(this.recordingPage);
|
|
1179
|
+
// Invalidate CDP session since we switched pages
|
|
1180
|
+
await this.invalidateCDPSession();
|
|
1181
|
+
// Navigate to URL if provided or captured
|
|
1182
|
+
if (url) {
|
|
1183
|
+
await this.recordingPage.goto(url, { waitUntil: 'load' });
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Stop recording and save the video file
|
|
1188
|
+
* @returns Recording result with path
|
|
1189
|
+
*/
|
|
1190
|
+
async stopRecording() {
|
|
1191
|
+
if (!this.recordingContext || !this.recordingPage) {
|
|
1192
|
+
return { path: '', frames: 0, error: 'No recording in progress' };
|
|
1193
|
+
}
|
|
1194
|
+
const outputPath = this.recordingOutputPath;
|
|
1195
|
+
try {
|
|
1196
|
+
// Get the video object before closing the page
|
|
1197
|
+
const video = this.recordingPage.video();
|
|
1198
|
+
// Remove recording page/context from our managed lists before closing
|
|
1199
|
+
const pageIndex = this.pages.indexOf(this.recordingPage);
|
|
1200
|
+
if (pageIndex !== -1) {
|
|
1201
|
+
this.pages.splice(pageIndex, 1);
|
|
1202
|
+
}
|
|
1203
|
+
const contextIndex = this.contexts.indexOf(this.recordingContext);
|
|
1204
|
+
if (contextIndex !== -1) {
|
|
1205
|
+
this.contexts.splice(contextIndex, 1);
|
|
1206
|
+
}
|
|
1207
|
+
// Close the page to finalize the video
|
|
1208
|
+
await this.recordingPage.close();
|
|
1209
|
+
// Save the video to the desired output path
|
|
1210
|
+
if (video) {
|
|
1211
|
+
await video.saveAs(outputPath);
|
|
1212
|
+
}
|
|
1213
|
+
// Clean up temp directory
|
|
1214
|
+
if (this.recordingTempDir) {
|
|
1215
|
+
rmSync(this.recordingTempDir, { recursive: true, force: true });
|
|
1216
|
+
}
|
|
1217
|
+
// Close the recording context
|
|
1218
|
+
await this.recordingContext.close();
|
|
1219
|
+
// Reset recording state
|
|
1220
|
+
this.recordingContext = null;
|
|
1221
|
+
this.recordingPage = null;
|
|
1222
|
+
this.recordingOutputPath = '';
|
|
1223
|
+
this.recordingTempDir = '';
|
|
1224
|
+
// Adjust active page index
|
|
1225
|
+
if (this.pages.length > 0) {
|
|
1226
|
+
this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
this.activePageIndex = 0;
|
|
1230
|
+
}
|
|
1231
|
+
// Invalidate CDP session since we may have switched pages
|
|
1232
|
+
await this.invalidateCDPSession();
|
|
1233
|
+
return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
|
|
1234
|
+
}
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
// Clean up temp directory on error
|
|
1237
|
+
if (this.recordingTempDir) {
|
|
1238
|
+
rmSync(this.recordingTempDir, { recursive: true, force: true });
|
|
1239
|
+
}
|
|
1240
|
+
// Reset state on error
|
|
1241
|
+
this.recordingContext = null;
|
|
1242
|
+
this.recordingPage = null;
|
|
1243
|
+
this.recordingOutputPath = '';
|
|
1244
|
+
this.recordingTempDir = '';
|
|
1245
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1246
|
+
return { path: outputPath, frames: 0, error: message };
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Restart recording - stops current recording (if any) and starts a new one.
|
|
1251
|
+
* Convenience method that combines stopRecording and startRecording.
|
|
1252
|
+
*
|
|
1253
|
+
* @param outputPath - Path to the output video file (must be .webm)
|
|
1254
|
+
* @param url - Optional URL to navigate to (defaults to current page URL)
|
|
1255
|
+
* @returns Result from stopping the previous recording (if any)
|
|
1256
|
+
*/
|
|
1257
|
+
async restartRecording(outputPath, url) {
|
|
1258
|
+
let previousPath;
|
|
1259
|
+
let stopped = false;
|
|
1260
|
+
// Stop current recording if active
|
|
1261
|
+
if (this.recordingContext) {
|
|
1262
|
+
const result = await this.stopRecording();
|
|
1263
|
+
previousPath = result.path;
|
|
1264
|
+
stopped = true;
|
|
1265
|
+
}
|
|
1266
|
+
// Start new recording
|
|
1267
|
+
await this.startRecording(outputPath, url);
|
|
1268
|
+
return { previousPath, stopped };
|
|
1269
|
+
}
|
|
891
1270
|
/**
|
|
892
1271
|
* Close the browser and clean up
|
|
893
1272
|
*/
|
|
894
1273
|
async close() {
|
|
1274
|
+
// Stop recording if active (saves video)
|
|
1275
|
+
if (this.recordingContext) {
|
|
1276
|
+
await this.stopRecording();
|
|
1277
|
+
}
|
|
895
1278
|
// Stop screencast if active
|
|
896
1279
|
if (this.screencastActive) {
|
|
897
1280
|
await this.stopScreencast();
|
|
@@ -901,8 +1284,20 @@ export class BrowserManager {
|
|
|
901
1284
|
await this.cdpSession.detach().catch(() => { });
|
|
902
1285
|
this.cdpSession = null;
|
|
903
1286
|
}
|
|
904
|
-
|
|
905
|
-
|
|
1287
|
+
if (this.browserbaseSessionId && this.browserbaseApiKey) {
|
|
1288
|
+
await this.closeBrowserbaseSession(this.browserbaseSessionId, this.browserbaseApiKey).catch((error) => {
|
|
1289
|
+
console.error('Failed to close Browserbase session:', error);
|
|
1290
|
+
});
|
|
1291
|
+
this.browser = null;
|
|
1292
|
+
}
|
|
1293
|
+
else if (this.browserUseSessionId && this.browserUseApiKey) {
|
|
1294
|
+
await this.closeBrowserUseSession(this.browserUseSessionId, this.browserUseApiKey).catch((error) => {
|
|
1295
|
+
console.error('Failed to close Browser Use session:', error);
|
|
1296
|
+
});
|
|
1297
|
+
this.browser = null;
|
|
1298
|
+
}
|
|
1299
|
+
else if (this.cdpEndpoint !== null) {
|
|
1300
|
+
// CDP: only disconnect, don't close external app's pages
|
|
906
1301
|
if (this.browser) {
|
|
907
1302
|
await this.browser.close().catch(() => { });
|
|
908
1303
|
this.browser = null;
|
|
@@ -923,7 +1318,11 @@ export class BrowserManager {
|
|
|
923
1318
|
}
|
|
924
1319
|
this.pages = [];
|
|
925
1320
|
this.contexts = [];
|
|
926
|
-
this.
|
|
1321
|
+
this.cdpEndpoint = null;
|
|
1322
|
+
this.browserbaseSessionId = null;
|
|
1323
|
+
this.browserbaseApiKey = null;
|
|
1324
|
+
this.browserUseSessionId = null;
|
|
1325
|
+
this.browserUseApiKey = null;
|
|
927
1326
|
this.isPersistentContext = false;
|
|
928
1327
|
this.activePageIndex = 0;
|
|
929
1328
|
this.refMap = {};
|