mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 14:13:21 +00:00
fix(browser): 加固 browser_click 激活语义并修复相关验证阻塞
- 将 browser_click 调整为 mousedown/mouseup 后走 native click - 在取消激活和中途 detached 场景下避免强制触发 native activation - 补齐 browser_click 的回归测试与取消激活场景覆盖 - 修复 cliproxy 本地代理、Droid 环境隔离、图像分析 hook 与 proxy 集成测试的验证阻塞 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb8149e8fa
commit
74f028168d
7 files changed
+400
-15
No files matched your search
@@ -53,6 +53,11 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '', // clear any inherited skip flag
|
||||
CCS_IMAGE_ANALYSIS_SKIP_HOOK: '', // clear any inherited MCP-ready bypass
|
||||
CCS_IMAGE_ANALYSIS_MODEL: '', // let each test control explicit model fallback behavior
|
||||
CCS_IMAGE_ANALYSIS_BACKEND_ID: '',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '',
|
||||
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
||||
CCS_CLIPROXY_PORT: String(mockPort),
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
|
||||
@@ -774,6 +774,11 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
});
|
||||
|
||||
it('handles upstream connection errors gracefully', async () => {
|
||||
const originalNoProxy = process.env.NO_PROXY;
|
||||
const originalNoProxyLower = process.env.no_proxy;
|
||||
process.env.NO_PROXY = '127.0.0.1,localhost';
|
||||
process.env.no_proxy = '127.0.0.1,localhost';
|
||||
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: 'http://127.0.0.1:1', // Invalid port
|
||||
timeoutMs: 1000,
|
||||
@@ -790,6 +795,16 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
expect(response.status).toBe(502);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
if (originalNoProxy === undefined) {
|
||||
delete process.env.NO_PROXY;
|
||||
} else {
|
||||
process.env.NO_PROXY = originalNoProxy;
|
||||
}
|
||||
if (originalNoProxyLower === undefined) {
|
||||
delete process.env.no_proxy;
|
||||
} else {
|
||||
process.env.no_proxy = originalNoProxyLower;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,22 @@ type MockPageState = {
|
||||
visibleText?: string;
|
||||
domSnapshot?: string;
|
||||
navigate?: Record<string, { finalUrl: string; readyStates?: string[]; errorText?: string }>;
|
||||
click?: Record<string, { error?: string; disabled?: boolean }>;
|
||||
click?: Record<
|
||||
string,
|
||||
{
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
detached?: boolean;
|
||||
hidden?: boolean;
|
||||
requireMouseSequence?: boolean;
|
||||
requireNativeClick?: boolean;
|
||||
forbidSyntheticClickEvent?: boolean;
|
||||
cancelMouseDown?: boolean;
|
||||
cancelMouseUp?: boolean;
|
||||
detachAfterMouseDown?: boolean;
|
||||
mouseSequenceError?: string;
|
||||
}
|
||||
>;
|
||||
screenshot?: {
|
||||
data?: string;
|
||||
lastCaptureBeyondViewport?: boolean;
|
||||
@@ -267,14 +282,77 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
||||
if (expression.includes('scrollIntoView') && expression.includes('.click()')) {
|
||||
const selector = parseJsonArgument(expression, 'selector') || '';
|
||||
const clickPlan = page.click?.[selector];
|
||||
const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'");
|
||||
const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'");
|
||||
const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp;
|
||||
const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'");
|
||||
const readsDispatchResult = expression.includes('const dispatchResult = {');
|
||||
const gatesNativeClickOnDispatchResult = expression.includes('if (!dispatchResult.shouldActivate)');
|
||||
const checksIsConnectedBeforeNativeClick = expression.includes('if (!element.isConnected)');
|
||||
const catchIndex = expression.indexOf('catch (mouseError) {');
|
||||
const catchBlockEnd = catchIndex === -1 ? -1 : expression.indexOf('\n }', catchIndex);
|
||||
const nativeClickIndexes = Array.from(expression.matchAll(/element\.click\(\)/g)).map(
|
||||
(match) => match.index ?? -1
|
||||
);
|
||||
const attemptedFallbackClick = nativeClickIndexes.some(
|
||||
(index) => catchIndex !== -1 && catchBlockEnd !== -1 && index > catchIndex && index < catchBlockEnd
|
||||
);
|
||||
const attemptedNativeClickOutsideCatch = nativeClickIndexes.some(
|
||||
(index) =>
|
||||
catchIndex === -1 || catchBlockEnd === -1 || index < catchIndex || index > catchBlockEnd
|
||||
);
|
||||
if (!clickPlan) {
|
||||
replyError(`element not found for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (clickPlan.detached && expression.includes('element.isConnected')) {
|
||||
replyError(`element is detached for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (clickPlan.disabled) {
|
||||
replyError(`element is disabled for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (clickPlan.hidden && expression.includes('getBoundingClientRect')) {
|
||||
replyError(`element is hidden or not interactable for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (clickPlan.requireMouseSequence && !attemptedMouseSequence) {
|
||||
replyError(`mousedown/mouseup required for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (clickPlan.forbidSyntheticClickEvent && attemptedClickEvent) {
|
||||
replyError(`synthetic click event forbidden for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !readsDispatchResult) {
|
||||
replyError(`dispatch result must be checked for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) {
|
||||
replyError(`native click must be gated for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (clickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) {
|
||||
replyError(`connected state must be rechecked for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (clickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) {
|
||||
replyError(`native click required for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (clickPlan.mouseSequenceError) {
|
||||
if (!attemptedMouseSequence) {
|
||||
replyError(`mousedown/mouseup required for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) {
|
||||
reply({ result: { type: 'string', value: 'ok' } });
|
||||
return;
|
||||
}
|
||||
replyError(clickPlan.mouseSequenceError);
|
||||
return;
|
||||
}
|
||||
if (clickPlan.error) {
|
||||
replyError(clickPlan.error);
|
||||
return;
|
||||
@@ -402,7 +480,11 @@ describe('ccs-browser MCP server', () => {
|
||||
);
|
||||
|
||||
const tools = (responses.find((message) => message.id === 2)?.result as {
|
||||
tools: Array<{ name: string; inputSchema?: { properties?: Record<string, { type?: string; minimum?: number }> } }>;
|
||||
tools: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: { properties?: Record<string, { type?: string; minimum?: number }> };
|
||||
}>;
|
||||
}).tools;
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual([
|
||||
@@ -416,6 +498,10 @@ describe('ccs-browser MCP server', () => {
|
||||
'browser_take_screenshot',
|
||||
]);
|
||||
|
||||
const clickTool = tools.find((tool) => tool.name === 'browser_click');
|
||||
expect(clickTool?.description).toContain('mouse event chain');
|
||||
expect(clickTool?.description).not.toContain('synthetic element.click()');
|
||||
|
||||
for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) {
|
||||
expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({
|
||||
type: 'integer',
|
||||
@@ -662,6 +748,220 @@ describe('ccs-browser MCP server', () => {
|
||||
expect(getResponseText(pageSideError)).toContain('click exploded');
|
||||
});
|
||||
|
||||
it('reports detached and hidden click targets as handled errors', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Example Page',
|
||||
currentUrl: 'https://example.com/',
|
||||
click: {
|
||||
'#hidden': { hidden: true },
|
||||
'#detached': { detached: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_click', arguments: { selector: '#hidden' } },
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_click', arguments: { selector: '#detached' } },
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const hiddenError = responses.find((message) => message.id === 2);
|
||||
expect((hiddenError?.result as { isError?: boolean }).isError).toBe(true);
|
||||
expect(getResponseText(hiddenError)).toContain('element is hidden or not interactable for selector: #hidden');
|
||||
|
||||
const detachedError = responses.find((message) => message.id === 3);
|
||||
expect((detachedError?.result as { isError?: boolean }).isError).toBe(true);
|
||||
expect(getResponseText(detachedError)).toContain('element is detached for selector: #detached');
|
||||
});
|
||||
|
||||
it('uses a mouse sequence when the target requires it', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Example Page',
|
||||
currentUrl: 'https://example.com/',
|
||||
click: {
|
||||
'#menu-trigger': { requireMouseSequence: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_click', arguments: { selector: '#menu-trigger' } },
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked');
|
||||
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #menu-trigger');
|
||||
});
|
||||
|
||||
it('preserves mouse sequence preparation without dispatching a synthetic click event', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Example Page',
|
||||
currentUrl: 'https://example.com/',
|
||||
click: {
|
||||
'#click-event': {
|
||||
requireMouseSequence: true,
|
||||
forbidSyntheticClickEvent: true,
|
||||
requireNativeClick: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_click', arguments: { selector: '#click-event' } },
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked');
|
||||
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #click-event');
|
||||
});
|
||||
|
||||
it('preserves native click activation after the mouse sequence', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Example Page',
|
||||
currentUrl: 'https://example.com/',
|
||||
click: {
|
||||
'#native-click': {
|
||||
requireMouseSequence: true,
|
||||
requireNativeClick: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_click', arguments: { selector: '#native-click' } },
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const clickResponse = responses.find((message) => message.id === 2);
|
||||
expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true);
|
||||
expect(getResponseText(clickResponse)).toContain('status: clicked');
|
||||
expect(getResponseText(clickResponse)).toContain('selector: #native-click');
|
||||
});
|
||||
|
||||
it('does not force activation when mousedown cancels the interaction', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Example Page',
|
||||
currentUrl: 'https://example.com/',
|
||||
click: {
|
||||
'#cancel-mousedown': {
|
||||
requireMouseSequence: true,
|
||||
cancelMouseDown: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_click', arguments: { selector: '#cancel-mousedown' } },
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const clickResponse = responses.find((message) => message.id === 2);
|
||||
expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true);
|
||||
expect(getResponseText(clickResponse)).toContain('status: clicked');
|
||||
expect(getResponseText(clickResponse)).toContain('selector: #cancel-mousedown');
|
||||
});
|
||||
|
||||
it('rechecks connectivity before native activation after the mouse sequence', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Example Page',
|
||||
currentUrl: 'https://example.com/',
|
||||
click: {
|
||||
'#detached-during-click': {
|
||||
requireMouseSequence: true,
|
||||
requireNativeClick: true,
|
||||
detachAfterMouseDown: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_click', arguments: { selector: '#detached-during-click' } },
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const clickResponse = responses.find((message) => message.id === 2);
|
||||
expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true);
|
||||
expect(getResponseText(clickResponse)).toContain('status: clicked');
|
||||
expect(getResponseText(clickResponse)).toContain('selector: #detached-during-click');
|
||||
});
|
||||
|
||||
it('falls back to click when mouse sequence dispatch fails', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Example Page',
|
||||
currentUrl: 'https://example.com/',
|
||||
click: {
|
||||
'#fallback': { mouseSequenceError: 'mouse dispatch exploded' },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_click', arguments: { selector: '#fallback' } },
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked');
|
||||
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #fallback');
|
||||
});
|
||||
|
||||
it('captures screenshots and reports empty payload failures', async () => {
|
||||
const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' };
|
||||
const responses = await runMcpRequests(
|
||||
|
||||
Reference in new issue
Block a user