需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type { ToolContext } from '@opencode-ai/plugin/tool';
|
|
import { NianCodeAgentBrowserPlugin } from '../../.opencode/plugins/agent-browser-tools';
|
|
|
|
const originalBaseUrl = process.env.NIANCODE_HOST_API_BASE_URL;
|
|
const originalToken = process.env.NIANCODE_HOST_API_TOKEN;
|
|
|
|
function toolContext(directory: string): ToolContext {
|
|
return {
|
|
sessionID: 'session-1',
|
|
messageID: 'message-1',
|
|
agent: 'build',
|
|
directory,
|
|
worktree: directory,
|
|
abort: new AbortController().signal,
|
|
metadata: vi.fn(),
|
|
ask: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function response(payload: Record<string, unknown>, status = 200): Response {
|
|
return new Response(JSON.stringify(payload), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
if (originalBaseUrl === undefined) delete process.env.NIANCODE_HOST_API_BASE_URL;
|
|
else process.env.NIANCODE_HOST_API_BASE_URL = originalBaseUrl;
|
|
if (originalToken === undefined) delete process.env.NIANCODE_HOST_API_TOKEN;
|
|
else process.env.NIANCODE_HOST_API_TOKEN = originalToken;
|
|
});
|
|
|
|
describe('Agent Browser OpenCode plugin', () => {
|
|
it('loads the bundled runtime plugin artifact', async () => {
|
|
const bundled = await import(
|
|
'../../.opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js'
|
|
);
|
|
|
|
expect(bundled.NianCodeAgentBrowserPlugin).toBeTypeOf('function');
|
|
});
|
|
|
|
it('binds lifecycle requests to context.directory without exposing model-selected ids', async () => {
|
|
process.env.NIANCODE_HOST_API_BASE_URL = 'http://127.0.0.1:43210/';
|
|
process.env.NIANCODE_HOST_API_TOKEN = 'host-token';
|
|
const fetchMock = vi.fn(async () => response({
|
|
success: true,
|
|
browser: { state: 'attached', url: 'http://localhost:5173' },
|
|
}));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const plugin = await NianCodeAgentBrowserPlugin();
|
|
|
|
await plugin.tool.browser_context.execute(
|
|
{ action: 'open', url: 'http://localhost:5173' },
|
|
toolContext('D:\\Students\\demo'),
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:43210/api/agent-browser/open');
|
|
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
|
expect(init.headers).toMatchObject({ Authorization: 'Bearer host-token' });
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
project_path: 'D:\\Students\\demo',
|
|
url: 'http://localhost:5173',
|
|
});
|
|
expect(String(init.body)).not.toMatch(/projectId|tabId|webContentsId/);
|
|
});
|
|
|
|
it('returns Full CDP results and event pages without stripping nested data', async () => {
|
|
process.env.NIANCODE_HOST_API_BASE_URL = 'http://127.0.0.1:43210';
|
|
process.env.NIANCODE_HOST_API_TOKEN = 'host-token';
|
|
const fullResult = {
|
|
kind: 'inline',
|
|
value: {
|
|
result: {
|
|
type: 'object',
|
|
value: { headers: { authorization: 'page-owned-value' }, nested: [1, 2, 3] },
|
|
},
|
|
},
|
|
};
|
|
const fullPage = {
|
|
events: [{
|
|
sequence: 41,
|
|
method: 'Network.responseReceived',
|
|
params: { response: { headers: { 'x-debug': 'complete' } } },
|
|
}],
|
|
nextCursor: 41,
|
|
hasMore: false,
|
|
};
|
|
const fetchMock = vi.fn()
|
|
.mockResolvedValueOnce(response({ success: true, result: fullResult }))
|
|
.mockResolvedValueOnce(response({ success: true, page: fullPage }));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const plugin = await NianCodeAgentBrowserPlugin();
|
|
const context = toolContext('D:\\Students\\demo');
|
|
|
|
const sendResult = await plugin.tool.browser_cdp.execute({
|
|
action: 'send',
|
|
method: 'Runtime.evaluate',
|
|
params: { expression: 'window.__debugState' },
|
|
}, context);
|
|
const eventsResult = await plugin.tool.browser_cdp.execute({
|
|
action: 'read_events',
|
|
after: 30,
|
|
methods: ['Network.responseReceived'],
|
|
}, context);
|
|
|
|
expect(sendResult).toMatchObject({ output: JSON.stringify(fullResult, null, 2) });
|
|
expect(eventsResult).toMatchObject({ output: JSON.stringify(fullPage, null, 2) });
|
|
expect(JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body))).toEqual({
|
|
project_path: 'D:\\Students\\demo',
|
|
method: 'Runtime.evaluate',
|
|
params: { expression: 'window.__debugState' },
|
|
});
|
|
expect(JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body))).toEqual({
|
|
project_path: 'D:\\Students\\demo',
|
|
after: 30,
|
|
methods: ['Network.responseReceived'],
|
|
});
|
|
});
|
|
});
|