feat: add persistent workbar browser tabs and scoped MCP routing
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { browserTabIdSchema } from '../../shared/contracts'
|
||||
import {
|
||||
BrowserModelTools,
|
||||
browserBackInputSchema,
|
||||
@@ -42,12 +43,16 @@ function createService(): BrowserToolService {
|
||||
|
||||
const signal = new AbortController().signal
|
||||
const ref = 'b_abcdefghijklmnop'
|
||||
const browserTabId = browserTabIdSchema.parse(
|
||||
'00000000-0000-4000-8000-000000000101'
|
||||
)
|
||||
|
||||
describe('BrowserModelTools', () => {
|
||||
it('publishes seven strict, bounded builtin tool definitions', () => {
|
||||
const tools = new BrowserModelTools({
|
||||
service: createService(),
|
||||
conversationId: 'conversation'
|
||||
conversationId: 'conversation',
|
||||
browserTabId
|
||||
})
|
||||
const definitions = tools.listTools()
|
||||
expect(definitions.map((definition) => definition.name)).toEqual([
|
||||
@@ -93,7 +98,8 @@ describe('BrowserModelTools', () => {
|
||||
it('creates dynamic origin-scoped navigation approvals without exposing query values', () => {
|
||||
const tools = new BrowserModelTools({
|
||||
service: createService(),
|
||||
conversationId: 'conversation'
|
||||
conversationId: 'conversation',
|
||||
browserTabId
|
||||
})
|
||||
const approval = tools.getApproval('browser_navigate', {
|
||||
url: 'https://example.com/path?token=top-secret'
|
||||
@@ -115,7 +121,8 @@ describe('BrowserModelTools', () => {
|
||||
const service = createService()
|
||||
const tools = new BrowserModelTools({
|
||||
service,
|
||||
conversationId: 'conversation'
|
||||
conversationId: 'conversation',
|
||||
browserTabId
|
||||
})
|
||||
const first = tools.getApproval('browser_type', {
|
||||
ref,
|
||||
@@ -139,7 +146,8 @@ describe('BrowserModelTools', () => {
|
||||
'conversation',
|
||||
ref,
|
||||
'top-secret',
|
||||
signal
|
||||
signal,
|
||||
browserTabId
|
||||
)
|
||||
expect(JSON.stringify(result)).not.toContain('top-secret')
|
||||
expect(result.parts[0]).toMatchObject({
|
||||
@@ -158,7 +166,8 @@ describe('BrowserModelTools', () => {
|
||||
const service = createService()
|
||||
const tools = new BrowserModelTools({
|
||||
service,
|
||||
conversationId: 'conversation'
|
||||
conversationId: 'conversation',
|
||||
browserTabId
|
||||
})
|
||||
const navigate = await tools.callTool(
|
||||
'browser_navigate',
|
||||
@@ -189,12 +198,40 @@ describe('BrowserModelTools', () => {
|
||||
})
|
||||
await tools.release()
|
||||
expect(service.releaseConversation).toHaveBeenCalledWith('conversation')
|
||||
expect(service.navigate).toHaveBeenCalledWith(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
signal,
|
||||
browserTabId
|
||||
)
|
||||
expect(service.snapshot).toHaveBeenCalledWith(
|
||||
'conversation',
|
||||
signal,
|
||||
browserTabId
|
||||
)
|
||||
expect(service.click).toHaveBeenCalledWith(
|
||||
'conversation',
|
||||
ref,
|
||||
signal,
|
||||
browserTabId
|
||||
)
|
||||
expect(service.back).toHaveBeenCalledWith(
|
||||
'conversation',
|
||||
signal,
|
||||
browserTabId
|
||||
)
|
||||
expect(service.screenshot).toHaveBeenCalledWith(
|
||||
'conversation',
|
||||
signal,
|
||||
browserTabId
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => {
|
||||
const tools = new BrowserModelTools({
|
||||
service: createService(),
|
||||
conversationId: 'conversation'
|
||||
conversationId: 'conversation',
|
||||
browserTabId
|
||||
})
|
||||
expect(() =>
|
||||
tools.getApproval('browser_click', { ref, extra: true })
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
|
||||
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
|
||||
} from './browser-limits'
|
||||
import type { BrowserTabId } from '../../shared/contracts'
|
||||
|
||||
const MAX_REF_LENGTH = 64
|
||||
|
||||
@@ -195,6 +196,7 @@ export type BrowserToolService = Pick<
|
||||
export type BrowserModelToolsOptions = {
|
||||
service: BrowserToolService
|
||||
conversationId: string
|
||||
browserTabId: BrowserTabId
|
||||
}
|
||||
|
||||
function createTextResult(value: unknown): ModelToolResult {
|
||||
@@ -218,10 +220,12 @@ function navigationLabel(url: URL): string {
|
||||
export class BrowserModelTools {
|
||||
private readonly service: BrowserToolService
|
||||
private readonly conversationId: string
|
||||
private readonly browserTabId: BrowserTabId
|
||||
|
||||
constructor(options: BrowserModelToolsOptions) {
|
||||
this.service = options.service
|
||||
this.conversationId = options.conversationId
|
||||
this.browserTabId = options.browserTabId
|
||||
if (!this.conversationId || this.conversationId.length > 500) {
|
||||
throw new Error('浏览器对话标识无效')
|
||||
}
|
||||
@@ -249,7 +253,7 @@ export class BrowserModelTools {
|
||||
throw new Error(`未知浏览器工具:${name}`)
|
||||
}
|
||||
const currentOrigin = safeOrigin(
|
||||
this.service.getOrigin(this.conversationId)
|
||||
this.service.getOrigin(this.conversationId, this.browserTabId)
|
||||
)
|
||||
let description: string
|
||||
let argumentSummary: string
|
||||
@@ -320,18 +324,32 @@ export class BrowserModelTools {
|
||||
if (name === 'browser_navigate') {
|
||||
const input = browserNavigateInputSchema.parse(argumentsValue)
|
||||
return createTextResult(
|
||||
await this.service.navigate(this.conversationId, input.url, signal)
|
||||
await this.service.navigate(
|
||||
this.conversationId,
|
||||
input.url,
|
||||
signal,
|
||||
this.browserTabId
|
||||
)
|
||||
)
|
||||
}
|
||||
if (name === 'browser_snapshot') {
|
||||
browserSnapshotInputSchema.parse(argumentsValue)
|
||||
return createTextResult(
|
||||
await this.service.snapshot(this.conversationId, signal)
|
||||
await this.service.snapshot(
|
||||
this.conversationId,
|
||||
signal,
|
||||
this.browserTabId
|
||||
)
|
||||
)
|
||||
}
|
||||
if (name === 'browser_click') {
|
||||
const input = browserClickInputSchema.parse(argumentsValue)
|
||||
await this.service.click(this.conversationId, input.ref, signal)
|
||||
await this.service.click(
|
||||
this.conversationId,
|
||||
input.ref,
|
||||
signal,
|
||||
this.browserTabId
|
||||
)
|
||||
return createTextResult({ clicked: input.ref })
|
||||
}
|
||||
if (name === 'browser_type') {
|
||||
@@ -340,7 +358,8 @@ export class BrowserModelTools {
|
||||
this.conversationId,
|
||||
input.ref,
|
||||
input.text,
|
||||
signal
|
||||
signal,
|
||||
this.browserTabId
|
||||
)
|
||||
return createTextResult({
|
||||
typed: input.ref,
|
||||
@@ -354,20 +373,26 @@ export class BrowserModelTools {
|
||||
this.conversationId,
|
||||
input.ref,
|
||||
input.value,
|
||||
signal
|
||||
signal,
|
||||
this.browserTabId
|
||||
)
|
||||
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
|
||||
}
|
||||
if (name === 'browser_back') {
|
||||
browserBackInputSchema.parse(argumentsValue)
|
||||
return createTextResult(
|
||||
await this.service.back(this.conversationId, signal)
|
||||
await this.service.back(
|
||||
this.conversationId,
|
||||
signal,
|
||||
this.browserTabId
|
||||
)
|
||||
)
|
||||
}
|
||||
browserScreenshotInputSchema.parse(argumentsValue)
|
||||
const screenshot = await this.service.screenshot(
|
||||
this.conversationId,
|
||||
signal
|
||||
signal,
|
||||
this.browserTabId
|
||||
)
|
||||
return {
|
||||
parts: [
|
||||
|
||||
@@ -31,6 +31,8 @@ function deferred<T>() {
|
||||
|
||||
function createHarness(options: {
|
||||
maximumSessions?: number
|
||||
maximumTabsPerConversation?: number
|
||||
maximumTabsPerWindow?: number
|
||||
idleTimeoutMs?: number
|
||||
cleanupTimeoutMs?: number
|
||||
dispose?: () => Promise<void>
|
||||
@@ -145,6 +147,8 @@ function createHarness(options: {
|
||||
const service = new BrowserService({
|
||||
policy: new BrowserUrlPolicy(dnsResolver),
|
||||
maximumSessions: options.maximumSessions,
|
||||
maximumTabsPerConversation: options.maximumTabsPerConversation,
|
||||
maximumTabsPerWindow: options.maximumTabsPerWindow,
|
||||
idleTimeoutMs: options.idleTimeoutMs,
|
||||
cleanupTimeoutMs: options.cleanupTimeoutMs,
|
||||
liveFrameDelayMs: 0,
|
||||
@@ -165,12 +169,21 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('BrowserService', () => {
|
||||
const firstLeaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2'
|
||||
const secondLeaseToken = 'db8a2c28-43a6-4aab-93e0-1c01b9374dca'
|
||||
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
|
||||
|
||||
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)
|
||||
harness.service.setViewport(
|
||||
'conversation',
|
||||
bounds,
|
||||
undefined,
|
||||
firstLeaseToken
|
||||
)
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
@@ -196,7 +209,12 @@ describe('BrowserService', () => {
|
||||
'https://example.com'
|
||||
)
|
||||
|
||||
harness.service.setViewport()
|
||||
harness.service.setViewport(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
firstLeaseToken
|
||||
)
|
||||
expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined)
|
||||
await harness.service.dispose()
|
||||
})
|
||||
@@ -222,6 +240,7 @@ describe('BrowserService', () => {
|
||||
|
||||
expect(states.map((state) => state.status)).toEqual([
|
||||
'creating',
|
||||
'ready',
|
||||
'loading',
|
||||
'ready',
|
||||
'acting',
|
||||
@@ -242,7 +261,7 @@ describe('BrowserService', () => {
|
||||
isLoading: false,
|
||||
canGoBack: false
|
||||
})
|
||||
expect(states.find((state) => state.status === 'ready')?.frameDataUrl).toBe(
|
||||
expect(states.filter((state) => state.status === 'ready').at(-1)?.frameDataUrl).toBe(
|
||||
'data:image/jpeg;base64,/9j/2Q=='
|
||||
)
|
||||
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
|
||||
@@ -435,6 +454,253 @@ describe('BrowserService', () => {
|
||||
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('blocks closing a leased tab until every request releases it', 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)
|
||||
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
|
||||
await expect(
|
||||
harness.service.closeTab('conversation', sibling.tabId, 11)
|
||||
).resolves.toBeUndefined()
|
||||
first.release()
|
||||
first.release()
|
||||
await expect(
|
||||
harness.service.closeTab('conversation', primary.tabId, 11)
|
||||
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
|
||||
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('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
|
||||
@@ -625,6 +891,42 @@ describe('BrowserService', () => {
|
||||
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
|
||||
@@ -905,10 +1207,19 @@ describe('BrowserService', () => {
|
||||
'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()
|
||||
|
||||
+945
-794
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,12 @@ import {
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
@@ -348,6 +350,65 @@ describe('ElectronBrowserSession', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('creates sibling views in the same partition and cleans shared state after the final tab', async () => {
|
||||
const first = createHarness()
|
||||
const second = createHarness()
|
||||
const createPartition = vi.fn(async () => first.partition)
|
||||
const createView = vi
|
||||
.fn<(options: Record<string, unknown>) => Promise<BrowserViewHandle>>()
|
||||
.mockResolvedValueOnce(first.window)
|
||||
.mockResolvedValueOnce(second.window)
|
||||
const root = await ElectronBrowserSession.create({
|
||||
policy: first.policy,
|
||||
createPartition,
|
||||
createView,
|
||||
createProxy: () => first.proxy
|
||||
})
|
||||
|
||||
const sibling = await root.createTab(new AbortController().signal)
|
||||
|
||||
expect(createPartition).toHaveBeenCalledOnce()
|
||||
expect(sibling.partition).toBe(root.partition)
|
||||
expect(
|
||||
(createView.mock.calls[1]?.[0].webPreferences as Record<string, unknown>)
|
||||
.partition
|
||||
).toBe(root.partition)
|
||||
await root.dispose()
|
||||
expect(first.partition.clearData).not.toHaveBeenCalled()
|
||||
expect(first.proxy.dispose).not.toHaveBeenCalled()
|
||||
await sibling.dispose()
|
||||
expect(first.partition.clearData).toHaveBeenCalledOnce()
|
||||
expect(first.partition.closeAllConnections).toHaveBeenCalledOnce()
|
||||
expect(first.proxy.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleans shared state once when sibling creation fails after root disposal', async () => {
|
||||
const first = createHarness()
|
||||
const second = createHarness()
|
||||
const setupGate = deferred<Record<string, never>>()
|
||||
second.sendCommand.mockImplementationOnce(() => setupGate.promise)
|
||||
const createView = vi
|
||||
.fn<(options: Record<string, unknown>) => Promise<BrowserViewHandle>>()
|
||||
.mockResolvedValueOnce(first.window)
|
||||
.mockResolvedValueOnce(second.window)
|
||||
const root = await ElectronBrowserSession.create({
|
||||
policy: first.policy,
|
||||
createPartition: async () => first.partition,
|
||||
createView,
|
||||
createProxy: () => first.proxy
|
||||
})
|
||||
|
||||
const sibling = root.createTab(new AbortController().signal)
|
||||
await vi.waitFor(() => expect(second.sendCommand).toHaveBeenCalled())
|
||||
await root.dispose()
|
||||
setupGate.reject(new Error('debugger failed'))
|
||||
await expect(sibling).rejects.toThrow('无法创建浏览器标签页')
|
||||
|
||||
expect(first.partition.clearData).toHaveBeenCalledOnce()
|
||||
expect(first.partition.closeAllConnections).toHaveBeenCalledOnce()
|
||||
expect(first.proxy.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
|
||||
const harness = createHarness()
|
||||
const session = await ElectronBrowserSession.create({
|
||||
|
||||
@@ -126,6 +126,30 @@ type Listener = {
|
||||
listener: BrowserEventListener
|
||||
}
|
||||
|
||||
type SharedBrowserResources = {
|
||||
partition: string
|
||||
partitionSession: BrowserPartitionSession
|
||||
proxy: FilteringProxyLike
|
||||
createView: (options: Record<string, unknown>) => Promise<BrowserViewHandle>
|
||||
setupTimeoutMs: number
|
||||
cleanupTimeoutMs: number
|
||||
references: number
|
||||
cleanup?: Promise<void>
|
||||
}
|
||||
|
||||
async function releaseSharedResources(
|
||||
resources: SharedBrowserResources
|
||||
): Promise<void> {
|
||||
resources.references -= 1
|
||||
if (resources.references !== 0) return
|
||||
resources.cleanup ??= cleanupIsolatedState(
|
||||
resources.partitionSession,
|
||||
resources.proxy,
|
||||
resources.cleanupTimeoutMs
|
||||
)
|
||||
await resources.cleanup
|
||||
}
|
||||
|
||||
function managedBrowserUserAgent(): string {
|
||||
const platform =
|
||||
process.platform === 'win32'
|
||||
@@ -243,14 +267,12 @@ export class ElectronBrowserSession {
|
||||
|
||||
private constructor(
|
||||
private readonly policy: BrowserUrlPolicy,
|
||||
private readonly partitionSession: BrowserPartitionSession,
|
||||
private readonly view: BrowserViewHandle,
|
||||
private readonly proxy: FilteringProxyLike,
|
||||
partition: string,
|
||||
private readonly cleanupTimeoutMs: number,
|
||||
private readonly parentWindow?: BrowserParentWindowHandle
|
||||
private readonly resources: SharedBrowserResources,
|
||||
private readonly parentWindow?: BrowserParentWindowHandle,
|
||||
private readonly listenForPartitionEvents = true
|
||||
) {
|
||||
this.partition = partition
|
||||
this.partition = resources.partition
|
||||
this.webContents = view.webContents
|
||||
}
|
||||
|
||||
@@ -369,11 +391,16 @@ export class ElectronBrowserSession {
|
||||
)
|
||||
result = new ElectronBrowserSession(
|
||||
options.policy,
|
||||
partitionSession,
|
||||
view,
|
||||
managedProxy,
|
||||
partition,
|
||||
cleanupTimeoutMs,
|
||||
{
|
||||
partition,
|
||||
partitionSession,
|
||||
proxy: managedProxy,
|
||||
createView,
|
||||
setupTimeoutMs,
|
||||
cleanupTimeoutMs,
|
||||
references: 1
|
||||
},
|
||||
options.parentWindow
|
||||
)
|
||||
setupStage = '初始化浏览器协议'
|
||||
@@ -480,14 +507,16 @@ export class ElectronBrowserSession {
|
||||
this.emitNavigation(url)
|
||||
}
|
||||
})
|
||||
this.listen(
|
||||
this.partitionSession,
|
||||
'will-download',
|
||||
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
|
||||
event.preventDefault()
|
||||
item.cancel?.()
|
||||
}
|
||||
)
|
||||
if (this.listenForPartitionEvents) {
|
||||
this.listen(
|
||||
this.resources.partitionSession,
|
||||
'will-download',
|
||||
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
|
||||
event.preventDefault()
|
||||
item.cancel?.()
|
||||
}
|
||||
)
|
||||
}
|
||||
contents.debugger.attach('1.3')
|
||||
await contents.debugger.sendCommand('Page.enable')
|
||||
this.assertOpen()
|
||||
@@ -625,6 +654,83 @@ export class ElectronBrowserSession {
|
||||
this.approvedOrigin = target.origin
|
||||
}
|
||||
|
||||
async createTab(signal: AbortSignal): Promise<ElectronBrowserSession> {
|
||||
this.assertOpen()
|
||||
const resources = this.resources
|
||||
resources.references += 1
|
||||
let view: BrowserViewHandle | undefined
|
||||
let viewAttached = false
|
||||
let tab: ElectronBrowserSession | undefined
|
||||
try {
|
||||
view = await boundedSetup(
|
||||
resources.createView({
|
||||
webPreferences: {
|
||||
partition: resources.partition,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
nodeIntegrationInWorker: false,
|
||||
backgroundThrottling: false,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
plugins: false,
|
||||
devTools: false,
|
||||
safeDialogs: true
|
||||
}
|
||||
}),
|
||||
signal,
|
||||
resources.setupTimeoutMs,
|
||||
(lateView) => {
|
||||
if (!lateView.webContents.isDestroyed()) {
|
||||
lateView.webContents.close?.({ waitForBeforeUnload: false })
|
||||
if (!lateView.webContents.isDestroyed()) {
|
||||
lateView.webContents.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (this.parentWindow?.contentView && view.nativeView) {
|
||||
this.parentWindow.contentView.addChildView(view.nativeView)
|
||||
viewAttached = true
|
||||
}
|
||||
if (!view.webContents.loadURL) {
|
||||
throw new Error('浏览器视图导航不可用')
|
||||
}
|
||||
await boundedSetup(
|
||||
view.webContents.loadURL('about:blank'),
|
||||
signal,
|
||||
resources.setupTimeoutMs
|
||||
)
|
||||
tab = new ElectronBrowserSession(
|
||||
this.policy,
|
||||
view,
|
||||
resources,
|
||||
this.parentWindow
|
||||
)
|
||||
await boundedSetup(tab.initialize(), signal, resources.setupTimeoutMs)
|
||||
return tab
|
||||
} catch (error) {
|
||||
if (tab) {
|
||||
await tab.dispose().catch(() => undefined)
|
||||
} else {
|
||||
if (view) {
|
||||
if (viewAttached && view.nativeView) {
|
||||
this.parentWindow?.contentView?.removeChildView(view.nativeView)
|
||||
}
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
view.webContents.close?.({ waitForBeforeUnload: false })
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
view.webContents.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
await releaseSharedResources(resources).catch(() => undefined)
|
||||
}
|
||||
throw new Error('无法创建浏览器标签页', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
@@ -650,10 +756,6 @@ export class ElectronBrowserSession {
|
||||
this.webContents.destroy()
|
||||
}
|
||||
}
|
||||
await cleanupIsolatedState(
|
||||
this.partitionSession,
|
||||
this.proxy,
|
||||
this.cleanupTimeoutMs
|
||||
)
|
||||
await releaseSharedResources(this.resources)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user