Skip to content

Commit 14fbbad

Browse files
author
GitLab CI
committed
feat: Shadow DOM support, headless mode, proxy, SSL ignore, max tabs, API testing
Competitive advantages over Playwright MCP / Appium MCP: - Shadow DOM piercing: deepQuery/deepQueryAll traverse shadow roots (Playwright MCP issue #90 — users can't interact with Shadow DOM) - Headless mode: --headless flag for CI/CD (Playwright MCP issue #126 — 'how to set headless?') - Custom Chrome path: chrome_path parameter (Playwright MCP issue #139 — custom browser executable) - Proxy support: --proxy-server=URL (Playwright MCP issue #203 — proxy configuration) - SSL certificate ignore: --ignore-certificate-errors (Playwright MCP issues #94, MS#1358 — SSL errors) - Max tabs limit (default 20): prevents runaway tab creation (MS Playwright MCP issue #1299 — agent opens 1000 tabs) - http_request tool: GET/POST/PUT/PATCH/DELETE with JSON/headers (Playwright MCP issues #166, #164 — API testing support) Total MCP tools: ~150
1 parent 8f4efbd commit 14fbbad

File tree

2 files changed

+227
-19
lines changed

2 files changed

+227
-19
lines changed

lib/src/bridge/cdp_driver.dart

Lines changed: 109 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ class CdpDriver implements AppDriver {
1515
final String _url;
1616
final int _port;
1717
final bool _launchChrome;
18+
final bool _headless;
19+
final String? _chromePath;
20+
final String? _proxy;
21+
final bool _ignoreSsl;
22+
final int _maxTabs;
1823

1924
WebSocket? _ws;
2025
bool _connected = false;
@@ -30,13 +35,28 @@ class CdpDriver implements AppDriver {
3035
/// [url] is the page to navigate to.
3136
/// [port] is the Chrome remote debugging port.
3237
/// [launchChrome] whether to launch a new Chrome instance.
38+
/// [headless] run Chrome in headless mode (default: false).
39+
/// [chromePath] custom Chrome/Chromium executable path.
40+
/// [proxy] proxy server URL (e.g. 'http://proxy:8080').
41+
/// [ignoreSsl] ignore SSL certificate errors.
42+
/// [maxTabs] maximum number of tabs to allow (prevents runaway tab creation).
3343
CdpDriver({
3444
required String url,
3545
int port = 9222,
3646
bool launchChrome = true,
47+
bool headless = false,
48+
String? chromePath,
49+
String? proxy,
50+
bool ignoreSsl = false,
51+
int maxTabs = 20,
3752
}) : _url = url,
3853
_port = port,
39-
_launchChrome = launchChrome;
54+
_launchChrome = launchChrome,
55+
_headless = headless,
56+
_chromePath = chromePath,
57+
_proxy = proxy,
58+
_ignoreSsl = ignoreSsl,
59+
_maxTabs = maxTabs;
4060

4161
@override
4262
String get frameworkName => 'CDP (Web)';
@@ -1413,6 +1433,14 @@ class CdpDriver implements AppDriver {
14131433
}
14141434

14151435
Future<Map<String, dynamic>> newTab(String url) async {
1436+
// Enforce max_tabs limit to prevent runaway tab creation
1437+
try {
1438+
final tabs = await getTabs();
1439+
final tabList = tabs['tabs'] as List?;
1440+
if (tabList != null && tabList.length >= _maxTabs) {
1441+
return {"success": false, "error": "Max tabs limit reached ($_maxTabs). Close some tabs first."};
1442+
}
1443+
} catch (_) {}
14161444
final result = await _call('Target.createTarget', {'url': url});
14171445
return {"success": true, "targetId": result['targetId']};
14181446
}
@@ -1622,21 +1650,31 @@ class CdpDriver implements AppDriver {
16221650
chromePaths.add(r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe');
16231651
}
16241652

1653+
// Allow custom Chrome path
1654+
if (_chromePath != null) {
1655+
chromePaths.insert(0, _chromePath!);
1656+
}
1657+
16251658
// Create a temporary user data dir so we don't conflict with existing Chrome
16261659
final tmpDir = await Directory.systemTemp.createTemp('cdp_chrome_');
16271660

1661+
final chromeArgs = [
1662+
'--remote-debugging-port=$_port',
1663+
'--no-first-run',
1664+
'--no-default-browser-check',
1665+
'--user-data-dir=${tmpDir.path}',
1666+
'--disable-background-timer-throttling',
1667+
'--disable-backgrounding-occluded-windows',
1668+
'--disable-renderer-backgrounding',
1669+
if (_headless) '--headless=new',
1670+
if (_proxy != null) '--proxy-server=$_proxy',
1671+
if (_ignoreSsl) '--ignore-certificate-errors',
1672+
_url,
1673+
];
1674+
16281675
for (final chromePath in chromePaths) {
16291676
try {
1630-
_chromeProcess = await Process.start(chromePath, [
1631-
'--remote-debugging-port=$_port',
1632-
'--no-first-run',
1633-
'--no-default-browser-check',
1634-
'--user-data-dir=${tmpDir.path}',
1635-
'--disable-background-timer-throttling',
1636-
'--disable-backgrounding-occluded-windows',
1637-
'--disable-renderer-backgrounding',
1638-
_url,
1639-
]);
1677+
_chromeProcess = await Process.start(chromePath, chromeArgs);
16401678
return;
16411679
} catch (_) {
16421680
continue;
@@ -1739,6 +1777,36 @@ class CdpDriver implements AppDriver {
17391777
);
17401778
}
17411779

1780+
/// JavaScript helper that pierces Shadow DOM when querying elements.
1781+
/// Use `deepQuery(selector)` instead of `document.querySelector(selector)`
1782+
/// and `deepQueryAll(selector)` instead of `document.querySelectorAll(selector)`.
1783+
static const String _shadowDomHelper = '''
1784+
function deepQuery(selector, root) {
1785+
root = root || document;
1786+
let el = root.querySelector(selector);
1787+
if (el) return el;
1788+
const shadows = root.querySelectorAll('*');
1789+
for (const node of shadows) {
1790+
if (node.shadowRoot) {
1791+
el = deepQuery(selector, node.shadowRoot);
1792+
if (el) return el;
1793+
}
1794+
}
1795+
return null;
1796+
}
1797+
function deepQueryAll(selector, root) {
1798+
root = root || document;
1799+
let results = Array.from(root.querySelectorAll(selector));
1800+
const nodes = root.querySelectorAll('*');
1801+
for (const node of nodes) {
1802+
if (node.shadowRoot) {
1803+
results = results.concat(deepQueryAll(selector, node.shadowRoot));
1804+
}
1805+
}
1806+
return results;
1807+
}
1808+
''';
1809+
17421810
Future<Map<String, dynamic>> _evalJs(String expression) async {
17431811
return _call('Runtime.evaluate', {
17441812
'expression': expression,
@@ -1780,18 +1848,37 @@ class CdpDriver implements AppDriver {
17801848

17811849
/// Generate JS code to find an element by selector or text.
17821850
String _jsFindElement(String selector, {String? text, String? ref}) {
1851+
// Deep query helper pierces Shadow DOM
1852+
const deepQ = '''
1853+
function _dq(sel, root) {
1854+
root = root || document;
1855+
let el = root.querySelector(sel);
1856+
if (el) return el;
1857+
for (const n of root.querySelectorAll('*')) {
1858+
if (n.shadowRoot) { el = _dq(sel, n.shadowRoot); if (el) return el; }
1859+
}
1860+
return null;
1861+
}
1862+
function _dqAll(sel, root) {
1863+
root = root || document;
1864+
let r = Array.from(root.querySelectorAll(sel));
1865+
for (const n of root.querySelectorAll('*')) {
1866+
if (n.shadowRoot) r = r.concat(_dqAll(sel, n.shadowRoot));
1867+
}
1868+
return r;
1869+
}
1870+
''';
1871+
17831872
if (text != null) {
17841873
final escaped = text.replaceAll("'", "\\'").replaceAll('\n', '\\n');
17851874
return '''(() => {
1786-
// Try CSS selector first
1787-
let el = document.querySelector('$selector');
1875+
$deepQ
1876+
let el = _dq('$selector');
17881877
if (el) return el;
1789-
// Search by text content
1790-
const all = document.querySelectorAll('a, button, input, select, textarea, label, span, p, h1, h2, h3, h4, h5, h6, div, li, td, th, [role]');
1878+
const all = _dqAll('a, button, input, select, textarea, label, span, p, h1, h2, h3, h4, h5, h6, div, li, td, th, [role]');
17911879
for (const e of all) {
17921880
if (e.textContent && e.textContent.trim() === '$escaped') return e;
17931881
}
1794-
// Partial match
17951882
for (const e of all) {
17961883
if (e.textContent && e.textContent.trim().includes('$escaped')) return e;
17971884
}
@@ -1818,7 +1905,8 @@ class CdpDriver implements AppDriver {
18181905
tagSelector = '*';
18191906
}
18201907
return '''(() => {
1821-
const candidates = document.querySelectorAll('$tagSelector');
1908+
$deepQ
1909+
const candidates = _dqAll('$tagSelector');
18221910
for (const e of candidates) {
18231911
const t = (e.textContent || '').trim();
18241912
const label = e.getAttribute('aria-label') || e.getAttribute('placeholder') || '';
@@ -1832,7 +1920,10 @@ class CdpDriver implements AppDriver {
18321920
})()''';
18331921
}
18341922
}
1835-
return "document.querySelector('$selector')";
1923+
return '''(() => {
1924+
$deepQ
1925+
return _dq('$selector');
1926+
})()''';
18361927
}
18371928

18381929
/// Get element bounds (returns {x, y, w, h, cx, cy} or null).

lib/src/cli/server.dart

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,46 @@ They will automatically route through the CDP connection.""",
711711
"description":
712712
"Launch a new Chrome instance (default: true). Set to false to connect to already-running Chrome."
713713
},
714+
"headless": {
715+
"type": "boolean",
716+
"description": "Run Chrome in headless mode (default: false). Useful for CI/CD."
717+
},
718+
"chrome_path": {
719+
"type": "string",
720+
"description": "Custom Chrome/Chromium executable path."
721+
},
722+
"proxy": {
723+
"type": "string",
724+
"description": "Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080')."
725+
},
726+
"ignore_ssl": {
727+
"type": "boolean",
728+
"description": "Ignore SSL certificate errors (default: false)."
729+
},
730+
"max_tabs": {
731+
"type": "integer",
732+
"description": "Maximum number of tabs allowed (default: 20). Prevents runaway tab creation."
733+
},
734+
},
735+
"required": ["url"],
736+
},
737+
},
738+
739+
// HTTP Request tool (API testing)
740+
{
741+
"name": "http_request",
742+
"description": """Make an HTTP request for API testing.
743+
744+
Supports GET, POST, PUT, PATCH, DELETE with JSON bodies, custom headers, and authentication.
745+
Returns status code, headers, and response body.""",
746+
"inputSchema": {
747+
"type": "object",
748+
"properties": {
749+
"url": {"type": "string", "description": "Request URL"},
750+
"method": {"type": "string", "description": "HTTP method (GET, POST, PUT, PATCH, DELETE). Default: GET"},
751+
"headers": {"type": "object", "description": "Request headers as key-value pairs"},
752+
"body": {"type": "string", "description": "Request body (typically JSON string)"},
753+
"timeout": {"type": "integer", "description": "Timeout in milliseconds (default: 30000)"},
714754
},
715755
"required": ["url"],
716756
},
@@ -3379,10 +3419,78 @@ function toggleImg(el) { el.classList.toggle('expanded'); }
33793419
};
33803420
}
33813421

3422+
if (name == 'http_request') {
3423+
final url = args['url'] as String;
3424+
final method = (args['method'] as String? ?? 'GET').toUpperCase();
3425+
final headers = (args['headers'] as Map<String, dynamic>?)?.map((k, v) => MapEntry(k, v.toString())) ?? {};
3426+
final body = args['body'] as String?;
3427+
final timeout = args['timeout'] as int? ?? 30000;
3428+
3429+
try {
3430+
final client = http.Client();
3431+
final uri = Uri.parse(url);
3432+
http.Response response;
3433+
3434+
final reqHeaders = Map<String, String>.from(headers);
3435+
if (body != null && !reqHeaders.containsKey('content-type') && !reqHeaders.containsKey('Content-Type')) {
3436+
reqHeaders['Content-Type'] = 'application/json';
3437+
}
3438+
3439+
switch (method) {
3440+
case 'POST':
3441+
response = await client.post(uri, headers: reqHeaders, body: body)
3442+
.timeout(Duration(milliseconds: timeout));
3443+
break;
3444+
case 'PUT':
3445+
response = await client.put(uri, headers: reqHeaders, body: body)
3446+
.timeout(Duration(milliseconds: timeout));
3447+
break;
3448+
case 'PATCH':
3449+
response = await client.patch(uri, headers: reqHeaders, body: body)
3450+
.timeout(Duration(milliseconds: timeout));
3451+
break;
3452+
case 'DELETE':
3453+
response = await client.delete(uri, headers: reqHeaders)
3454+
.timeout(Duration(milliseconds: timeout));
3455+
break;
3456+
default: // GET
3457+
response = await client.get(uri, headers: reqHeaders)
3458+
.timeout(Duration(milliseconds: timeout));
3459+
}
3460+
client.close();
3461+
3462+
// Try to parse JSON body
3463+
dynamic responseBody;
3464+
try {
3465+
responseBody = jsonDecode(response.body);
3466+
} catch (_) {
3467+
responseBody = response.body.length > 10000
3468+
? '${response.body.substring(0, 10000)}... (truncated)'
3469+
: response.body;
3470+
}
3471+
3472+
return {
3473+
"status": response.statusCode,
3474+
"headers": response.headers,
3475+
"body": responseBody,
3476+
"content_length": response.contentLength,
3477+
"method": method,
3478+
"url": url,
3479+
};
3480+
} catch (e) {
3481+
return {"error": e.toString(), "method": method, "url": url};
3482+
}
3483+
}
3484+
33823485
if (name == 'connect_cdp') {
33833486
final url = args['url'] as String;
33843487
final port = args['port'] as int? ?? 9222;
33853488
final launchChrome = args['launch_chrome'] ?? true;
3489+
final headless = args['headless'] ?? false;
3490+
final chromePath = args['chrome_path'] as String?;
3491+
final proxy = args['proxy'] as String?;
3492+
final ignoreSsl = args['ignore_ssl'] ?? false;
3493+
final maxTabs = args['max_tabs'] as int? ?? 20;
33863494

33873495
// Disconnect existing CDP connection if any
33883496
if (_cdpDriver != null) {
@@ -3391,7 +3499,16 @@ function toggleImg(el) { el.classList.toggle('expanded'); }
33913499
}
33923500

33933501
try {
3394-
final driver = CdpDriver(url: url, port: port, launchChrome: launchChrome);
3502+
final driver = CdpDriver(
3503+
url: url,
3504+
port: port,
3505+
launchChrome: launchChrome,
3506+
headless: headless,
3507+
chromePath: chromePath,
3508+
proxy: proxy,
3509+
ignoreSsl: ignoreSsl,
3510+
maxTabs: maxTabs,
3511+
);
33953512
await driver.connect();
33963513
_cdpDriver = driver;
33973514

0 commit comments

Comments
 (0)