feat: improve runtime visibility and browser interaction
This commit is contained in:
@@ -127,6 +127,7 @@ describe('BrowserModelTools', () => {
|
||||
})
|
||||
expect(first.scopeKey).not.toBe(second.scopeKey)
|
||||
expect(first.allowPermanent).toBe(false)
|
||||
expect(first.description).toContain('包括密码字段')
|
||||
expect(JSON.stringify(first)).not.toContain('top-secret')
|
||||
|
||||
const result = await tools.callTool(
|
||||
|
||||
@@ -84,7 +84,7 @@ const definitions = [
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 8_192,
|
||||
description: '完整的公开 HTTP(S) URL'
|
||||
description: '当前设备可连接的完整 HTTP 或 HTTPS URL'
|
||||
}
|
||||
},
|
||||
required: ['url'],
|
||||
@@ -267,7 +267,7 @@ export class BrowserModelTools {
|
||||
scopeKey = `model:browser:click:${currentOrigin}:${input.ref}`
|
||||
} else if (name === 'browser_type') {
|
||||
const input = browserTypeInputSchema.parse(argumentsValue)
|
||||
description = `向 ${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本。密码、文件和隐藏字段会被拒绝。`
|
||||
description = `向 ${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本,包括密码字段;文件、隐藏、禁用和只读字段不支持输入。`
|
||||
argumentSummary = `元素:${input.ref};内容:[已隐藏,${input.text.length} 个字符]`
|
||||
// A session approval must never authorize a later value, even for the
|
||||
// same element. The nonce intentionally makes this invocation-only.
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type BrowserSessionLike
|
||||
} from './browser-service'
|
||||
import type { BrowserWebContents } from './electron-browser-session'
|
||||
import type { BrowserLiveState } from '../../shared/contracts'
|
||||
|
||||
type HarnessSlot = {
|
||||
currentOrigin?: string
|
||||
@@ -30,19 +31,33 @@ function createHarness(options: {
|
||||
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 createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
|
||||
await options.sessionGate
|
||||
const webContents = {} as BrowserWebContents
|
||||
const slot = {} as HarnessSlot
|
||||
const webContents = {
|
||||
getURL: () => `${slot.currentOrigin}/page`
|
||||
} as BrowserWebContents
|
||||
const session: BrowserSessionLike = {
|
||||
webContents,
|
||||
approveNavigation: vi.fn((target) => {
|
||||
slot.approvedOrigin = target.origin
|
||||
}),
|
||||
getCurrentOrigin: vi.fn(() => slot.currentOrigin),
|
||||
openInteraction: vi.fn(async () => undefined),
|
||||
...(options.captureScreenshot
|
||||
? { captureScreenshot: vi.fn(options.captureScreenshot) }
|
||||
: {}),
|
||||
dispose: vi.fn(options.dispose ?? (async () => undefined))
|
||||
}
|
||||
const driver: BrowserDriverLike = {
|
||||
@@ -67,11 +82,14 @@ function createHarness(options: {
|
||||
slot.currentOrigin = canonicalizeBrowserUrl(target.url).origin
|
||||
return { url: target.url }
|
||||
}),
|
||||
screenshot: vi.fn(async () => ({
|
||||
type: 'image' as const,
|
||||
mimeType: 'image/jpeg' as const,
|
||||
data: '/9j/2Q=='
|
||||
})),
|
||||
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 })
|
||||
@@ -148,8 +166,8 @@ describe('BrowserService', () => {
|
||||
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))
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
@@ -181,7 +199,128 @@ describe('BrowserService', () => {
|
||||
await harness.service.releaseConversation('conversation')
|
||||
|
||||
await expect(click).rejects.toThrow('浏览器会话已释放')
|
||||
expect(states.at(-1)).toBe('stopped')
|
||||
expect(states.at(-1)?.status).toBe('stopped')
|
||||
})
|
||||
|
||||
it('falls back to CDP when native capture cannot produce the live frame', async () => {
|
||||
const nativeCapture = vi.fn(async () => {
|
||||
throw new Error('native capture unavailable while hidden')
|
||||
})
|
||||
const harness = createHarness({
|
||||
captureScreenshot: nativeCapture
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(nativeCapture).toHaveBeenCalledOnce()
|
||||
expect(harness.slots[0]?.driver.screenshot).toHaveBeenCalledOnce()
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('retries live capture while a newly committed page starts painting', async () => {
|
||||
let attempts = 0
|
||||
const harness = createHarness({
|
||||
captureScreenshot: async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) {
|
||||
throw new Error('page has not painted yet')
|
||||
}
|
||||
return {
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
}
|
||||
},
|
||||
driverScreenshot: async () => {
|
||||
throw new Error('CDP frame not ready')
|
||||
}
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(attempts).toBe(2)
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('reports a live-frame failure instead of waiting indefinitely', async () => {
|
||||
const harness = createHarness({
|
||||
captureScreenshot: async () => {
|
||||
throw new Error('native capture failed')
|
||||
},
|
||||
driverScreenshot: async () => {
|
||||
throw new Error('CDP capture failed')
|
||||
}
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'failed',
|
||||
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('keeps the last frame when a later refresh cannot capture a minimized window', async () => {
|
||||
let nativeAttempts = 0
|
||||
const harness = createHarness({
|
||||
captureScreenshot: async () => {
|
||||
nativeAttempts += 1
|
||||
if (nativeAttempts === 1) {
|
||||
return {
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
}
|
||||
}
|
||||
throw new Error('minimized native capture unavailable')
|
||||
},
|
||||
driverScreenshot: async () => {
|
||||
throw new Error('minimized CDP capture unavailable')
|
||||
}
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
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)
|
||||
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('isolates browser state and drivers by conversation', async () => {
|
||||
@@ -244,6 +383,58 @@ describe('BrowserService', () => {
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('pauses agent operations while the user interacts with the same session', 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://a.example/',
|
||||
signal
|
||||
)
|
||||
const interactionGate = deferred<
|
||||
Awaited<ReturnType<BrowserSessionLike['openInteraction']>>
|
||||
>()
|
||||
const slot = harness.slots[0]
|
||||
if (!slot) {
|
||||
throw new Error('slot missing')
|
||||
}
|
||||
vi.mocked(slot.session.openInteraction).mockReturnValueOnce(
|
||||
interactionGate.promise
|
||||
)
|
||||
|
||||
const interaction = harness.service.interact(
|
||||
'conversation',
|
||||
signal
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(slot.session.openInteraction).toHaveBeenCalledOnce()
|
||||
)
|
||||
const snapshot = harness.service.snapshot('conversation', signal)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(slot.driver.snapshot).not.toHaveBeenCalled()
|
||||
|
||||
interactionGate.resolve({
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'closing-frame'
|
||||
})
|
||||
await interaction
|
||||
expect(states.slice(-2).map((state) => state.status)).toEqual([
|
||||
'interactive',
|
||||
'ready'
|
||||
])
|
||||
expect(states.at(-1)?.frameDataUrl).toBe(
|
||||
'data:image/jpeg;base64,closing-frame'
|
||||
)
|
||||
expect(harness.service.getSessionCount()).toBe(1)
|
||||
expect(slot.session.dispose).not.toHaveBeenCalled()
|
||||
await snapshot
|
||||
expect(slot.driver.snapshot).toHaveBeenCalledOnce()
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { BrowserScreenshot } from './browser-screenshot'
|
||||
import {
|
||||
ElectronBrowserSession,
|
||||
type BrowserParentWindowHandle,
|
||||
type BrowserWebContents
|
||||
} from './electron-browser-session'
|
||||
import type { BrowserLiveState } from '../../shared/contracts'
|
||||
@@ -20,6 +21,7 @@ export type BrowserSessionLike = {
|
||||
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
|
||||
): void
|
||||
getCurrentOrigin(): string | undefined
|
||||
openInteraction(): Promise<BrowserScreenshot | undefined>
|
||||
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
@@ -45,6 +47,7 @@ export type BrowserServiceOptions = {
|
||||
idleTimeoutMs?: number
|
||||
cleanupTimeoutMs?: number
|
||||
liveFrameDelayMs?: number
|
||||
parentWindow?: BrowserParentWindowHandle
|
||||
createSession?: (
|
||||
policy: BrowserUrlPolicy,
|
||||
signal: AbortSignal
|
||||
@@ -112,9 +115,16 @@ async function boundedCleanup(
|
||||
|
||||
async function defaultCreateSession(
|
||||
policy: BrowserUrlPolicy,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
parentWindow?: BrowserParentWindowHandle
|
||||
): Promise<BrowserSessionLike> {
|
||||
return ElectronBrowserSession.create({ policy }, signal)
|
||||
return ElectronBrowserSession.create(
|
||||
{
|
||||
policy,
|
||||
...(parentWindow ? { parentWindow } : {})
|
||||
},
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
|
||||
@@ -152,7 +162,10 @@ export class BrowserService {
|
||||
this.cleanupTimeoutMs =
|
||||
options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
|
||||
this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100
|
||||
this.createSession = options.createSession ?? defaultCreateSession
|
||||
this.createSession =
|
||||
options.createSession ??
|
||||
((policy, signal) =>
|
||||
defaultCreateSession(policy, signal, options.parentWindow))
|
||||
this.createDriver = options.createDriver ?? defaultCreateDriver
|
||||
if (
|
||||
!Number.isSafeInteger(this.maximumSessions) ||
|
||||
@@ -198,6 +211,9 @@ export class BrowserService {
|
||||
conversationId,
|
||||
status,
|
||||
...(previous?.url ? { url: previous.url } : {}),
|
||||
...(status !== 'stopped' && previous?.frameDataUrl
|
||||
? { frameDataUrl: previous.frameDataUrl }
|
||||
: {}),
|
||||
...update,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
@@ -235,40 +251,73 @@ export class BrowserService {
|
||||
signal
|
||||
)
|
||||
}
|
||||
const previewController = new AbortController()
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
previewController.abort(
|
||||
new Error('浏览器实时画面捕获超时')
|
||||
),
|
||||
2_000
|
||||
)
|
||||
try {
|
||||
const previewSignal = AbortSignal.any([
|
||||
signal,
|
||||
previewController.signal
|
||||
])
|
||||
frame = slot.session.captureScreenshot
|
||||
? await slot.session.captureScreenshot(previewSignal)
|
||||
: await slot.driver.screenshot(previewSignal)
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
// Browser control succeeds even when the optional live frame fails.
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
const captureDeadline = AbortSignal.any([
|
||||
signal,
|
||||
AbortSignal.timeout(6_000)
|
||||
])
|
||||
for (let attempt = 0; attempt < 3 && !frame; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
try {
|
||||
await waitFor(
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, attempt * 150)
|
||||
),
|
||||
captureDeadline
|
||||
)
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
break
|
||||
}
|
||||
}
|
||||
if (slot.session.captureScreenshot) {
|
||||
try {
|
||||
frame = await slot.session.captureScreenshot(
|
||||
AbortSignal.any([
|
||||
captureDeadline,
|
||||
AbortSignal.timeout(1_500)
|
||||
])
|
||||
)
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
if (!frame && !captureDeadline.aborted) {
|
||||
try {
|
||||
frame = await slot.driver.screenshot(
|
||||
AbortSignal.any([
|
||||
captureDeadline,
|
||||
AbortSignal.timeout(1_500)
|
||||
])
|
||||
)
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (slot.released || this.slots.get(conversationId) !== slot) {
|
||||
return
|
||||
}
|
||||
if (!frame) {
|
||||
const previousFrame =
|
||||
this.liveStates.get(conversationId)?.frameDataUrl
|
||||
if (previousFrame) {
|
||||
this.emitState(conversationId, 'ready', {
|
||||
...(url ? { url } : {}),
|
||||
frameDataUrl: previousFrame
|
||||
})
|
||||
return
|
||||
}
|
||||
this.emitState(conversationId, 'failed', {
|
||||
...(url ? { url } : {}),
|
||||
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
|
||||
})
|
||||
return
|
||||
}
|
||||
this.emitState(conversationId, 'ready', {
|
||||
...(url ? { url } : {}),
|
||||
...(frame
|
||||
? {
|
||||
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
|
||||
}
|
||||
: {})
|
||||
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -429,7 +478,7 @@ export class BrowserService {
|
||||
slot: BrowserSlot,
|
||||
signal: AbortSignal,
|
||||
operation: (effectiveSignal: AbortSignal) => Promise<T>,
|
||||
status?: 'loading' | 'acting'
|
||||
status?: 'loading' | 'acting' | 'interactive'
|
||||
): Promise<T> {
|
||||
signal.throwIfAborted()
|
||||
if (slot.released || this.disposed) {
|
||||
@@ -496,7 +545,7 @@ export class BrowserService {
|
||||
private async runInSession<T>(
|
||||
conversationId: string,
|
||||
signal: AbortSignal,
|
||||
status: 'loading' | 'acting',
|
||||
status: 'loading' | 'acting' | 'interactive',
|
||||
failureStage: string,
|
||||
operation: (
|
||||
slot: BrowserSlot,
|
||||
@@ -715,10 +764,17 @@ export class BrowserService {
|
||||
'浏览器截图',
|
||||
async (slot, effectiveSignal) => {
|
||||
await this.verifyCurrentOriginOrRelease(slot)
|
||||
const screenshot =
|
||||
slot.session.captureScreenshot
|
||||
? await slot.session.captureScreenshot(effectiveSignal)
|
||||
: await slot.driver.screenshot(effectiveSignal)
|
||||
let screenshot: BrowserScreenshot | undefined
|
||||
if (slot.session.captureScreenshot) {
|
||||
try {
|
||||
screenshot = await slot.session.captureScreenshot(
|
||||
effectiveSignal
|
||||
)
|
||||
} catch {
|
||||
effectiveSignal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
screenshot ??= await slot.driver.screenshot(effectiveSignal)
|
||||
await this.captureFrame(
|
||||
conversationId,
|
||||
slot,
|
||||
@@ -731,6 +787,36 @@ export class BrowserService {
|
||||
)
|
||||
}
|
||||
|
||||
async interact(
|
||||
conversationId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
await this.runInSession(
|
||||
conversationId,
|
||||
signal,
|
||||
'interactive',
|
||||
'浏览器交互',
|
||||
async (slot, effectiveSignal) => {
|
||||
await this.verifyCurrentOriginOrRelease(slot)
|
||||
const closingFrame = await waitFor(
|
||||
slot.session.openInteraction(),
|
||||
effectiveSignal
|
||||
)
|
||||
const currentUrl = canonicalizeBrowserUrl(
|
||||
slot.session.webContents.getURL()
|
||||
)
|
||||
slot.origin = currentUrl.origin
|
||||
await this.captureFrame(
|
||||
conversationId,
|
||||
slot,
|
||||
effectiveSignal,
|
||||
currentUrl.href,
|
||||
closingFrame
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
this.releaseRequests.add(conversationId)
|
||||
let releasedSlot = false
|
||||
|
||||
@@ -536,7 +536,7 @@ describe('CdpBrowserDriver', () => {
|
||||
driver.dispose()
|
||||
})
|
||||
|
||||
it('rejects password, file, hidden, and stale typing targets', async () => {
|
||||
it('allows password typing while keeping the password value redacted', async () => {
|
||||
const harness = createHarness(standardCommand)
|
||||
const driver = new CdpBrowserDriver(harness.webContents)
|
||||
const snapshot = await driver.snapshot(new AbortController().signal)
|
||||
@@ -545,15 +545,16 @@ describe('CdpBrowserDriver', () => {
|
||||
throw new Error('password missing')
|
||||
}
|
||||
await expect(
|
||||
driver.type(password.ref, 'never-send', new AbortController().signal)
|
||||
).rejects.toThrow('受保护')
|
||||
driver.type(password.ref, 'login-secret', new AbortController().signal)
|
||||
).resolves.toBeUndefined()
|
||||
expect(
|
||||
harness.sendCommand.mock.calls.some(
|
||||
([method, parameters]) =>
|
||||
method === 'Input.insertText' &&
|
||||
parameters?.text === 'never-send'
|
||||
parameters?.text === 'login-secret'
|
||||
)
|
||||
).toBe(false)
|
||||
).toBe(true)
|
||||
expect(JSON.stringify(snapshot)).not.toContain('secret')
|
||||
driver.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ type RefBinding = {
|
||||
backendNodeId: number
|
||||
generation: number
|
||||
role: string
|
||||
protected: boolean
|
||||
}
|
||||
|
||||
export type CdpBrowserDriverOptions = {
|
||||
@@ -591,8 +590,7 @@ export class CdpBrowserDriver {
|
||||
this.refs.set(ref, {
|
||||
backendNodeId: node.backendDOMNodeId,
|
||||
generation: this.generation,
|
||||
role,
|
||||
protected: protectedNode
|
||||
role
|
||||
})
|
||||
output.push(item)
|
||||
}
|
||||
@@ -648,7 +646,6 @@ export class CdpBrowserDriver {
|
||||
typeof node.nodeName === 'string' ? node.nodeName.toLowerCase() : ''
|
||||
const inputType = (attributeMap.get('type') ?? '').toLowerCase()
|
||||
const blocked =
|
||||
binding.protected ||
|
||||
attributeMap.has('hidden') ||
|
||||
attributeMap.has('disabled') ||
|
||||
attributeMap.has('inert') ||
|
||||
@@ -656,10 +653,9 @@ export class CdpBrowserDriver {
|
||||
attributeMap.get('aria-hidden') === 'true' ||
|
||||
attributeMap.get('aria-disabled') === 'true' ||
|
||||
inputType === 'hidden' ||
|
||||
inputType === 'password' ||
|
||||
inputType === 'file'
|
||||
if (blocked) {
|
||||
throw new Error('浏览器拒绝操作受保护、隐藏或禁用字段')
|
||||
throw new Error('浏览器拒绝操作隐藏、禁用、只读或文件字段')
|
||||
}
|
||||
if (
|
||||
action === 'type' &&
|
||||
|
||||
@@ -21,6 +21,7 @@ function createHarness() {
|
||||
const debuggerEvents = new EventEmitter()
|
||||
const contentEvents = new EventEmitter()
|
||||
const partitionEvents = new EventEmitter()
|
||||
const windowEvents = new EventEmitter()
|
||||
let currentUrl = ''
|
||||
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
|
||||
const sendCommand = vi.fn(async () => ({}))
|
||||
@@ -71,6 +72,21 @@ function createHarness() {
|
||||
loadURL: vi.fn(async (url: string) => {
|
||||
currentUrl = url
|
||||
}),
|
||||
show: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
isMinimized: vi.fn(() => false),
|
||||
focus: vi.fn(),
|
||||
on: (event, listener) =>
|
||||
windowEvents.on(
|
||||
event,
|
||||
listener as (...argumentsValue: unknown[]) => void
|
||||
),
|
||||
off: (event, listener) =>
|
||||
windowEvents.off(
|
||||
event,
|
||||
listener as (...argumentsValue: unknown[]) => void
|
||||
),
|
||||
destroy: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
@@ -125,6 +141,7 @@ function createHarness() {
|
||||
contentEvents,
|
||||
debuggerEvents,
|
||||
partitionEvents,
|
||||
windowEvents,
|
||||
partition,
|
||||
proxy,
|
||||
policy,
|
||||
@@ -276,6 +293,74 @@ describe('ElectronBrowserSession', () => {
|
||||
await session.dispose()
|
||||
})
|
||||
|
||||
it('restores the browser for interaction and minimizes it on close', async () => {
|
||||
const harness = createHarness()
|
||||
const parentWindow = {
|
||||
setEnabled: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
let createdWindowOptions: Record<string, unknown> | undefined
|
||||
const createWindow = vi.fn(
|
||||
async (options: Record<string, unknown>) => {
|
||||
createdWindowOptions = options
|
||||
return harness.window
|
||||
}
|
||||
)
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
parentWindow,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
expect(createWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: parentWindow,
|
||||
show: false,
|
||||
title: 'GoodBuddy 浏览器交互'
|
||||
})
|
||||
)
|
||||
expect(createdWindowOptions?.modal).toBeUndefined()
|
||||
const interaction = session.openInteraction()
|
||||
expect(parentWindow.setEnabled).toHaveBeenCalledWith(false)
|
||||
expect(harness.window.show).toHaveBeenCalledOnce()
|
||||
expect(harness.window.focus).toHaveBeenCalledOnce()
|
||||
const closeEvent = { preventDefault: vi.fn() }
|
||||
harness.windowEvents.emit('close', closeEvent)
|
||||
|
||||
await expect(interaction).resolves.toEqual({
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
})
|
||||
expect(closeEvent.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(harness.webContents.capturePage).toHaveBeenCalledOnce()
|
||||
expect(harness.window.minimize).toHaveBeenCalledOnce()
|
||||
expect(parentWindow.setEnabled).toHaveBeenLastCalledWith(true)
|
||||
expect(parentWindow.focus).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
vi.mocked(harness.webContents.capturePage!).mock
|
||||
.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
vi.mocked(harness.window.minimize).mock.invocationCallOrder[0] ??
|
||||
Number.POSITIVE_INFINITY
|
||||
)
|
||||
const repeatedCloseEvent = { preventDefault: vi.fn() }
|
||||
harness.windowEvents.emit('close', repeatedCloseEvent)
|
||||
expect(repeatedCloseEvent.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(harness.window.minimize).toHaveBeenCalledTimes(2)
|
||||
expect(harness.window.destroy).not.toHaveBeenCalled()
|
||||
vi.mocked(harness.window.isMinimized).mockReturnValue(true)
|
||||
const reopenedInteraction = session.openInteraction()
|
||||
expect(harness.window.restore).toHaveBeenCalledOnce()
|
||||
harness.windowEvents.emit('close', { preventDefault: vi.fn() })
|
||||
await reopenedInteraction
|
||||
await session.dispose()
|
||||
expect(harness.window.destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
|
||||
const harness = createHarness()
|
||||
const session = await ElectronBrowserSession.create({
|
||||
|
||||
@@ -49,10 +49,23 @@ export type BrowserWebContents = {
|
||||
export type BrowserWindowHandle = {
|
||||
webContents: BrowserWebContents
|
||||
loadURL(url: string): Promise<unknown>
|
||||
show(): void
|
||||
minimize(): void
|
||||
restore(): void
|
||||
isMinimized(): boolean
|
||||
focus(): void
|
||||
on(event: string, listener: BrowserEventListener): unknown
|
||||
off(event: string, listener: BrowserEventListener): unknown
|
||||
destroy(): void
|
||||
isDestroyed(): boolean
|
||||
}
|
||||
|
||||
export type BrowserParentWindowHandle = {
|
||||
setEnabled?(enabled: boolean): void
|
||||
focus?(): void
|
||||
isDestroyed?(): boolean
|
||||
}
|
||||
|
||||
export type BrowserPartitionSession = {
|
||||
setPermissionCheckHandler(
|
||||
handler: (...argumentsValue: never[]) => boolean
|
||||
@@ -100,6 +113,7 @@ export type ElectronBrowserSessionOptions = {
|
||||
options: Record<string, unknown>
|
||||
) => Promise<BrowserWindowHandle>
|
||||
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
|
||||
parentWindow?: BrowserParentWindowHandle
|
||||
}
|
||||
|
||||
type Listener = {
|
||||
@@ -211,6 +225,11 @@ export class ElectronBrowserSession {
|
||||
readonly webContents: BrowserWebContents
|
||||
private approvedOrigin?: string
|
||||
private readonly listeners: Listener[] = []
|
||||
private interaction?: {
|
||||
promise: Promise<BrowserScreenshot | undefined>
|
||||
resolve(frame?: BrowserScreenshot): void
|
||||
}
|
||||
private interactionClosing?: Promise<void>
|
||||
private disposed = false
|
||||
|
||||
private constructor(
|
||||
@@ -219,7 +238,8 @@ export class ElectronBrowserSession {
|
||||
private readonly window: BrowserWindowHandle,
|
||||
private readonly proxy: FilteringProxyLike,
|
||||
partition: string,
|
||||
private readonly cleanupTimeoutMs: number
|
||||
private readonly cleanupTimeoutMs: number,
|
||||
private readonly parentWindow?: BrowserParentWindowHandle
|
||||
) {
|
||||
this.partition = partition
|
||||
this.webContents = window.webContents
|
||||
@@ -301,6 +321,13 @@ export class ElectronBrowserSession {
|
||||
show: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
title: 'GoodBuddy 浏览器交互',
|
||||
autoHideMenuBar: true,
|
||||
...(options.parentWindow
|
||||
? {
|
||||
parent: options.parentWindow
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
partition,
|
||||
sandbox: true,
|
||||
@@ -308,6 +335,7 @@ export class ElectronBrowserSession {
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
nodeIntegrationInWorker: false,
|
||||
backgroundThrottling: false,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
plugins: false,
|
||||
@@ -337,7 +365,8 @@ export class ElectronBrowserSession {
|
||||
window,
|
||||
managedProxy,
|
||||
partition,
|
||||
cleanupTimeoutMs
|
||||
cleanupTimeoutMs,
|
||||
options.parentWindow
|
||||
)
|
||||
setupStage = '初始化浏览器协议'
|
||||
await boundedSetup(result.initialize(), signal, setupTimeoutMs)
|
||||
@@ -383,6 +412,21 @@ export class ElectronBrowserSession {
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
const contents = this.webContents
|
||||
this.listen(
|
||||
this.window,
|
||||
'close',
|
||||
(event: { preventDefault(): void }) => {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (this.interaction) {
|
||||
void this.captureAndFinishInteraction()
|
||||
} else {
|
||||
this.window.minimize()
|
||||
}
|
||||
}
|
||||
)
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
|
||||
const url = typeof details === 'string' ? details : details.url
|
||||
@@ -513,12 +557,98 @@ export class ElectronBrowserSession {
|
||||
this.approvedOrigin = target.origin
|
||||
}
|
||||
|
||||
openInteraction(): Promise<BrowserScreenshot | undefined> {
|
||||
this.assertOpen()
|
||||
if (this.interaction) {
|
||||
this.setParentEnabled(false)
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore()
|
||||
}
|
||||
this.window.show()
|
||||
this.window.focus()
|
||||
return this.interaction.promise
|
||||
}
|
||||
let resolve!: (frame?: BrowserScreenshot) => void
|
||||
const promise = new Promise<BrowserScreenshot | undefined>(
|
||||
(resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
}
|
||||
)
|
||||
this.interaction = { promise, resolve }
|
||||
this.setParentEnabled(false)
|
||||
try {
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore()
|
||||
}
|
||||
this.window.show()
|
||||
this.window.focus()
|
||||
} catch (error) {
|
||||
this.finishInteraction()
|
||||
throw error
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
private captureAndFinishInteraction(): Promise<void> {
|
||||
if (this.interactionClosing) {
|
||||
return this.interactionClosing
|
||||
}
|
||||
const operation = (async (): Promise<void> => {
|
||||
let frame: BrowserScreenshot | undefined
|
||||
try {
|
||||
frame = await this.captureScreenshot(AbortSignal.timeout(2_000))
|
||||
} catch {
|
||||
// The session remains usable even if the final visible frame fails.
|
||||
}
|
||||
try {
|
||||
if (!this.disposed && !this.window.isDestroyed()) {
|
||||
this.window.minimize()
|
||||
}
|
||||
} catch {
|
||||
// Resolving interaction must not depend on native minimize success.
|
||||
}
|
||||
this.finishInteraction(frame)
|
||||
})()
|
||||
this.interactionClosing = operation
|
||||
void operation.finally(() => {
|
||||
if (this.interactionClosing === operation) {
|
||||
this.interactionClosing = undefined
|
||||
}
|
||||
})
|
||||
return operation
|
||||
}
|
||||
|
||||
private finishInteraction(frame?: BrowserScreenshot): void {
|
||||
const interaction = this.interaction
|
||||
this.interaction = undefined
|
||||
this.setParentEnabled(true)
|
||||
interaction?.resolve(frame)
|
||||
}
|
||||
|
||||
private setParentEnabled(enabled: boolean): void {
|
||||
try {
|
||||
if (
|
||||
!this.parentWindow ||
|
||||
this.parentWindow.isDestroyed?.() === true
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.parentWindow.setEnabled?.(enabled)
|
||||
if (enabled) {
|
||||
this.parentWindow.focus?.()
|
||||
}
|
||||
} catch {
|
||||
// Parent-window state must not break browser-session cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
this.approvedOrigin = undefined
|
||||
this.finishInteraction()
|
||||
for (const { target, event, listener } of this.listeners.splice(0)) {
|
||||
target.off(event, listener)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user