Skip to content
Open
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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,28 @@ jobs:
- name: Install dependencies
run: bun install

- name: Install Chrome dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2

- name: Run type check
run: bun run type-check

Expand All @@ -39,6 +61,9 @@ jobs:
- name: Run unit tests
run: bun run test:unit

- name: Run integration tests
run: bun run test:integration

- name: Generate coverage report
run: bun run test:unit --coverage

Expand Down
197 changes: 71 additions & 126 deletions test/integration/browser/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,177 +1,122 @@
/**
* Browser module tests
* Browser module integration tests - Real browser
*/

import type { Browser, Page } from 'puppeteer'
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'

// Mock browser and page objects
const mockBrowser = {
close: mock(() => Promise.resolve()),
pages: mock(() => Promise.resolve([])),
newPage: mock(() => Promise.resolve({})),
} as unknown as Browser

const mockPage = {
url: mock(() => 'about:blank'),
title: mock(() => Promise.resolve('Test Page')),
bringToFront: mock(() => Promise.resolve()),
close: mock(() => Promise.resolve()),
} as unknown as Page

// Mock puppeteer module
const mockPuppeteer = {
launch: mock(() => Promise.resolve(mockBrowser)),
connect: mock(() => Promise.resolve(mockBrowser)),
}

mock.module('puppeteer', () => ({
default: mockPuppeteer,
}))

