|
@@ -9,7 +9,7 @@ import delay from "delay"
|
|
|
import axios from "axios"
|
|
import axios from "axios"
|
|
|
import { fileExistsAtPath } from "../../utils/fs"
|
|
import { fileExistsAtPath } from "../../utils/fs"
|
|
|
import { BrowserActionResult } from "../../shared/ExtensionMessage"
|
|
import { BrowserActionResult } from "../../shared/ExtensionMessage"
|
|
|
-import { discoverChromeInstances, testBrowserConnection } from "./browserDiscovery"
|
|
|
|
|
|
|
+import { discoverChromeHostUrl, tryChromeHostUrl } from "./browserDiscovery"
|
|
|
|
|
|
|
|
interface PCRStats {
|
|
interface PCRStats {
|
|
|
puppeteer: { launch: typeof launch }
|
|
puppeteer: { launch: typeof launch }
|
|
@@ -21,20 +21,12 @@ export class BrowserSession {
|
|
|
private browser?: Browser
|
|
private browser?: Browser
|
|
|
private page?: Page
|
|
private page?: Page
|
|
|
private currentMousePosition?: string
|
|
private currentMousePosition?: string
|
|
|
- private cachedWebSocketEndpoint?: string
|
|
|
|
|
- private lastConnectionAttempt: number = 0
|
|
|
|
|
|
|
+ private lastConnectionAttempt?: number
|
|
|
|
|
|
|
|
constructor(context: vscode.ExtensionContext) {
|
|
constructor(context: vscode.ExtensionContext) {
|
|
|
this.context = context
|
|
this.context = context
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * Test connection to a remote browser
|
|
|
|
|
- */
|
|
|
|
|
- async testConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> {
|
|
|
|
|
- return testBrowserConnection(host)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
private async ensureChromiumExists(): Promise<PCRStats> {
|
|
private async ensureChromiumExists(): Promise<PCRStats> {
|
|
|
const globalStoragePath = this.context?.globalStorageUri?.fsPath
|
|
const globalStoragePath = this.context?.globalStorageUri?.fsPath
|
|
|
if (!globalStoragePath) {
|
|
if (!globalStoragePath) {
|
|
@@ -56,162 +48,173 @@ export class BrowserSession {
|
|
|
return stats
|
|
return stats
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async launchBrowser(): Promise<void> {
|
|
|
|
|
- console.log("launch browser called")
|
|
|
|
|
- if (this.browser) {
|
|
|
|
|
- // throw new Error("Browser already launched")
|
|
|
|
|
- await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Function to get viewport size
|
|
|
|
|
- const getViewport = () => {
|
|
|
|
|
- const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
|
|
|
|
|
- const [width, height] = size.split("x").map(Number)
|
|
|
|
|
- return { width, height }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Gets the viewport size from global state or returns default
|
|
|
|
|
+ */
|
|
|
|
|
+ private getViewport() {
|
|
|
|
|
+ const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
|
|
|
|
|
+ const [width, height] = size.split("x").map(Number)
|
|
|
|
|
+ return { width, height }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // Check if remote browser connection is enabled
|
|
|
|
|
- const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Launches a local browser instance
|
|
|
|
|
+ */
|
|
|
|
|
+ private async launchLocalBrowser(): Promise<void> {
|
|
|
|
|
+ console.log("Launching local browser")
|
|
|
|
|
+ const stats = await this.ensureChromiumExists()
|
|
|
|
|
+ this.browser = await stats.puppeteer.launch({
|
|
|
|
|
+ args: [
|
|
|
|
|
+ "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
|
|
|
|
+ ],
|
|
|
|
|
+ executablePath: stats.executablePath,
|
|
|
|
|
+ defaultViewport: this.getViewport(),
|
|
|
|
|
+ // headless: false,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // If remote browser connection is not enabled, use local browser
|
|
|
|
|
- if (!remoteBrowserEnabled) {
|
|
|
|
|
- console.log("Remote browser connection is disabled, using local browser")
|
|
|
|
|
- const stats = await this.ensureChromiumExists()
|
|
|
|
|
- this.browser = await stats.puppeteer.launch({
|
|
|
|
|
- args: [
|
|
|
|
|
- "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
|
|
|
|
- ],
|
|
|
|
|
- executablePath: stats.executablePath,
|
|
|
|
|
- defaultViewport: getViewport(),
|
|
|
|
|
- // headless: false,
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Connects to a browser using a WebSocket URL
|
|
|
|
|
+ */
|
|
|
|
|
+ private async connectWithChromeHostUrl(chromeHostUrl: string): Promise<boolean> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ this.browser = await connect({
|
|
|
|
|
+ browserURL: chromeHostUrl,
|
|
|
|
|
+ defaultViewport: this.getViewport(),
|
|
|
})
|
|
})
|
|
|
- this.page = await this.browser?.newPage()
|
|
|
|
|
- return
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Cache the successful endpoint
|
|
|
|
|
+ console.log(`Connected to remote browser at ${chromeHostUrl}`)
|
|
|
|
|
+ this.context.globalState.update("cachedChromeHostUrl", chromeHostUrl)
|
|
|
|
|
+ this.lastConnectionAttempt = Date.now()
|
|
|
|
|
+
|
|
|
|
|
+ return true
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.log(`Failed to connect using WebSocket endpoint: ${error}`)
|
|
|
|
|
+ return false
|
|
|
}
|
|
}
|
|
|
- // Remote browser connection is enabled
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Attempts to connect to a remote browser using various methods
|
|
|
|
|
+ * Returns true if connection was successful, false otherwise
|
|
|
|
|
+ */
|
|
|
|
|
+ private async connectToRemoteBrowser(): Promise<boolean> {
|
|
|
let remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined
|
|
let remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined
|
|
|
- let browserWSEndpoint: string | undefined = this.cachedWebSocketEndpoint
|
|
|
|
|
let reconnectionAttempted = false
|
|
let reconnectionAttempted = false
|
|
|
|
|
|
|
|
// Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old)
|
|
// Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old)
|
|
|
- if (browserWSEndpoint && Date.now() - this.lastConnectionAttempt < 3600000) {
|
|
|
|
|
- try {
|
|
|
|
|
- console.log(`Attempting to connect using cached WebSocket endpoint: ${browserWSEndpoint}`)
|
|
|
|
|
- this.browser = await connect({
|
|
|
|
|
- browserWSEndpoint,
|
|
|
|
|
- defaultViewport: getViewport(),
|
|
|
|
|
- })
|
|
|
|
|
- this.page = await this.browser?.newPage()
|
|
|
|
|
- return
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.log(`Failed to connect using cached endpoint: ${error}`)
|
|
|
|
|
- // Clear the cached endpoint since it's no longer valid
|
|
|
|
|
- this.cachedWebSocketEndpoint = undefined
|
|
|
|
|
- // User wants to give up after one reconnection attempt
|
|
|
|
|
- if (remoteBrowserHost) {
|
|
|
|
|
- reconnectionAttempted = true
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const cachedChromeHostUrl = this.context.globalState.get("cachedChromeHostUrl") as string | undefined
|
|
|
|
|
+ if (cachedChromeHostUrl && this.lastConnectionAttempt && Date.now() - this.lastConnectionAttempt < 3_600_000) {
|
|
|
|
|
+ console.log(`Attempting to connect using cached Chrome Host Url: ${cachedChromeHostUrl}`)
|
|
|
|
|
+ if (await this.connectWithChromeHostUrl(cachedChromeHostUrl)) {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`Failed to connect using cached Chrome Host Url: ${cachedChromeHostUrl}`)
|
|
|
|
|
+ // Clear the cached endpoint since it's no longer valid
|
|
|
|
|
+ this.context.globalState.update("cachedChromeHostUrl", undefined)
|
|
|
|
|
+
|
|
|
|
|
+ // User wants to give up after one reconnection attempt
|
|
|
|
|
+ if (remoteBrowserHost) {
|
|
|
|
|
+ reconnectionAttempted = true
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// If user provided a remote browser host, try to connect to it
|
|
// If user provided a remote browser host, try to connect to it
|
|
|
- if (remoteBrowserHost && !reconnectionAttempted) {
|
|
|
|
|
|
|
+ else if (remoteBrowserHost && !reconnectionAttempted) {
|
|
|
console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`)
|
|
console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`)
|
|
|
try {
|
|
try {
|
|
|
- // Fetch the WebSocket endpoint from the Chrome DevTools Protocol
|
|
|
|
|
- const versionUrl = `${remoteBrowserHost.replace(/\/$/, "")}/json/version`
|
|
|
|
|
- console.log(`Fetching WebSocket endpoint from ${versionUrl}`)
|
|
|
|
|
|
|
+ const hostIsValid = await tryChromeHostUrl(remoteBrowserHost)
|
|
|
|
|
|
|
|
- const response = await axios.get(versionUrl)
|
|
|
|
|
- browserWSEndpoint = response.data.webSocketDebuggerUrl
|
|
|
|
|
-
|
|
|
|
|
- if (!browserWSEndpoint) {
|
|
|
|
|
- throw new Error("Could not find webSocketDebuggerUrl in the response")
|
|
|
|
|
|
|
+ if (!hostIsValid) {
|
|
|
|
|
+ throw new Error("Could not find chromeHostUrl in the response")
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- console.log(`Found WebSocket endpoint: ${browserWSEndpoint}`)
|
|
|
|
|
-
|
|
|
|
|
- // Cache the successful endpoint
|
|
|
|
|
- this.cachedWebSocketEndpoint = browserWSEndpoint
|
|
|
|
|
- this.lastConnectionAttempt = Date.now()
|
|
|
|
|
|
|
+ console.log(`Found WebSocket endpoint: ${remoteBrowserHost}`)
|
|
|
|
|
|
|
|
- this.browser = await connect({
|
|
|
|
|
- browserWSEndpoint,
|
|
|
|
|
- defaultViewport: getViewport(),
|
|
|
|
|
- })
|
|
|
|
|
- this.page = await this.browser?.newPage()
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ if (await this.connectWithChromeHostUrl(remoteBrowserHost)) {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error(`Failed to connect to remote browser: ${error}`)
|
|
console.error(`Failed to connect to remote browser: ${error}`)
|
|
|
// Fall back to auto-discovery if remote connection fails
|
|
// Fall back to auto-discovery if remote connection fails
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Always try auto-discovery if no custom URL is specified or if connection failed
|
|
|
|
|
try {
|
|
try {
|
|
|
- console.log("Attempting auto-discovery...")
|
|
|
|
|
- const discoveredHost = await discoverChromeInstances()
|
|
|
|
|
-
|
|
|
|
|
- if (discoveredHost) {
|
|
|
|
|
- console.log(`Auto-discovered Chrome at ${discoveredHost}`)
|
|
|
|
|
|
|
+ console.log("Attempting browser auto-discovery...")
|
|
|
|
|
+ const chromeHostUrl = await discoverChromeHostUrl()
|
|
|
|
|
|
|
|
- // Don't save the discovered host to global state to avoid overriding user preference
|
|
|
|
|
- // We'll just use it for this session
|
|
|
|
|
-
|
|
|
|
|
- // Try to connect to the discovered host
|
|
|
|
|
- const testResult = await testBrowserConnection(discoveredHost)
|
|
|
|
|
-
|
|
|
|
|
- if (testResult.success && testResult.endpoint) {
|
|
|
|
|
- // Cache the successful endpoint
|
|
|
|
|
- this.cachedWebSocketEndpoint = testResult.endpoint
|
|
|
|
|
- this.lastConnectionAttempt = Date.now()
|
|
|
|
|
-
|
|
|
|
|
- this.browser = await connect({
|
|
|
|
|
- browserWSEndpoint: testResult.endpoint,
|
|
|
|
|
- defaultViewport: getViewport(),
|
|
|
|
|
- })
|
|
|
|
|
- this.page = await this.browser?.newPage()
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (chromeHostUrl && (await this.connectWithChromeHostUrl(chromeHostUrl))) {
|
|
|
|
|
+ return true
|
|
|
}
|
|
}
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error(`Auto-discovery failed: ${error}`)
|
|
console.error(`Auto-discovery failed: ${error}`)
|
|
|
// Fall back to local browser if auto-discovery fails
|
|
// Fall back to local browser if auto-discovery fails
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // If all remote connection attempts fail, fall back to local browser
|
|
|
|
|
- console.log("Falling back to local browser")
|
|
|
|
|
- const stats = await this.ensureChromiumExists()
|
|
|
|
|
- this.browser = await stats.puppeteer.launch({
|
|
|
|
|
- args: [
|
|
|
|
|
- "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
|
|
|
|
- ],
|
|
|
|
|
- executablePath: stats.executablePath,
|
|
|
|
|
- defaultViewport: getViewport(),
|
|
|
|
|
- // headless: false,
|
|
|
|
|
- })
|
|
|
|
|
- // (latest version of puppeteer does not add headless to user agent)
|
|
|
|
|
- this.page = await this.browser?.newPage()
|
|
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async launchBrowser(): Promise<void> {
|
|
|
|
|
+ console.log("launch browser called")
|
|
|
|
|
+
|
|
|
|
|
+ // Check if remote browser connection is enabled
|
|
|
|
|
+ const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined
|
|
|
|
|
+
|
|
|
|
|
+ if (!remoteBrowserEnabled) {
|
|
|
|
|
+ console.log("Launching local browser")
|
|
|
|
|
+ if (this.browser) {
|
|
|
|
|
+ // throw new Error("Browser already launched")
|
|
|
|
|
+ await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // If browser wasn't open, just reset the state
|
|
|
|
|
+ this.resetBrowserState()
|
|
|
|
|
+ }
|
|
|
|
|
+ await this.launchLocalBrowser()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log("Connecting to remote browser")
|
|
|
|
|
+ // Remote browser connection is enabled
|
|
|
|
|
+ const remoteConnected = await this.connectToRemoteBrowser()
|
|
|
|
|
+
|
|
|
|
|
+ // If all remote connection attempts fail, fall back to local browser
|
|
|
|
|
+ if (!remoteConnected) {
|
|
|
|
|
+ console.log("Falling back to local browser")
|
|
|
|
|
+ await this.launchLocalBrowser()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Closes the browser and resets browser state
|
|
|
|
|
+ */
|
|
|
async closeBrowser(): Promise<BrowserActionResult> {
|
|
async closeBrowser(): Promise<BrowserActionResult> {
|
|
|
if (this.browser || this.page) {
|
|
if (this.browser || this.page) {
|
|
|
console.log("closing browser...")
|
|
console.log("closing browser...")
|
|
|
|
|
|
|
|
- const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as string | undefined
|
|
|
|
|
|
|
+ const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined
|
|
|
if (remoteBrowserEnabled && this.browser) {
|
|
if (remoteBrowserEnabled && this.browser) {
|
|
|
await this.browser.disconnect().catch(() => {})
|
|
await this.browser.disconnect().catch(() => {})
|
|
|
} else {
|
|
} else {
|
|
|
await this.browser?.close().catch(() => {})
|
|
await this.browser?.close().catch(() => {})
|
|
|
|
|
+ this.resetBrowserState()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- this.browser = undefined
|
|
|
|
|
- this.page = undefined
|
|
|
|
|
- this.currentMousePosition = undefined
|
|
|
|
|
|
|
+ // this.resetBrowserState()
|
|
|
}
|
|
}
|
|
|
return {}
|
|
return {}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Resets all browser state variables
|
|
|
|
|
+ */
|
|
|
|
|
+ private resetBrowserState(): void {
|
|
|
|
|
+ this.browser = undefined
|
|
|
|
|
+ this.page = undefined
|
|
|
|
|
+ this.currentMousePosition = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async doAction(action: (page: Page) => Promise<void>): Promise<BrowserActionResult> {
|
|
async doAction(action: (page: Page) => Promise<void>): Promise<BrowserActionResult> {
|
|
|
if (!this.page) {
|
|
if (!this.page) {
|
|
|
throw new Error(
|
|
throw new Error(
|
|
@@ -297,13 +300,118 @@ export class BrowserSession {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async navigateToUrl(url: string): Promise<BrowserActionResult> {
|
|
|
|
|
- return this.doAction(async (page) => {
|
|
|
|
|
- // networkidle2 isn't good enough since page may take some time to load. we can assume locally running dev sites will reach networkidle0 in a reasonable amount of time
|
|
|
|
|
- await page.goto(url, { timeout: 7_000, waitUntil: ["domcontentloaded", "networkidle2"] })
|
|
|
|
|
- // await page.goto(url, { timeout: 10_000, waitUntil: "load" })
|
|
|
|
|
- await this.waitTillHTMLStable(page) // in case the page is loading more resources
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Extract the root domain from a URL
|
|
|
|
|
+ * e.g., http://localhost:3000/path -> localhost:3000
|
|
|
|
|
+ * e.g., https://example.com/path -> example.com
|
|
|
|
|
+ */
|
|
|
|
|
+ private getRootDomain(url: string): string {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const urlObj = new URL(url)
|
|
|
|
|
+ // Remove www. prefix if present
|
|
|
|
|
+ return urlObj.host.replace(/^www\./, "")
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // If URL parsing fails, return the original URL
|
|
|
|
|
+ return url
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Navigate to a URL with standard loading options
|
|
|
|
|
+ */
|
|
|
|
|
+ private async navigatePageToUrl(page: Page, url: string): Promise<void> {
|
|
|
|
|
+ await page.goto(url, { timeout: 7_000, waitUntil: ["domcontentloaded", "networkidle2"] })
|
|
|
|
|
+ await this.waitTillHTMLStable(page)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Creates a new tab and navigates to the specified URL
|
|
|
|
|
+ */
|
|
|
|
|
+ private async createNewTab(url: string): Promise<BrowserActionResult> {
|
|
|
|
|
+ if (!this.browser) {
|
|
|
|
|
+ throw new Error("Browser is not launched")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Create a new page
|
|
|
|
|
+ const newPage = await this.browser.newPage()
|
|
|
|
|
+
|
|
|
|
|
+ // Set the new page as the active page
|
|
|
|
|
+ this.page = newPage
|
|
|
|
|
+
|
|
|
|
|
+ // Navigate to the URL
|
|
|
|
|
+ const result = await this.doAction(async (page) => {
|
|
|
|
|
+ await this.navigatePageToUrl(page, url)
|
|
|
})
|
|
})
|
|
|
|
|
+
|
|
|
|
|
+ return result
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async navigateToUrl(url: string): Promise<BrowserActionResult> {
|
|
|
|
|
+ if (!this.browser) {
|
|
|
|
|
+ throw new Error("Browser is not launched")
|
|
|
|
|
+ }
|
|
|
|
|
+ // Remove trailing slash for comparison
|
|
|
|
|
+ const normalizedNewUrl = url.replace(/\/$/, "")
|
|
|
|
|
+
|
|
|
|
|
+ // Extract the root domain from the URL
|
|
|
|
|
+ const rootDomain = this.getRootDomain(normalizedNewUrl)
|
|
|
|
|
+
|
|
|
|
|
+ // Get all current pages
|
|
|
|
|
+ const pages = await this.browser.pages()
|
|
|
|
|
+
|
|
|
|
|
+ // Try to find a page with the same root domain
|
|
|
|
|
+ let existingPage: Page | undefined
|
|
|
|
|
+
|
|
|
|
|
+ for (const page of pages) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const pageUrl = page.url()
|
|
|
|
|
+ if (pageUrl && this.getRootDomain(pageUrl) === rootDomain) {
|
|
|
|
|
+ existingPage = page
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // Skip pages that might have been closed or have errors
|
|
|
|
|
+ console.log(`Error checking page URL: ${error}`)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (existingPage) {
|
|
|
|
|
+ // Tab with the same root domain exists, switch to it
|
|
|
|
|
+ console.log(`Tab with domain ${rootDomain} already exists, switching to it`)
|
|
|
|
|
+
|
|
|
|
|
+ // Update the active page
|
|
|
|
|
+ this.page = existingPage
|
|
|
|
|
+ existingPage.bringToFront()
|
|
|
|
|
+
|
|
|
|
|
+ // Navigate to the new URL if it's different]
|
|
|
|
|
+ const currentUrl = existingPage.url().replace(/\/$/, "") // Remove trailing / if present
|
|
|
|
|
+ if (this.getRootDomain(currentUrl) === rootDomain && currentUrl !== normalizedNewUrl) {
|
|
|
|
|
+ console.log(`Navigating to new URL: ${normalizedNewUrl}`)
|
|
|
|
|
+ console.log(`Current URL: ${currentUrl}`)
|
|
|
|
|
+ console.log(`Root domain: ${this.getRootDomain(currentUrl)}`)
|
|
|
|
|
+ console.log(`New URL: ${normalizedNewUrl}`)
|
|
|
|
|
+ // Navigate to the new URL
|
|
|
|
|
+ return this.doAction(async (page) => {
|
|
|
|
|
+ await this.navigatePageToUrl(page, normalizedNewUrl)
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log(`Tab with domain ${rootDomain} already exists, and URL is the same: ${normalizedNewUrl}`)
|
|
|
|
|
+ // URL is the same, just reload the page to ensure it's up to date
|
|
|
|
|
+ console.log(`Reloading page: ${normalizedNewUrl}`)
|
|
|
|
|
+ console.log(`Current URL: ${currentUrl}`)
|
|
|
|
|
+ console.log(`Root domain: ${this.getRootDomain(currentUrl)}`)
|
|
|
|
|
+ console.log(`New URL: ${normalizedNewUrl}`)
|
|
|
|
|
+ return this.doAction(async (page) => {
|
|
|
|
|
+ await page.reload({ timeout: 7_000, waitUntil: ["domcontentloaded", "networkidle2"] })
|
|
|
|
|
+ await this.waitTillHTMLStable(page)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // No tab with this root domain exists, create a new one
|
|
|
|
|
+ console.log(`No tab with domain ${rootDomain} exists, creating a new one`)
|
|
|
|
|
+ return this.createNewTab(normalizedNewUrl)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// page.goto { waitUntil: "networkidle0" } may not ever resolve, and not waiting could return page content too early before js has loaded
|
|
// page.goto { waitUntil: "networkidle0" } may not ever resolve, and not waiting could return page content too early before js has loaded
|
|
@@ -339,36 +447,50 @@ export class BrowserSession {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async click(coordinate: string): Promise<BrowserActionResult> {
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Handles mouse interaction with network activity monitoring
|
|
|
|
|
+ */
|
|
|
|
|
+ private async handleMouseInteraction(
|
|
|
|
|
+ page: Page,
|
|
|
|
|
+ coordinate: string,
|
|
|
|
|
+ action: (x: number, y: number) => Promise<void>,
|
|
|
|
|
+ ): Promise<void> {
|
|
|
const [x, y] = coordinate.split(",").map(Number)
|
|
const [x, y] = coordinate.split(",").map(Number)
|
|
|
- return this.doAction(async (page) => {
|
|
|
|
|
- // Set up network request monitoring
|
|
|
|
|
- let hasNetworkActivity = false
|
|
|
|
|
- const requestListener = () => {
|
|
|
|
|
- hasNetworkActivity = true
|
|
|
|
|
- }
|
|
|
|
|
- page.on("request", requestListener)
|
|
|
|
|
-
|
|
|
|
|
- // Perform the click
|
|
|
|
|
- await page.mouse.click(x, y)
|
|
|
|
|
- this.currentMousePosition = coordinate
|
|
|
|
|
-
|
|
|
|
|
- // Small delay to check if click triggered any network activity
|
|
|
|
|
- await delay(100)
|
|
|
|
|
-
|
|
|
|
|
- if (hasNetworkActivity) {
|
|
|
|
|
- // If we detected network activity, wait for navigation/loading
|
|
|
|
|
- await page
|
|
|
|
|
- .waitForNavigation({
|
|
|
|
|
- waitUntil: ["domcontentloaded", "networkidle2"],
|
|
|
|
|
- timeout: 7000,
|
|
|
|
|
- })
|
|
|
|
|
- .catch(() => {})
|
|
|
|
|
- await this.waitTillHTMLStable(page)
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- // Clean up listener
|
|
|
|
|
- page.off("request", requestListener)
|
|
|
|
|
|
|
+ // Set up network request monitoring
|
|
|
|
|
+ let hasNetworkActivity = false
|
|
|
|
|
+ const requestListener = () => {
|
|
|
|
|
+ hasNetworkActivity = true
|
|
|
|
|
+ }
|
|
|
|
|
+ page.on("request", requestListener)
|
|
|
|
|
+
|
|
|
|
|
+ // Perform the mouse action
|
|
|
|
|
+ await action(x, y)
|
|
|
|
|
+ this.currentMousePosition = coordinate
|
|
|
|
|
+
|
|
|
|
|
+ // Small delay to check if action triggered any network activity
|
|
|
|
|
+ await delay(100)
|
|
|
|
|
+
|
|
|
|
|
+ if (hasNetworkActivity) {
|
|
|
|
|
+ // If we detected network activity, wait for navigation/loading
|
|
|
|
|
+ await page
|
|
|
|
|
+ .waitForNavigation({
|
|
|
|
|
+ waitUntil: ["domcontentloaded", "networkidle2"],
|
|
|
|
|
+ timeout: 7000,
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => {})
|
|
|
|
|
+ await this.waitTillHTMLStable(page)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Clean up listener
|
|
|
|
|
+ page.off("request", requestListener)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async click(coordinate: string): Promise<BrowserActionResult> {
|
|
|
|
|
+ return this.doAction(async (page) => {
|
|
|
|
|
+ await this.handleMouseInteraction(page, coordinate, async (x, y) => {
|
|
|
|
|
+ await page.mouse.click(x, y)
|
|
|
|
|
+ })
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -378,31 +500,42 @@ export class BrowserSession {
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Scrolls the page by the specified amount
|
|
|
|
|
+ */
|
|
|
|
|
+ private async scrollPage(page: Page, direction: "up" | "down"): Promise<void> {
|
|
|
|
|
+ const { height } = this.getViewport()
|
|
|
|
|
+ const scrollAmount = direction === "down" ? height : -height
|
|
|
|
|
+
|
|
|
|
|
+ await page.evaluate((scrollHeight) => {
|
|
|
|
|
+ window.scrollBy({
|
|
|
|
|
+ top: scrollHeight,
|
|
|
|
|
+ behavior: "auto",
|
|
|
|
|
+ })
|
|
|
|
|
+ }, scrollAmount)
|
|
|
|
|
+
|
|
|
|
|
+ await delay(300)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async scrollDown(): Promise<BrowserActionResult> {
|
|
async scrollDown(): Promise<BrowserActionResult> {
|
|
|
- const size = ((await this.context.globalState.get("browserViewportSize")) as string | undefined) || "900x600"
|
|
|
|
|
- const height = parseInt(size.split("x")[1])
|
|
|
|
|
return this.doAction(async (page) => {
|
|
return this.doAction(async (page) => {
|
|
|
- await page.evaluate((scrollHeight) => {
|
|
|
|
|
- window.scrollBy({
|
|
|
|
|
- top: scrollHeight,
|
|
|
|
|
- behavior: "auto",
|
|
|
|
|
- })
|
|
|
|
|
- }, height)
|
|
|
|
|
- await delay(300)
|
|
|
|
|
|
|
+ await this.scrollPage(page, "down")
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async scrollUp(): Promise<BrowserActionResult> {
|
|
async scrollUp(): Promise<BrowserActionResult> {
|
|
|
- const size = ((await this.context.globalState.get("browserViewportSize")) as string | undefined) || "900x600"
|
|
|
|
|
- const height = parseInt(size.split("x")[1])
|
|
|
|
|
return this.doAction(async (page) => {
|
|
return this.doAction(async (page) => {
|
|
|
- await page.evaluate((scrollHeight) => {
|
|
|
|
|
- window.scrollBy({
|
|
|
|
|
- top: -scrollHeight,
|
|
|
|
|
- behavior: "auto",
|
|
|
|
|
- })
|
|
|
|
|
- }, height)
|
|
|
|
|
- await delay(300)
|
|
|
|
|
|
|
+ await this.scrollPage(page, "up")
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async hover(coordinate: string): Promise<BrowserActionResult> {
|
|
|
|
|
+ return this.doAction(async (page) => {
|
|
|
|
|
+ await this.handleMouseInteraction(page, coordinate, async (x, y) => {
|
|
|
|
|
+ await page.mouse.move(x, y)
|
|
|
|
|
+ // Small delay to allow any hover effects to appear
|
|
|
|
|
+ await delay(300)
|
|
|
|
|
+ })
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|