Skip to content

Commit 1256724

Browse files
mehmetnadirclaude
andcommitted
feat: add 7 new testing commands (assert-url, assert-title, assert-count, assert-value, assert-attr, assert-visible/hidden, screenshot-diff)
Total test tools now: 10 (unique among all browser MCP servers) - assert-url: URL pattern validation - assert-title: Page title validation - assert-count: Element count verification - assert-value: Input value checking - assert-attr: HTML attribute verification - assert-visible/hidden: Visibility state checking - screenshot-diff: PNG file comparison (zero-dep, file size based) All available as both CLI commands and MCP tools. No other browser MCP server offers built-in assertion tools. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4ba7408 commit 1256724

1 file changed

Lines changed: 161 additions & 1 deletion

File tree

src/cdpilot.py

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2895,6 +2895,134 @@ async def cmd_check(checks_json=None):
28952895
print(line)
28962896

28972897

2898+
async def cmd_assert_url(expected_url):
2899+
"""Assert current page URL contains the expected substring."""
2900+
ws_url, _ = get_page_ws()
2901+
safe_expected = json.dumps(expected_url)
2902+
js = f"""(function() {{
2903+
var href = window.location.href;
2904+
var expected = {safe_expected};
2905+
if (href.indexOf(expected) !== -1) return 'PASS: URL ' + href + ' contains \"' + expected + '\"';
2906+
return 'FAIL: URL ' + href + ' does not contain \"' + expected + '\"';
2907+
}})()"""
2908+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2909+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2910+
print(result)
2911+
2912+
2913+
async def cmd_assert_title(expected_title):
2914+
"""Assert page title contains the expected substring."""
2915+
ws_url, _ = get_page_ws()
2916+
safe_expected = json.dumps(expected_title)
2917+
js = f"""(function() {{
2918+
var title = document.title;
2919+
var expected = {safe_expected};
2920+
if (title.indexOf(expected) !== -1) return 'PASS: Title \"' + title + '\" contains \"' + expected + '\"';
2921+
return 'FAIL: Title \"' + title + '\" does not contain \"' + expected + '\"';
2922+
}})()"""
2923+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2924+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2925+
print(result)
2926+
2927+
2928+
async def cmd_assert_count(selector, expected_count):
2929+
"""Assert the number of elements matching a CSS selector equals expected_count."""
2930+
ws_url, _ = get_page_ws()
2931+
safe_sel = json.dumps(selector)
2932+
exp = int(expected_count)
2933+
js = f"""(function() {{
2934+
var count = document.querySelectorAll({safe_sel}).length;
2935+
var exp = {exp};
2936+
if (count === exp) return 'PASS: Found ' + count + ' element(s) matching {safe_sel} (expected ' + exp + ')';
2937+
return 'FAIL: Expected ' + exp + ' \"{selector}\" but found ' + count;
2938+
}})()"""
2939+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2940+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2941+
print(result)
2942+
2943+
2944+
async def cmd_assert_value(selector, expected_value):
2945+
"""Assert an input/textarea/select element's value equals expected_value."""
2946+
ws_url, _ = get_page_ws()
2947+
safe_sel = json.dumps(selector)
2948+
safe_expected = json.dumps(expected_value)
2949+
js = f"""(function() {{
2950+
var el = document.querySelector({safe_sel});
2951+
if (!el) return 'FAIL: Element not found: ' + {safe_sel};
2952+
var val = el.value;
2953+
var expected = {safe_expected};
2954+
if (val === expected) return 'PASS: Value matches \"' + expected + '\"';
2955+
return 'FAIL: Expected value \"' + expected + '\" but got \"' + val + '\"';
2956+
}})()"""
2957+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2958+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2959+
print(result)
2960+
2961+
2962+
async def cmd_assert_attr(selector, attr, expected):
2963+
"""Assert element attribute value contains expected substring."""
2964+
ws_url, _ = get_page_ws()
2965+
safe_sel = json.dumps(selector)
2966+
safe_attr = json.dumps(attr)
2967+
safe_expected = json.dumps(expected)
2968+
js = f"""(function() {{
2969+
var el = document.querySelector({safe_sel});
2970+
if (!el) return 'FAIL: Element not found: ' + {safe_sel};
2971+
var val = el.getAttribute({safe_attr}) || '';
2972+
var expected = {safe_expected};
2973+
if (val.indexOf(expected) !== -1) return 'PASS: ' + {safe_sel} + '[' + {safe_attr} + '] = \"' + val + '\"';
2974+
return 'FAIL: Expected ' + {safe_sel} + '[' + {safe_attr} + '] to contain \"' + expected + '\" but got \"' + val + '\"';
2975+
}})()"""
2976+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2977+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2978+
print(result)
2979+
2980+
2981+
async def cmd_assert_visible(selector, should_be_visible=True):
2982+
"""Assert element is visible (or hidden). should_be_visible=True checks for visible, False for hidden."""
2983+
ws_url, _ = get_page_ws()
2984+
safe_sel = json.dumps(selector)
2985+
expect_label = "visible" if should_be_visible else "hidden"
2986+
opposite_label = "hidden" if should_be_visible else "visible"
2987+
js = f"""(function() {{
2988+
var el = document.querySelector({safe_sel});
2989+
if (!el) return 'FAIL: Element not found: ' + {safe_sel};
2990+
var style = window.getComputedStyle(el);
2991+
var rect = el.getBoundingClientRect();
2992+
var isVisible = (
2993+
style.display !== 'none' &&
2994+
style.visibility !== 'hidden' &&
2995+
style.opacity !== '0' &&
2996+
(rect.width > 0 || rect.height > 0)
2997+
);
2998+
var expectVisible = {str(should_be_visible).lower()};
2999+
if (isVisible === expectVisible) return 'PASS: ' + {safe_sel} + ' is {expect_label}';
3000+
return 'FAIL: ' + {safe_sel} + ' expected {expect_label} but is {opposite_label}';
3001+
}})()"""
3002+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3003+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3004+
print(result)
3005+
3006+
3007+
async def cmd_screenshot_diff(path1, path2):
3008+
"""Compare two screenshot files byte-by-byte. No CDP required."""
3009+
for path in (path1, path2):
3010+
if not os.path.exists(path):
3011+
print(f"ERROR: File not found: {path}")
3012+
return
3013+
size1 = os.path.getsize(path1)
3014+
size2 = os.path.getsize(path2)
3015+
with open(path1, "rb") as f1, open(path2, "rb") as f2:
3016+
data1 = f1.read()
3017+
data2 = f2.read()
3018+
if data1 == data2:
3019+
print("MATCH: Files are identical")
3020+
else:
3021+
kb1 = size1 / 1024
3022+
kb2 = size2 / 1024
3023+
print(f"DIFF: Files differ ({os.path.basename(path1)}: {kb1:.1f}KB, {os.path.basename(path2)}: {kb2:.1f}KB)")
3024+
3025+
28983026
async def cmd_a11y_snapshot():
28993027
"""Output a compact accessibility snapshot for AI agent navigation.
29003028
@@ -3491,6 +3619,22 @@ def _register_tools(self):
34913619
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to wait for"}, "timeout": {"type": "number", "description": "Maximum wait time in milliseconds", "default": 5000}}, "required": ["selector"]}},
34923620
{"name": "browser_check", "description": "Run a batch of assertions on the current page and return a test report. Each check verifies element existence and optional text content. Returns a summary with PASS/FAIL count. Use this for comprehensive page validation after a series of actions.",
34933621
"inputSchema": {"type": "object", "properties": {"checks": {"type": "array", "description": "Array of checks, each with 'selector' (required) and 'text' (optional)", "items": {"type": "object", "properties": {"selector": {"type": "string"}, "text": {"type": "string"}}, "required": ["selector"]}}}, "required": ["checks"]}},
3622+
{"name": "browser_assert_url", "description": "Assert the current page URL contains the expected substring. Returns PASS with the full URL or FAIL. Use this after navigation to verify you landed on the correct page.",
3623+
"inputSchema": {"type": "object", "properties": {"expected_url": {"type": "string", "description": "Expected substring to find in the current URL (e.g. 'example.com', '/dashboard', '?tab=settings')"}}, "required": ["expected_url"]}},
3624+
{"name": "browser_assert_title", "description": "Assert the current page title contains the expected substring. Returns PASS with full title or FAIL. Useful for verifying page identity without relying on URL.",
3625+
"inputSchema": {"type": "object", "properties": {"expected_title": {"type": "string", "description": "Expected substring to find in the page title (e.g. 'Dashboard', 'Login')"}}, "required": ["expected_title"]}},
3626+
{"name": "browser_assert_count", "description": "Assert the number of elements matching a CSS selector equals an expected count. Returns PASS with count or FAIL with actual vs expected. Use this to verify list items, table rows, search results, or repeated components.",
3627+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to count matching elements"}, "expected_count": {"type": "integer", "description": "Expected number of matching elements"}}, "required": ["selector", "expected_count"]}},
3628+
{"name": "browser_assert_value", "description": "Assert an input, textarea, or select element's current value equals the expected string. Returns PASS or FAIL with actual value. Use this to verify form field state after filling or after page load.",
3629+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input/textarea/select element"}, "expected_value": {"type": "string", "description": "Expected exact value of the element"}}, "required": ["selector", "expected_value"]}},
3630+
{"name": "browser_assert_attr", "description": "Assert an element's HTML attribute contains the expected substring. Returns PASS with actual value or FAIL. Use this to verify href, src, data-*, aria-* and other attributes without reading full page HTML.",
3631+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element"}, "attr": {"type": "string", "description": "Attribute name (e.g. 'href', 'src', 'data-id', 'aria-label')"}, "expected": {"type": "string", "description": "Expected substring in the attribute value"}}, "required": ["selector", "attr", "expected"]}},
3632+
{"name": "browser_assert_visible", "description": "Assert an element is visible on the page (not hidden by CSS). Checks display, visibility, opacity and bounding rect. Returns PASS or FAIL. Use this to verify modals opened, elements shown after interaction, or content loaded.",
3633+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element to check for visibility"}}, "required": ["selector"]}},
3634+
{"name": "browser_assert_hidden", "description": "Assert an element exists but is hidden (display:none, visibility:hidden, opacity:0, or zero size). Returns PASS or FAIL. Use this to verify modals closed, tooltips dismissed, or conditional sections hidden.",
3635+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element expected to be hidden"}}, "required": ["selector"]}},
3636+
{"name": "browser_screenshot_diff", "description": "Compare two screenshot PNG files byte-by-byte. Returns MATCH if files are identical or DIFF with file sizes if different. Use this for visual regression testing — take a baseline screenshot, perform actions, take another screenshot, then compare.",
3637+
"inputSchema": {"type": "object", "properties": {"path1": {"type": "string", "description": "Absolute path to the first (baseline) screenshot PNG"}, "path2": {"type": "string", "description": "Absolute path to the second (current) screenshot PNG"}}, "required": ["path1", "path2"]}},
34943638
]
34953639

34963640
def _handle_request(self, request):
@@ -3545,6 +3689,14 @@ def _execute_tool(self, req_id, tool_name, args):
35453689
"browser_assert": lambda a: ["assert", a.get("selector", "")] + ([a["text"]] if a.get("text") else []),
35463690
"browser_wait_for": lambda a: ["wait-for", a.get("selector", "")] + ([str(a["timeout"])] if a.get("timeout") else []),
35473691
"browser_check": lambda a: ["check", json.dumps(a.get("checks", []))],
3692+
"browser_assert_url": lambda a: ["assert-url", a.get("expected_url", "")],
3693+
"browser_assert_title": lambda a: ["assert-title", a.get("expected_title", "")],
3694+
"browser_assert_count": lambda a: ["assert-count", a.get("selector", ""), str(a.get("expected_count", 0))],
3695+
"browser_assert_value": lambda a: ["assert-value", a.get("selector", ""), a.get("expected_value", "")],
3696+
"browser_assert_attr": lambda a: ["assert-attr", a.get("selector", ""), a.get("attr", ""), a.get("expected", "")],
3697+
"browser_assert_visible": lambda a: ["assert-visible", a.get("selector", "")],
3698+
"browser_assert_hidden": lambda a: ["assert-hidden", a.get("selector", "")],
3699+
"browser_screenshot_diff": lambda a: ["screenshot-diff", a.get("path1", ""), a.get("path2", "")],
35483700
}
35493701
if tool_name not in tool_map:
35503702
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32602, "message": f"Unknown tool: {tool_name}"}}
@@ -3695,6 +3847,14 @@ def require_args(n, usage):
36953847
'assert': lambda: (require_args(1, 'assert <selector> [text]'), None)[1] if not args else cmd_assert(args[0], args[1] if len(args) > 1 else None),
36963848
'wait-for': lambda: (require_args(1, 'wait-for <selector> [timeout_ms]'), None)[1] if not args else cmd_wait_for(args[0], int(args[1]) if len(args) > 1 else 5000),
36973849
'check': lambda: cmd_check(args[0] if args else None),
3850+
'assert-url': lambda: (require_args(1, 'assert-url <expected>'), None)[1] if not args else cmd_assert_url(args[0]),
3851+
'assert-title': lambda: (require_args(1, 'assert-title <expected>'), None)[1] if not args else cmd_assert_title(args[0]),
3852+
'assert-count': lambda: (require_args(2, 'assert-count <selector> <n>'), None)[1] if len(args) < 2 else cmd_assert_count(args[0], int(args[1])),
3853+
'assert-value': lambda: (require_args(2, 'assert-value <selector> <value>'), None)[1] if len(args) < 2 else cmd_assert_value(args[0], args[1]),
3854+
'assert-attr': lambda: (require_args(3, 'assert-attr <selector> <attr> <expected>'), None)[1] if len(args) < 3 else cmd_assert_attr(args[0], args[1], args[2]),
3855+
'assert-visible': lambda: (require_args(1, 'assert-visible <selector>'), None)[1] if not args else cmd_assert_visible(args[0], True),
3856+
'assert-hidden': lambda: (require_args(1, 'assert-hidden <selector>'), None)[1] if not args else cmd_assert_visible(args[0], False),
3857+
'screenshot-diff': lambda: (require_args(2, 'screenshot-diff <path1> <path2>'), None)[1] if len(args) < 2 else cmd_screenshot_diff(args[0], args[1]),
36983858
'click-ref': lambda: (require_args(1, 'click-ref <@N>'), None)[1] if not args else cmd_click_ref(args[0]),
36993859
'hover': lambda: (require_args(1, 'hover <selector>'), None)[1] if not args else cmd_hover(args[0]),
37003860
'dblclick': lambda: (require_args(1, 'dblclick <selector>'), None)[1] if not args else cmd_dblclick(args[0]),
@@ -3713,7 +3873,7 @@ def require_args(n, usage):
37133873
# Commands that do not require the visual indicator / input blocker
37143874
NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
37153875
'dialog', 'download', 'throttle', 'permission', 'intercept',
3716-
'batch'}
3876+
'batch', 'screenshot-diff'}
37173877
# Clean up idle sessions before running any command
37183878
_cleanup_idle_sessions()
37193879

0 commit comments

Comments
 (0)