diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e7a95d..e5348f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,11 @@ jobs: run: npm run lint - name: Ensure no changes run: git diff --exit-code + - name: Run cli lint + run: | + npm ci + npm run lint + working-directory: ./cli test: strategy: diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..26cfd3f --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,563 @@ +{ + "name": "@playwright/cli", + "version": "0.0.56", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@playwright/cli", + "version": "0.0.56", + "license": "Apache-2.0", + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/minimist": "^1.2.5", + "debug": "^4.4.3", + "esbuild": "^0.24.0", + "minimist": "^1.2.8", + "typescript": "^5.8.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..ad5d53f --- /dev/null +++ b/cli/package.json @@ -0,0 +1,33 @@ +{ + "name": "@playwright/cli", + "version": "0.0.56", + "description": "Playwright CLI", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/playwright-mcp.git" + }, + "homepage": "https://playwright.dev", + "engines": { + "node": ">=18" + }, + "author": { + "name": "Microsoft Corporation" + }, + "license": "Apache-2.0", + "scripts": { + "lint": "tsc --project .", + "build": "esbuild src/cli.ts --bundle --platform=node --target=node18 --format=cjs --outfile=lib/cli.js", + "watch": "esbuild src/cli.ts --bundle --platform=node --target=node18 --format=cjs --outfile=lib/cli.js --watch", + "test": "playwright test", + "clean": "rm -rf lib" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/minimist": "^1.2.5", + "debug": "^4.4.3", + "esbuild": "^0.24.0", + "minimist": "^1.2.8", + "typescript": "^5.8.2" + } +} diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 0000000..094adf0 --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,331 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable no-console */ +/* eslint-disable no-restricted-properties */ + +import { spawn } from 'child_process'; +import crypto from 'crypto'; +import fs from 'fs'; +import net from 'net'; +import os from 'os'; +import path from 'path'; +import { debug } from 'debug'; +import minimist from 'minimist'; +import { SocketConnection } from './socketConnection'; + +const debugCli = debug('pw:cli'); + +const packageJSON = require('../package.json'); + +async function runCliCommand(sessionName: string, args: any) { + const session = await connectToDaemon(sessionName); + const result = await session.runCliCommand(args); + console.log(result); + session.dispose(); +} + +async function socketExists(socketPath: string): Promise { + try { + const stat = await fs.promises.stat(socketPath); + if (stat?.isSocket()) + return true; + } catch (e) { + } + return false; +} + +class SocketSession { + private _connection: SocketConnection; + private _nextMessageId = 1; + private _callbacks = new Map void, reject: (e: Error) => void }>(); + + constructor(connection: SocketConnection) { + this._connection = connection; + this._connection.onmessage = message => this._onMessage(message); + this._connection.onclose = () => this.dispose(); + } + + async runCliCommand(args: any): Promise { + return await this._send('runCliCommand', { args }); + } + + private async _send(method: string, params: any = {}): Promise { + const messageId = this._nextMessageId++; + const message = { + id: messageId, + method, + params, + }; + await this._connection.send(message); + return new Promise((resolve, reject) => { + this._callbacks.set(messageId, { resolve, reject }); + }); + } + + dispose() { + for (const callback of this._callbacks.values()) + callback.reject(new Error('Disposed')); + this._callbacks.clear(); + this._connection.close(); + } + + private _onMessage(object: any) { + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id)!; + this._callbacks.delete(object.id); + if (object.error) + callback.reject(new Error(object.error)); + else + callback.resolve(object.result); + } else if (object.id) { + throw new Error(`Unexpected message id: ${object.id}`); + } else { + throw new Error(`Unexpected message without id: ${JSON.stringify(object)}`); + } + } +} + +function localCacheDir(): string { + if (process.platform === 'linux') + return process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); + if (process.platform === 'darwin') + return path.join(os.homedir(), 'Library', 'Caches'); + if (process.platform === 'win32') + return process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + throw new Error('Unsupported platform: ' + process.platform); +} + +function playwrightCacheDir(): string { + return path.join(localCacheDir(), 'ms-playwright'); +} + +function calculateSha1(buffer: Buffer | string): string { + const hash = crypto.createHash('sha1'); + hash.update(buffer); + return hash.digest('hex'); +} + +function socketDirHash(): string { + return calculateSha1(__dirname); +} + +function daemonSocketDir(): string { + return path.resolve(playwrightCacheDir(), 'daemon', socketDirHash()); +} + +function daemonSocketPath(sessionName: string): string { + const socketName = `${sessionName}.sock`; + if (os.platform() === 'win32') + return `\\\\.\\pipe\\${socketDirHash()}-${socketName}`; + return path.resolve(daemonSocketDir(), socketName); +} + +async function connectToDaemon(sessionName: string): Promise { + const socketPath = daemonSocketPath(sessionName); + debugCli(`Connecting to daemon at ${socketPath}`); + + if (await socketExists(socketPath)) { + debugCli(`Socket file exists, attempting to connect...`); + try { + return await connectToSocket(socketPath); + } catch (e) { + // Connection failed, delete the stale socket file. + if (os.platform() !== 'win32') + await fs.promises.unlink(socketPath).catch(() => {}); + } + } + + debugCli(`Will launch daemon process: npx playwright run-mcp-server`); + const userDataDir = path.resolve(daemonSocketDir(), `${sessionName}-user-data`); + const child = spawn('npx', ['playwright', 'run-mcp-server', `--daemon=${socketPath}`, `--user-data-dir=${userDataDir}`], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), // Will be used as root. + }); + child.unref(); + + // Wait for the socket to become available with retries. + const maxRetries = 50; + const retryDelay = 100; // ms + for (let i = 0; i < maxRetries; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + try { + return await connectToSocket(socketPath); + } catch (e: any) { + if (e.code !== 'ENOENT') + throw e; + debugCli(`Retrying to connect to daemon at ${socketPath} (${i + 1}/${maxRetries})`); + } + } + throw new Error(`Failed to connect to daemon at ${socketPath} after ${maxRetries * retryDelay}ms`); +} + +async function connectToSocket(socketPath: string): Promise { + const socket = await new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath, () => { + debugCli(`Connected to daemon at ${socketPath}`); + resolve(socket); + }); + socket.on('error', reject); + }); + return new SocketSession(new SocketConnection(socket)); +} + +function currentSessionPath(): string { + return path.resolve(daemonSocketDir(), 'current-session'); +} + +async function getCurrentSession(): Promise { + try { + const session = await fs.promises.readFile(currentSessionPath(), 'utf-8'); + return session.trim() || 'default'; + } catch { + return 'default'; + } +} + +async function setCurrentSession(sessionName: string): Promise { + await fs.promises.mkdir(daemonSocketDir(), { recursive: true }); + await fs.promises.writeFile(currentSessionPath(), sessionName); +} + +async function canConnectToSocket(socketPath: string): Promise { + return new Promise(resolve => { + const socket = net.createConnection(socketPath, () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + resolve(false); + }); + }); +} + +async function listSessions(): Promise<{ name: string, live: boolean }[]> { + const dir = daemonSocketDir(); + try { + const files = await fs.promises.readdir(dir); + const sessions: { name: string, live: boolean }[] = []; + for (const file of files) { + if (file.endsWith('-user-data')) { + const sessionName = file.slice(0, -'-user-data'.length); + const socketPath = daemonSocketPath(sessionName); + const live = await canConnectToSocket(socketPath); + sessions.push({ name: sessionName, live }); + } + } + return sessions; + } catch { + return []; + } +} + +function resolveSessionName(args: any): string { + if (args.session) + return args.session; + if (process.env.PLAYWRIGHT_CLI_SESSION) + return process.env.PLAYWRIGHT_CLI_SESSION; + return 'default'; +} + +async function handleSessionCommand(args: any): Promise { + const subcommand = args._[1]; + + if (!subcommand) { + // Show current session + const current = await getCurrentSession(); + console.log(current); + return; + } + + if (subcommand === 'list') { + const sessions = await listSessions(); + const current = await getCurrentSession(); + console.log('Sessions:'); + for (const session of sessions) { + const marker = session.name === current ? '->' : ' '; + const liveMarker = session.live ? ' (live)' : ''; + console.log(`${marker} ${session.name}${liveMarker}`); + } + if (sessions.length === 0) + console.log(' (no sessions)'); + return; + } + + if (subcommand === 'set') { + const sessionName = args._[2]; + if (!sessionName) { + console.error('Usage: playwright-cli session set '); + process.exit(1); + } + await setCurrentSession(sessionName); + console.log(`Current session set to: ${sessionName}`); + return; + } + + console.error(`Unknown session subcommand: ${subcommand}`); + process.exit(1); +} + +async function main() { + const argv = process.argv.slice(2); + const args = minimist(argv); + const commandName = args._[0]; + + if (args.version || args.v) { + console.log(packageJSON.version); + process.exit(0); + } + + // Handle 'session' command specially - it doesn't need daemon connection + if (commandName === 'session') { + await handleSessionCommand(args); + return; + } + + const help = require('./help.json'); + const command = help.commands[commandName]; + if (args.help || args.h) { + if (command) { + console.log(command); + } else { + console.log('playwright-cli - run playwright mcp commands from terminal\n'); + console.log(help.global); + } + process.exit(0); + } + if (!command) { + console.error(`Unknown command: ${commandName}\n`); + console.log(help.global); + process.exit(1); + } + + // Resolve session name: --session flag > PLAYWRIGHT_CLI_SESSION env > current session > 'default' + let sessionName = resolveSessionName(args); + if (sessionName === 'default' && !args.session && !process.env.PLAYWRIGHT_CLI_SESSION) + sessionName = await getCurrentSession(); + + runCliCommand(sessionName, args).catch(e => { + console.error(e.message); + process.exit(1); + }); +} + +main().catch(e => { + console.error(e.message); + process.exit(1); +}); diff --git a/cli/src/help.json b/cli/src/help.json new file mode 100644 index 0000000..e064df9 --- /dev/null +++ b/cli/src/help.json @@ -0,0 +1,32 @@ +{ + "global": "Usage: playwright-cli [options]\nCommands:\n click perform click on a web page\n close close the page\n dblclick perform double click on a web page\n console returns all console messages\n drag perform drag and drop between two elements\n evaluate evaluate javascript expression on page or element\n upload-file upload one or multiple files\n handle-dialog handle a dialog\n hover hover over element on page\n open open url\n go-back go back to the previous page\n network-requests returns all network requests since loading the page\n press-key press a key on the keyboard\n resize resize the browser window\n run-code run playwright code snippet\n select-option select an option in a dropdown\n snapshot capture accessibility snapshot of the current page, this is better than screenshot\n screenshot take a screenshot of the current page. you can't perform actions based on the screenshot, use browser_snapshot for actions.\n type type text into editable element\n wait-for wait for text to appear or disappear or a specified time to pass\n tab close a browser tab\n mouse-click-xy click left mouse button at a given position\n mouse-drag-xy drag left mouse button to a given position\n mouse-move-xy move mouse to a given position\n pdf-save save page as pdf\n start-tracing start trace recording\n stop-tracing stop trace recording", + "commands": { + "click": "playwright-cli click \n\nPerform click on a web page\n\nArguments:\n \tExact target element reference from the page snapshot\nOptions:\n --button\tbutton to click, defaults to left\n --modifiers\tmodifier keys to press", + "close": "playwright-cli close \n\nClose the page\n", + "dblclick": "playwright-cli dblclick \n\nPerform double click on a web page\n\nArguments:\n \tExact target element reference from the page snapshot\nOptions:\n --button\tbutton to click, defaults to left\n --modifiers\tmodifier keys to press", + "console": "playwright-cli console \n\nReturns all console messages\n\nArguments:\n \tLevel of the console messages to return. Each level includes the messages of more severe levels. Defaults to \"info\".", + "drag": "playwright-cli drag \n\nPerform drag and drop between two elements\n\nArguments:\n \tExact source element reference from the page snapshot\n \tExact target element reference from the page snapshot\nOptions:\n --headed\trun browser in headed mode", + "evaluate": "playwright-cli evaluate \n\nEvaluate JavaScript expression on page or element\n\nArguments:\n \t() => { /* code */ } or (element) => { /* code */ } when element is provided\n \tExact target element reference from the page snapshot", + "upload-file": "playwright-cli upload-file \n\nUpload one or multiple files\n\nOptions:\n --paths\tthe absolute paths to the files to upload. can be single file or multiple files. if omitted, file chooser is cancelled.", + "handle-dialog": "playwright-cli handle-dialog \n\nHandle a dialog\n\nArguments:\n \tWhether to accept the dialog.\n \tThe text of the prompt in case of a prompt dialog.", + "hover": "playwright-cli hover \n\nHover over element on page\n\nArguments:\n \tExact target element reference from the page snapshot", + "open": "playwright-cli open \n\nOpen URL\n\nArguments:\n \tThe URL to navigate to\nOptions:\n --headed\trun browser in headed mode", + "go-back": "playwright-cli go-back \n\nGo back to the previous page\n", + "network-requests": "playwright-cli network-requests \n\nReturns all network requests since loading the page\n\nOptions:\n --includeStatic\twhether to include successful static resources like images, fonts, scripts, etc. defaults to false.", + "press-key": "playwright-cli press-key \n\nPress a key on the keyboard\n\nArguments:\n \tName of the key to press or a character to generate, such as `ArrowLeft` or `a`", + "resize": "playwright-cli resize \n\nResize the browser window\n\nArguments:\n \tWidth of the browser window\n \tHeight of the browser window", + "run-code": "playwright-cli run-code \n\nRun Playwright code snippet\n\nArguments:\n \tA JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction.", + "select-option": "playwright-cli select-option \n\nSelect an option in a dropdown\n\nArguments:\n \tExact target element reference from the page snapshot\n \tArray of values to select in the dropdown. This can be a single value or multiple values.", + "snapshot": "playwright-cli snapshot \n\nCapture accessibility snapshot of the current page, this is better than screenshot\n\nOptions:\n --filename\tsave snapshot to markdown file instead of returning it in the response.", + "screenshot": "playwright-cli screenshot \n\nTake a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.\n\nArguments:\n \tExact target element reference from the page snapshot.\nOptions:\n --filename\tfile name to save the screenshot to. defaults to `page-{timestamp}.{png|jpeg}` if not specified.\n --fullPage\twhen true, takes a screenshot of the full scrollable page, instead of the currently visible viewport.", + "type": "playwright-cli type \n\nType text into editable element\n\nArguments:\n \tExact target element reference from the page snapshot\n \tText to type into the element\nOptions:\n --submit\twhether to submit entered text (press enter after)\n --slowly\twhether to type one character at a time. useful for triggering key handlers in the page.", + "wait-for": "playwright-cli wait-for \n\nWait for text to appear or disappear or a specified time to pass\n\nOptions:\n --time\tthe time to wait in seconds\n --text\tthe text to wait for\n --textGone\tthe text to wait for to disappear", + "tab": "playwright-cli tab \n\nClose a browser tab\n\nArguments:\n \tAction to perform on tabs, 'list' | 'new' | 'close' | 'select'\n \tTab index. If omitted, current tab is closed.", + "mouse-click-xy": "playwright-cli mouse-click-xy \n\nClick left mouse button at a given position\n\nArguments:\n \tX coordinate\n \tY coordinate", + "mouse-drag-xy": "playwright-cli mouse-drag-xy \n\nDrag left mouse button to a given position\n\nArguments:\n \tStart X coordinate\n \tStart Y coordinate\n \tEnd X coordinate\n \tEnd Y coordinate", + "mouse-move-xy": "playwright-cli mouse-move-xy \n\nMove mouse to a given position\n\nArguments:\n \tX coordinate\n \tY coordinate", + "pdf-save": "playwright-cli pdf-save \n\nSave page as PDF\n\nOptions:\n --filename\tfile name to save the pdf to. defaults to `page-{timestamp}.pdf` if not specified.", + "start-tracing": "playwright-cli start-tracing \n\nStart trace recording\n", + "stop-tracing": "playwright-cli stop-tracing \n\nStop trace recording\n" + } +} \ No newline at end of file diff --git a/cli/src/socketConnection.ts b/cli/src/socketConnection.ts new file mode 100644 index 0000000..8de8f81 --- /dev/null +++ b/cli/src/socketConnection.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import net from 'net'; + +import { debug } from 'debug'; + +const daemonDebug = debug('pw:daemon'); + +export class SocketConnection { + private _socket: net.Socket; + private _pendingBuffers: Buffer[] = []; + + onclose?: () => void; + onmessage?: (message: any) => void; + + constructor(socket: net.Socket) { + this._socket = socket; + socket.on('data', buffer => this._onData(buffer)); + socket.on('close', () => { + this.onclose?.(); + }); + socket.on('error', e => daemonDebug(`error: ${e.message}`)); + } + + async send(message: any) { + await new Promise((resolve, reject) => { + this._socket.write(`${JSON.stringify(message)}\n`, error => { + if (error) + reject(error); + else + resolve(undefined); + }); + }); + } + + close() { + this._socket.destroy(); + } + + private _onData(buffer: Buffer) { + let end = buffer.indexOf('\n'); + if (end === -1) { + this._pendingBuffers.push(buffer); + return; + } + this._pendingBuffers.push(buffer.slice(0, end)); + const message = Buffer.concat(this._pendingBuffers).toString(); + this._dispatchMessage(message); + + let start = end + 1; + end = buffer.indexOf('\n', start); + while (end !== -1) { + const message = buffer.toString(undefined, start, end); + this._dispatchMessage(message); + start = end + 1; + end = buffer.indexOf('\n', start); + } + this._pendingBuffers = [buffer.slice(start)]; + } + + private _dispatchMessage(message: string) { + try { + this.onmessage?.(JSON.parse(message)); + } catch (e) { + daemonDebug('failed to dispatch message', e); + } + } +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..02aeb89 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "module": "ESNext", + "rootDir": "src", + "outDir": "./lib", + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": [ + "src", + ] +}