feat: add computer control and managed browser

This commit is contained in:
lofyer
2026-08-05 12:55:24 +08:00
parent 2f549387a6
commit 38ac2206f2
92 changed files with 21028 additions and 766 deletions
+473
View File
@@ -0,0 +1,473 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy'
import {
BrowserService,
type BrowserDriverLike,
type BrowserSessionLike
} from './browser-service'
import type { BrowserWebContents } from './electron-browser-session'
type HarnessSlot = {
currentOrigin?: string
approvedOrigin?: string
session: BrowserSessionLike
driver: BrowserDriverLike
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function createHarness(options: {
maximumSessions?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
dispose?: () => Promise<void>
sessionGate?: Promise<void>
} = {}) {
const slots: HarnessSlot[] = []
const byContents = new Map<BrowserWebContents, HarnessSlot>()
const createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
await options.sessionGate
const webContents = {} as BrowserWebContents
const slot = {} as HarnessSlot
const session: BrowserSessionLike = {
webContents,
approveNavigation: vi.fn((target) => {
slot.approvedOrigin = target.origin
}),
getCurrentOrigin: vi.fn(() => slot.currentOrigin),
dispose: vi.fn(options.dispose ?? (async () => undefined))
}
const driver: BrowserDriverLike = {
navigate: vi.fn(async (url) => {
slot.currentOrigin = canonicalizeBrowserUrl(url).origin
return { url }
}),
snapshot: vi.fn(async () => ({
url: `${slot.currentOrigin}/page`,
title: 'Page',
nodes: [],
truncated: false
})),
click: vi.fn(async () => undefined),
type: vi.fn(async () => undefined),
select: vi.fn(async () => undefined),
getBackTarget: vi.fn(async () => ({
entryId: 4,
url: 'https://previous.example/back'
})),
backTo: vi.fn(async (target) => {
slot.currentOrigin = canonicalizeBrowserUrl(target.url).origin
return { url: target.url }
}),
screenshot: vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/png' as const,
data: 'iVBORw0KGgo='
})),
dispose: vi.fn()
}
Object.assign(slot, { session, driver })
slots.push(slot)
byContents.set(webContents, slot)
return session
})
const service = new BrowserService({
policy: new BrowserUrlPolicy(async () => [
{ address: '93.184.216.34', family: 4 }
]),
maximumSessions: options.maximumSessions,
idleTimeoutMs: options.idleTimeoutMs,
cleanupTimeoutMs: options.cleanupTimeoutMs,
liveFrameDelayMs: 0,
createSession,
createDriver: (contents) => {
const slot = byContents.get(contents)
if (!slot) {
throw new Error('unknown contents')
}
return slot.driver
}
})
return { createSession, service, slots }
}
afterEach(() => {
vi.useRealTimers()
})
describe('BrowserService', () => {
it('publishes browser status and live frames through session cleanup', async () => {
const harness = createHarness()
const states: Array<{
status: string
frameDataUrl?: string
}> = []
const removeListener = harness.service.onState((state) => {
states.push(state)
})
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
await harness.service.click('conversation', 'button_ref', signal)
await harness.service.releaseConversation('conversation')
expect(states.map((state) => state.status)).toEqual([
'creating',
'loading',
'ready',
'acting',
'ready',
'stopped'
])
expect(states.find((state) => state.status === 'ready')?.frameDataUrl).toBe(
'data:image/png;base64,iVBORw0KGgo='
)
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
const replayed: string[] = []
const removeReplayListener = harness.service.onState((state) => {
replayed.push(state.status)
})
expect(replayed).toEqual([])
removeReplayListener()
removeListener()
await harness.service.dispose()
})
it('does not publish ready after a session is stopped during frame capture', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const states: string[] = []
harness.service.onState((state) => states.push(state.status))
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.driver.screenshot).mockImplementationOnce(
async (operationSignal) =>
new Promise<never>((_resolve, reject) => {
operationSignal.addEventListener(
'abort',
() => reject(operationSignal.reason),
{ once: true }
)
})
)
const click = harness.service.click(
'conversation',
'button_ref',
signal
)
await vi.waitFor(() =>
expect(slot.driver.screenshot).toHaveBeenCalledTimes(2)
)
await harness.service.releaseConversation('conversation')
await expect(click).rejects.toThrow('浏览器会话已释放')
expect(states.at(-1)).toBe('stopped')
})
it('isolates browser state and drivers by conversation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation-a', 'https://a.example/', signal)
await harness.service.navigate('conversation-b', 'https://b.example/', signal)
await harness.service.snapshot('conversation-a', signal)
expect(harness.service.getSessionCount()).toBe(2)
expect(harness.service.getOrigin('conversation-a')).toBe(
'https://a.example'
)
expect(harness.service.getOrigin('conversation-b')).toBe(
'https://b.example'
)
expect(harness.slots[0]?.driver.snapshot).toHaveBeenCalledOnce()
expect(harness.slots[1]?.driver.snapshot).not.toHaveBeenCalled()
await harness.service.dispose()
})
it('enforces a hard maximum of three sessions', async () => {
const harness = createHarness({ maximumSessions: 3 })
const signal = new AbortController().signal
for (const id of ['one', 'two', 'three']) {
await harness.service.navigate(id, `https://${id}.example/`, signal)
}
await expect(
harness.service.navigate('four', 'https://four.example/', signal)
).rejects.toThrow('3 个上限')
expect(harness.createSession).toHaveBeenCalledTimes(3)
await harness.service.dispose()
})
it('serializes operations in one conversation and lets queued callers cancel', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const clickGate = deferred<void>()
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.driver.click).mockImplementationOnce(async () =>
clickGate.promise
)
const click = harness.service.click('conversation', 'b_ref', signal)
await vi.waitFor(() => expect(slot.driver.click).toHaveBeenCalled())
const queuedController = new AbortController()
const queued = harness.service.snapshot(
'conversation',
queuedController.signal
)
queuedController.abort(new Error('cancel queued'))
await expect(queued).rejects.toThrow('cancel queued')
expect(slot.driver.snapshot).not.toHaveBeenCalled()
clickGate.resolve()
await click
await harness.service.dispose()
})
it('does not let a canceled queued waiter clear the active operation owner', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const clickGate = deferred<void>()
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
let activeSignal: AbortSignal | undefined
vi.mocked(slot.driver.click).mockImplementationOnce(
async (_ref, operationSignal) => {
activeSignal = operationSignal
await clickGate.promise
}
)
const click = harness.service.click('conversation', 'b_ref', signal)
await vi.waitFor(() => expect(activeSignal).toBeDefined())
const queuedController = new AbortController()
const queued = harness.service.snapshot(
'conversation',
queuedController.signal
)
queuedController.abort(new Error('cancel queued'))
await expect(queued).rejects.toThrow('cancel queued')
const clickResult = expect(click).rejects.toThrow(
'浏览器会话已释放'
)
await harness.service.releaseConversation('conversation')
expect(activeSignal?.aborted).toBe(true)
clickGate.resolve()
await clickResult
await harness.service.dispose()
})
it('abandons a sole canceled creation without retaining or consuming a slot', async () => {
const creationGate = deferred<void>()
const harness = createHarness({
maximumSessions: 1,
sessionGate: creationGate.promise
})
const canceledController = new AbortController()
const canceled = harness.service.navigate(
'canceled',
'https://canceled.example/',
canceledController.signal
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
canceledController.abort(new Error('cancel creation'))
await expect(canceled).rejects.toThrow('cancel creation')
expect(harness.service.getSessionCount()).toBe(0)
const replacement = harness.service.navigate(
'replacement',
'https://replacement.example/',
new AbortController().signal
)
await vi.waitFor(() =>
expect(harness.createSession).toHaveBeenCalledTimes(2)
)
creationGate.resolve()
await expect(replacement).resolves.toMatchObject({
origin: 'https://replacement.example'
})
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('preserves a shared creation while another waiter cancels', async () => {
const creationGate = deferred<void>()
const harness = createHarness({ sessionGate: creationGate.promise })
const canceledController = new AbortController()
const canceled = harness.service.navigate(
'conversation',
'https://example.com/first',
canceledController.signal
)
const shared = harness.service.navigate(
'conversation',
'https://example.com/second',
new AbortController().signal
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
canceledController.abort(new Error('cancel one waiter'))
await expect(canceled).rejects.toThrow('cancel one waiter')
creationGate.resolve()
await expect(shared).resolves.toMatchObject({
origin: 'https://example.com'
})
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.slots[0]?.session.dispose).not.toHaveBeenCalled()
await harness.service.dispose()
})
it('expires idle sessions and clears their isolated resources', async () => {
vi.useFakeTimers()
const harness = createHarness({ idleTimeoutMs: 100 })
await harness.service.navigate(
'conversation',
'https://a.example/',
new AbortController().signal
)
await vi.advanceTimersByTimeAsync(101)
await vi.waitFor(() => expect(harness.service.getSessionCount()).toBe(0))
expect(harness.slots[0]?.driver.dispose).toHaveBeenCalledOnce()
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('tracks an approved origin across validated back navigation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://current.example/',
signal
)
await expect(harness.service.back('conversation', signal)).resolves.toEqual({
url: 'https://previous.example/back',
origin: 'https://previous.example'
})
expect(harness.service.getOrigin('conversation')).toBe(
'https://previous.example'
)
expect(harness.slots[0]?.approvedOrigin).toBe(
'https://previous.example'
)
await harness.service.dispose()
})
it('fails closed and releases a slot when navigation origin does not match', async () => {
const harness = createHarness()
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
slot.currentOrigin = 'https://attacker.example'
await expect(
harness.service.snapshot(
'conversation',
new AbortController().signal
)
).rejects.toThrow('来源已改变')
expect(harness.service.getSessionCount()).toBe(0)
expect(slot.session.dispose).toHaveBeenCalled()
await harness.service.dispose()
})
it('bounds cleanup and makes release and dispose idempotent', async () => {
const harness = createHarness({
cleanupTimeoutMs: 5,
dispose: async () => new Promise(() => undefined)
})
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
await expect(
harness.service.releaseConversation('conversation')
).rejects.toThrow('清理超时')
expect(harness.service.getSessionCount()).toBe(0)
await harness.service.releaseConversation('conversation')
await harness.service.dispose()
await harness.service.dispose()
})
it('settles a creation and release race without blocking later reuse', async () => {
const creationGate = deferred<void>()
const harness = createHarness({ sessionGate: creationGate.promise })
const firstNavigation = harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
const release = harness.service.releaseConversation('conversation')
creationGate.resolve()
await release
await expect(firstNavigation).rejects.toThrow()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await expect(
harness.service.navigate(
'conversation',
'https://example.com/new',
new AbortController().signal
)
).resolves.toMatchObject({ origin: 'https://example.com' })
expect(harness.createSession).toHaveBeenCalledTimes(2)
await harness.service.dispose()
})
it('clears current sessions and remains reusable', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
await harness.service.clearSessions()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await expect(
harness.service.navigate(
'conversation',
'https://example.com/again',
signal
)
).resolves.toMatchObject({ origin: 'https://example.com' })
expect(harness.createSession).toHaveBeenCalledTimes(2)
await harness.service.dispose()
})
})