Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/branch-cleaner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ jobs:
steps:
- uses: mmorenoregalado/action-branches-cleaner@v2.0.3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
base_branches: main
days_old_threshold: 30
2 changes: 1 addition & 1 deletion apps/agent/biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"root": false,
"extends": "//",
"vcs": {
Expand Down
1 change: 1 addition & 0 deletions apps/agent/entrypoints/background/scheduledJobRuns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export const scheduledJobRuns = async () => {

let runningMissedJobs = false

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO(dani) refactor to reduce complexity
const runMissedJobs = async () => {
if (runningMissedJobs) return
runningMissedJobs = true
Expand Down
1 change: 1 addition & 0 deletions apps/agent/lib/graphql/getQueryKeyFromDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const getOperationName = <T, V>(

export const getQueryKeyFromDocument = <
TResult,
// biome-ignore lint/suspicious/noExplicitAny: TODO(dani) type GraphQL variables properly
TVariables extends Record<string, any> | undefined = undefined,
>(
doc: TypedDocumentString<TResult, TVariables>,
Expand Down
1 change: 1 addition & 0 deletions apps/agent/lib/graphql/useGraphqlInfiniteQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getQueryKeyFromDocument } from './getQueryKeyFromDocument'
*/
export const useGraphqlInfiniteQuery = <
TQueryFnData,
// biome-ignore lint/suspicious/noExplicitAny: TODO(dani) type GraphQL variables properly
TVariables extends Record<string, any> | undefined = undefined,
TPageParam extends string | undefined | number = undefined,
>(
Expand Down
1 change: 1 addition & 0 deletions apps/agent/lib/graphql/useGraphqlQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getQueryKeyFromDocument } from './getQueryKeyFromDocument'
*/
export const useGraphqlQuery = <
TResult,
// biome-ignore lint/suspicious/noExplicitAny: TODO(dani) type GraphQL variables properly
TVariables extends Record<string, any> | undefined = undefined,
>(
query: TypedDocumentString<TResult, TVariables>,
Expand Down
1 change: 1 addition & 0 deletions apps/agent/lib/schedules/syncSchedulesToBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function getRemoteUpdatedAt(remote: RemoteScheduledJob): Date {
return new Date(normalizeTimestamp(remote.updatedAt))
}

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO(dani) refactor to reduce complexity
export async function syncSchedulesToBackend(
localJobs: ScheduledJob[],
userId: string,
Expand Down
1 change: 1 addition & 0 deletions apps/agent/web-ext.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const chromiumArgs = [
if (env.BROWSEROS_CDP_PORT) {
// TODO: replace with --browseros-cdp-port once we fix the browseros bug
chromiumArgs.push(`--remote-debugging-port=${env.BROWSEROS_CDP_PORT}`)
// chromiumArgs.push(`--browseros-cdp-port =${env.BROWSEROS_CDP_PORT}`)
}
if (env.BROWSEROS_SERVER_PORT) {
chromiumArgs.push(`--browseros-mcp-port=${env.BROWSEROS_SERVER_PORT}`)
Expand Down
5 changes: 1 addition & 4 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@
"scripts": {
"start": "bun --watch --env-file=.env.development src/index.ts",
"build": "bun ../../scripts/build/server.ts --mode=prod --target=all",
"test": "bun run test:cleanup && bun --env-file=.env.development test tests/tools tests/common",
"test:all": "bun run test:cleanup && bun --env-file=.env.development test",
"test:cdp": "bun run test:cleanup && bun --env-file=.env.development test tests/tools/cdp-based",
"test:controller": "bun run test:cleanup && bun --env-file=.env.development test tests/tools/controller-based",
"test:tools": "bun run test:cleanup && bun --env-file=.env.development test tests/tools",
"test:integration": "bun run test:cleanup && bun --env-file=.env.development test tests/server.integration.test.ts",
"test:sdk": "bun run test:cleanup && bun --env-file=.env.development test tests/sdk",
"test:cleanup": "./tests/__helpers__/cleanup.sh",
Expand Down
16 changes: 12 additions & 4 deletions apps/server/src/browser/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,18 @@ export class Browser {
...(opts?.windowId !== undefined && { windowId: opts.windowId }),
})

const infoResult = await this.cdp.Browser.getTabInfo({
tabId: (createResult.tab as TabInfo).tabId,
})
const tabInfo = infoResult.tab as TabInfo
const tabId = (createResult.tab as TabInfo).tabId
let tabInfo: TabInfo | undefined
for (let i = 0; i < 10; i++) {
try {
const infoResult = await this.cdp.Browser.getTabInfo({ tabId })
tabInfo = infoResult.tab as TabInfo
break
} catch {
await new Promise((r) => setTimeout(r, 100))
}
}
if (!tabInfo) throw new Error(`Tab ${tabId} not found after creation`)

const pageId = this.nextPageId++
this.pages.set(pageId, {
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/browser/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function buildInteractiveTree(nodes: AXNode[]): string[] {

const lines: string[] = []

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: tree-walking with multiple node types is inherently complex
function walk(nodeId: string): void {
const node = nodeMap.get(nodeId)
if (!node) return
Expand Down Expand Up @@ -113,6 +114,7 @@ export function buildEnhancedTree(nodes: AXNode[]): string[] {

const lines: string[] = []

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: tree-walking with multiple node types is inherently complex
function walk(nodeId: string, depth: number): void {
const node = nodeMap.get(nodeId)
if (!node) return
Expand Down
1 change: 1 addition & 0 deletions apps/server/tests/__helpers__/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export async function spawnBrowser(
'--enable-logging=stderr',
...(headless ? ['--headless=new'] : []),
`--user-data-dir=${tempUserDataDir}`,
// TODO: replace with --browseros-cdp-port once we fix the browseros bug
`--remote-debugging-port=${config.cdpPort}`,
`--browseros-mcp-port=${config.serverPort}`,
`--browseros-extension-port=${config.extensionPort}`,
Expand Down
15 changes: 1 addition & 14 deletions apps/server/tests/__helpers__/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
/**
* @license
* Copyright 2025 BrowserOS
*
* Test helpers public API.
*/

// Setup & lifecycle
export {
cleanupBrowserOS,
ensureBrowserOS,
type TestEnvironmentConfig,
} from './setup'
// Types
export type { McpContentItem, TypedCallToolResult } from './utils'
// Test wrappers
// Port management
// Mocks
export {
asToolResult,
getMockRequest,
getMockResponse,
html,
killProcessOnPort,
withMcpServer,
} from './utils'
export { type WithBrowserContext, withBrowser } from './with-browser'
119 changes: 1 addition & 118 deletions apps/server/tests/__helpers__/utils.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,8 @@
/**
* @license
* Copyright 2025 BrowserOS
*
* Test utilities: wrappers, mocks, and port management.
*/
import { execSync } from 'node:child_process'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { Mutex } from 'async-mutex'
import type { Browser } from 'puppeteer'
import puppeteer from 'puppeteer'
import type { HTTPRequest, HTTPResponse } from 'puppeteer-core'
import { CdpClient } from '../../src/browser/cdp/cdp-client'
import { PageRegistry } from '../../src/browser/page-registry'
import { SessionState } from '../../src/browser/session-state'
import { logger as cdpLogger } from '../../src/tools/cdp/context/logger'
import { CdpResponse } from '../../src/tools/cdp/response/cdp-response'

import { ensureBrowserOS } from './setup'

Expand Down Expand Up @@ -63,53 +49,7 @@ export async function killProcessOnPort(port: number): Promise<void> {
// =============================================================================

const envMutex = new Mutex()
let cachedBrowser: Browser | undefined

export async function withCdpBrowser(
cb: (response: CdpResponse, context: CdpClient) => Promise<void>,
_options: { debug?: boolean } = {},
): Promise<void> {
return await envMutex.runExclusive(async () => {
const config = await ensureBrowserOS({ skipExtension: true })

if (!cachedBrowser || !cachedBrowser.connected) {
cachedBrowser = await puppeteer.connect({
browserURL: `http://127.0.0.1:${config.cdpPort}`,
})
}

const response = new CdpResponse()
const registry = new PageRegistry()
const context = await CdpClient.from(
cachedBrowser,
cdpLogger,
{
experimentalDevToolsDebugging: false,
},
registry,
)

try {
const page = await context.newPage(true)
const state = new SessionState()
await context.withPage(page, state, () => cb(response, context))
} finally {
context.dispose()
}
})
}

/**
* Test helper that provides an MCP client connected to the BrowserOS server.
*
* Lifecycle:
* - First test: Starts full environment (~15-20s)
* - Subsequent tests: Reuses existing environment (fast)
* - After suite exits: Environment stays running (ready for next run)
*
* Cleanup:
* - Run `bun run test:cleanup` when you need to kill processes
*/
export async function withMcpServer(
cb: (client: Client) => Promise<void>,
): Promise<void> {
Expand All @@ -134,66 +74,9 @@ export async function withMcpServer(
}

// =============================================================================
// Mock Helpers
// HTML Helper
// =============================================================================

export function getMockRequest(
options: {
method?: string
response?: HTTPResponse
failure?: HTTPRequest['failure']
resourceType?: string
hasPostData?: boolean
postData?: string
fetchPostData?: Promise<string>
} = {},
): HTTPRequest {
return {
url() {
return 'http://example.com'
},
method() {
return options.method ?? 'GET'
},
fetchPostData() {
return options.fetchPostData ?? Promise.reject()
},
hasPostData() {
return options.hasPostData ?? false
},
postData() {
return options.postData
},
response() {
return options.response ?? null
},
failure() {
return options.failure?.() ?? null
},
resourceType() {
return options.resourceType ?? 'document'
},
headers(): Record<string, string> {
return {
'content-size': '10',
}
},
redirectChain(): HTTPRequest[] {
return []
},
} as HTTPRequest
}

export function getMockResponse(
options: { status?: number } = {},
): HTTPResponse {
return {
status() {
return options.status ?? 200
},
} as HTTPResponse
}

export function html(
strings: TemplateStringsArray,
...values: unknown[]
Expand Down
82 changes: 82 additions & 0 deletions apps/server/tests/__helpers__/with-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { TEST_PORTS } from '@browseros/shared/constants/ports'
import { Mutex } from 'async-mutex'
import { CdpBackend } from '../../src/browser/backends/cdp'
import type { ControllerBackend } from '../../src/browser/backends/types'
import { Browser } from '../../src/browser/browser'
import type { ToolDefinition } from '../../src/tools/framework'
import { executeTool } from '../../src/tools/framework'
import type { ToolResult } from '../../src/tools/response'
import { type BrowserConfig, spawnBrowser } from './browser'
import { killProcessOnPort } from './utils'

const cdpPort = Number.parseInt(
process.env.BROWSEROS_CDP_PORT || String(TEST_PORTS.cdp),
10,
)
const serverPort = Number.parseInt(
process.env.BROWSEROS_SERVER_PORT || String(TEST_PORTS.server),
10,
)
const extensionPort = Number.parseInt(
process.env.BROWSEROS_EXTENSION_PORT || String(TEST_PORTS.extension),
10,
)
const binaryPath =
process.env.BROWSEROS_BINARY ??
'/Applications/BrowserOS.app/Contents/MacOS/BrowserOS'

const mutex = new Mutex()
let cachedCdp: CdpBackend | null = null
let cachedBrowser: Browser | null = null

const stubController: ControllerBackend = {
start: async () => {},
stop: async () => {},
isConnected: () => false,
send: async () => {
throw new Error('Controller not available in test mode')
},
}

async function getOrCreateBrowser(): Promise<Browser> {
if (cachedBrowser && cachedCdp?.isConnected()) return cachedBrowser

await killProcessOnPort(cdpPort)

const config: BrowserConfig = {
cdpPort,
serverPort,
extensionPort,
binaryPath,
}
await spawnBrowser(config)

cachedCdp = new CdpBackend({ port: cdpPort })
await cachedCdp.connect()

cachedBrowser = new Browser(cachedCdp, stubController)
return cachedBrowser
}

export interface WithBrowserContext {
browser: Browser
execute: (tool: ToolDefinition, args: unknown) => Promise<ToolResult>
}

export async function withBrowser(
cb: (ctx: WithBrowserContext) => Promise<void>,
): Promise<void> {
return await mutex.runExclusive(async () => {
const browser = await getOrCreateBrowser()

const execute = async (
tool: ToolDefinition,
args: unknown,
): Promise<ToolResult> => {
const signal = AbortSignal.timeout(30_000)
return executeTool(tool, args, { browser }, signal)
}

await cb({ browser, execute })
})
}
Loading
Loading