Files
goodbuddy/src/main/browser/browser-service.test.ts
T
mesalogo b84250f62a fix: allow closing browser tabs during active requests
Allow users to close leased browser tabs without cancelling the AI request or revoking its other tools. Closed-page operations report a recoverable error; explicit navigation creates a dedicated replacement tab for direct-model and MCP calls.

Add regression coverage and real Electron lifecycle checks, and update browser behavior documentation.

Release note: 浏览器标签页被 AI 请求占用时也可关闭,其他任务继续运行;再次导航会打开独立新页。
2026-09-13 11:21:37 +08:00

1370 lines
50 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy'
import {
BrowserNavigationStoppedError,
BrowserService,
type BrowserDriverLike,
type BrowserSessionLike
} from './browser-service'
import type { BrowserWebContents } from './electron-browser-session'
import type { BrowserLiveState } from '../../shared/contracts'
type HarnessSlot = {
currentOrigin?: string
currentUrl?: string
approvedOrigin?: string
emitLoading(isLoading: boolean): void
emitNavigation(url: string): void
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
maximumTabsPerConversation?: number
maximumTabsPerWindow?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
dispose?: () => Promise<void>
sessionGate?: Promise<void>
captureScreenshot?: (
signal: AbortSignal
) => Promise<{
type: 'image'
mimeType: 'image/jpeg'
data: string
}>
driverScreenshot?: BrowserDriverLike['screenshot']
} = {}) {
const slots: HarnessSlot[] = []
const byContents = new Map<BrowserWebContents, HarnessSlot>()
const dnsResolver = vi.fn(async () => [
{ address: '93.184.216.34', family: 4 as const }
])
const createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
await options.sessionGate
const slot = {} as HarnessSlot
const webContents = {
getURL: () => slot.currentUrl ?? `${slot.currentOrigin}/page`
} as BrowserWebContents
const loadingListeners = new Set<(isLoading: boolean) => void>()
const navigationListeners = new Set<(url: string) => void>()
let isLoading = false
const session: BrowserSessionLike = {
webContents,
approveNavigation: vi.fn((target) => {
slot.approvedOrigin = target.origin
}),
getCurrentOrigin: vi.fn(() => slot.currentOrigin),
isLoading: vi.fn(() => isLoading),
onLoadingChange: vi.fn((listener) => {
loadingListeners.add(listener)
return () => loadingListeners.delete(listener)
}),
onNavigationChange: vi.fn((listener) => {
navigationListeners.add(listener)
return () => navigationListeners.delete(listener)
}),
setViewport: vi.fn(),
stopLoading: vi.fn(),
...(options.captureScreenshot
? { captureScreenshot: vi.fn(options.captureScreenshot) }
: {}),
dispose: vi.fn(options.dispose ?? (async () => undefined))
}
const driver: BrowserDriverLike = {
navigate: vi.fn(async (url) => {
slot.currentOrigin = canonicalizeBrowserUrl(url).origin
slot.currentUrl = url
return { url }
}),
reload: vi.fn(async () => ({
url: slot.currentUrl ?? `${slot.currentOrigin}/page`
})),
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
slot.currentUrl = target.url
return { url: target.url }
}),
getNavigationMetadata: vi.fn(async () => ({
url: slot.currentUrl ?? `${slot.currentOrigin}/page`,
canGoBack: false
})),
screenshot: vi.fn(
options.driverScreenshot ??
(async () => ({
type: 'image' as const,
mimeType: 'image/jpeg' as const,
data: '/9j/2Q=='
}))
),
dispose: vi.fn()
}
Object.assign(slot, {
session,
driver,
emitLoading: (loading: boolean) => {
isLoading = loading
for (const listener of loadingListeners) {
listener(loading)
}
},
emitNavigation: (url: string) => {
slot.currentUrl = url
slot.currentOrigin = canonicalizeBrowserUrl(url).origin
for (const listener of navigationListeners) {
listener(url)
}
}
})
slots.push(slot)
byContents.set(webContents, slot)
return session
})
const service = new BrowserService({
policy: new BrowserUrlPolicy(dnsResolver),
maximumSessions: options.maximumSessions,
maximumTabsPerConversation: options.maximumTabsPerConversation,
maximumTabsPerWindow: options.maximumTabsPerWindow,
idleTimeoutMs: options.idleTimeoutMs,
cleanupTimeoutMs: options.cleanupTimeoutMs,
createSession,
createDriver: (contents) => {
const slot = byContents.get(contents)
if (!slot) {
throw new Error('unknown contents')
}
return slot.driver
}
})
return { createSession, dnsResolver, service, slots }
}
afterEach(() => {
vi.useRealTimers()
})
describe('BrowserService', () => {
const firstLeaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2'
const secondLeaseToken = 'db8a2c28-43a6-4aab-93e0-1c01b9374dca'
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
it('reserves four unused requests without resources, events, or consuming session capacity', async () => {
const harness = createHarness({ maximumSessions: 3 })
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const leases = ['one', 'two', 'three', 'four'].map((id) =>
harness.service.reserveRequestTab(id, `request-${id}`, 21)
)
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.service.getTabCount()).toBe(0)
expect(harness.createSession).not.toHaveBeenCalled()
expect(states).toEqual([])
expect(harness.service.getOwnerWindowId('one')).toBe(21)
for (const lease of leases) {
lease.release()
lease.release()
expect(lease.signal.aborted).toBe(true)
expect(harness.service.getOwnerWindowId(lease.conversationId)).toBeUndefined()
}
const replacement = harness.service.reserveRequestTab('one', 'replacement', 22)
expect(replacement.tabId).not.toBe(leases[0]!.tabId)
await expect(harness.service.navigate('one', 'https://example.com/', new AbortController().signal, leases[0]!.tabId, 22))
.rejects.toThrow('不属于当前对话')
expect(harness.createSession).not.toHaveBeenCalled()
replacement.release()
await harness.service.dispose()
})
it('shares the reserved identity through navigation, workbar lookup, and all usage leases', async () => {
const harness = createHarness()
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const first = harness.service.reserveRequestTab('conversation', 'first', 21)
const second = harness.service.reserveRequestTab('conversation', 'second', 21)
expect(second.tabId).toBe(first.tabId)
expect(() => harness.service.reserveRequestTab('conversation', 'wrong-window', 22)).toThrow('不属于当前窗口')
const signal = new AbortController().signal
await Promise.all([
harness.service.navigate('conversation', 'https://example.com/first', signal, first.tabId),
harness.service.navigate('conversation', 'https://example.com/second', signal, second.tabId)
])
expect(harness.createSession).toHaveBeenCalledOnce()
expect(states[0]).toMatchObject({
status: 'creating',
tabId: first.tabId,
workbarInstanceId: first.tabId,
ownerWindowId: 21
})
expect(states.every((state) => state.workbarInstanceId === first.tabId)).toBe(true)
expect(harness.service.listTabs('conversation', 21)).toEqual([
expect.objectContaining({ tabId: first.tabId, workbarInstanceId: first.tabId })
])
const restored = await harness.service.createTab('conversation', 21, signal, first.tabId)
expect(restored.tabId).toBe(first.tabId)
expect(harness.service.getTabCount()).toBe(1)
first.release()
await harness.service.closeTab('conversation', first.tabId, 21)
expect(second.signal.aborted).toBe(true)
second.release()
await harness.service.closeTab('conversation', first.tabId, 21)
await harness.service.closeTab('conversation', first.tabId, 21)
await harness.service.dispose()
})
it('keeps a fresh explicit workbar separate from a reserved request target', async () => {
const harness = createHarness()
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const request = harness.service.reserveRequestTab('conversation', 'request', 21)
const signal = new AbortController().signal
const explicit = await harness.service.createTab('conversation', 21, signal, workbarInstanceId)
expect(explicit.tabId).not.toBe(request.tabId)
expect(explicit.workbarInstanceId).toBe(workbarInstanceId)
expect(harness.service.getTabCount()).toBe(1)
expect(states.every((state) => state.tabId === explicit.tabId)).toBe(true)
await harness.service.navigate('conversation', 'https://example.com/', signal, request.tabId, 21)
expect(harness.service.listTabs('conversation', 21)).toHaveLength(2)
expect(states.find((state) => state.tabId === request.tabId)).toMatchObject({
status: 'creating',
workbarInstanceId: request.tabId
})
expect(explicit.url).toBeUndefined()
request.release()
await harness.service.dispose()
})
it('publishes the explicit workbar identity from the first creating event', async () => {
const harness = createHarness()
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const tab = await harness.service.createTab('conversation', 21, new AbortController().signal, workbarInstanceId)
expect(states[0]).toMatchObject({ status: 'creating', tabId: tab.tabId, workbarInstanceId })
expect(states.every((state) => state.workbarInstanceId === workbarInstanceId)).toBe(true)
expect(tab.workbarInstanceId).toBe(workbarInstanceId)
await expect(harness.service.createTab('conversation', 22, new AbortController().signal, workbarInstanceId)).rejects.toThrow('不属于当前窗口')
await harness.service.dispose()
})
it('cleans unused reservations on conversation release, service clear, and disposal', async () => {
const harness = createHarness()
const first = harness.service.reserveRequestTab('first', 'first', 21)
await expect(harness.service.releaseConversation('first', 22)).rejects.toThrow('不属于当前窗口')
expect(first.signal.aborted).toBe(false)
await harness.service.releaseConversation('first', 21)
expect(first.signal.aborted).toBe(true)
expect(harness.service.getOwnerWindowId('first')).toBeUndefined()
const second = harness.service.reserveRequestTab('second', 'second', 21)
await harness.service.clearSessions()
expect(second.signal.aborted).toBe(true)
const third = harness.service.reserveRequestTab('third', 'third', 21)
await harness.service.dispose()
expect(third.signal.aborted).toBe(true)
expect(harness.createSession).not.toHaveBeenCalled()
expect(() => harness.service.reserveRequestTab('fourth', 'fourth', 21)).toThrow('已关闭')
})
it('abandons materialization when the final request lease is released', async () => {
const gate = deferred<void>()
const harness = createHarness({ sessionGate: gate.promise })
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const request = harness.service.reserveRequestTab('conversation', 'request', 21)
const navigation = harness.service.navigate('conversation', 'https://example.com/', new AbortController().signal, request.tabId)
const rejection = expect(navigation).rejects.toThrow('已取消')
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
request.release()
gate.resolve()
await rejection
expect(harness.service.getSessionCount()).toBe(0)
await vi.waitFor(() => expect(harness.slots[0]!.session.dispose).toHaveBeenCalledOnce())
expect(states.at(-1)?.status).toBe('stopped')
await harness.service.dispose()
})
it('preserves shared reserved materialization when one request cancels', async () => {
const gate = deferred<void>()
const harness = createHarness({ sessionGate: gate.promise })
const first = harness.service.reserveRequestTab('conversation', 'first', 21)
const second = harness.service.reserveRequestTab('conversation', 'second', 21)
const firstNavigation = harness.service.navigate('conversation', 'https://example.com/first', first.signal, first.tabId)
const canceled = expect(firstNavigation).rejects.toThrow('租约已终止')
const secondNavigation = harness.service.navigate('conversation', 'https://example.com/second', second.signal, second.tabId)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
first.release()
await canceled
gate.resolve()
await secondNavigation
expect(harness.service.listTabs('conversation', 21)).toEqual([
expect.objectContaining({ tabId: second.tabId, workbarInstanceId: second.tabId, url: 'https://example.com/second' })
])
expect(harness.slots[0]!.session.dispose).not.toHaveBeenCalled()
second.release()
await harness.service.dispose()
})
it('retains a materialized reserved tab until the request lease stops protecting it from idle expiry', async () => {
vi.useFakeTimers()
const harness = createHarness({ idleTimeoutMs: 100 })
const lease = harness.service.reserveRequestTab('conversation', 'request', 21)
await harness.service.navigate('conversation', 'https://example.com/', lease.signal, lease.tabId)
await vi.advanceTimersByTimeAsync(500)
expect(harness.service.getSessionCount()).toBe(1)
lease.release()
await vi.advanceTimersByTimeAsync(101)
expect(harness.service.getSessionCount()).toBe(0)
await harness.service.dispose()
})
it('keeps stale close idempotent but rejects another conversation or window', async () => {
const harness = createHarness()
const first = await harness.service.createTab('first', 21)
const sibling = await harness.service.createTab('first', 21)
const other = await harness.service.createTab('other', 21)
await harness.service.closeTab('first', sibling.tabId, 21)
await harness.service.closeTab('first', sibling.tabId, 21)
await expect(harness.service.closeTab('first', sibling.tabId, 22)).rejects.toThrow('不属于当前窗口')
await expect(harness.service.closeTab('first', other.tabId, 21)).rejects.toThrow('不属于当前对话')
await expect(harness.service.closeTab('missing', first.tabId, 21)).rejects.toThrow('不属于当前对话')
await harness.service.dispose()
})
it('presents one session in the shared viewport and tracks user navigation', async () => {
const harness = createHarness()
const bounds = { x: 900, y: 120, width: 420, height: 640 }
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
harness.service.setViewport(
'conversation',
bounds,
undefined,
firstLeaseToken
)
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
const slot = harness.slots[0]
expect(slot?.session.setViewport).toHaveBeenCalledWith(bounds)
vi.mocked(slot!.driver.getNavigationMetadata).mockResolvedValueOnce({
url: 'https://example.com/account',
canGoBack: true
})
slot?.emitNavigation('https://example.com/account')
await vi.waitFor(() => {
expect(states.at(-1)).toMatchObject({
status: 'ready',
url: 'https://example.com/account',
canGoBack: true
})
})
expect(harness.service.getOrigin('conversation')).toBe(
'https://example.com'
)
harness.service.setViewport(
undefined,
undefined,
undefined,
firstLeaseToken
)
expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.dispose()
})
it('publishes lightweight browser status 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',
'ready',
'loading',
'ready',
'acting',
'ready',
'stopped'
])
expect(states[0]).toMatchObject({
sessionActive: false,
isLoading: true,
canGoBack: false
})
expect(states.find((state) => state.status === 'ready')).toMatchObject({
sessionActive: true,
isLoading: false
})
expect(states.at(-1)).toMatchObject({
sessionActive: false,
isLoading: false,
canGoBack: false
})
expect(states.every((state) => state.frameDataUrl === undefined)).toBe(true)
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('ordinary actions only refresh navigation metadata and never capture images', async () => {
const nativeCapture = vi.fn(async () => {
throw new Error('native capture unavailable while hidden')
})
const harness = createHarness({ captureScreenshot: nativeCapture })
const signal = new AbortController().signal
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate('conversation', 'https://example.com/', signal)
await harness.service.snapshot('conversation', signal)
await harness.service.click('conversation', 'button_ref', signal)
await harness.service.type('conversation', 'input_ref', 'text', signal)
await harness.service.select('conversation', 'select_ref', 'value', signal)
await harness.service.reload('conversation', signal)
await harness.service.back('conversation', signal)
const slot = harness.slots[0]!
expect(nativeCapture).not.toHaveBeenCalled()
expect(slot.driver.screenshot).not.toHaveBeenCalled()
expect(slot.driver.getNavigationMetadata).toHaveBeenCalledTimes(7)
expect(states.at(-1)?.status).toBe('ready')
expect(states.every((state) => !('frameDataUrl' in state))).toBe(true)
const screenshot = await harness.service.screenshot('conversation', signal)
expect(screenshot.data).toBe('/9j/2Q==')
expect(nativeCapture).toHaveBeenCalledOnce()
expect(slot.driver.screenshot).toHaveBeenCalledOnce()
expect(states.at(-1)?.status).toBe('ready')
expect(states.every((state) => !('frameDataUrl' in state))).toBe(true)
await harness.service.dispose()
})
it('does not publish ready after a session is stopped during metadata refresh', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate('conversation', 'https://example.com/', signal)
const slot = harness.slots[0]!
vi.mocked(slot.driver.getNavigationMetadata).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)
const rejected = expect(click).rejects.toThrow('浏览器会话已释放')
await vi.waitFor(() => expect(slot.driver.getNavigationMetadata).toHaveBeenCalledTimes(2))
await harness.service.releaseConversation('conversation')
await rejected
expect(states.at(-1)?.status).toBe('stopped')
await harness.service.dispose()
})
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('owns independent tabs inside one conversation and closes only the requested tab', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://first.example/', signal)
const [primary] = harness.service.listTabs('conversation', 11)
const sibling = await harness.service.createTab('conversation', 11, signal)
await harness.service.navigate(
'conversation',
'https://second.example/',
signal,
sibling.tabId,
11
)
await harness.service.snapshot(
'conversation',
signal,
sibling.tabId,
11
)
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.service.getTabCount('conversation')).toBe(2)
expect(harness.slots[0]?.driver.snapshot).not.toHaveBeenCalled()
expect(harness.slots[1]?.driver.snapshot).toHaveBeenCalledOnce()
await harness.service.closeTab('conversation', sibling.tabId, 11)
expect(harness.service.getTabCount('conversation')).toBe(1)
expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce()
expect(harness.slots[0]?.session.dispose).not.toHaveBeenCalled()
expect(harness.service.listTabs('conversation', 11)[0]?.tabId).toBe(
primary?.tabId
)
await harness.service.dispose()
})
it('closes a leased tab and ends its leases without closing sibling tabs', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 11, signal)
const sibling = await harness.service.createTab('conversation', 11, signal)
const first = harness.service.acquireTabUsage(
'conversation',
primary.tabId,
'request-one',
11
)
const second = harness.service.acquireTabUsage(
'conversation',
primary.tabId,
'request-two',
11
)
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).resolves.toBeUndefined()
expect(first.signal.aborted).toBe(true)
expect(second.signal.aborted).toBe(true)
expect(harness.service.listTabs('conversation', 11).map((tab) => tab.tabId)).toEqual([sibling.tabId])
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
expect(harness.slots[1]?.session.dispose).not.toHaveBeenCalled()
first.release()
first.release()
second.release()
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).resolves.toBeUndefined()
await harness.service.dispose()
})
it('reports only the visible tab owned by the requesting window', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const sibling = await harness.service.createTab('conversation', 21, signal)
expect(harness.service.getVisibleTabId('conversation', 21)).toBeUndefined()
harness.service.setViewport(
'conversation',
{ x: 0, y: 0, width: 320, height: 480 },
sibling.tabId,
firstLeaseToken,
21
)
expect(harness.service.getVisibleTabId('conversation', 21)).toBe(
sibling.tabId
)
expect(primary.tabId).not.toBe(sibling.tabId)
expect(() => harness.service.getVisibleTabId('conversation', 22)).toThrow(
'不属于当前窗口'
)
await harness.service.dispose()
})
it('aborts active tab leases when a conversation is force-released', async () => {
const harness = createHarness()
const tab = await harness.service.createTab('conversation', 21)
const lease = harness.service.acquireTabUsage(
'conversation',
tab.tabId,
'request',
21
)
await harness.service.releaseConversation('conversation', 21)
expect(lease.signal.aborted).toBe(true)
expect(harness.service.getTabCount('conversation')).toBe(0)
lease.release()
await harness.service.dispose()
})
it('enforces conversation/window ownership, tab limits, and opaque viewport leases', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const sibling = await harness.service.createTab('conversation', 21, signal)
const bounds = { x: 10, y: 20, width: 300, height: 400 }
expect(
harness.service.setViewport(
'conversation',
bounds,
sibling.tabId,
firstLeaseToken,
21
)
).toBe(true)
expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith(
bounds
)
expect(
harness.service.setViewport(
undefined,
undefined,
undefined,
secondLeaseToken,
21
)
).toBe(false)
expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith(
bounds
)
await expect(
harness.service.navigate(
'conversation',
'https://example.com/',
signal,
primary.tabId,
22
)
).rejects.toThrow('不属于当前窗口')
await harness.service.dispose()
const conversationLimited = createHarness({
maximumTabsPerConversation: 2
})
await conversationLimited.service.createTab('limited', 31, signal)
await conversationLimited.service.createTab('limited', 31, signal)
await expect(
conversationLimited.service.createTab('limited', 31, signal)
).rejects.toThrow('2 个上限')
await conversationLimited.service.dispose()
const windowLimited = createHarness({ maximumTabsPerWindow: 2 })
await windowLimited.service.createTab('first', 41, signal)
await windowLimited.service.createTab('second', 41, signal)
await expect(
windowLimited.service.createTab('third', 41, signal)
).rejects.toThrow('2 个上限')
await windowLimited.service.dispose()
})
it('creates a fresh tab instead of adopting a navigated unowned primary, and restores only the same owner', async () => {
const harness = createHarness()
const signal = new AbortController().signal
try {
await harness.service.navigate('conversation', 'https://old.example/', signal, undefined, 21)
const [primary] = harness.service.listTabs('conversation', 21)
const fresh = await harness.service.createTab('conversation', 21, signal, workbarInstanceId)
expect(fresh.tabId).not.toBe(primary!.tabId)
expect(fresh.url).toBeUndefined()
expect(fresh.canGoBack).toBe(false)
expect(harness.slots[1]!.driver.navigate).not.toHaveBeenCalled()
await harness.service.navigate('conversation', 'https://new.example/', signal, fresh.tabId, 21)
const restored = await harness.service.createTab('conversation', 21, signal, workbarInstanceId)
expect(restored.tabId).toBe(fresh.tabId)
expect(restored.url).toBe('https://new.example/')
expect(harness.service.listTabs('conversation', 21)[0]!.url).toBe('https://old.example/')
expect(harness.service.getTabCount('conversation')).toBe(2)
} finally {
await harness.service.dispose()
}
})
it('returns one live tab for concurrent requests from the same workbar instance', async () => {
const gate = deferred<void>()
const harness = createHarness({ sessionGate: gate.promise })
const signal = new AbortController().signal
const first = harness.service.createTab(
'conversation',
21,
signal,
workbarInstanceId
)
const duplicate = harness.service.createTab(
'conversation',
21,
signal,
workbarInstanceId
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
gate.resolve()
const [firstTab, duplicateTab] = await Promise.all([first, duplicate])
expect(duplicateTab.tabId).toBe(firstTab.tabId)
expect(harness.service.getTabCount('conversation')).toBe(1)
expect(harness.service.getOwnerWindowId('conversation')).toBe(21)
await harness.service.dispose()
})
it('disposes a tab creation that resolves after its context is closed', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const gate = deferred<void>()
const implementation = harness.createSession.getMockImplementation()!
harness.createSession.mockImplementationOnce(async (...args) => {
await gate.promise
return implementation(...args)
})
const pending = harness.service.createTab(
'conversation',
21,
signal,
'c22e845b-4bc8-4246-9543-dd18dbf20782'
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledTimes(2))
await harness.service.closeTab('conversation', primary.tabId, 21)
gate.resolve()
await expect(pending).rejects.toThrow()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('reserves window capacity while tab contexts are still being created', async () => {
const gate = deferred<void>()
const harness = createHarness({
maximumTabsPerWindow: 1,
sessionGate: gate.promise
})
const signal = new AbortController().signal
const first = harness.service.createTab('first', 51, signal)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
await expect(
harness.service.createTab('second', 51, signal)
).rejects.toThrow('1 个上限')
expect(harness.createSession).toHaveBeenCalledOnce()
gate.resolve()
await first
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('serializes user navigation behind an active Agent action', 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 navigation = harness.service.navigate(
'conversation',
'https://b.example/',
signal
)
await new Promise((resolve) => setTimeout(resolve, 10))
expect(slot.driver.navigate).toHaveBeenCalledTimes(1)
clickGate.resolve()
await click
await navigation
expect(slot.driver.navigate).toHaveBeenLastCalledWith(
'https://b.example/',
expect.any(AbortSignal)
)
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('does not expire a session while a serialized operation is active', async () => {
vi.useFakeTimers()
const harness = createHarness({ idleTimeoutMs: 100 })
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const gate = deferred<void>()
vi.mocked(harness.slots[0]!.driver.click).mockImplementationOnce(async () => gate.promise)
const click = harness.service.click('conversation', 'button_ref', signal)
await vi.advanceTimersByTimeAsync(500)
expect(harness.service.getSessionCount()).toBe(1)
gate.resolve()
await click
await vi.advanceTimersByTimeAsync(101)
expect(harness.service.getSessionCount()).toBe(0)
await harness.service.dispose()
})
it('ignores stale viewport cleanup tokens', async () => {
const harness = createHarness()
const tab = await harness.service.createTab('conversation', 21)
const bounds = { x: 1, y: 2, width: 300, height: 400 }
harness.service.setViewport('conversation', bounds, tab.tabId, firstLeaseToken, 21)
harness.service.setViewport('conversation', bounds, tab.tabId, secondLeaseToken, 21)
expect(
harness.service.setViewport(undefined, undefined, undefined, firstLeaseToken, 21)
).toBe(false)
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(bounds)
expect(
harness.service.setViewport(undefined, undefined, undefined, secondLeaseToken, 21)
).toBe(true)
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined)
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('reloads in the retained session and publishes actual history metadata', async () => {
const harness = createHarness()
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/first',
signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.driver.getNavigationMetadata).mockResolvedValue({
url: 'https://example.com/committed',
canGoBack: true
})
slot.currentUrl = 'https://example.com/committed'
await expect(
harness.service.reload('conversation', signal)
).resolves.toEqual({
url: 'https://example.com/committed',
origin: 'https://example.com'
})
expect(slot.driver.reload).toHaveBeenCalledOnce()
expect(states.at(-1)).toMatchObject({
status: 'ready',
url: 'https://example.com/committed',
canGoBack: true,
sessionActive: true,
isLoading: false
})
await harness.service.dispose()
})
it('stops only active navigation and retains the reusable session', async () => {
const harness = createHarness()
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/first',
signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.driver.navigate).mockImplementationOnce(
async (_url, operationSignal) =>
new Promise<never>((_resolve, reject) => {
operationSignal.addEventListener(
'abort',
() => reject(operationSignal.reason),
{ once: true }
)
})
)
const navigation = harness.service.navigate(
'conversation',
'https://example.com/slow',
signal
)
await vi.waitFor(() =>
expect(slot.driver.navigate).toHaveBeenCalledTimes(2)
)
await expect(
harness.service.stopLoading('conversation')
).resolves.toBe(true)
await expect(
harness.service.stopLoading('conversation')
).resolves.toBe(false)
await expect(navigation).rejects.toBeInstanceOf(
BrowserNavigationStoppedError
)
expect(slot.session.stopLoading).toHaveBeenCalledOnce()
expect(slot.session.dispose).not.toHaveBeenCalled()
expect(harness.service.getSessionCount()).toBe(1)
expect(states.at(-1)).toMatchObject({
status: 'ready',
url: 'https://example.com/first',
sessionActive: true,
isLoading: false
})
await harness.service.dispose()
})
it('stops click-triggered page loading without releasing the session', async () => {
const harness = createHarness()
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const signal = new AbortController().signal
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.click).mockImplementationOnce(
async (_ref, operationSignal) =>
new Promise<never>((_resolve, reject) => {
slot.emitLoading(true)
operationSignal.addEventListener(
'abort',
() => reject(operationSignal.reason),
{ once: true }
)
})
)
const click = harness.service.click('conversation', 'b_ref', signal)
await vi.waitFor(() =>
expect(states.at(-1)).toMatchObject({
status: 'acting',
isLoading: true
})
)
await expect(
harness.service.stopLoading('conversation')
).resolves.toBe(true)
await expect(click).rejects.toBeInstanceOf(
BrowserNavigationStoppedError
)
expect(slot.session.stopLoading).toHaveBeenCalledOnce()
expect(slot.session.dispose).not.toHaveBeenCalled()
expect(states.at(-1)).toMatchObject({
status: 'ready',
isLoading: false
})
await harness.service.dispose()
})
it('stops a page load that outlives the click operation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
await harness.service.click('conversation', 'b_ref', signal)
slot.emitLoading(true)
await expect(
harness.service.stopLoading('conversation')
).resolves.toBe(true)
await expect(
harness.service.stopLoading('conversation')
).resolves.toBe(false)
expect(slot.session.stopLoading).toHaveBeenCalledOnce()
expect(slot.session.dispose).not.toHaveBeenCalled()
slot.emitLoading(false)
slot.emitLoading(true)
await expect(
harness.service.stopLoading('conversation')
).resolves.toBe(true)
expect(slot.session.stopLoading).toHaveBeenCalledTimes(2)
await harness.service.dispose()
})
it('never interrupts noninterruptible operations even when loading events fire', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
const typeGate = deferred<void>()
vi.mocked(slot.driver.type).mockImplementationOnce(async () => {
slot.emitLoading(true)
await typeGate.promise
})
const typing = harness.service.type(
'conversation',
'input_ref',
'text',
signal
)
await vi.waitFor(() => expect(slot.driver.type).toHaveBeenCalled())
await expect(
harness.service.stopLoading('conversation')
).resolves.toBe(false)
expect(slot.session.stopLoading).not.toHaveBeenCalled()
slot.emitLoading(false)
typeGate.resolve()
await expect(typing).resolves.toBeUndefined()
await harness.service.dispose()
})
it('does not resolve DNS again while refreshing navigation metadata', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
expect(harness.dnsResolver).toHaveBeenCalledTimes(2)
await harness.service.click('conversation', 'button_ref', signal)
expect(harness.dnsResolver).toHaveBeenCalledTimes(2)
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
)
const [tab] = harness.service.listTabs('conversation')
harness.service.setViewport(
'conversation',
{ x: 1, y: 2, width: 300, height: 400 },
tab!.tabId,
firstLeaseToken
)
await expect(
harness.service.releaseConversation('conversation')
).rejects.toThrow('清理超时')
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.service.listTabs('conversation')).toEqual([])
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined)
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()
})
})