feat: implement browser open functionality and related tests
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
require("electron");
|
require("electron");
|
||||||
require("./main-DpM79zJd.js");
|
require("./main-CFKIo_7y.js");
|
||||||
require("electron-squirrel-startup");
|
require("electron-squirrel-startup");
|
||||||
require("electron-log");
|
require("electron-log");
|
||||||
require("bytenode");
|
require("bytenode");
|
||||||
|
|||||||
97
electron/gateway/browser-shortcut.ts
Normal file
97
electron/gateway/browser-shortcut.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import logManager from '@electron/service/logger';
|
||||||
|
import { extractBrowserOpenIntent, openUrlInBrowser } from '@electron/service/browser-open-service';
|
||||||
|
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
|
||||||
|
import type { RawMessage } from '@runtime/shared/chat-model';
|
||||||
|
import { sessionStore } from './session-store';
|
||||||
|
import type { GatewayEvent } from './types';
|
||||||
|
|
||||||
|
function buildBrowserOpenResponseText(result: { pageUrl: string; title?: string }): string {
|
||||||
|
const suffix = result.title ? `(${result.title})` : '';
|
||||||
|
return `已为你打开 ${result.pageUrl}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBrowserOpenErrorText(error: unknown): string {
|
||||||
|
return `打开失败:${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processBrowserOpen(
|
||||||
|
sessionKey: string,
|
||||||
|
runId: string,
|
||||||
|
url: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
broadcast: (event: GatewayEvent) => void,
|
||||||
|
) {
|
||||||
|
let assistantText = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await openUrlInBrowser(url, { signal });
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assistantText = buildBrowserOpenResponseText(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assistantText = buildBrowserOpenErrorText(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStore.clearActiveRun(sessionKey);
|
||||||
|
|
||||||
|
const finalMessage: RawMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantText,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
sessionStore.appendMessage(sessionKey, finalMessage);
|
||||||
|
|
||||||
|
appendTranscriptLine(sessionKey, {
|
||||||
|
type: 'message',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantText,
|
||||||
|
tool: 'browser.open_url',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcast({
|
||||||
|
type: 'chat:final',
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
message: finalMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeHandleBrowserOpenMessage(
|
||||||
|
sessionKey: string,
|
||||||
|
runId: string,
|
||||||
|
message: RawMessage,
|
||||||
|
broadcast: (event: GatewayEvent) => void,
|
||||||
|
): boolean {
|
||||||
|
const browserIntent = typeof message.content === 'string'
|
||||||
|
? extractBrowserOpenIntent(message.content)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!browserIntent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
sessionStore.setActiveRun(sessionKey, runId, abortController);
|
||||||
|
|
||||||
|
processBrowserOpen(sessionKey, runId, browserIntent.url, abortController.signal, broadcast).catch(
|
||||||
|
(error) => {
|
||||||
|
logManager.error('Unexpected error in processBrowserOpen:', error);
|
||||||
|
sessionStore.clearActiveRun(sessionKey);
|
||||||
|
broadcast({
|
||||||
|
type: 'chat:error',
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { createProvider } from '@electron/providers';
|
import { createProvider } from '@electron/providers';
|
||||||
import type { BaseProvider } from '@electron/providers/BaseProvider';
|
import type { BaseProvider } from '@electron/providers/BaseProvider';
|
||||||
import { providerApiService } from '@electron/service/provider-api-service';
|
import { providerApiService } from '@electron/service/provider-api-service';
|
||||||
@@ -8,6 +8,7 @@ import type { RawMessage } from '@runtime/shared/chat-model';
|
|||||||
import { sessionStore } from '../session-store';
|
import { sessionStore } from '../session-store';
|
||||||
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
|
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
|
||||||
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
|
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
|
||||||
|
import { maybeHandleBrowserOpenMessage } from '../browser-shortcut';
|
||||||
|
|
||||||
export interface GatewayChatMessage {
|
export interface GatewayChatMessage {
|
||||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||||
@@ -131,6 +132,10 @@ export function handleChatSend(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (maybeHandleBrowserOpenMessage(sessionKey, runId, userMessage, broadcast)) {
|
||||||
|
return { runId };
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Resolve provider account
|
// 2. Resolve provider account
|
||||||
const accountId = options?.providerAccountId || providerApiService.getDefault().accountId;
|
const accountId = options?.providerAccountId || providerApiService.getDefault().accountId;
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
|
|||||||
159
electron/service/browser-open-service.ts
Normal file
159
electron/service/browser-open-service.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import logManager from '@electron/service/logger';
|
||||||
|
import { launchLocalChrome } from '@electron/utils/chrome/launchLocalChrome';
|
||||||
|
|
||||||
|
const CDP_ENDPOINT = 'http://127.0.0.1:9222';
|
||||||
|
const NAVIGATION_TIMEOUT_MS = 15_000;
|
||||||
|
const OPEN_URL_PATTERNS = [
|
||||||
|
/^\s*(?:打开|打开网页|打开链接)\s*[::]?\s*(https?:\/\/\S+)\s*$/iu,
|
||||||
|
/^\s*(?:open|visit)\s*[::]?\s*(https?:\/\/\S+)\s*$/iu,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface BrowserOpenIntent {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserOpenResult {
|
||||||
|
url: string;
|
||||||
|
pageUrl: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTrailingPunctuation(value: string): string {
|
||||||
|
return value.trim().replace(/[)\]}>.,!?;:'",。!?;:)】》]+$/u, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHttpUrl(value: string): string {
|
||||||
|
const raw = stripTrailingPunctuation(value);
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlankLikePage(url: string): boolean {
|
||||||
|
const normalized = String(url || '').trim().toLowerCase();
|
||||||
|
if (!normalized) return true;
|
||||||
|
return normalized === 'about:blank'
|
||||||
|
|| normalized.startsWith('about:blank#')
|
||||||
|
|| normalized.startsWith('about:blank?')
|
||||||
|
|| normalized === 'chrome://newtab/'
|
||||||
|
|| normalized.startsWith('chrome://newtab')
|
||||||
|
|| normalized.startsWith('chrome://new-tab-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameTarget(currentUrl: string, targetUrl: string): boolean {
|
||||||
|
if (!currentUrl || !targetUrl) return false;
|
||||||
|
if (currentUrl.startsWith(targetUrl)) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const current = new URL(currentUrl);
|
||||||
|
const target = new URL(targetUrl);
|
||||||
|
return current.origin === target.origin && current.pathname.startsWith(target.pathname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNotAborted(signal?: AbortSignal): void {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error('Browser open aborted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBrowserConnectError(error: unknown): Error {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (
|
||||||
|
message.includes('connect ECONNREFUSED')
|
||||||
|
|| message.includes('retrieving websocket url from http://127.0.0.1:9222')
|
||||||
|
) {
|
||||||
|
return new Error('Chrome 已启动但远程调试端口 9222 不可用,请稍后重试或重启本地 Chrome');
|
||||||
|
}
|
||||||
|
|
||||||
|
return error instanceof Error ? error : new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBrowserOpenIntent(text: string): BrowserOpenIntent | null {
|
||||||
|
for (const pattern of OPEN_URL_PATTERNS) {
|
||||||
|
const match = pattern.exec(text);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
url: normalizeHttpUrl(match[1]),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openUrlInBrowser(
|
||||||
|
url: string,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
): Promise<BrowserOpenResult> {
|
||||||
|
const normalizedUrl = normalizeHttpUrl(url);
|
||||||
|
assertNotAborted(options?.signal);
|
||||||
|
|
||||||
|
await launchLocalChrome();
|
||||||
|
assertNotAborted(options?.signal);
|
||||||
|
|
||||||
|
const browser = await chromium.connectOverCDP(CDP_ENDPOINT).catch((error) => {
|
||||||
|
throw toBrowserConnectError(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = browser.contexts()[0];
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('No browser context available');
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = context.pages().find((candidate) => isSameTarget(candidate.url(), normalizedUrl));
|
||||||
|
if (!page) {
|
||||||
|
page = context.pages().find((candidate) => isBlankLikePage(candidate.url()));
|
||||||
|
}
|
||||||
|
if (!page) {
|
||||||
|
page = await context.newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.bringToFront();
|
||||||
|
assertNotAborted(options?.signal);
|
||||||
|
|
||||||
|
if (!isSameTarget(page.url(), normalizedUrl)) {
|
||||||
|
await page.goto(normalizedUrl, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: NAVIGATION_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageUrl = page.url() || normalizedUrl;
|
||||||
|
const title = (await page.title().catch(() => '')).trim() || undefined;
|
||||||
|
|
||||||
|
logManager.info('Browser open request completed', {
|
||||||
|
requestedUrl: normalizedUrl,
|
||||||
|
pageUrl,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: normalizedUrl,
|
||||||
|
pageUrl,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (typeof browser.disconnect === 'function') {
|
||||||
|
await browser.disconnect();
|
||||||
|
} else {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logManager.warn('Failed to disconnect browser after open request:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,30 +5,56 @@ import { isChromeRunning } from './isChromeRunning';
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
const CHROME_CDP_PORT = 9222;
|
||||||
|
const CHROME_READY_TIMEOUT_MS = 15_000;
|
||||||
|
const CHROME_READY_POLL_MS = 250;
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForChromeReady(timeoutMs = CHROME_READY_TIMEOUT_MS): Promise<boolean> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
if (await isChromeRunning()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await sleep(CHROME_READY_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await isChromeRunning();
|
||||||
|
}
|
||||||
|
|
||||||
// 启动本地浏览器
|
// 启动本地浏览器
|
||||||
export async function launchLocalChrome() {
|
export async function launchLocalChrome() {
|
||||||
const chromePath = getChromePath();
|
const chromePath = getChromePath();
|
||||||
|
if (!chromePath) {
|
||||||
|
throw new Error(`Unsupported platform for Chrome launch: ${process.platform}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 多账号隔离
|
// 多账号隔离
|
||||||
const profileDir = getProfileDir('default');
|
const profileDir = getProfileDir('default');
|
||||||
log.info(`Launching Chrome with user data dir: ${profileDir}`);
|
log.info(`Launching Chrome with user data dir: ${profileDir}`);
|
||||||
|
|
||||||
// 检查端口是否被占用
|
// 检查端口是否被占用
|
||||||
const portInUse = await isPortInUse(9222);
|
const portInUse = await isPortInUse(CHROME_CDP_PORT);
|
||||||
|
const chromeReady = await isChromeRunning();
|
||||||
|
|
||||||
|
if (chromeReady) {
|
||||||
|
log.info('Chrome DevTools endpoint already available, skip launching.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (portInUse) {
|
if (portInUse) {
|
||||||
log.info('Chrome already running on port 9222, skip launching.');
|
throw new Error(`Port ${CHROME_CDP_PORT} is already in use, but Chrome DevTools is unavailable`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await isChromeRunning()) {
|
await new Promise<void>((resolve, reject) => {
|
||||||
log.info('Chrome already running, skip launching.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chromeProcess = spawn(chromePath as string, [
|
const chromeProcess = spawn(chromePath as string, [
|
||||||
'--remote-debugging-port=9222',
|
`--remote-debugging-port=${CHROME_CDP_PORT}`,
|
||||||
'--window-size=1920,1080',
|
'--window-size=1920,1080',
|
||||||
'--window-position=0,0',
|
'--window-position=0,0',
|
||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
@@ -38,14 +64,19 @@ export async function launchLocalChrome() {
|
|||||||
// '--window-maximized',
|
// '--window-maximized',
|
||||||
], {
|
], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore'
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
|
|
||||||
chromeProcess.on('error', reject);
|
chromeProcess.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to launch Chrome at ${chromePath}: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
// 延迟几秒等浏览器起来
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve(0);
|
|
||||||
}, 1000); // 延迟1秒
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chromeProcess.unref();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = await waitForChromeReady();
|
||||||
|
if (!ready) {
|
||||||
|
throw new Error(`Chrome launched but DevTools endpoint http://127.0.0.1:${CHROME_CDP_PORT} was not ready within ${CHROME_READY_TIMEOUT_MS}ms`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
tests/browser-open-service.test.ts
Normal file
113
tests/browser-open-service.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const state = {
|
||||||
|
currentUrl: 'about:blank',
|
||||||
|
};
|
||||||
|
|
||||||
|
const page = {
|
||||||
|
url: vi.fn(() => state.currentUrl),
|
||||||
|
goto: vi.fn(async (url: string) => {
|
||||||
|
state.currentUrl = url;
|
||||||
|
}),
|
||||||
|
bringToFront: vi.fn(async () => {}),
|
||||||
|
title: vi.fn(async () => '百度一下,你就知道'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
pages: vi.fn(() => [page]),
|
||||||
|
newPage: vi.fn(async () => page),
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = {
|
||||||
|
contexts: vi.fn(() => [context]),
|
||||||
|
disconnect: vi.fn(async () => {}),
|
||||||
|
close: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
browser,
|
||||||
|
launchLocalChrome: vi.fn(async () => {}),
|
||||||
|
isChromeRunning: vi.fn(async () => true),
|
||||||
|
connectOverCDP: vi.fn(async () => browser),
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/chrome/launchLocalChrome', () => ({
|
||||||
|
launchLocalChrome: mocks.launchLocalChrome,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/chrome/isChromeRunning', () => ({
|
||||||
|
isChromeRunning: mocks.isChromeRunning,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/service/logger', () => ({
|
||||||
|
default: mocks.logger,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('playwright', () => ({
|
||||||
|
chromium: {
|
||||||
|
connectOverCDP: mocks.connectOverCDP,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('browser open service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.state.currentUrl = 'about:blank';
|
||||||
|
mocks.page.url.mockImplementation(() => mocks.state.currentUrl);
|
||||||
|
mocks.page.goto.mockImplementation(async (url: string) => {
|
||||||
|
mocks.state.currentUrl = url;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts an explicit open-url intent and normalizes the URL', async () => {
|
||||||
|
const { extractBrowserOpenIntent } = await import('../electron/service/browser-open-service');
|
||||||
|
|
||||||
|
expect(extractBrowserOpenIntent('打开 http://www.baidu.com。')).toEqual({
|
||||||
|
url: 'http://www.baidu.com/',
|
||||||
|
});
|
||||||
|
expect(extractBrowserOpenIntent('请帮我分析一下 http://www.baidu.com')).toBeNull();
|
||||||
|
expect(extractBrowserOpenIntent('打开 javascript:alert(1)')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('launches chrome via cdp and opens the requested page', async () => {
|
||||||
|
const { openUrlInBrowser } = await import('../electron/service/browser-open-service');
|
||||||
|
|
||||||
|
const result = await openUrlInBrowser('http://www.baidu.com');
|
||||||
|
|
||||||
|
expect(mocks.launchLocalChrome).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:9222');
|
||||||
|
expect(mocks.page.bringToFront).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.page.goto).toHaveBeenCalledWith('http://www.baidu.com/', expect.objectContaining({
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 15000,
|
||||||
|
}));
|
||||||
|
expect(mocks.browser.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({
|
||||||
|
url: 'http://www.baidu.com/',
|
||||||
|
pageUrl: 'http://www.baidu.com/',
|
||||||
|
title: '百度一下,你就知道',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps cdp connection errors with a clearer message', async () => {
|
||||||
|
mocks.connectOverCDP.mockRejectedValueOnce(new Error('connect ECONNREFUSED 127.0.0.1:9222'));
|
||||||
|
|
||||||
|
const { openUrlInBrowser } = await import('../electron/service/browser-open-service');
|
||||||
|
|
||||||
|
await expect(openUrlInBrowser('http://www.baidu.com')).rejects.toThrow(
|
||||||
|
'Chrome 已启动但远程调试端口 9222 不可用',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
144
tests/browser-shortcut.test.ts
Normal file
144
tests/browser-shortcut.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
appendMessage: vi.fn(),
|
||||||
|
setActiveRun: vi.fn(),
|
||||||
|
clearActiveRun: vi.fn(),
|
||||||
|
extractBrowserOpenIntent: vi.fn(),
|
||||||
|
openUrlInBrowser: vi.fn(),
|
||||||
|
appendTranscriptLine: vi.fn(),
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../electron/gateway/session-store', () => ({
|
||||||
|
sessionStore: {
|
||||||
|
appendMessage: mocks.appendMessage,
|
||||||
|
setActiveRun: mocks.setActiveRun,
|
||||||
|
clearActiveRun: mocks.clearActiveRun,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/service/browser-open-service', () => ({
|
||||||
|
extractBrowserOpenIntent: mocks.extractBrowserOpenIntent,
|
||||||
|
openUrlInBrowser: mocks.openUrlInBrowser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/token-usage-writer', () => ({
|
||||||
|
appendTranscriptLine: mocks.appendTranscriptLine,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/service/logger', () => ({
|
||||||
|
default: mocks.logger,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function flushAsyncTasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('gateway browser shortcut', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.extractBrowserOpenIntent.mockImplementation((text: string) => (
|
||||||
|
text === '打开 http://www.baidu.com'
|
||||||
|
? { url: 'http://www.baidu.com/' }
|
||||||
|
: null
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a browser-open run and emits a final assistant message on success', async () => {
|
||||||
|
mocks.openUrlInBrowser.mockResolvedValue({
|
||||||
|
url: 'http://www.baidu.com/',
|
||||||
|
pageUrl: 'http://www.baidu.com/',
|
||||||
|
title: '百度一下,你就知道',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { maybeHandleBrowserOpenMessage } = await import('../electron/gateway/browser-shortcut');
|
||||||
|
const broadcast = vi.fn();
|
||||||
|
|
||||||
|
const handled = maybeHandleBrowserOpenMessage(
|
||||||
|
'agent:test:main',
|
||||||
|
'run-1',
|
||||||
|
{ role: 'user', content: '打开 http://www.baidu.com' },
|
||||||
|
broadcast,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(mocks.setActiveRun).toHaveBeenCalledWith(
|
||||||
|
'agent:test:main',
|
||||||
|
'run-1',
|
||||||
|
expect.any(AbortController),
|
||||||
|
);
|
||||||
|
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
expect(mocks.openUrlInBrowser).toHaveBeenCalledWith(
|
||||||
|
'http://www.baidu.com/',
|
||||||
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||||
|
);
|
||||||
|
expect(mocks.appendMessage).toHaveBeenCalledWith(
|
||||||
|
'agent:test:main',
|
||||||
|
expect.objectContaining({
|
||||||
|
role: 'assistant',
|
||||||
|
content: '已为你打开 http://www.baidu.com/(百度一下,你就知道)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'chat:final',
|
||||||
|
sessionKey: 'agent:test:main',
|
||||||
|
runId: 'run-1',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a failure assistant message when browser open fails', async () => {
|
||||||
|
mocks.openUrlInBrowser.mockRejectedValue(new Error('No browser context available'));
|
||||||
|
|
||||||
|
const { maybeHandleBrowserOpenMessage } = await import('../electron/gateway/browser-shortcut');
|
||||||
|
const broadcast = vi.fn();
|
||||||
|
|
||||||
|
const handled = maybeHandleBrowserOpenMessage(
|
||||||
|
'agent:test:main',
|
||||||
|
'run-2',
|
||||||
|
{ role: 'user', content: '打开 http://www.baidu.com' },
|
||||||
|
broadcast,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
expect(mocks.appendMessage).toHaveBeenCalledWith(
|
||||||
|
'agent:test:main',
|
||||||
|
expect.objectContaining({
|
||||||
|
role: 'assistant',
|
||||||
|
content: '打开失败:No browser context available',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'chat:final',
|
||||||
|
runId: 'run-2',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the user message is not an explicit browser-open command', async () => {
|
||||||
|
mocks.extractBrowserOpenIntent.mockReturnValue(null);
|
||||||
|
|
||||||
|
const { maybeHandleBrowserOpenMessage } = await import('../electron/gateway/browser-shortcut');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
maybeHandleBrowserOpenMessage(
|
||||||
|
'agent:test:main',
|
||||||
|
'run-3',
|
||||||
|
{ role: 'user', content: '帮我总结一下百度首页' },
|
||||||
|
vi.fn(),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(mocks.openUrlInBrowser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user