Spaces:
Paused
Paused
Pavel Feldman commited on
chore: experimental agent mode (#516)
Browse files- README.md +1 -0
- config.d.ts +5 -0
- package-lock.json +77 -169
- package.json +1 -0
- src/browserAgent.ts +197 -0
- src/browserContextFactory.ts +56 -1
- src/config.ts +4 -17
- src/fileUtils.ts +37 -0
- src/httpServer.ts +232 -0
- src/program.ts +1 -0
- tests/agent.spec.ts +77 -0
- tests/fixtures.ts +5 -3
README.md
CHANGED
|
@@ -124,6 +124,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|
| 124 |
--block-service-workers block service workers
|
| 125 |
--browser <browser> browser or chrome channel to use, possible
|
| 126 |
values: chrome, firefox, webkit, msedge.
|
|
|
|
| 127 |
--caps <caps> comma-separated list of capabilities to enable,
|
| 128 |
possible values: tabs, pdf, history, wait, files,
|
| 129 |
install. Default is all.
|
|
|
|
| 124 |
--block-service-workers block service workers
|
| 125 |
--browser <browser> browser or chrome channel to use, possible
|
| 126 |
values: chrome, firefox, webkit, msedge.
|
| 127 |
+
--browser-agent <endpoint> Use browser agent (experimental).
|
| 128 |
--caps <caps> comma-separated list of capabilities to enable,
|
| 129 |
possible values: tabs, pdf, history, wait, files,
|
| 130 |
install. Default is all.
|
config.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ export type Config = {
|
|
| 23 |
* The browser to use.
|
| 24 |
*/
|
| 25 |
browser?: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
/**
|
| 27 |
* The type of browser to use.
|
| 28 |
*/
|
|
|
|
| 23 |
* The browser to use.
|
| 24 |
*/
|
| 25 |
browser?: {
|
| 26 |
+
/**
|
| 27 |
+
* Use browser agent (experimental).
|
| 28 |
+
*/
|
| 29 |
+
browserAgent?: string;
|
| 30 |
+
|
| 31 |
/**
|
| 32 |
* The type of browser to use.
|
| 33 |
*/
|
package-lock.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
| 12 |
"@modelcontextprotocol/sdk": "^1.11.0",
|
| 13 |
"commander": "^13.1.0",
|
| 14 |
"debug": "^4.4.1",
|
|
|
|
| 15 |
"playwright": "1.53.0-alpha-2025-05-27",
|
| 16 |
"zod-to-json-schema": "^3.24.4"
|
| 17 |
},
|
|
@@ -853,16 +854,16 @@
|
|
| 853 |
"license": "MIT"
|
| 854 |
},
|
| 855 |
"node_modules/body-parser": {
|
| 856 |
-
"version": "2.
|
| 857 |
-
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.
|
| 858 |
-
"integrity": "sha512-/
|
| 859 |
"license": "MIT",
|
| 860 |
"dependencies": {
|
| 861 |
"bytes": "^3.1.2",
|
| 862 |
"content-type": "^1.0.5",
|
| 863 |
"debug": "^4.4.0",
|
| 864 |
"http-errors": "^2.0.0",
|
| 865 |
-
"iconv-lite": "^0.
|
| 866 |
"on-finished": "^2.4.1",
|
| 867 |
"qs": "^6.14.0",
|
| 868 |
"raw-body": "^3.0.0",
|
|
@@ -872,21 +873,6 @@
|
|
| 872 |
"node": ">=18"
|
| 873 |
}
|
| 874 |
},
|
| 875 |
-
"node_modules/body-parser/node_modules/qs": {
|
| 876 |
-
"version": "6.14.0",
|
| 877 |
-
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
| 878 |
-
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
| 879 |
-
"license": "BSD-3-Clause",
|
| 880 |
-
"dependencies": {
|
| 881 |
-
"side-channel": "^1.1.0"
|
| 882 |
-
},
|
| 883 |
-
"engines": {
|
| 884 |
-
"node": ">=0.6"
|
| 885 |
-
},
|
| 886 |
-
"funding": {
|
| 887 |
-
"url": "https://github.com/sponsors/ljharb"
|
| 888 |
-
}
|
| 889 |
-
},
|
| 890 |
"node_modules/brace-expansion": {
|
| 891 |
"version": "1.1.11",
|
| 892 |
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
|
@@ -1220,16 +1206,6 @@
|
|
| 1220 |
"node": ">= 0.8"
|
| 1221 |
}
|
| 1222 |
},
|
| 1223 |
-
"node_modules/destroy": {
|
| 1224 |
-
"version": "1.2.0",
|
| 1225 |
-
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
| 1226 |
-
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
| 1227 |
-
"license": "MIT",
|
| 1228 |
-
"engines": {
|
| 1229 |
-
"node": ">= 0.8",
|
| 1230 |
-
"npm": "1.2.8000 || >= 1.4.16"
|
| 1231 |
-
}
|
| 1232 |
-
},
|
| 1233 |
"node_modules/doctrine": {
|
| 1234 |
"version": "2.1.0",
|
| 1235 |
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
|
@@ -1765,46 +1741,45 @@
|
|
| 1765 |
}
|
| 1766 |
},
|
| 1767 |
"node_modules/express": {
|
| 1768 |
-
"version": "5.
|
| 1769 |
-
"resolved": "https://registry.npmjs.org/express/-/express-5.
|
| 1770 |
-
"integrity": "sha512-
|
| 1771 |
"license": "MIT",
|
| 1772 |
"dependencies": {
|
| 1773 |
"accepts": "^2.0.0",
|
| 1774 |
-
"body-parser": "^2.
|
| 1775 |
"content-disposition": "^1.0.0",
|
| 1776 |
-
"content-type": "
|
| 1777 |
-
"cookie": "0.7.1",
|
| 1778 |
"cookie-signature": "^1.2.1",
|
| 1779 |
-
"debug": "4.
|
| 1780 |
-
"
|
| 1781 |
-
"
|
| 1782 |
-
"
|
| 1783 |
-
"
|
| 1784 |
-
"
|
| 1785 |
-
"
|
| 1786 |
-
"http-errors": "2.0.0",
|
| 1787 |
"merge-descriptors": "^2.0.0",
|
| 1788 |
-
"methods": "~1.1.2",
|
| 1789 |
"mime-types": "^3.0.0",
|
| 1790 |
-
"on-finished": "2.4.1",
|
| 1791 |
-
"once": "1.4.0",
|
| 1792 |
-
"parseurl": "
|
| 1793 |
-
"proxy-addr": "
|
| 1794 |
-
"qs": "6.
|
| 1795 |
-
"range-parser": "
|
| 1796 |
-
"router": "^2.
|
| 1797 |
-
"safe-buffer": "5.2.1",
|
| 1798 |
"send": "^1.1.0",
|
| 1799 |
-
"serve-static": "^2.
|
| 1800 |
-
"
|
| 1801 |
-
"
|
| 1802 |
-
"
|
| 1803 |
-
"utils-merge": "1.0.1",
|
| 1804 |
-
"vary": "~1.1.2"
|
| 1805 |
},
|
| 1806 |
"engines": {
|
| 1807 |
"node": ">= 18"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1808 |
}
|
| 1809 |
},
|
| 1810 |
"node_modules/express-rate-limit": {
|
|
@@ -1822,29 +1797,6 @@
|
|
| 1822 |
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
| 1823 |
}
|
| 1824 |
},
|
| 1825 |
-
"node_modules/express/node_modules/debug": {
|
| 1826 |
-
"version": "4.3.6",
|
| 1827 |
-
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
| 1828 |
-
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
| 1829 |
-
"license": "MIT",
|
| 1830 |
-
"dependencies": {
|
| 1831 |
-
"ms": "2.1.2"
|
| 1832 |
-
},
|
| 1833 |
-
"engines": {
|
| 1834 |
-
"node": ">=6.0"
|
| 1835 |
-
},
|
| 1836 |
-
"peerDependenciesMeta": {
|
| 1837 |
-
"supports-color": {
|
| 1838 |
-
"optional": true
|
| 1839 |
-
}
|
| 1840 |
-
}
|
| 1841 |
-
},
|
| 1842 |
-
"node_modules/express/node_modules/ms": {
|
| 1843 |
-
"version": "2.1.2",
|
| 1844 |
-
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
| 1845 |
-
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
| 1846 |
-
"license": "MIT"
|
| 1847 |
-
},
|
| 1848 |
"node_modules/fast-deep-equal": {
|
| 1849 |
"version": "3.1.3",
|
| 1850 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
@@ -2308,12 +2260,12 @@
|
|
| 2308 |
}
|
| 2309 |
},
|
| 2310 |
"node_modules/iconv-lite": {
|
| 2311 |
-
"version": "0.
|
| 2312 |
-
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.
|
| 2313 |
-
"integrity": "sha512-
|
| 2314 |
"license": "MIT",
|
| 2315 |
"dependencies": {
|
| 2316 |
-
"safer-buffer": ">= 2.1.2 < 3"
|
| 2317 |
},
|
| 2318 |
"engines": {
|
| 2319 |
"node": ">=0.10.0"
|
|
@@ -2924,15 +2876,6 @@
|
|
| 2924 |
"node": ">= 8"
|
| 2925 |
}
|
| 2926 |
},
|
| 2927 |
-
"node_modules/methods": {
|
| 2928 |
-
"version": "1.1.2",
|
| 2929 |
-
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
| 2930 |
-
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
| 2931 |
-
"license": "MIT",
|
| 2932 |
-
"engines": {
|
| 2933 |
-
"node": ">= 0.6"
|
| 2934 |
-
}
|
| 2935 |
-
},
|
| 2936 |
"node_modules/metric-lcs": {
|
| 2937 |
"version": "0.1.2",
|
| 2938 |
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
|
|
@@ -2954,6 +2897,21 @@
|
|
| 2954 |
"node": ">=8.6"
|
| 2955 |
}
|
| 2956 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2957 |
"node_modules/mime-db": {
|
| 2958 |
"version": "1.54.0",
|
| 2959 |
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
|
@@ -2964,12 +2922,12 @@
|
|
| 2964 |
}
|
| 2965 |
},
|
| 2966 |
"node_modules/mime-types": {
|
| 2967 |
-
"version": "3.0.
|
| 2968 |
-
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.
|
| 2969 |
-
"integrity": "sha512-
|
| 2970 |
"license": "MIT",
|
| 2971 |
"dependencies": {
|
| 2972 |
-
"mime-db": "^1.
|
| 2973 |
},
|
| 2974 |
"engines": {
|
| 2975 |
"node": ">= 0.6"
|
|
@@ -3367,12 +3325,12 @@
|
|
| 3367 |
}
|
| 3368 |
},
|
| 3369 |
"node_modules/qs": {
|
| 3370 |
-
"version": "6.
|
| 3371 |
-
"resolved": "https://registry.npmjs.org/qs/-/qs-6.
|
| 3372 |
-
"integrity": "sha512-
|
| 3373 |
"license": "BSD-3-Clause",
|
| 3374 |
"dependencies": {
|
| 3375 |
-
"side-channel": "^1.
|
| 3376 |
},
|
| 3377 |
"engines": {
|
| 3378 |
"node": ">=0.6"
|
|
@@ -3426,18 +3384,6 @@
|
|
| 3426 |
"node": ">= 0.8"
|
| 3427 |
}
|
| 3428 |
},
|
| 3429 |
-
"node_modules/raw-body/node_modules/iconv-lite": {
|
| 3430 |
-
"version": "0.6.3",
|
| 3431 |
-
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
| 3432 |
-
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
| 3433 |
-
"license": "MIT",
|
| 3434 |
-
"dependencies": {
|
| 3435 |
-
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
| 3436 |
-
},
|
| 3437 |
-
"engines": {
|
| 3438 |
-
"node": ">=0.10.0"
|
| 3439 |
-
}
|
| 3440 |
-
},
|
| 3441 |
"node_modules/reflect.getprototypeof": {
|
| 3442 |
"version": "1.0.10",
|
| 3443 |
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
|
@@ -3525,11 +3471,13 @@
|
|
| 3525 |
}
|
| 3526 |
},
|
| 3527 |
"node_modules/router": {
|
| 3528 |
-
"version": "2.
|
| 3529 |
-
"resolved": "https://registry.npmjs.org/router/-/router-2.
|
| 3530 |
-
"integrity": "sha512-/
|
| 3531 |
"license": "MIT",
|
| 3532 |
"dependencies": {
|
|
|
|
|
|
|
| 3533 |
"is-promise": "^4.0.0",
|
| 3534 |
"parseurl": "^1.3.3",
|
| 3535 |
"path-to-regexp": "^8.0.0"
|
|
@@ -3657,19 +3605,18 @@
|
|
| 3657 |
}
|
| 3658 |
},
|
| 3659 |
"node_modules/send": {
|
| 3660 |
-
"version": "1.
|
| 3661 |
-
"resolved": "https://registry.npmjs.org/send/-/send-1.
|
| 3662 |
-
"integrity": "sha512-
|
| 3663 |
"license": "MIT",
|
| 3664 |
"dependencies": {
|
| 3665 |
"debug": "^4.3.5",
|
| 3666 |
-
"destroy": "^1.2.0",
|
| 3667 |
"encodeurl": "^2.0.0",
|
| 3668 |
"escape-html": "^1.0.3",
|
| 3669 |
"etag": "^1.8.1",
|
| 3670 |
-
"fresh": "^0.
|
| 3671 |
"http-errors": "^2.0.0",
|
| 3672 |
-
"mime-types": "^
|
| 3673 |
"ms": "^2.1.3",
|
| 3674 |
"on-finished": "^2.4.1",
|
| 3675 |
"range-parser": "^1.2.1",
|
|
@@ -3679,46 +3626,16 @@
|
|
| 3679 |
"node": ">= 18"
|
| 3680 |
}
|
| 3681 |
},
|
| 3682 |
-
"node_modules/send/node_modules/fresh": {
|
| 3683 |
-
"version": "0.5.2",
|
| 3684 |
-
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
| 3685 |
-
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
| 3686 |
-
"license": "MIT",
|
| 3687 |
-
"engines": {
|
| 3688 |
-
"node": ">= 0.6"
|
| 3689 |
-
}
|
| 3690 |
-
},
|
| 3691 |
-
"node_modules/send/node_modules/mime-db": {
|
| 3692 |
-
"version": "1.52.0",
|
| 3693 |
-
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
| 3694 |
-
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
| 3695 |
-
"license": "MIT",
|
| 3696 |
-
"engines": {
|
| 3697 |
-
"node": ">= 0.6"
|
| 3698 |
-
}
|
| 3699 |
-
},
|
| 3700 |
-
"node_modules/send/node_modules/mime-types": {
|
| 3701 |
-
"version": "2.1.35",
|
| 3702 |
-
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
| 3703 |
-
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
| 3704 |
-
"license": "MIT",
|
| 3705 |
-
"dependencies": {
|
| 3706 |
-
"mime-db": "1.52.0"
|
| 3707 |
-
},
|
| 3708 |
-
"engines": {
|
| 3709 |
-
"node": ">= 0.6"
|
| 3710 |
-
}
|
| 3711 |
-
},
|
| 3712 |
"node_modules/serve-static": {
|
| 3713 |
-
"version": "2.
|
| 3714 |
-
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.
|
| 3715 |
-
"integrity": "sha512-
|
| 3716 |
"license": "MIT",
|
| 3717 |
"dependencies": {
|
| 3718 |
"encodeurl": "^2.0.0",
|
| 3719 |
"escape-html": "^1.0.3",
|
| 3720 |
"parseurl": "^1.3.3",
|
| 3721 |
-
"send": "^1.
|
| 3722 |
},
|
| 3723 |
"engines": {
|
| 3724 |
"node": ">= 18"
|
|
@@ -4051,9 +3968,9 @@
|
|
| 4051 |
}
|
| 4052 |
},
|
| 4053 |
"node_modules/type-is": {
|
| 4054 |
-
"version": "2.0.
|
| 4055 |
-
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.
|
| 4056 |
-
"integrity": "sha512-
|
| 4057 |
"license": "MIT",
|
| 4058 |
"dependencies": {
|
| 4059 |
"content-type": "^1.0.5",
|
|
@@ -4201,15 +4118,6 @@
|
|
| 4201 |
"punycode": "^2.1.0"
|
| 4202 |
}
|
| 4203 |
},
|
| 4204 |
-
"node_modules/utils-merge": {
|
| 4205 |
-
"version": "1.0.1",
|
| 4206 |
-
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
| 4207 |
-
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
| 4208 |
-
"license": "MIT",
|
| 4209 |
-
"engines": {
|
| 4210 |
-
"node": ">= 0.4.0"
|
| 4211 |
-
}
|
| 4212 |
-
},
|
| 4213 |
"node_modules/vary": {
|
| 4214 |
"version": "1.1.2",
|
| 4215 |
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
|
|
|
| 12 |
"@modelcontextprotocol/sdk": "^1.11.0",
|
| 13 |
"commander": "^13.1.0",
|
| 14 |
"debug": "^4.4.1",
|
| 15 |
+
"mime": "^4.0.7",
|
| 16 |
"playwright": "1.53.0-alpha-2025-05-27",
|
| 17 |
"zod-to-json-schema": "^3.24.4"
|
| 18 |
},
|
|
|
|
| 854 |
"license": "MIT"
|
| 855 |
},
|
| 856 |
"node_modules/body-parser": {
|
| 857 |
+
"version": "2.2.0",
|
| 858 |
+
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
| 859 |
+
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
| 860 |
"license": "MIT",
|
| 861 |
"dependencies": {
|
| 862 |
"bytes": "^3.1.2",
|
| 863 |
"content-type": "^1.0.5",
|
| 864 |
"debug": "^4.4.0",
|
| 865 |
"http-errors": "^2.0.0",
|
| 866 |
+
"iconv-lite": "^0.6.3",
|
| 867 |
"on-finished": "^2.4.1",
|
| 868 |
"qs": "^6.14.0",
|
| 869 |
"raw-body": "^3.0.0",
|
|
|
|
| 873 |
"node": ">=18"
|
| 874 |
}
|
| 875 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
"node_modules/brace-expansion": {
|
| 877 |
"version": "1.1.11",
|
| 878 |
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
|
|
|
| 1206 |
"node": ">= 0.8"
|
| 1207 |
}
|
| 1208 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1209 |
"node_modules/doctrine": {
|
| 1210 |
"version": "2.1.0",
|
| 1211 |
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
|
|
|
| 1741 |
}
|
| 1742 |
},
|
| 1743 |
"node_modules/express": {
|
| 1744 |
+
"version": "5.1.0",
|
| 1745 |
+
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
| 1746 |
+
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
| 1747 |
"license": "MIT",
|
| 1748 |
"dependencies": {
|
| 1749 |
"accepts": "^2.0.0",
|
| 1750 |
+
"body-parser": "^2.2.0",
|
| 1751 |
"content-disposition": "^1.0.0",
|
| 1752 |
+
"content-type": "^1.0.5",
|
| 1753 |
+
"cookie": "^0.7.1",
|
| 1754 |
"cookie-signature": "^1.2.1",
|
| 1755 |
+
"debug": "^4.4.0",
|
| 1756 |
+
"encodeurl": "^2.0.0",
|
| 1757 |
+
"escape-html": "^1.0.3",
|
| 1758 |
+
"etag": "^1.8.1",
|
| 1759 |
+
"finalhandler": "^2.1.0",
|
| 1760 |
+
"fresh": "^2.0.0",
|
| 1761 |
+
"http-errors": "^2.0.0",
|
|
|
|
| 1762 |
"merge-descriptors": "^2.0.0",
|
|
|
|
| 1763 |
"mime-types": "^3.0.0",
|
| 1764 |
+
"on-finished": "^2.4.1",
|
| 1765 |
+
"once": "^1.4.0",
|
| 1766 |
+
"parseurl": "^1.3.3",
|
| 1767 |
+
"proxy-addr": "^2.0.7",
|
| 1768 |
+
"qs": "^6.14.0",
|
| 1769 |
+
"range-parser": "^1.2.1",
|
| 1770 |
+
"router": "^2.2.0",
|
|
|
|
| 1771 |
"send": "^1.1.0",
|
| 1772 |
+
"serve-static": "^2.2.0",
|
| 1773 |
+
"statuses": "^2.0.1",
|
| 1774 |
+
"type-is": "^2.0.1",
|
| 1775 |
+
"vary": "^1.1.2"
|
|
|
|
|
|
|
| 1776 |
},
|
| 1777 |
"engines": {
|
| 1778 |
"node": ">= 18"
|
| 1779 |
+
},
|
| 1780 |
+
"funding": {
|
| 1781 |
+
"type": "opencollective",
|
| 1782 |
+
"url": "https://opencollective.com/express"
|
| 1783 |
}
|
| 1784 |
},
|
| 1785 |
"node_modules/express-rate-limit": {
|
|
|
|
| 1797 |
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
| 1798 |
}
|
| 1799 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1800 |
"node_modules/fast-deep-equal": {
|
| 1801 |
"version": "3.1.3",
|
| 1802 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
|
|
| 2260 |
}
|
| 2261 |
},
|
| 2262 |
"node_modules/iconv-lite": {
|
| 2263 |
+
"version": "0.6.3",
|
| 2264 |
+
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
| 2265 |
+
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
| 2266 |
"license": "MIT",
|
| 2267 |
"dependencies": {
|
| 2268 |
+
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
| 2269 |
},
|
| 2270 |
"engines": {
|
| 2271 |
"node": ">=0.10.0"
|
|
|
|
| 2876 |
"node": ">= 8"
|
| 2877 |
}
|
| 2878 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2879 |
"node_modules/metric-lcs": {
|
| 2880 |
"version": "0.1.2",
|
| 2881 |
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
|
|
|
|
| 2897 |
"node": ">=8.6"
|
| 2898 |
}
|
| 2899 |
},
|
| 2900 |
+
"node_modules/mime": {
|
| 2901 |
+
"version": "4.0.7",
|
| 2902 |
+
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
|
| 2903 |
+
"integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
|
| 2904 |
+
"funding": [
|
| 2905 |
+
"https://github.com/sponsors/broofa"
|
| 2906 |
+
],
|
| 2907 |
+
"license": "MIT",
|
| 2908 |
+
"bin": {
|
| 2909 |
+
"mime": "bin/cli.js"
|
| 2910 |
+
},
|
| 2911 |
+
"engines": {
|
| 2912 |
+
"node": ">=16"
|
| 2913 |
+
}
|
| 2914 |
+
},
|
| 2915 |
"node_modules/mime-db": {
|
| 2916 |
"version": "1.54.0",
|
| 2917 |
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
|
|
|
| 2922 |
}
|
| 2923 |
},
|
| 2924 |
"node_modules/mime-types": {
|
| 2925 |
+
"version": "3.0.1",
|
| 2926 |
+
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
| 2927 |
+
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
| 2928 |
"license": "MIT",
|
| 2929 |
"dependencies": {
|
| 2930 |
+
"mime-db": "^1.54.0"
|
| 2931 |
},
|
| 2932 |
"engines": {
|
| 2933 |
"node": ">= 0.6"
|
|
|
|
| 3325 |
}
|
| 3326 |
},
|
| 3327 |
"node_modules/qs": {
|
| 3328 |
+
"version": "6.14.0",
|
| 3329 |
+
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
| 3330 |
+
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
| 3331 |
"license": "BSD-3-Clause",
|
| 3332 |
"dependencies": {
|
| 3333 |
+
"side-channel": "^1.1.0"
|
| 3334 |
},
|
| 3335 |
"engines": {
|
| 3336 |
"node": ">=0.6"
|
|
|
|
| 3384 |
"node": ">= 0.8"
|
| 3385 |
}
|
| 3386 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3387 |
"node_modules/reflect.getprototypeof": {
|
| 3388 |
"version": "1.0.10",
|
| 3389 |
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
|
|
|
| 3471 |
}
|
| 3472 |
},
|
| 3473 |
"node_modules/router": {
|
| 3474 |
+
"version": "2.2.0",
|
| 3475 |
+
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
| 3476 |
+
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
| 3477 |
"license": "MIT",
|
| 3478 |
"dependencies": {
|
| 3479 |
+
"debug": "^4.4.0",
|
| 3480 |
+
"depd": "^2.0.0",
|
| 3481 |
"is-promise": "^4.0.0",
|
| 3482 |
"parseurl": "^1.3.3",
|
| 3483 |
"path-to-regexp": "^8.0.0"
|
|
|
|
| 3605 |
}
|
| 3606 |
},
|
| 3607 |
"node_modules/send": {
|
| 3608 |
+
"version": "1.2.0",
|
| 3609 |
+
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
| 3610 |
+
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
| 3611 |
"license": "MIT",
|
| 3612 |
"dependencies": {
|
| 3613 |
"debug": "^4.3.5",
|
|
|
|
| 3614 |
"encodeurl": "^2.0.0",
|
| 3615 |
"escape-html": "^1.0.3",
|
| 3616 |
"etag": "^1.8.1",
|
| 3617 |
+
"fresh": "^2.0.0",
|
| 3618 |
"http-errors": "^2.0.0",
|
| 3619 |
+
"mime-types": "^3.0.1",
|
| 3620 |
"ms": "^2.1.3",
|
| 3621 |
"on-finished": "^2.4.1",
|
| 3622 |
"range-parser": "^1.2.1",
|
|
|
|
| 3626 |
"node": ">= 18"
|
| 3627 |
}
|
| 3628 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3629 |
"node_modules/serve-static": {
|
| 3630 |
+
"version": "2.2.0",
|
| 3631 |
+
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
| 3632 |
+
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
| 3633 |
"license": "MIT",
|
| 3634 |
"dependencies": {
|
| 3635 |
"encodeurl": "^2.0.0",
|
| 3636 |
"escape-html": "^1.0.3",
|
| 3637 |
"parseurl": "^1.3.3",
|
| 3638 |
+
"send": "^1.2.0"
|
| 3639 |
},
|
| 3640 |
"engines": {
|
| 3641 |
"node": ">= 18"
|
|
|
|
| 3968 |
}
|
| 3969 |
},
|
| 3970 |
"node_modules/type-is": {
|
| 3971 |
+
"version": "2.0.1",
|
| 3972 |
+
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
| 3973 |
+
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
| 3974 |
"license": "MIT",
|
| 3975 |
"dependencies": {
|
| 3976 |
"content-type": "^1.0.5",
|
|
|
|
| 4118 |
"punycode": "^2.1.0"
|
| 4119 |
}
|
| 4120 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4121 |
"node_modules/vary": {
|
| 4122 |
"version": "1.1.2",
|
| 4123 |
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
package.json
CHANGED
|
@@ -38,6 +38,7 @@
|
|
| 38 |
"@modelcontextprotocol/sdk": "^1.11.0",
|
| 39 |
"commander": "^13.1.0",
|
| 40 |
"debug": "^4.4.1",
|
|
|
|
| 41 |
"playwright": "1.53.0-alpha-2025-05-27",
|
| 42 |
"zod-to-json-schema": "^3.24.4"
|
| 43 |
},
|
|
|
|
| 38 |
"@modelcontextprotocol/sdk": "^1.11.0",
|
| 39 |
"commander": "^13.1.0",
|
| 40 |
"debug": "^4.4.1",
|
| 41 |
+
"mime": "^4.0.7",
|
| 42 |
"playwright": "1.53.0-alpha-2025-05-27",
|
| 43 |
"zod-to-json-schema": "^3.24.4"
|
| 44 |
},
|
src/browserAgent.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Copyright (c) Microsoft Corporation.
|
| 3 |
+
*
|
| 4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
| 5 |
+
* you may not use this file except in compliance with the License.
|
| 6 |
+
* You may obtain a copy of the License at
|
| 7 |
+
*
|
| 8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
| 9 |
+
*
|
| 10 |
+
* Unless required by applicable law or agreed to in writing, software
|
| 11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
| 12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 13 |
+
* See the License for the specific language governing permissions and
|
| 14 |
+
* limitations under the License.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
/* eslint-disable no-console */
|
| 18 |
+
|
| 19 |
+
import net from 'net';
|
| 20 |
+
|
| 21 |
+
import { program } from 'commander';
|
| 22 |
+
import playwright from 'playwright';
|
| 23 |
+
|
| 24 |
+
import { HttpServer } from './httpServer.js';
|
| 25 |
+
import { packageJSON } from './package.js';
|
| 26 |
+
|
| 27 |
+
import type http from 'http';
|
| 28 |
+
|
| 29 |
+
export type LaunchBrowserRequest = {
|
| 30 |
+
browserType: string;
|
| 31 |
+
userDataDir: string;
|
| 32 |
+
launchOptions: playwright.LaunchOptions;
|
| 33 |
+
contextOptions: playwright.BrowserContextOptions;
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export type BrowserInfo = {
|
| 37 |
+
browserType: string;
|
| 38 |
+
userDataDir: string;
|
| 39 |
+
cdpPort: number;
|
| 40 |
+
launchOptions: playwright.LaunchOptions;
|
| 41 |
+
contextOptions: playwright.BrowserContextOptions;
|
| 42 |
+
error?: string;
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
type BrowserEntry = {
|
| 46 |
+
browser?: playwright.Browser;
|
| 47 |
+
info: BrowserInfo;
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
class Agent {
|
| 51 |
+
private _server = new HttpServer();
|
| 52 |
+
private _entries: BrowserEntry[] = [];
|
| 53 |
+
|
| 54 |
+
constructor() {
|
| 55 |
+
this._setupExitHandler();
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
async start(port: number) {
|
| 59 |
+
await this._server.start({ port });
|
| 60 |
+
this._server.routePath('/json/list', (req, res) => {
|
| 61 |
+
this._handleJsonList(res);
|
| 62 |
+
});
|
| 63 |
+
this._server.routePath('/json/launch', async (req, res) => {
|
| 64 |
+
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
|
| 65 |
+
});
|
| 66 |
+
this._setEntries([]);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
private _handleJsonList(res: http.ServerResponse) {
|
| 70 |
+
const list = this._entries.map(browser => browser.info);
|
| 71 |
+
res.end(JSON.stringify(list));
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
|
| 75 |
+
const request = await readBody<LaunchBrowserRequest>(req);
|
| 76 |
+
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
|
| 77 |
+
if (!info || info.error)
|
| 78 |
+
info = await this._newBrowser(request);
|
| 79 |
+
res.end(JSON.stringify(info));
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
|
| 83 |
+
const cdpPort = await findFreePort();
|
| 84 |
+
(request.launchOptions as any).cdpPort = cdpPort;
|
| 85 |
+
const info: BrowserInfo = {
|
| 86 |
+
browserType: request.browserType,
|
| 87 |
+
userDataDir: request.userDataDir,
|
| 88 |
+
cdpPort,
|
| 89 |
+
launchOptions: request.launchOptions,
|
| 90 |
+
contextOptions: request.contextOptions,
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
|
| 94 |
+
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
|
| 95 |
+
...request.launchOptions,
|
| 96 |
+
...request.contextOptions,
|
| 97 |
+
handleSIGINT: false,
|
| 98 |
+
handleSIGTERM: false,
|
| 99 |
+
}).then(context => {
|
| 100 |
+
return { browser: context.browser()!, error: undefined };
|
| 101 |
+
}).catch(error => {
|
| 102 |
+
return { browser: undefined, error: error.message };
|
| 103 |
+
});
|
| 104 |
+
this._setEntries([...this._entries, {
|
| 105 |
+
browser,
|
| 106 |
+
info: {
|
| 107 |
+
browserType: request.browserType,
|
| 108 |
+
userDataDir: request.userDataDir,
|
| 109 |
+
cdpPort,
|
| 110 |
+
launchOptions: request.launchOptions,
|
| 111 |
+
contextOptions: request.contextOptions,
|
| 112 |
+
error,
|
| 113 |
+
},
|
| 114 |
+
}]);
|
| 115 |
+
browser?.on('disconnected', () => {
|
| 116 |
+
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
|
| 117 |
+
});
|
| 118 |
+
return info;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
private _updateReport() {
|
| 122 |
+
// Clear the current line and move cursor to top of screen
|
| 123 |
+
process.stdout.write('\x1b[2J\x1b[H');
|
| 124 |
+
process.stdout.write(`Playwright Browser agent v${packageJSON.version}\n`);
|
| 125 |
+
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
|
| 126 |
+
|
| 127 |
+
if (this._entries.length === 0) {
|
| 128 |
+
process.stdout.write('No browsers currently running\n');
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
process.stdout.write('Running browsers:\n');
|
| 133 |
+
for (const entry of this._entries) {
|
| 134 |
+
const status = entry.browser ? 'running' : 'error';
|
| 135 |
+
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
|
| 136 |
+
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
|
| 137 |
+
if (entry.info.error)
|
| 138 |
+
process.stdout.write(` Error: ${entry.info.error}\n`);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
private _setEntries(entries: BrowserEntry[]) {
|
| 144 |
+
this._entries = entries;
|
| 145 |
+
this._updateReport();
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
private _setupExitHandler() {
|
| 149 |
+
let isExiting = false;
|
| 150 |
+
const handleExit = async () => {
|
| 151 |
+
if (isExiting)
|
| 152 |
+
return;
|
| 153 |
+
isExiting = true;
|
| 154 |
+
setTimeout(() => process.exit(0), 15000);
|
| 155 |
+
for (const entry of this._entries)
|
| 156 |
+
await entry.browser?.close().catch(() => {});
|
| 157 |
+
process.exit(0);
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
process.stdin.on('close', handleExit);
|
| 161 |
+
process.on('SIGINT', handleExit);
|
| 162 |
+
process.on('SIGTERM', handleExit);
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
program
|
| 167 |
+
.name('browser-agent')
|
| 168 |
+
.option('-p, --port <port>', 'Port to listen on', '9224')
|
| 169 |
+
.action(async options => {
|
| 170 |
+
await main(options);
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
void program.parseAsync(process.argv);
|
| 174 |
+
|
| 175 |
+
async function main(options: { port: string }) {
|
| 176 |
+
const agent = new Agent();
|
| 177 |
+
await agent.start(+options.port);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function readBody<T>(req: http.IncomingMessage): Promise<T> {
|
| 181 |
+
return new Promise((resolve, reject) => {
|
| 182 |
+
const chunks: Buffer[] = [];
|
| 183 |
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
| 184 |
+
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
|
| 185 |
+
});
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
async function findFreePort(): Promise<number> {
|
| 189 |
+
return new Promise((resolve, reject) => {
|
| 190 |
+
const server = net.createServer();
|
| 191 |
+
server.listen(0, () => {
|
| 192 |
+
const { port } = server.address() as net.AddressInfo;
|
| 193 |
+
server.close(() => resolve(port));
|
| 194 |
+
});
|
| 195 |
+
server.on('error', reject);
|
| 196 |
+
});
|
| 197 |
+
}
|
src/browserContextFactory.ts
CHANGED
|
@@ -15,13 +15,16 @@
|
|
| 15 |
*/
|
| 16 |
|
| 17 |
import fs from 'node:fs';
|
| 18 |
-
import
|
| 19 |
import path from 'node:path';
|
|
|
|
| 20 |
|
| 21 |
import debug from 'debug';
|
| 22 |
import * as playwright from 'playwright';
|
|
|
|
| 23 |
|
| 24 |
import type { FullConfig } from './config.js';
|
|
|
|
| 25 |
|
| 26 |
const testDebug = debug('pw:mcp:test');
|
| 27 |
|
|
@@ -32,6 +35,8 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon
|
|
| 32 |
return new CdpContextFactory(browserConfig);
|
| 33 |
if (browserConfig.isolated)
|
| 34 |
return new IsolatedContextFactory(browserConfig);
|
|
|
|
|
|
|
| 35 |
return new PersistentContextFactory(browserConfig);
|
| 36 |
}
|
| 37 |
|
|
@@ -97,6 +102,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|
| 97 |
}
|
| 98 |
|
| 99 |
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
|
|
|
| 100 |
const browserType = playwright[this.browserConfig.browserName];
|
| 101 |
return browserType.launch({
|
| 102 |
...this.browserConfig.launchOptions,
|
|
@@ -155,6 +161,7 @@ class PersistentContextFactory implements BrowserContextFactory {
|
|
| 155 |
}
|
| 156 |
|
| 157 |
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
|
|
|
| 158 |
testDebug('create browser context (persistent)');
|
| 159 |
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
| 160 |
|
|
@@ -209,3 +216,51 @@ class PersistentContextFactory implements BrowserContextFactory {
|
|
| 209 |
return result;
|
| 210 |
}
|
| 211 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
*/
|
| 16 |
|
| 17 |
import fs from 'node:fs';
|
| 18 |
+
import net from 'node:net';
|
| 19 |
import path from 'node:path';
|
| 20 |
+
import os from 'node:os';
|
| 21 |
|
| 22 |
import debug from 'debug';
|
| 23 |
import * as playwright from 'playwright';
|
| 24 |
+
import { userDataDir } from './fileUtils.js';
|
| 25 |
|
| 26 |
import type { FullConfig } from './config.js';
|
| 27 |
+
import type { BrowserInfo, LaunchBrowserRequest } from './browserAgent.js';
|
| 28 |
|
| 29 |
const testDebug = debug('pw:mcp:test');
|
| 30 |
|
|
|
|
| 35 |
return new CdpContextFactory(browserConfig);
|
| 36 |
if (browserConfig.isolated)
|
| 37 |
return new IsolatedContextFactory(browserConfig);
|
| 38 |
+
if (browserConfig.browserAgent)
|
| 39 |
+
return new AgentContextFactory(browserConfig);
|
| 40 |
return new PersistentContextFactory(browserConfig);
|
| 41 |
}
|
| 42 |
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
| 105 |
+
await injectCdpPort(this.browserConfig);
|
| 106 |
const browserType = playwright[this.browserConfig.browserName];
|
| 107 |
return browserType.launch({
|
| 108 |
...this.browserConfig.launchOptions,
|
|
|
|
| 161 |
}
|
| 162 |
|
| 163 |
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
| 164 |
+
await injectCdpPort(this.browserConfig);
|
| 165 |
testDebug('create browser context (persistent)');
|
| 166 |
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
| 167 |
|
|
|
|
| 216 |
return result;
|
| 217 |
}
|
| 218 |
}
|
| 219 |
+
|
| 220 |
+
export class AgentContextFactory extends BaseContextFactory {
|
| 221 |
+
constructor(browserConfig: FullConfig['browser']) {
|
| 222 |
+
super('persistent', browserConfig);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
| 226 |
+
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
|
| 227 |
+
method: 'POST',
|
| 228 |
+
body: JSON.stringify({
|
| 229 |
+
browserType: this.browserConfig.browserName,
|
| 230 |
+
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
|
| 231 |
+
launchOptions: this.browserConfig.launchOptions,
|
| 232 |
+
contextOptions: this.browserConfig.contextOptions,
|
| 233 |
+
} as LaunchBrowserRequest),
|
| 234 |
+
});
|
| 235 |
+
const info = await response.json() as BrowserInfo;
|
| 236 |
+
if (info.error)
|
| 237 |
+
throw new Error(info.error);
|
| 238 |
+
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
| 242 |
+
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
private async _createUserDataDir() {
|
| 246 |
+
const dir = await userDataDir(this.browserConfig);
|
| 247 |
+
await fs.promises.mkdir(dir, { recursive: true });
|
| 248 |
+
return dir;
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
| 253 |
+
if (browserConfig.browserName === 'chromium')
|
| 254 |
+
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
async function findFreePort() {
|
| 258 |
+
return new Promise((resolve, reject) => {
|
| 259 |
+
const server = net.createServer();
|
| 260 |
+
server.listen(0, () => {
|
| 261 |
+
const { port } = server.address() as net.AddressInfo;
|
| 262 |
+
server.close(() => resolve(port));
|
| 263 |
+
});
|
| 264 |
+
server.on('error', reject);
|
| 265 |
+
});
|
| 266 |
+
}
|
src/config.ts
CHANGED
|
@@ -15,7 +15,6 @@
|
|
| 15 |
*/
|
| 16 |
|
| 17 |
import fs from 'fs';
|
| 18 |
-
import net from 'net';
|
| 19 |
import os from 'os';
|
| 20 |
import path from 'path';
|
| 21 |
import { devices } from 'playwright';
|
|
@@ -29,6 +28,7 @@ export type CLIOptions = {
|
|
| 29 |
blockedOrigins?: string[];
|
| 30 |
blockServiceWorkers?: boolean;
|
| 31 |
browser?: string;
|
|
|
|
| 32 |
caps?: string;
|
| 33 |
cdpEndpoint?: string;
|
| 34 |
config?: string;
|
|
@@ -96,8 +96,6 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
|
|
| 96 |
// Derive artifact output directory from config.outputDir
|
| 97 |
if (result.saveTrace)
|
| 98 |
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
| 99 |
-
if (result.browser.browserName === 'chromium')
|
| 100 |
-
(result.browser.launchOptions as any).cdpPort = await findFreePort();
|
| 101 |
return result;
|
| 102 |
}
|
| 103 |
|
|
@@ -171,6 +169,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|
| 171 |
|
| 172 |
const result: Config = {
|
| 173 |
browser: {
|
|
|
|
| 174 |
browserName,
|
| 175 |
isolated: cliOptions.isolated,
|
| 176 |
userDataDir: cliOptions.userDataDir,
|
|
@@ -196,17 +195,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|
| 196 |
return result;
|
| 197 |
}
|
| 198 |
|
| 199 |
-
async function findFreePort() {
|
| 200 |
-
return new Promise((resolve, reject) => {
|
| 201 |
-
const server = net.createServer();
|
| 202 |
-
server.listen(0, () => {
|
| 203 |
-
const { port } = server.address() as net.AddressInfo;
|
| 204 |
-
server.close(() => resolve(port));
|
| 205 |
-
});
|
| 206 |
-
server.on('error', reject);
|
| 207 |
-
});
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
| 211 |
if (!configFile)
|
| 212 |
return {};
|
|
@@ -232,6 +220,8 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
|
| 232 |
|
| 233 |
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
| 234 |
const browser: FullConfig['browser'] = {
|
|
|
|
|
|
|
| 235 |
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
| 236 |
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
| 237 |
launchOptions: {
|
|
@@ -243,9 +233,6 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
|
| 243 |
...pickDefined(base.browser?.contextOptions),
|
| 244 |
...pickDefined(overrides.browser?.contextOptions),
|
| 245 |
},
|
| 246 |
-
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
|
| 247 |
-
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
|
| 248 |
-
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
|
| 249 |
};
|
| 250 |
|
| 251 |
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
|
|
|
| 15 |
*/
|
| 16 |
|
| 17 |
import fs from 'fs';
|
|
|
|
| 18 |
import os from 'os';
|
| 19 |
import path from 'path';
|
| 20 |
import { devices } from 'playwright';
|
|
|
|
| 28 |
blockedOrigins?: string[];
|
| 29 |
blockServiceWorkers?: boolean;
|
| 30 |
browser?: string;
|
| 31 |
+
browserAgent?: string;
|
| 32 |
caps?: string;
|
| 33 |
cdpEndpoint?: string;
|
| 34 |
config?: string;
|
|
|
|
| 96 |
// Derive artifact output directory from config.outputDir
|
| 97 |
if (result.saveTrace)
|
| 98 |
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
|
|
|
|
|
|
| 99 |
return result;
|
| 100 |
}
|
| 101 |
|
|
|
|
| 169 |
|
| 170 |
const result: Config = {
|
| 171 |
browser: {
|
| 172 |
+
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
|
| 173 |
browserName,
|
| 174 |
isolated: cliOptions.isolated,
|
| 175 |
userDataDir: cliOptions.userDataDir,
|
|
|
|
| 195 |
return result;
|
| 196 |
}
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
| 199 |
if (!configFile)
|
| 200 |
return {};
|
|
|
|
| 220 |
|
| 221 |
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
| 222 |
const browser: FullConfig['browser'] = {
|
| 223 |
+
...pickDefined(base.browser),
|
| 224 |
+
...pickDefined(overrides.browser),
|
| 225 |
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
| 226 |
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
| 227 |
launchOptions: {
|
|
|
|
| 233 |
...pickDefined(base.browser?.contextOptions),
|
| 234 |
...pickDefined(overrides.browser?.contextOptions),
|
| 235 |
},
|
|
|
|
|
|
|
|
|
|
| 236 |
};
|
| 237 |
|
| 238 |
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
src/fileUtils.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Copyright (c) Microsoft Corporation.
|
| 3 |
+
*
|
| 4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
| 5 |
+
* you may not use this file except in compliance with the License.
|
| 6 |
+
* You may obtain a copy of the License at
|
| 7 |
+
*
|
| 8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
| 9 |
+
*
|
| 10 |
+
* Unless required by applicable law or agreed to in writing, software
|
| 11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
| 12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 13 |
+
* See the License for the specific language governing permissions and
|
| 14 |
+
* limitations under the License.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import os from 'node:os';
|
| 18 |
+
import path from 'node:path';
|
| 19 |
+
|
| 20 |
+
import type { FullConfig } from './config.js';
|
| 21 |
+
|
| 22 |
+
export function cacheDir() {
|
| 23 |
+
let cacheDirectory: string;
|
| 24 |
+
if (process.platform === 'linux')
|
| 25 |
+
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
| 26 |
+
else if (process.platform === 'darwin')
|
| 27 |
+
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
| 28 |
+
else if (process.platform === 'win32')
|
| 29 |
+
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
| 30 |
+
else
|
| 31 |
+
throw new Error('Unsupported platform: ' + process.platform);
|
| 32 |
+
return path.join(cacheDirectory, 'ms-playwright');
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export async function userDataDir(browserConfig: FullConfig['browser']) {
|
| 36 |
+
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
| 37 |
+
}
|
src/httpServer.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Copyright (c) Microsoft Corporation.
|
| 3 |
+
*
|
| 4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
| 5 |
+
* you may not use this file except in compliance with the License.
|
| 6 |
+
* You may obtain a copy of the License at
|
| 7 |
+
*
|
| 8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
| 9 |
+
*
|
| 10 |
+
* Unless required by applicable law or agreed to in writing, software
|
| 11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
| 12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 13 |
+
* See the License for the specific language governing permissions and
|
| 14 |
+
* limitations under the License.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import fs from 'fs';
|
| 18 |
+
import path from 'path';
|
| 19 |
+
import http from 'http';
|
| 20 |
+
import net from 'net';
|
| 21 |
+
|
| 22 |
+
import mime from 'mime';
|
| 23 |
+
|
| 24 |
+
import { ManualPromise } from './manualPromise.js';
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
|
| 28 |
+
|
| 29 |
+
export type Transport = {
|
| 30 |
+
sendEvent?: (method: string, params: any) => void;
|
| 31 |
+
close?: () => void;
|
| 32 |
+
onconnect: () => void;
|
| 33 |
+
dispatch: (method: string, params: any) => Promise<any>;
|
| 34 |
+
onclose: () => void;
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
export class HttpServer {
|
| 38 |
+
private _server: http.Server;
|
| 39 |
+
private _urlPrefixPrecise: string = '';
|
| 40 |
+
private _urlPrefixHumanReadable: string = '';
|
| 41 |
+
private _port: number = 0;
|
| 42 |
+
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
|
| 43 |
+
|
| 44 |
+
constructor() {
|
| 45 |
+
this._server = http.createServer(this._onRequest.bind(this));
|
| 46 |
+
decorateServer(this._server);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
server() {
|
| 50 |
+
return this._server;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
routePrefix(prefix: string, handler: ServerRouteHandler) {
|
| 54 |
+
this._routes.push({ prefix, handler });
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
routePath(path: string, handler: ServerRouteHandler) {
|
| 58 |
+
this._routes.push({ exact: path, handler });
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
port(): number {
|
| 62 |
+
return this._port;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
private async _tryStart(port: number | undefined, host: string) {
|
| 66 |
+
const errorPromise = new ManualPromise();
|
| 67 |
+
const errorListener = (error: Error) => errorPromise.reject(error);
|
| 68 |
+
this._server.on('error', errorListener);
|
| 69 |
+
|
| 70 |
+
try {
|
| 71 |
+
this._server.listen(port, host);
|
| 72 |
+
await Promise.race([
|
| 73 |
+
new Promise(cb => this._server!.once('listening', cb)),
|
| 74 |
+
errorPromise,
|
| 75 |
+
]);
|
| 76 |
+
} finally {
|
| 77 |
+
this._server.removeListener('error', errorListener);
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
|
| 82 |
+
const host = options.host || 'localhost';
|
| 83 |
+
if (options.preferredPort) {
|
| 84 |
+
try {
|
| 85 |
+
await this._tryStart(options.preferredPort, host);
|
| 86 |
+
} catch (e: any) {
|
| 87 |
+
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
| 88 |
+
throw e;
|
| 89 |
+
await this._tryStart(undefined, host);
|
| 90 |
+
}
|
| 91 |
+
} else {
|
| 92 |
+
await this._tryStart(options.port, host);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const address = this._server.address();
|
| 96 |
+
if (typeof address === 'string') {
|
| 97 |
+
this._urlPrefixPrecise = address;
|
| 98 |
+
this._urlPrefixHumanReadable = address;
|
| 99 |
+
} else {
|
| 100 |
+
this._port = address!.port;
|
| 101 |
+
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
|
| 102 |
+
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
|
| 103 |
+
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
async stop() {
|
| 108 |
+
await new Promise(cb => this._server!.close(cb));
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
urlPrefix(purpose: 'human-readable' | 'precise'): string {
|
| 112 |
+
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
| 116 |
+
try {
|
| 117 |
+
for (const [name, value] of Object.entries(headers || {}))
|
| 118 |
+
response.setHeader(name, value);
|
| 119 |
+
if (request.headers.range)
|
| 120 |
+
this._serveRangeFile(request, response, absoluteFilePath);
|
| 121 |
+
else
|
| 122 |
+
this._serveFile(response, absoluteFilePath);
|
| 123 |
+
return true;
|
| 124 |
+
} catch (e) {
|
| 125 |
+
return false;
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
|
| 130 |
+
const content = fs.readFileSync(absoluteFilePath);
|
| 131 |
+
response.statusCode = 200;
|
| 132 |
+
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
| 133 |
+
response.setHeader('Content-Type', contentType);
|
| 134 |
+
response.setHeader('Content-Length', content.byteLength);
|
| 135 |
+
response.end(content);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
|
| 139 |
+
const range = request.headers.range;
|
| 140 |
+
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
| 141 |
+
response.statusCode = 400;
|
| 142 |
+
return response.end('Bad request');
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
| 146 |
+
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
| 147 |
+
|
| 148 |
+
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
| 149 |
+
let start: number;
|
| 150 |
+
let end: number;
|
| 151 |
+
const size = fs.statSync(absoluteFilePath).size;
|
| 152 |
+
if (startStr !== '' && endStr === '') {
|
| 153 |
+
// No end specified: use the whole file
|
| 154 |
+
start = +startStr;
|
| 155 |
+
end = size - 1;
|
| 156 |
+
} else if (startStr === '' && endStr !== '') {
|
| 157 |
+
// No start specified: calculate start manually
|
| 158 |
+
start = size - +endStr;
|
| 159 |
+
end = size - 1;
|
| 160 |
+
} else {
|
| 161 |
+
start = +startStr;
|
| 162 |
+
end = +endStr;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
// Handle unavailable range request
|
| 166 |
+
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
| 167 |
+
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
| 168 |
+
response.writeHead(416, {
|
| 169 |
+
'Content-Range': `bytes */${size}`
|
| 170 |
+
});
|
| 171 |
+
return response.end();
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
| 175 |
+
response.writeHead(206, {
|
| 176 |
+
'Content-Range': `bytes ${start}-${end}/${size}`,
|
| 177 |
+
'Accept-Ranges': 'bytes',
|
| 178 |
+
'Content-Length': end - start + 1,
|
| 179 |
+
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
| 183 |
+
readable.pipe(response);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
| 187 |
+
if (request.method === 'OPTIONS') {
|
| 188 |
+
response.writeHead(200);
|
| 189 |
+
response.end();
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
request.on('error', () => response.end());
|
| 194 |
+
try {
|
| 195 |
+
if (!request.url) {
|
| 196 |
+
response.end();
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
+
const url = new URL('http://localhost' + request.url);
|
| 200 |
+
for (const route of this._routes) {
|
| 201 |
+
if (route.exact && url.pathname === route.exact) {
|
| 202 |
+
route.handler(request, response);
|
| 203 |
+
return;
|
| 204 |
+
}
|
| 205 |
+
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
| 206 |
+
route.handler(request, response);
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
response.statusCode = 404;
|
| 211 |
+
response.end();
|
| 212 |
+
} catch (e) {
|
| 213 |
+
response.end();
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function decorateServer(server: net.Server) {
|
| 219 |
+
const sockets = new Set<net.Socket>();
|
| 220 |
+
server.on('connection', socket => {
|
| 221 |
+
sockets.add(socket);
|
| 222 |
+
socket.once('close', () => sockets.delete(socket));
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
const close = server.close;
|
| 226 |
+
server.close = (callback?: (err?: Error) => void) => {
|
| 227 |
+
for (const socket of sockets)
|
| 228 |
+
socket.destroy();
|
| 229 |
+
sockets.clear();
|
| 230 |
+
return close.call(server, callback);
|
| 231 |
+
};
|
| 232 |
+
}
|
src/program.ts
CHANGED
|
@@ -30,6 +30,7 @@ program
|
|
| 30 |
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
| 31 |
.option('--block-service-workers', 'block service workers')
|
| 32 |
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
|
|
|
| 33 |
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
| 34 |
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
| 35 |
.option('--config <path>', 'path to the configuration file.')
|
|
|
|
| 30 |
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
| 31 |
.option('--block-service-workers', 'block service workers')
|
| 32 |
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
| 33 |
+
.option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
|
| 34 |
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
| 35 |
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
| 36 |
.option('--config <path>', 'path to the configuration file.')
|
tests/agent.spec.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Copyright (c) Microsoft Corporation.
|
| 3 |
+
*
|
| 4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
| 5 |
+
* you may not use this file except in compliance with the License.
|
| 6 |
+
* You may obtain a copy of the License at
|
| 7 |
+
*
|
| 8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
| 9 |
+
*
|
| 10 |
+
* Unless required by applicable law or agreed to in writing, software
|
| 11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
| 12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 13 |
+
* See the License for the specific language governing permissions and
|
| 14 |
+
* limitations under the License.
|
| 15 |
+
*/
|
| 16 |
+
import path from 'path';
|
| 17 |
+
import url from 'node:url';
|
| 18 |
+
|
| 19 |
+
import { spawn } from 'child_process';
|
| 20 |
+
import { test as baseTest, expect } from './fixtures.js';
|
| 21 |
+
|
| 22 |
+
import type { ChildProcess } from 'child_process';
|
| 23 |
+
|
| 24 |
+
const __filename = url.fileURLToPath(import.meta.url);
|
| 25 |
+
|
| 26 |
+
const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({
|
| 27 |
+
agentEndpoint: async ({}, use) => {
|
| 28 |
+
let cp: ChildProcess | undefined;
|
| 29 |
+
await use(async (options?: { args?: string[] }) => {
|
| 30 |
+
if (cp)
|
| 31 |
+
throw new Error('Process already running');
|
| 32 |
+
|
| 33 |
+
cp = spawn('node', [
|
| 34 |
+
path.join(path.dirname(__filename), '../lib/browserAgent.js'),
|
| 35 |
+
...(options?.args || []),
|
| 36 |
+
], {
|
| 37 |
+
stdio: 'pipe',
|
| 38 |
+
env: {
|
| 39 |
+
...process.env,
|
| 40 |
+
DEBUG: 'pw:mcp:test',
|
| 41 |
+
DEBUG_COLORS: '0',
|
| 42 |
+
DEBUG_HIDE_DATE: '1',
|
| 43 |
+
},
|
| 44 |
+
});
|
| 45 |
+
let stdout = '';
|
| 46 |
+
const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => {
|
| 47 |
+
stdout += data.toString();
|
| 48 |
+
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
| 49 |
+
if (match)
|
| 50 |
+
resolve(match[1]);
|
| 51 |
+
}));
|
| 52 |
+
|
| 53 |
+
return { url: new URL(url), stdout: () => stdout };
|
| 54 |
+
});
|
| 55 |
+
cp?.kill('SIGTERM');
|
| 56 |
+
},
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now');
|
| 60 |
+
|
| 61 |
+
test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => {
|
| 62 |
+
const { url: agentUrl } = await agentEndpoint();
|
| 63 |
+
const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
| 64 |
+
expect(await client1.callTool({
|
| 65 |
+
name: 'browser_navigate',
|
| 66 |
+
arguments: { url: server.HELLO_WORLD },
|
| 67 |
+
})).toContainTextContent('Hello, world!');
|
| 68 |
+
|
| 69 |
+
const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
| 70 |
+
expect(await client2.callTool({
|
| 71 |
+
name: 'browser_navigate',
|
| 72 |
+
arguments: { url: server.HELLO_WORLD },
|
| 73 |
+
})).toContainTextContent('Hello, world!');
|
| 74 |
+
|
| 75 |
+
await client1.close();
|
| 76 |
+
await client2.close();
|
| 77 |
+
});
|
tests/fixtures.ts
CHANGED
|
@@ -65,12 +65,14 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|
| 65 |
},
|
| 66 |
|
| 67 |
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
| 68 |
-
const userDataDir = testInfo.outputPath('user-data-dir');
|
| 69 |
const configDir = path.dirname(test.info().config.configFile!);
|
| 70 |
let client: Client | undefined;
|
| 71 |
|
| 72 |
await use(async options => {
|
| 73 |
-
const args
|
|
|
|
|
|
|
| 74 |
if (process.env.CI && process.platform === 'linux')
|
| 75 |
args.push('--no-sandbox');
|
| 76 |
if (mcpHeadless)
|
|
@@ -239,5 +241,5 @@ export const expect = baseExpect.extend({
|
|
| 239 |
});
|
| 240 |
|
| 241 |
export function formatOutput(output: string): string[] {
|
| 242 |
-
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/
|
| 243 |
}
|
|
|
|
| 65 |
},
|
| 66 |
|
| 67 |
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
| 68 |
+
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
| 69 |
const configDir = path.dirname(test.info().config.configFile!);
|
| 70 |
let client: Client | undefined;
|
| 71 |
|
| 72 |
await use(async options => {
|
| 73 |
+
const args: string[] = [];
|
| 74 |
+
if (userDataDir)
|
| 75 |
+
args.push('--user-data-dir', userDataDir);
|
| 76 |
if (process.env.CI && process.platform === 'linux')
|
| 77 |
args.push('--no-sandbox');
|
| 78 |
if (mcpHeadless)
|
|
|
|
| 241 |
});
|
| 242 |
|
| 243 |
export function formatOutput(output: string): string[] {
|
| 244 |
+
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
| 245 |
}
|