describe('browser module', () => {
beforeEach(() => {
// Reset mocks before each test
mockPuppeteer.launch.mockClear()
mockPuppeteer.connect.mockClear()
})
import { afterEach, describe, expect, it } from 'bun:test'
import { closeBrowser, getBrowser, getPage, listPages, newPage } from '../../../src/browser/index.js'

describe('browser module integration', () => {
afterEach(async () => {
// Clean up browser instance after each test
const { closeBrowser } = await import('../../../src/browser/index.js')
await closeBrowser()
})

describe('getBrowser', () => {
it('should launch a new browser when none exists', async () => {
const { getBrowser } = await import('../../../src/browser/index.js')
const browser = await getBrowser()
it('should launch a real headless browser', async () => {
const browser = await getBrowser({ headless: true })

expect(browser).toBeDefined()
expect(mockPuppeteer.launch).toHaveBeenCalledTimes(1)
})
expect(browser.isConnected()).toBe(true)

it('should return existing browser instance', async () => {
const { getBrowser } = await import('../../../src/browser/index.js')
const browser1 = await getBrowser()
const browser2 = await getBrowser()

expect(browser1).toBe(browser2)
expect(mockPuppeteer.launch).toHaveBeenCalledTimes(1)
// Verify browser has basic capabilities
const pages = await browser.pages()
expect(Array.isArray(pages)).toBe(true)
})

it('should connect to existing browser when wsEndpoint provided', async () => {
const { getBrowser, closeBrowser } = await import('../../../src/browser/index.js')
await closeBrowser()

await getBrowser({ wsEndpoint: 'ws://localhost:9222' })

expect(mockPuppeteer.connect).toHaveBeenCalledWith({
browserWSEndpoint: 'ws://localhost:9222',
})
})

it('should launch headless browser when headless option is true', async () => {
const { getBrowser, closeBrowser } = await import('../../../src/browser/index.js')
await closeBrowser()

await getBrowser({ headless: true })
it('should return same browser instance on subsequent calls', async () => {
const browser1 = await getBrowser({ headless: true })
const browser2 = await getBrowser({ headless: true })

expect(mockPuppeteer.launch).toHaveBeenCalledWith(
expect.objectContaining({ headless: true }),
)
expect(browser1).toBe(browser2)
})

it('should set viewport when provided', async () => {
const { getBrowser, closeBrowser } = await import('../../../src/browser/index.js')
await closeBrowser()

await getBrowser({ viewport: '1920x1080' })

expect(mockPuppeteer.launch).toHaveBeenCalledWith(
expect.objectContaining({
defaultViewport: { width: 1920, height: 1080 },
}),
)
})

it('should add proxy server to args when provided', async () => {
const { getBrowser, closeBrowser } = await import('../../../src/browser/index.js')
await closeBrowser()

await getBrowser({ proxyServer: 'http://proxy.example.com:8080' })

expect(mockPuppeteer.launch).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--proxy-server=http://proxy.example.com:8080']),
}),
)
})

it('should ignore certificate errors when acceptInsecureCerts is true', async () => {
const { getBrowser, closeBrowser } = await import('../../../src/browser/index.js')
await closeBrowser()
await getBrowser({
headless: true,
viewport: '1920x1080',
})

await getBrowser({ acceptInsecureCerts: true })
const page = await getPage()
const viewport = page.viewport()

expect(mockPuppeteer.launch).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--ignore-certificate-errors']),
}),
)
expect(viewport).toBeDefined()
expect(viewport?.width).toBe(1920)
expect(viewport?.height).toBe(1080)
})
})

describe('closeBrowser', () => {
it('should close browser and clear state', async () => {
const { getBrowser, closeBrowser } = await import('../../../src/browser/index.js')
mockBrowser.close.mockClear()
await getBrowser()
it('should close browser and allow new instance', async () => {
const browser1 = await getBrowser({ headless: true })
const browser1Id = browser1.process()?.pid

await closeBrowser()

expect(mockBrowser.close).toHaveBeenCalled()
const browser2 = await getBrowser({ headless: true })
const browser2Id = browser2.process()?.pid

expect(browser1Id).not.toBe(browser2Id)
})

it('should not throw when no browser exists', async () => {
const { closeBrowser } = await import('../../../src/browser/index.js')
await expect(closeBrowser()).resolves.toBeUndefined()
})
})

describe('getPage', () => {
it('should return existing page when available', async () => {
const { getPage, closeBrowser } = await import('../../../src/browser/index.js')
const mockBrowserWithPages = {
...mockBrowser,
pages: mock(() => Promise.resolve([mockPage])),
} as unknown as Browser
await getBrowser({ headless: true })
const page = await getPage()

mockPuppeteer.launch.mockResolvedValue(mockBrowserWithPages)
expect(page).toBeDefined()
expect(page.url()).toBeDefined()
})

await closeBrowser()
it('should create new page when none exist', async () => {
const browser = await getBrowser({ headless: true })

// Close all existing pages
const pages = await browser.pages()
await Promise.all(pages.map(p => p.close()))

const page = await getPage()

expect(page).toBeDefined()
})

it('should create new page when none exist', async () => {
const { getPage, closeBrowser } = await import('../../../src/browser/index.js')
const newMockPage = { ...mockPage }
const mockBrowserNoPages = {
...mockBrowser,
pages: mock(() => Promise.resolve([])),
newPage: mock(() => Promise.resolve(newMockPage)),
} as unknown as Browser
it('should allow navigation to real URL', async () => {
const page = await getPage()

await page.goto('about:blank')
expect(page.url()).toBe('about:blank')
})
})

mockPuppeteer.launch.mockResolvedValue(mockBrowserNoPages)
describe('listPages', () => {
it('should list all open pages', async () => {
await getBrowser({ headless: true })
const page1 = await getPage()

await closeBrowser()
const pages = await listPages()

const page = await getPage()
expect(pages.length).toBeGreaterThanOrEqual(1)
expect(pages).toContain(page1)
})
})

expect(page).toBeDefined()
expect(mockBrowserNoPages.newPage).toHaveBeenCalledTimes(1)
describe('newPage', () => {
it('should create a new page', async () => {
await getBrowser({ headless: true })

const initialPages = await listPages()
const initialCount = initialPages.length

const newPageInstance = await newPage()

const updatedPages = await listPages()

expect(updatedPages.length).toBe(initialCount + 1)
expect(updatedPages).toContain(newPageInstance)
})
})
})
Loading