From 94491740610066d7369c2596f8d78f5a6baa2e15 Mon Sep 17 00:00:00 2001 From: panayot-cankov Date: Fri, 19 Sep 2025 15:01:16 +0300 Subject: [PATCH 1/6] chore: mcp for qa workflows --- examples/selenium-interop/w3schools.test.ts | 2 +- package-lock.json | 1011 +++++++++++++++++- packages/@progress/roadkill/.mcp/server.json | 21 + packages/@progress/roadkill/chromedriver.ts | 28 +- packages/@progress/roadkill/package.json | 8 +- packages/@progress/roadkill/roadkill-mcp.ts | 572 ++++++++++ packages/@progress/roadkill/server.ts | 7 +- packages/@progress/roadkill/webdriver.ts | 3 +- 8 files changed, 1608 insertions(+), 44 deletions(-) create mode 100644 packages/@progress/roadkill/.mcp/server.json create mode 100644 packages/@progress/roadkill/roadkill-mcp.ts diff --git a/examples/selenium-interop/w3schools.test.ts b/examples/selenium-interop/w3schools.test.ts index f7eb879..c524cbf 100644 --- a/examples/selenium-interop/w3schools.test.ts +++ b/examples/selenium-interop/w3schools.test.ts @@ -29,7 +29,7 @@ describe("w3schools", () => { await driver?.quit(); }); - test("navigate to js statements page", async () => { + test.skip("navigate to js statements page", async () => { await driver.navigate().to('http://www.w3schools.com'); await sleep(3000); await session.navigateTo('http://www.w3schools.com/js') diff --git a/package-lock.json b/package-lock.json index b466bf5..07d0784 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1274,6 +1274,29 @@ "node": "^14.17.0 || >=16.0.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.0.tgz", + "integrity": "sha512-JvKyB6YwS3quM+88JPR0axeRgvdDu3Pv6mdZUy+w4qVkCzGgumb9bXG/TmtDRQv+671yaofVfXSQmFLlWU5qPQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2205,6 +2228,49 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/add-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", @@ -2248,6 +2314,22 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -2546,6 +2628,38 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2695,6 +2809,15 @@ "node": ">=12.17" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacache": { "version": "17.1.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-17.1.4.tgz", @@ -2794,6 +2917,35 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3133,6 +3285,27 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "dev": true }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/conventional-changelog-angular": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-6.0.0.tgz", @@ -3255,11 +3428,42 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -3308,10 +3512,10 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3325,7 +3529,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -3355,12 +3558,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -3616,6 +3819,15 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "dev": true }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -3694,6 +3906,20 @@ "node": ">=12" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -3706,6 +3932,12 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", @@ -3744,6 +3976,15 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -3823,6 +4064,36 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -3831,6 +4102,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -3853,12 +4130,42 @@ "node": ">=4" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz", @@ -3913,6 +4220,84 @@ "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", "dev": true }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -3939,6 +4324,12 @@ "node": ">=0.6.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -3958,8 +4349,7 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "node_modules/fastq": { "version": "1.15.0", @@ -4052,6 +4442,23 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -4136,6 +4543,24 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -4196,6 +4621,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -4232,6 +4666,30 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -4271,6 +4729,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz", @@ -4439,6 +4910,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4492,12 +4975,36 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "dev": true }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -4522,6 +5029,31 @@ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", "dev": true }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -4831,6 +5363,15 @@ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "dev": true }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4970,6 +5511,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-ssh": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.0.tgz", @@ -5032,8 +5579,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isobject": { "version": "3.0.1", @@ -5800,6 +6346,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -6357,6 +6909,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -6471,6 +7041,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6773,10 +7355,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/multimatch": { "version": "5.0.0", @@ -7404,6 +7986,30 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7821,6 +8427,15 @@ "parse-path": "^7.0.0" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7843,7 +8458,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -7879,6 +8493,16 @@ "node": "14 || >=16.14" } }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -7951,6 +8575,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -8066,12 +8699,34 @@ "integrity": "sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==", "dev": true }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", @@ -8088,6 +8743,21 @@ } ] }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8117,6 +8787,46 @@ "node": ">=8" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -8661,6 +9371,22 @@ "node": ">=8" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -8706,7 +9432,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -8725,8 +9450,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/seek-bzip": { "version": "1.0.6", @@ -8773,6 +9497,64 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -8785,6 +9567,12 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "dev": true }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -8801,7 +9589,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -8813,11 +9600,82 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9031,6 +9889,15 @@ "node": ">=8" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9426,6 +10293,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -9436,7 +10312,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, "bin": { "tree-kill": "cli.js" } @@ -9566,6 +10441,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -9646,6 +10556,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/upath": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", @@ -9686,6 +10605,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9746,6 +10674,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -10081,17 +11018,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "packages/@progress/roadkill": { "version": "0.2.4", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.18.0", "@types/decompress": "^4.2.5", "decompress": "^4.2.1", "plist": "^3.1.0", + "tree-kill": "^1.2.2", "yargs": "^17.7.2" }, "bin": { - "roadkill": "roadkill.js" + "roadkill": "roadkill.js", + "roadkill-mcp": "roadkill-mcp.js" }, "devDependencies": { "@types/jest": "^29.5.6", @@ -10099,7 +11057,6 @@ "@types/plist": "^3.0.3", "@types/yargs": "^17.0.25", "jest": "^29.7.0", - "tree-kill": "^1.2.2", "typescript": "^5.2.2" } }, diff --git a/packages/@progress/roadkill/.mcp/server.json b/packages/@progress/roadkill/.mcp/server.json new file mode 100644 index 0000000..16bf632 --- /dev/null +++ b/packages/@progress/roadkill/.mcp/server.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://modelcontextprotocol.io/schemas/draft/2025-07-09/server.json", + "name": "io.github.telerik/roadkill", + "description": "Roadkill MCP: WebDriver utilities exposed to LLMs over stdio.", + "packages": [ + { + "registry_name": "npm", + "name": "@progress/roadkill", + "version": "0.2.4", + "package_arguments": [], + "environment_variables": [] + } + ], + "version_detail": { + "version": "0.2.4" + }, + "repository": { + "url": "https://github.com/telerik/roadkill", + "source": "github" + } +} diff --git a/packages/@progress/roadkill/chromedriver.ts b/packages/@progress/roadkill/chromedriver.ts index 8c02a12..5cbf75a 100644 --- a/packages/@progress/roadkill/chromedriver.ts +++ b/packages/@progress/roadkill/chromedriver.ts @@ -73,20 +73,26 @@ export class ChromeDriver extends Server { protected override onLine(line: string): void { super.onLine(line); - if (this.state == "starting") { - if (this._port == undefined) { - const result = /Starting ChromeDriver.*on port (\d*)/.exec(line); - if (result) this._port = Number.parseInt(result[1]); - } + if (this.state !== "starting") return; - if (line == "ChromeDriver was started successfully.") { - this._address = `http://localhost:${this._port}`; - this.state = "running"; - this.started(); - } + const l = line.trim(); + + // Capture port if present anywhere in the line + if (this._port === undefined) { + const m = /on port\s+(\d+)/i.exec(l); + if (m) this._port = Number.parseInt(m[1], 10); + } - this.startupLine.push(line); + // Transition to running when success message appears (with or without "on port ...") + if (/ChromeDriver was started successfully/i.test(l)) { + if (!this._address) { + const port = this._port ?? 9515; // fall back to known port if not parsed yet + this._address = `http://localhost:${port}`; + } + this.started(); // <-- this sets state = "running" when in "starting" } + + this.startupLine.push(l); } protected override startingErrorOnClose(code: number): Error { diff --git a/packages/@progress/roadkill/package.json b/packages/@progress/roadkill/package.json index 1ccd789..a13be54 100644 --- a/packages/@progress/roadkill/package.json +++ b/packages/@progress/roadkill/package.json @@ -7,7 +7,8 @@ "test": "node --experimental-vm-modules ../../../node_modules/jest/bin/jest.js --detectOpenHandles --forceExit" }, "bin": { - "roadkill": "./roadkill.js" + "roadkill": "./roadkill.js", + "roadkill-mcp": "./roadkill-mcp.js" }, "license": "MIT", "repository": { @@ -18,10 +19,12 @@ "access": "public" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.18.0", "@types/decompress": "^4.2.5", "decompress": "^4.2.1", "plist": "^3.1.0", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "tree-kill": "^1.2.2" }, "devDependencies": { "@types/jest": "^29.5.6", @@ -29,7 +32,6 @@ "@types/plist": "^3.0.3", "@types/yargs": "^17.0.25", "jest": "^29.7.0", - "tree-kill": "^1.2.2", "typescript": "^5.2.2" }, "files": [ diff --git a/packages/@progress/roadkill/roadkill-mcp.ts b/packages/@progress/roadkill/roadkill-mcp.ts new file mode 100644 index 0000000..fb9edae --- /dev/null +++ b/packages/@progress/roadkill/roadkill-mcp.ts @@ -0,0 +1,572 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +import { ChromeDriver } from "./chromedriver.js"; +import { WebDriverClient, Session, Element } from "./webdriver.js"; + +import { readFile } from "fs/promises"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +// ─────────────────────────────────────────────────────────── +// ChromeDriver singleton + runDriver() +// ─────────────────────────────────────────────────────────── + +const driver = new ChromeDriver({ + enableLogging: true, + log: console.error, + logPrefix: "ChromeDriver", + args: ["--port=9515", "--enable-chrome-logs"] +}); + +let driverWait: Promise | null = null; + +/** + * Idempotent bring-up for ChromeDriver: + * - running → resolve immediately with driver + * - new → start() once and wait + * - starting → wait for "running" or error + * - aborted/ disposed → throw + */ +async function runDriver(): Promise { + switch (driver.state) { + case "running": + return driver; + + case "new": + if (!driverWait) { + driverWait = driver + .start() + .then(() => driver) + .finally(() => { driverWait = null; }); + } + return driverWait; + + case "starting": + if (!driverWait) { + driverWait = new Promise((resolve, reject) => { + const onState = (s: string) => { + if (s === "running") { + cleanup(); + resolve(driver); + } else if (s === "disposed" || s === "abort start" || s === "abort running") { + cleanup(); + reject(new Error(`ChromeDriver failed to start (state: ${s}).`)); + } + }; + const cleanup = () => driver.off("state", onState as any); + driver.on("state", onState as any); + }).finally(() => { driverWait = null; }); + } + return driverWait; + + default: + throw new Error(`ChromeDriver is not available (state: ${driver.state}).`); + } +} + +// ─────────────────────────────────────────────────────────── +// Helpers +// ─────────────────────────────────────────────────────────── + +/** + * Return both a readable summary and a machine-parseable JSON payload. + * Using two text parts keeps compatibility with all MCP clients. + */ +function mcpResult(payload: T, summary?: string) { + const parts: Array<{ type: "text"; text: string }> = []; + if (summary) parts.push({ type: "text", text: summary }); + parts.push({ type: "text", text: JSON.stringify(payload) }); + return { content: parts }; +} + +// Resolve sibling file (prefers .ts, falls back to .js) +async function readSiblingModuleBase(nameNoExt: "webdriver" | "chromedriver"): Promise<{ path: string; content: string }> { + const base = dirname(fileURLToPath(import.meta.url)); + const tsPath = join(base, `${nameNoExt}.ts`); + const jsPath = join(base, `${nameNoExt}.js`); + try { + const content = await readFile(tsPath, "utf-8"); + return { path: tsPath, content }; + } catch { + try { + const content = await readFile(jsPath, "utf-8"); + return { path: jsPath, content }; + } catch { + throw new Error(`Could not find ${nameNoExt}.ts or ${nameNoExt}.js next to this MCP server.`); + } + } +} + +// ─────────────────────────────────────────────────────────── +// In-memory session store +// ─────────────────────────────────────────────────────────── +const sessions = new Map(); + +// ─────────────────────────────────────────────────────────── +// MCP server + tools +// ─────────────────────────────────────────────────────────── +const server = new McpServer({ + name: "roadkill-mcp", + version: "0.0.1", + description: + "Roadkill MCP: tools that let an LLM mimic WebDriver flows end-to-end. " + + "Typical usage: (1) webdriver.startSession → (2) webdriver.navigate → " + + "(3) webdriver.domSnapshot / webdriver.selectElements to explore and craft selectors → " + + "(4) webdriver.clickElement (and other future interactions) → (5) close session. " + + "Use the framework.* tools to read the shipped Roadkill WebDriver/ChromeDriver sources and fetch a Jest example. " + + "From user prompts, explore with the DOM tools, propose stable selectors (ids/roles/text), " + + "then generate portable tests in Jest using @progress/roadkill/webdriver.js." +}); + +// ── hello ────────────────────────────────────────────────── +server.tool( + "hello", + "Greets back the user! Useful for probing the MCP pipeline.", + { + name: z.string().describe("User name to greet") + }, + async ({ name }) => mcpResult({ hello: name }, `Hello, ${name}! 👋`) +); + +// ── webdriver.startSession ───────────────────────────────── +server.tool( + "webdriver.startSession", + "Start a WebDriver session and return a sessionId. " + + "LLM: Always call this first to obtain a session id before navigation or DOM exploration.", + { + browserName: z + .string() + .describe("Target browser name (e.g., 'chrome'). Defaults to 'chrome'.") + .default("chrome") + }, + async ({ browserName }) => { + const d = await runDriver(); + const address = String(d.address); + + const wd = new WebDriverClient({ + enableLogging: true, + // If your WebDriverClientOptions lacks `log`, keep the cast: + log: console.error as any, + address, + logPrefix: "[WebDriver]" + }); + + const session = await wd.newSession({ + capabilities: { browserName } + }); + + sessions.set(session.sessionId, session); + + const payload = { + sessionId: session.sessionId, + address, + capabilities: session.capabilities + }; + + return mcpResult( + payload, + `Started session ${session.sessionId} on ${address} (${session.capabilities.browserName} ${session.capabilities.browserVersion}).` + ); + } +); + +// ── webdriver.navigate ───────────────────────────────────── +server.tool( + "webdriver.navigate", + "Navigate an existing session to a URL. " + + "LLM: Use this immediately after starting a session and whenever you need a new page. Reuse the same sessionId.", + { + sessionId: z + .string() + .min(1) + .describe("Existing WebDriver session id"), + url: z + .string() + .url() + .describe("Absolute URL to navigate to (e.g., https://example.com)") + }, + async ({ sessionId, url }) => { + const session = sessions.get(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + await session.navigateTo(url); + const current = await session.getCurrentUrl().catch(() => undefined); + return mcpResult( + { sessionId, url, currentUrl: current ?? url }, + `Navigated ${sessionId} → ${url}` + ); + } +); + +// ── webdriver.status ─────────────────────────────────────── +server.tool( + "webdriver.status", + "Report ChromeDriver state/address. " + + "LLM: Use this to diagnose driver availability if a session fails to start.", + {}, + async () => { + const state = driver.state; + const addr = driver.address ? String(driver.address) : null; + return mcpResult({ state, address: addr }, `ChromeDriver: ${state}${addr ? ` @ ${addr}` : ""}`); + } +); + +// ── webdriver.domSnapshot ────────────────────────────────── +server.tool( + "webdriver.domSnapshot", + "Return a trimmed DOM tree (tag, id, classes, role, aria-*, type, href, text). " + + "LLM: Use to understand page structure and propose stable selectors. Prefer id/role/unique text.", + { + sessionId: z + .string() + .min(1) + .describe("Existing WebDriver session id"), + rootSelector: z + .string() + .describe("Optional CSS selector for sub-tree root; defaults to document.body") + .optional(), + maxDepth: z + .number() + .describe("Max depth to traverse (default 64)") + .int() + .min(0) + .max(255) + .default(64), + maxChildren: z + .number() + .describe("Max direct children per node (default 64)") + .int() + .min(1) + .max(255) + .default(64), + maxTextLen: z + .number() + .describe("Max characters of normalized text captured per node (default 120)") + .int() + .min(0) + .max(2000) + .default(120) + }, + async ({ sessionId, rootSelector, maxDepth = 64, maxChildren = 64, maxTextLen = 120 }) => { + const session = sessions.get(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + + const tree = await session.executeScript( + ` + return (function() { + const opts = arguments[0] || {}; + const selector = opts.rootSelector; + const maxDepth = Number.isFinite(opts.maxDepth) ? opts.maxDepth : 64; + const maxChildren = Number.isFinite(opts.maxChildren) ? opts.maxChildren : 64; + const maxTextLen = Number.isFinite(opts.maxTextLen) ? opts.maxTextLen : 120; + + const root = selector ? document.querySelector(selector) : document.body; + if (!root) return { error: "root-not-found", selector }; + + const ELEMENT_NODE = 1; + const norm = s => (s ?? "").replace(/\\s+/g, " ").trim(); + + function nodeTypeTag(el) { return el.tagName ? el.tagName.toLowerCase() : ""; } + function nodeTypeAttr(el, tag) { + if (tag === "button" && el.type) return String(el.type); + if (tag === "input" && el.type) return String(el.type); + return undefined; + } + function nodeHref(el, tag) { return (tag === "a" && el.href) ? String(el.href) : undefined; } + function nodeText(el) { + const raw = (el.innerText ?? el.textContent ?? ""); + const txt = norm(raw); + return maxTextLen > 0 ? txt.slice(0, maxTextLen) : txt; + } + + function snap(el, depth) { + if (!el || (el.nodeType || 0) !== ELEMENT_NODE) return null; + if (depth > maxDepth) return null; + + const tag = nodeTypeTag(el); + const id = el.id || undefined; + const classes = el.classList ? Array.from(el.classList) : []; + const role = el.getAttribute && el.getAttribute("role") || undefined; + const type = nodeTypeAttr(el, tag); + const href = nodeHref(el, tag); + const text = nodeText(el); + + const aria = {}; + if (el.hasAttributes && el.hasAttributes()) { + for (const a of el.attributes) { + if (a.name && a.name.startsWith("aria-")) aria[a.name] = a.value; + } + } + + const kids = []; + const children = el.children ? Array.from(el.children) : []; + for (let i = 0; i < children.length && i < maxChildren; i++) { + const child = snap(children[i], depth + 1); + if (child) kids.push(child); + } + + return { + tag, id, classes, role, type, href, text, + aria: Object.keys(aria).length ? aria : undefined, + children: kids.length ? kids : undefined + }; + } + + return snap(root, 0); + })(); + `, + undefined, + { rootSelector, maxDepth, maxChildren, maxTextLen } + ); + + return mcpResult( + { sessionId, rootSelector: rootSelector ?? null, tree }, + tree && !tree.error ? "DOM snapshot captured." : + tree?.error === "root-not-found" ? "DOM root not found." : + "DOM snapshot returned no data." + ); + } +); + +// ── webdriver.findElements (spec-accurate & minimal) ───────────────────────── +server.tool( + "webdriver.findElements", + "Direct WebDriver lookup using the standard locator strategies. " + + "Pass { using, value } exactly per the spec: 'css selector' | 'link text' | 'partial link text' | 'tag name' | 'xpath'. " + + "Returns elementIds for any matches.", + { + sessionId: z + .string() + .min(1) + .describe("Existing WebDriver session id"), + using: z + .enum(["css selector", "link text", "partial link text", "tag name", "xpath"]) + .describe("Locator strategy (WebDriver exact string)"), + value: z + .string() + .min(1) + .describe("Locator value (selector/xpath/text/tag)") + }, + async ({ sessionId, using, value }) => { + const session = sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + // Call WebDriver as-is + const found = await session.findElements({ using, value } as any); + + // Return only elementIds (minimal); easy to compose with clickElement later. + const elements = found.map(el => ({ elementId: el.elementId })); + + const payload = { + sessionId, + using, + value, + count: elements.length, + elements + }; + + const summary = + elements.length === 0 + ? "Found 0 elements" + : `Found ${elements.length} element${elements.length === 1 ? "" : "s"}`; + + return mcpResult(payload, summary); + } +); + +// ── webdriver.clickElement ───────────────────────────────── +server.tool( + "webdriver.clickElement", + "Click an element by WebDriver element id within a session. " + + "LLM: Typically obtain elementId from webdriver.selectElements when unique==true.", + { + sessionId: z + .string() + .min(1) + .describe("Existing WebDriver session id"), + elementId: z + .string() + .min(1) + .describe("WebDriver element id (e.g., from webdriver.selectElements)") + }, + async ({ sessionId, elementId }) => { + const session = sessions.get(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + const el = new Element(session, elementId); + await el.click(); + return mcpResult({ sessionId, elementId, clicked: true }, `Clicked element ${elementId}`); + } +); + +// ── webdriver.closeSession ──────────────────────────────── +server.tool( + "webdriver.closeSession", + "Dispose (delete) a WebDriver session. " + + "LLM: Always close sessions you created when done to keep the environment clean.", + { + sessionId: z + .string() + .min(1) + .describe("Existing WebDriver session id to close") + }, + async ({ sessionId }) => { + const session = sessions.get(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + await session.dispose(); + sessions.delete(sessionId); + return mcpResult({ sessionId, closed: true }, `Closed session ${sessionId}`); + } +); + +// ── framework.read ──────────────────────────────────────── +server.tool( + "framework.read", + "Read the shipped Roadkill framework source file for reference. " + + "LLM: Use this to understand available WebDriver/ChromeDriver APIs when authoring Jest tests.", + { + file: z + .enum(["webdriver", "chromedriver"]) + .describe("Which framework file to read") + }, + async ({ file }) => { + const { path, content } = await readSiblingModuleBase(file); + return mcpResult( + { file, path, length: content.length, content }, + `Read ${file} framework source from ${path} (${content.length} chars).` + ); + } +); + +// ── framework.exampleTest ───────────────────────────────── +server.tool( + "framework.exampleTest", + "Return a minimal Jest project (package.json, tsconfig.json, example.spec.ts) using Roadkill's ChromeDriver + WebDriverClient. LLM: Use as a template and fill in selectors you discovered via the DOM tools.", + {}, + async () => { + const files = [ + { + filename: "package.json", + content: `{ + "name": "jest-web", + "version": "0.1.4", + "description": "Example using jest and roadkill", + "type": "module", + "private": "true", + "scripts": { + "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --detectOpenHandles --forceExit" + }, + "devDependencies": { + "@jest/globals": "^29.7.0", + "@progress/roadkill": "^0.2.4", + "@types/jest": "^29.5.5", + "@types/node": "^20.8.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "typescript": "^5.2.2" + }, + "license": "MIT", + "jest": { + "preset": "ts-jest/presets/default-esm", + "testEnvironment": "@progress/roadkill/jest-environment.ts", + "reporters": [ + "summary" + ] + } +}` + }, + { + filename: "tsconfig.json", + content: `{ + "compilerOptions": { + "module": "Node16", + "target": "ESNext", + "moduleResolution": "Node16", + "esModuleInterop": true + } +}` + }, + { + filename: "example.spec.ts", + content: +`import { ChromeDriver } from "@progress/roadkill/chromedriver.js"; +import { WebDriverClient, Session, by } from "@progress/roadkill/webdriver.js"; +import { describe, test, beforeAll, afterAll, expect } from "@jest/globals"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; + +describe("example.com smoke", () => { + let chromedriver: ChromeDriver; + let webdriver: WebDriverClient; + let session: Session; + + beforeAll(async () => { + chromedriver = new ChromeDriver({ args: ["--port=9515"] }); + await chromedriver.start(); + webdriver = new WebDriverClient({ address: chromedriver.address }); + session = await webdriver.newSession({ + capabilities: { timeouts: { implicit: 2000 } } + }); + }, 30000); + + afterAll(async () => { try { await session?.dispose(); } finally { await chromedriver?.dispose(); } }, 20000); + + test("navigate and click 'More information...'", async () => { + await session.navigateTo("https://example.com"); + + // Try a robust selector: link text + const link = await session.findElement(by.link("More information...")); + await link.click(); + + const png = await session.takeScreenshot(); + const dir = join("dist","test","example"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir,"screenshot.png"), png, { encoding: "base64" }); + + const url = await session.getCurrentUrl(); + expect(url).toContain("iana.org"); + }, 20000); +});` + } + ]; + + return mcpResult( + { files, note: "Write these files into a new example folder, run `pnpm i` or `npm i`, then `npm test`." }, + `Returned Jest project with 3 files.` + ); + } +); + +// ─────────────────────────────────────────────────────────── +// Transport + cleanup +// ─────────────────────────────────────────────────────────── +const transport = new StdioServerTransport(); + +transport.onclose = async () => { + try { + for (const [id, s] of sessions) { + try { await s.dispose(); } catch {} + sessions.delete(id); + } + await driver?.dispose(); + } catch {} +}; + +process.on("SIGINT", async () => { + try { + for (const [id, s] of sessions) { try { await s.dispose(); } catch {} sessions.delete(id); } + await driver?.dispose(); + } finally { process.exit(0); } +}); + +process.on("SIGTERM", async () => { + try { + for (const [id, s] of sessions) { try { await s.dispose(); } catch {} sessions.delete(id); } + await driver?.dispose(); + } finally { process.exit(0); } +}); + +await server.connect(transport); diff --git a/packages/@progress/roadkill/server.ts b/packages/@progress/roadkill/server.ts index 280a649..c401be4 100644 --- a/packages/@progress/roadkill/server.ts +++ b/packages/@progress/roadkill/server.ts @@ -16,6 +16,11 @@ export interface ServerOptions { * A console.log prefix. */ logPrefix?: string; + + /** + * A console.log implementation. + */ + log?: (line: string) => void; } /** @@ -128,7 +133,7 @@ export abstract class Server extends EventEmitter } protected log(line: string) { - if (this.options.enableLogging) console.log(`${this.prefix ? "[" + this.prefix + "] " : ""}${line}`); + if (this.options.enableLogging) (this.options.log ?? console.log)(`${this.prefix ? "[" + this.prefix + "] " : ""}${line}`); } private onStdOut(line: string) { diff --git a/packages/@progress/roadkill/webdriver.ts b/packages/@progress/roadkill/webdriver.ts index bb3d5f4..a98a29b 100644 --- a/packages/@progress/roadkill/webdriver.ts +++ b/packages/@progress/roadkill/webdriver.ts @@ -467,6 +467,7 @@ export interface WebDriverClientOptions { address: string; enableLogging?: boolean; logPrefix?: string; + log?: (line: string) => void; } /** @@ -495,7 +496,7 @@ export class WebDriverClient { get prefix() { return this.options.logPrefix ?? "[WebDriverClient]" } protected log(line: string) { - if (this.options?.enableLogging) console.log(`${this.prefix ? this.prefix + " " : ""}${line}`); + if (this.options?.enableLogging) (this.options.log ?? console.log)(`${this.prefix ? this.prefix + " " : ""}${line}`); } /** From 8ca0ab093ca166e978154dcd02097a26c7624184 Mon Sep 17 00:00:00 2001 From: panayot-cankov Date: Sun, 21 Sep 2025 14:43:57 +0300 Subject: [PATCH 2/6] chore: add semantic objects --- packages/@progress/roadkill/SEMANTIC.md | 173 +++++++++++++++++++++ packages/@progress/roadkill/semantic.ts | 198 ++++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 packages/@progress/roadkill/SEMANTIC.md create mode 100644 packages/@progress/roadkill/semantic.ts diff --git a/packages/@progress/roadkill/SEMANTIC.md b/packages/@progress/roadkill/SEMANTIC.md new file mode 100644 index 0000000..62b5de7 --- /dev/null +++ b/packages/@progress/roadkill/SEMANTIC.md @@ -0,0 +1,173 @@ +# Semantic Objects + +**Semantic Objects** extend Page Objects with *discovery* and *snapshots*: + +* Discovered in the **browser** via a pure `static find()` (one round-trip). +* Hydrated into classes with **readonly properties** (captured by `find()`). +* Arranged in a **tree** by **DOM containment** using each object’s anchor **element**. +* Used with **methods for interactions** (click/type) and **properties for assertions**. + +The workflow is: **discover → hydrate → assert → (optionally) interact**. + +--- + +## Why not just Page Objects? + +Classic Page Objects are predefined and often read from the DOM during tests, causing chatty round-trips and drift. +Semantic Objects are **discovered at runtime**, capturing values up front so assertions are **synchronous** and **stable**. + +--- + +## Design + +* Every semantic class: + + * uses `@semantic()` and implements a **pure** `static find()` (no imports/closures). + * returns **DTOs**: `{ element, ...props }[]` (site-specific selectors). + * has a single constructor `(driver, dto)` that hydrates **readonly fields** from the DTO. + * exposes **methods** only for interactions (async). + +* The runner sends all `find()` functions in a single `executeScript`, builds the **tree in the browser** using `element.contains(...)`, and returns only root DTOs. + +* Node hydrates DTOs to instances and you get: + + * `JSON.stringify(root, null, " ")` → compact tuple snapshots: `["Class", {props}, ...children]` + * `root.toXML()` → pretty XML snapshots (future XPath-ready) + +--- + +## Quick start + +```ts +// discovery +const root = await discover(driver); + +// assert via hydrated properties (no extra reads) +const items = root.children.filter(x => x instanceof ShoppingItem) as ShoppingItem[]; +expect(items[0].name).toBe("USB-C Charger"); + +// interact only when needed +await items[0].addToCart(); + +// snapshots +console.log(JSON.stringify(root, null, " ")); +console.log(root.toXML()); +``` + +See `example.ts` for `ShoppingPage`, `Navigation`, `NavigationLink`, `ShoppingItem`. + +--- + +## Authoring a semantic object + +```ts +@semantic() +export class ShoppingItem extends SemanticObject { + public readonly name: string; + public readonly priceText: string; + private readonly addButton: WebElement; + + static find(): DTO<{ name: string; priceText: string; addButton: Element }>[] { + return findElementsByCss(".item-card", card => ({ + element: card, + name: (card.querySelector(".item-title")!.textContent || "").trim(), + priceText: (card.querySelector(".item-price")!.textContent || "").trim(), + addButton: card.querySelector(".add-to-cart")! + })); + } + + constructor(driver: WebDriver, dto: DTOOf) { + super(driver, dto); + this.name = dto.name; + this.priceText = dto.priceText; + this.addButton = dto.addButton; + } + + async addToCart() { await this.addButton.click(); } +} +``` + +### Recommendations + +* **Read in `find()`**: capture all values you’ll assert (strings/numbers/booleans). +* **Be strict**: if a field/button is required, ensure it in `find()` (skip/throw). +* **Single-site selectors**: prefer one clear selector per property; avoid multi-site heuristics. +* **One element per object**: the `element` is the anchor; other handles (e.g., `addButton`) can be captured as fields. +* **Methods for actions only**: keep assertions on readonly properties; actions are the only async part. + +--- + +## Built-in helper (usable inside `find()`) + +### `findElementsByCss(selector, mapFn)` + +Injected into the browser so that any `find()` can use it directly. + +* **selector**: CSS selector string +* **mapFn**: `(element: Element) => { element, …props }` +* **returns**: DTO array + +Example: + +```ts +static find(): DTO<{ text: string }>[] { + return findElementsByCss("nav.site-nav a", el => ({ + element: el, + text: (el.textContent || "").trim() + })); +} +``` + +--- + +## Snapshots + +* **JSON tuples** (machine-friendly, easy diffs): + + ```json + [ + "ShoppingItem", + { "name": "Item A", "priceText": "$19.99" } + ] + ``` +* **XML** (human-friendly, compact): + + ```xml + + ``` + +Use either/both in golden snapshot tests. XML also sets you up for XPath later if you choose to mirror to a DOM and map back to semantic instances. + +--- + +## API (short) + +```ts +export function semantic(): ClassDecorator; +export async function discover(driver: WebDriver): Promise; + +export class SemanticObject { + readonly element: WebElement | null; + readonly children: SemanticObject[]; + childrenOfType(ctor: new (...args:any[]) => T): T[]; + toJSON(): any; // ["Class",{props},...children] + toXML(): string; // ... +} + +export class Root extends SemanticObject {} + +export type DTO = { element: Element } & T; +export type DTOOf any }> = AnnotatedDTO>>; +``` + +--- + +## Notes + +* `find()` must be **pure** (serializable) because it’s injected via `.toString()` and executed in the browser sandbox. +* The tree is built **in the browser** to avoid many `executeScript` calls on Node. +* If your app state changes (modal opens, list expands), call `discover(driver)` again to hydrate a fresh tree. + +--- + +Happy testing! 🎯 diff --git a/packages/@progress/roadkill/semantic.ts b/packages/@progress/roadkill/semantic.ts new file mode 100644 index 0000000..a1a38af --- /dev/null +++ b/packages/@progress/roadkill/semantic.ts @@ -0,0 +1,198 @@ +// semantic.ts +import type { WebDriver, WebElement } from "selenium-webdriver"; + +// Registry, decorator, helper + +type FinderSrc = { name: string; src: string }; + +/** + * Keeps track of semantic classes and their registered static `find` functions. + * Stores the browser-side function sources and the constructors for hydration. + */ +class Registry { + private readonly list: FinderSrc[] = []; + private readonly ctors = new Map>(); + + /** Register a semantic class with its static `find()` function. */ + register(ctor: SemanticCtor) { + const name = ctor?.name; + const fn = (ctor as any)?.find; + if (!name || typeof fn !== "function") { + throw new Error(`@semantic: ${name || ""} must declare a static find() function`); + } + (ctor as any).semanticClass = name; + this.list.push({ name, src: fn.toString() }); + this.ctors.set(name, ctor); + } + + payload(): FinderSrc[] { return this.list.slice(); } + ctorOf(name: string) { return this.ctors.get(name); } +} + +const registry = new Registry(); + +/** Class decorator that marks a class as semantic and registers it. */ +export function semantic() { + return function any>(ctor: T) { + registry.register(ctor as unknown as SemanticCtor); + return ctor; + }; +} + +/** Get the list of registered finders (for custom runners or debugging). */ +export function getFinders(): FinderSrc[] { + return registry.payload(); +} + +// Semantic object hierarchy + +/** + * Base class for all semantic objects. Wraps its DTO (for data access), + * exposes the decomposed `element` (WebElement), and provides a tree of children. + */ +export class SemanticObject { + readonly children: SemanticObject[] = []; + readonly element: WebElement | null; + + constructor( + protected readonly driver: WebDriver, + protected readonly dto: AnnotatedDTO + ) { + this.element = (dto && "element" in dto ? (dto as any).element : null) ?? null; + } + + /** Return the direct children that are instances of the given semantic class. */ + childrenOfType(ctor: new (...args: any[]) => T): T[] { + return this.children.filter(c => c instanceof ctor) as T[]; + } + + /** JSON tuple: [ "ClassName", { props }, ...children ] (stable snapshots). */ + toJSON(): any { + const cls = (this as any).constructor.name; + const { element, children, ...props } = (this as any).dto || {}; + return [cls, props, ...this.children.map(c => c.toJSON())]; + } + + /** XML snapshot: ...children... (human-friendly). */ + toXML(): string { + function esc(s: string) { + return s.replace(/&/g,"&").replace(//g,">") + .replace(/"/g,""").replace(/'/g,"'"); + } + function attrs(props: Record): string { + return Object.entries(props || {}) + .filter(([_, v]) => v !== null && v !== undefined && (typeof v !== "object")) + .map(([k, v]) => ` ${k}="${esc(String(v))}"`) + .join(""); + } + const cls = (this as any).constructor.name; + const { element, children, ...rest } = (this as any).dto || {}; + const a = attrs(rest); + if (this.children.length === 0) return `<${cls}${a}/>`; + return `<${cls}${a}>${this.children.map(c => c.toXML()).join("")}`; + } +} + +/** Root of the semantic tree (no element). Always returned by `discover()`. */ +export class Root extends SemanticObject { + constructor(driver: WebDriver) { + super(driver, { "$semantic-class": "Root" } as AnnotatedDTO<{}>); + } +} + +// Class contract for constructors + +/** + * Contract implemented by semantic object classes. + * Each class must: + * - have a constructor `(driver, dto)` + * - define a pure static `find()` that runs in the page and returns DTOs with `element` + */ +export interface SemanticCtor { + new (driver: WebDriver, dto: AnnotatedDTO): T; + semanticClass: string; + find(): Array<{ element: any; [k: string]: any }>; +} + +// Type helpers + +type ElementOf = T extends (infer U)[] ? U : never; + +/** Browser-side DTO returned by `find()`; stamped later with "$semantic-class". */ +export type DTO = { element: Element } & T; + +/** A stamped DTO (Node side) with optional nested children. */ +export type AnnotatedDTO = T & { + ["$semantic-class"]: string; + element?: WebElement; + children?: Array>; +}; + +/** Infer the stamped DTO type from a class' static `find()` result. */ +export type DTOOf any }> = + AnnotatedDTO>>; + +// Discovery runner + +/** + * Run all registered `find()` functions inside the page, stamp them with + * "$semantic-class", build a tree by element containment (in-browser), and + * hydrate to instances. Returns Root. + */ +export async function discover(driver: WebDriver): Promise { + const finders = getFinders(); + + const runner = function(list: Array<{ name: string; src: string }>) { + // Helper available inside any find(): map CSS selector → DTOs + function findElementsByCss(selector: string, mapFn: (el: Element) => any) { + return Array.from(document.querySelectorAll(selector)).map(el => mapFn(el)); + } + + // Execute finders and stamp classes + const flat: any[] = []; + for (const { name, src } of list) { + // eslint-disable-next-line no-eval + const f = (0, eval)(`(${src})`); + const arr = f() || []; + for (const dto of arr) flat.push({ ["$semantic-class"]: name, ...dto, children: [] }); + } + + // Build containment-based tree + const contains = (a: Element | null | undefined, b: Element | null | undefined) => + !!(a && b && a !== b && a.contains(b)); + + for (let i = 0; i < flat.length; i++) { + let bestParent: any | null = null; + const child = flat[i]; + for (let j = 0; j < flat.length; j++) { + if (i === j) continue; + const maybe = flat[j]; + if (!contains(maybe.element, child.element)) continue; + if (!bestParent) bestParent = maybe; + else if (contains(bestParent.element, maybe.element)) bestParent = maybe; // tighter + } + if (bestParent) bestParent.children.push(child); + } + + // Roots = those not referenced as a child + const childSet = new Set(); + for (const n of flat) for (const c of n.children) childSet.add(c); + return flat.filter(n => !childSet.has(n)); + }; + + const script = `return (${runner.toString()})(${JSON.stringify(finders)});`; + const rootDtos: Array> = await driver.executeScript(script); + + // Hydration + const root = new Root(driver); + function hydrate(dto: AnnotatedDTO): SemanticObject { + const ctor = registry.ctorOf(dto["$semantic-class"]); + const inst = ctor ? new ctor(driver, dto) : new SemanticObject(driver, dto); + if (dto.children && dto.children.length) { + for (const c of dto.children) inst.children.push(hydrate(c)); + } + return inst; + } + for (const dto of rootDtos) root.children.push(hydrate(dto)); + return root; +} From f01b76b94e661e70374f210746a88e9a15d0f420 Mon Sep 17 00:00:00 2001 From: panayot-cankov Date: Sun, 21 Sep 2025 17:29:21 +0300 Subject: [PATCH 3/6] chore: add test-website, minor WebDriver protocol fixes, add express server --- examples/test-website/index.test.ts | 114 +++ examples/test-website/index.ts | 281 ++++++++ examples/test-website/package.json | 31 + examples/test-website/tsconfig.json | 14 + lerna.json | 3 +- package-lock.json | 649 +++++++++++++++++- package.json | 3 +- packages/@progress/roadkill/express.ts | 128 ++++ .../@progress/roadkill/jest-environment.ts | 153 +++-- packages/@progress/roadkill/tsconfig.json | 3 +- packages/@progress/roadkill/webdriver.ts | 255 +++---- 11 files changed, 1404 insertions(+), 230 deletions(-) create mode 100644 examples/test-website/index.test.ts create mode 100644 examples/test-website/index.ts create mode 100644 examples/test-website/package.json create mode 100644 examples/test-website/tsconfig.json create mode 100644 packages/@progress/roadkill/express.ts diff --git a/examples/test-website/index.test.ts b/examples/test-website/index.test.ts new file mode 100644 index 0000000..4f628c3 --- /dev/null +++ b/examples/test-website/index.test.ts @@ -0,0 +1,114 @@ +import { ChromeDriver } from "@progress/roadkill/chromedriver.js"; +import { Express } from "@progress/roadkill/express.js"; +import { Session, WebDriverClient, by } from "@progress/roadkill/webdriver.js"; +import { describe, test, expect, beforeAll, afterAll, afterEach, beforeEach } from "@jest/globals"; +import { getState, step } from "@progress/roadkill/utils.js"; +import { mkdir, writeFile } from "fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const enableLogging = false; + +describe("test-website", () => { + let chromedriver: ChromeDriver; + let express: Express; + let webdriver: WebDriverClient; + let session: Session; + + beforeAll(async () => { + const { signal } = getState(); + + express = new Express( + Express.npmStart({ cwd: dirname(fileURLToPath(import.meta.url)), enableLogging }), + ); + await express.start(signal); + + chromedriver = new ChromeDriver({ args: ["--port=5032"], enableLogging }); + await chromedriver.start(signal); + + webdriver = new WebDriverClient({ address: chromedriver.address!, enableLogging }); + session = await webdriver.newSession({ + capabilities: { timeouts: { implicit: 2000 } }, + }); + }, 60000); + + // Before each: set implicit: 2000 + beforeEach(async () => { + await session.setTimeouts({ implicit: 2000 }); + }); + + afterEach(async () => { + const { test } = getState(); + if (test && test.status === "fail") { + console.log("collecting failure artifacts for:", test.names.join(" > ")); + const screenshot = await session.takeScreenshot(); + const dir = `dist/test/${expect.getState().currentTestName}`; + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "screenshot.png"), screenshot, { encoding: "base64" }); + } + }); + + afterAll(async () => { + await session?.dispose(); + await chromedriver?.dispose(); + await express?.dispose(); + }, 20000); + + test("login flow", async () => { + const { signal } = getState(); + + await step("navigate to local test site", () => + session.navigateTo(express.address!) + ); + + await step("accept GDPR overlay", async () => { + const iframe = await session.findElement(by.css(".overlay-frame")); + await iframe.switchToFrame(); + + const accept = await session.findElement(by.css("button:nth-of-type(1)")); + await accept.click(); + + // Back to main/top-level browsing context + await session.switchToFrame(null); + }); + + await step("perform login", async () => { + const user = await session.findElement(by.css("#username")); + await user.clear(); + await user.sendKeys("admin"); + + const pass = await session.findElement(by.css("#password")); + await pass.clear(); + await pass.sendKeys("1234"); + + const btn = await session.findElement(by.css("button[type=submit]")); + await btn.click(); + }); + + // Assert navigation and page-unique content + await step("wait for Topics page", async () => { + await session.setTimeouts({ implicit: 10000 }); + + const grid = await session.findElement(by.css("#topics-grid")); + expect(await grid.getTagName()).toBe("div"); + + const url = await session.getCurrentUrl(); + expect(url).toContain("/toc"); + + await session.setTimeouts({ implicit: 2000 }); + }); + + await step("verify cards exist on Topics page", async () => { + const links = await session.findElements(by.css("#topics-grid .card a[href]")); + expect(links.length).toBeGreaterThanOrEqual(3); + }); + + await step("capture and save screenshot", async () => { + const screenshot = await session.takeScreenshot(); + const dir = `dist/test/${expect.getState().currentTestName}`; + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "screenshot.png"), screenshot, { encoding: "base64", signal }); + }); + + }, 30000); +}); diff --git a/examples/test-website/index.ts b/examples/test-website/index.ts new file mode 100644 index 0000000..b038a03 --- /dev/null +++ b/examples/test-website/index.ts @@ -0,0 +1,281 @@ +// index.ts +// +// Localhost-only demo site for Roadkill WebDriver QA. +// - Guards all routes to localhost +// - Login page with GDPR overlay in an + + + +`; + res.type("html").send(html("Roadkill – Login", body)); +}); + +// GDPR iframe content (light UI, inline errors) +app.get("/gdpr", (_req: Request, res: Response) => { + const body = ` +
+
+

GDPR Consent

+

+ This demo stores a single cookie gdprAccepted to enable the login form. + No other data is collected. +

+
+
+ + +

+
+
+
+ +`; + res.type("html").send(html("GDPR Consent", body)); +}); + +// Accept GDPR -> set cookie +app.post("/accept-gdpr", (_req: Request, res: Response) => { + res.cookie("gdprAccepted", "true", { + httpOnly: false, + sameSite: "strict", + secure: false, + maxAge: 24 * 60 * 60 * 1000 + }); + res.json({ ok: true }); +}); + +// Login endpoint – verify credentials, return randomized delay +app.post("/login", (req: Request, res: Response) => { + const { username, password } = (req.body ?? {}) as { username?: string; password?: string }; + if (username === "admin" && password === "1234") { + const delayMs = Math.floor(500 + Math.random() * 1000); // 500–1500 + return res.json({ ok: true, delayMs }); + } + return res.status(401).json({ ok: false, error: "Invalid credentials" }); +}); + +// Topics page (cards) +app.get("/toc", (_req: Request, res: Response) => { + const topics = [ + { title: "ChromeDriver", desc: "Standalone server implementing the WebDriver protocol for Chromium browsers. Roadkill manages lifecycle, logs, and startup detection.", href: "https://chromedriver.chromium.org/" }, + { title: "WebDriver", desc: "The W3C-standard browser automation protocol. Roadkill stays close to spec with typed commands and helpful errors.", href: "https://www.w3.org/TR/webdriver2/" }, + { title: "Semantic Objects", desc: "Higher-level DOM discovery helpers that make selectors readable, robust, and LLM-friendly.", href: "#semantic-objects" }, + { title: "Roadkill CLI", desc: "Checks Chrome/Node/ChromeDriver versions, manages drivers, and streamlines CI/dev workflows.", href: "#roadkill-cli" }, + { title: "MCP Integration", desc: "Expose Roadkill via the Model Context Protocol so LLMs can inspect pages and iteratively author tests.", href: "https://modelcontextprotocol.io/" } + ]; + + const cards = topics.map(t => ` +
+

${t.title}

+

${t.desc}

+

Learn more

+
+ `).join(""); + + const body = ` +
+
+

Roadkill – Topics

+

Targetable summary cards for QA flows.

+
+
+
${cards}
+
+

⬅ Back to Login

+
+`; + res.type("html").send(html("Roadkill – Topics", body)); +}); + +// Health check +app.get("/healthz", (_req, res) => res.json({ ok: true })); + +// ---- Boot -------------------------------------------------------------------- +if (import.meta.url === `file://${process.argv[1]}`) { + const port = Number(process.env.PORT || 3000); + app.listen(port, () => { + console.log(`Test site running at http://localhost:${port}`); + }); +} + +export default app; diff --git a/examples/test-website/package.json b/examples/test-website/package.json new file mode 100644 index 0000000..d86d213 --- /dev/null +++ b/examples/test-website/package.json @@ -0,0 +1,31 @@ +{ + "name": "test-website", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node --experimental-strip-types --no-warnings index.ts", + "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --detectOpenHandles --forceExit --no-warnings", + "prepare": "tsc -p ." + }, + "dependencies": { + "cookie-parser": "^1.4.6", + "express": "^4.19.2" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.9", + "@types/express": "^4.17.23", + "ts-node": "^10.9.2", + "typescript": "^5.6.3", + "@jest/globals": "^29.7.0", + "@progress/roadkill": "^0.2.4", + "@types/jest": "^29.5.5", + "@types/node": "^20.8.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.1" + }, + "jest": { + "preset": "ts-jest/presets/default-esm", + "testEnvironment": "@progress/roadkill/jest-environment.ts" + } +} diff --git a/examples/test-website/tsconfig.json b/examples/test-website/tsconfig.json new file mode 100644 index 0000000..6ebee1c --- /dev/null +++ b/examples/test-website/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "skipLibCheck": true, + "noEmit": true + }, + "include": ["index.ts"], + "exclude": ["node_modules/**"] +} \ No newline at end of file diff --git a/lerna.json b/lerna.json index 37231ff..f2c5b3a 100644 --- a/lerna.json +++ b/lerna.json @@ -4,7 +4,8 @@ "packages": [ "packages/@progress/roadkill", "examples/jest-web", - "examples/selenium-interop" + "examples/selenium-interop", + "examples/test-website" ], "command": { "bootstrap": { diff --git a/package-lock.json b/package-lock.json index 07d0784..119c1a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "workspaces": [ "packages/@progress/roadkill", "examples/jest-web", - "examples/selenium-interop" + "examples/selenium-interop", + "examples/test-website" ], "devDependencies": { "lerna": "^7.3.0" @@ -43,6 +44,301 @@ "ts-jest": "^29.1.1" } }, + "examples/test-website": { + "version": "0.1.0", + "dependencies": { + "cookie-parser": "^1.4.6", + "express": "^4.19.2" + }, + "devDependencies": { + "@jest/globals": "^29.7.0", + "@progress/roadkill": "^0.2.4", + "@types/cookie-parser": "^1.4.9", + "@types/express": "^4.17.23", + "@types/jest": "^29.5.5", + "@types/node": "^20.8.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typescript": "^5.6.3" + } + }, + "examples/test-website/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "examples/test-website/node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "examples/test-website/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "examples/test-website/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "examples/test-website/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "examples/test-website/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "examples/test-website/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "examples/test-website/node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "examples/test-website/node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "examples/test-website/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "examples/test-website/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "examples/test-website/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "examples/test-website/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "examples/test-website/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "examples/test-website/node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "examples/test-website/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "examples/test-website/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "examples/test-website/node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "examples/test-website/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "examples/test-website/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", @@ -693,6 +989,30 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@hutson/parse-repository-url": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz", @@ -1951,6 +2271,34 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tufjs/canonical-json": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-1.0.0.tgz", @@ -2038,6 +2386,37 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/decompress": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@types/decompress/-/decompress-4.2.5.tgz", @@ -2046,6 +2425,32 @@ "@types/node": "*" } }, + "node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.7.tgz", @@ -2055,6 +2460,13 @@ "@types/node": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -2089,6 +2501,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -2122,6 +2541,20 @@ "xmlbuilder": ">=11.0.1" } }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/selenium-webdriver": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.1.18.tgz", @@ -2131,6 +2564,29 @@ "@types/ws": "*" } }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -2271,6 +2727,32 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/add-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", @@ -2408,6 +2890,13 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2423,6 +2912,12 @@ "node": ">=8" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", @@ -3437,6 +3932,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -3511,6 +4025,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3834,6 +4355,16 @@ "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-indent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz", @@ -3852,6 +4383,16 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5103,7 +5644,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -7068,6 +7608,15 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", @@ -7081,11 +7630,22 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -7094,7 +7654,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -7404,7 +7963,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -10180,6 +10738,10 @@ "node": ">=8" } }, + "node_modules/test-website": { + "resolved": "examples/test-website", + "link": true + }, "node_modules/text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", @@ -10377,6 +10939,50 @@ "node": ">=12" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -10483,10 +11089,11 @@ "dev": true }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10619,6 +11226,15 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -10638,6 +11254,13 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.1.3", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.3.tgz", @@ -11006,6 +11629,16 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0bf0dc6..657295f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "workspaces": [ "packages/@progress/roadkill", "examples/jest-web", - "examples/selenium-interop" + "examples/selenium-interop", + "examples/test-website" ], "engines": { "node": "^18.17.0", diff --git a/packages/@progress/roadkill/express.ts b/packages/@progress/roadkill/express.ts new file mode 100644 index 0000000..87a533f --- /dev/null +++ b/packages/@progress/roadkill/express.ts @@ -0,0 +1,128 @@ +import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from "child_process"; +import { delimiter } from "node:path"; +import { Server, type ServerOptions } from "./server.js"; + +export interface ExpressOptions extends ServerOptions { + /** Working directory for the process. Defaults to process.cwd(). */ + cwd?: string; + + /** Command to start the server (e.g., "npm", "node"). */ + command: string; + + /** Arguments for the command. */ + args: string[]; + + /** Additional environment variables for the process. */ + env?: NodeJS.ProcessEnv; + + /** Additional PATH entries (prepended). */ + pathPrepend?: string[]; + + /** Fallback port if no address is parsed. Defaults to 3000. */ + defaultPort?: number; + + /** Regex to detect readiness in stdout. */ + readinessRegex?: RegExp; + + /** Regex to extract the address from stdout. */ + addressRegex?: RegExp; + + /** Entry file for node-ts helper. Defaults to "index.ts". */ + entry?: string; +} + +export class Express extends Server { + private _address?: string; + private startupLines: string[] = []; + + public get address(): string | undefined { return this._address; } + public override get prefix() { return this.options?.logPrefix ?? "Express"; } + + protected spawn(): ChildProcessWithoutNullStreams { + const { + cwd = process.cwd(), + env, + pathPrepend = [], + defaultPort = 3000, + command, + args, + } = this.options; + + const childEnv: NodeJS.ProcessEnv = { ...process.env, ...env }; + if (pathPrepend.length) { + const currentPath = process.env.PATH ?? ""; + childEnv.PATH = `${pathPrepend.join(delimiter)}${delimiter}${currentPath}`; + } + + const spawnOpts: SpawnOptionsWithoutStdio = { + cwd, + env: childEnv, + shell: true, + }; + + this._address = `http://localhost:${defaultPort}`; + return spawn(command, args, spawnOpts); + } + + protected override onLine(line: string): void { + super.onLine(line); + const l = (line ?? "").trim(); + this.startupLines.push(l); + + if (this.state !== "starting") return; + + const readiness = this.options?.readinessRegex ?? /Test site running at (https?:\/\/localhost:\d+)/i; + const addressRegex = this.options?.addressRegex ?? /(https?:\/\/localhost:\d+)/i; + + if (readiness.test(l)) { + const m = addressRegex.exec(l); + if (m && m[1]) this._address = m[1]; + this.started(); + return; + } + + const m2 = addressRegex.exec(l); + if (m2 && m2[1]) { + this._address = m2[1]; + this.started(); + } + } + + protected override startingErrorOnClose(code: number): Error { + if (this.startupLines.length) { + return new Error( + `Express server failed to start. Code ${code}.\n\n${this.startupLines.join("\n")}` + ); + } else { + return new Error(`Express server failed to start. Code ${code}.`); + } + } + + // Helpers to build options + public static nodeTs(entry = "index.ts", opts: Partial = {}): ExpressOptions { + return { + command: "node", + args: ["--experimental-strip-types", "--no-warnings", entry], + defaultPort: 3000, + ...opts, + }; + } + + public static npmStart(opts: Partial = {}): ExpressOptions { + return { + command: "npm", + args: ["start", "--silent"], + defaultPort: 3000, + ...opts, + }; + } + + public static custom(command: string, args: string[], opts: Partial = {}): ExpressOptions { + return { + command, + args, + defaultPort: 3000, + ...opts, + }; + } +} diff --git a/packages/@progress/roadkill/jest-environment.ts b/packages/@progress/roadkill/jest-environment.ts index 4e066ea..de211df 100644 --- a/packages/@progress/roadkill/jest-environment.ts +++ b/packages/@progress/roadkill/jest-environment.ts @@ -63,15 +63,16 @@ const red = color ? (text: string) => `\x1b[31m${text}\x1b[0m` : (text: string) abstract class Scope { static scopes: Scope[] = []; - static consoleLog; - static scopeLog = function () { + static consoleLog?: (...args: unknown[]) => void; + static scopeLog = function (...args: unknown[]) { Scope.flush(); - consoleModule.log(...arguments); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (consoleModule.log as any)(...args); }; - static flush(to: Scope = undefined) { + static flush(to?: Scope) { if (Scope.consoleLog != undefined) { - consoleModule.log = Scope.consoleLog; + consoleModule.log = Scope.consoleLog as any; Scope.consoleLog = undefined; } @@ -81,9 +82,10 @@ abstract class Scope { consoleModule.group(`${scope}`); } - if (to == scope) { - Scope.consoleLog = consoleModule.log; - consoleModule.log = Scope.scopeLog; + if (to === scope) { + Scope.consoleLog = consoleModule.log as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + consoleModule.log = Scope.scopeLog as any; break; } } @@ -93,11 +95,11 @@ abstract class Scope { protected endPrinted = false; constructor( - protected readonly parent: Scope, + protected readonly parent: Scope | undefined, protected readonly name: string) { } - static get top(): Scope { + static get top(): Scope | undefined { if (this.scopes.length == 0) { return undefined; } else { @@ -105,17 +107,22 @@ abstract class Scope { } } - static pop(): Scope { - Scope.top.end(); - return Scope.scopes.pop(); + static pop(): Scope | undefined { + const top = Scope.top; + if (top) { + top.end(); + return Scope.scopes.pop(); + } + return undefined; } begin() { Scope.scopes.push(this); if (consoleModule.log != Scope.scopeLog) { - Scope.consoleLog = consoleModule.log; - consoleModule.log = Scope.scopeLog; + Scope.consoleLog = consoleModule.log as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + consoleModule.log = Scope.scopeLog as any; } } @@ -168,12 +175,12 @@ export interface HookState { class FunctionScope extends Scope { public readonly names: ReadonlyArray; - public readonly source: string; + public readonly source?: string; private sourcePrinted = false; - private startTime: number; + private startTime = 0; - constructor(parent: Scope, name: string, source: string, names: string[]) { + constructor(parent: Scope | undefined, name: string, source: string | undefined, names: string[]) { super(parent, name); this.source = source; this.names = names; @@ -187,9 +194,10 @@ class FunctionScope extends Scope { protected formatSource(): string { if (this.sourcePrinted) return ""; this.sourcePrinted = true; + if (!this.source) return ""; try { const base = process.env.INIT_CWD; - return gray(", at " + relative(base, this.source)); + return gray(", at " + relative(base ?? "", this.source)); } catch { return gray(", at " + this.source); } @@ -202,16 +210,16 @@ class FunctionScope extends Scope { class HookScope extends FunctionScope implements HookState { - private _error: Error; + private _error?: Error; private _status: "started" | "fail" | "pass" = "started"; private conclusionPrinted = false; private beginWithDotDotDot = false; - private beginTimeout: NodeJS.Timeout; - private onTimeout: () => void; + private beginTimeout?: NodeJS.Timeout; + private onTimeout?: () => void; public get status() { return this._status; } - public get error() { return this.error; } + public get error() { return this._error; } public get fullName() { return this.names.join(" "); } public get hookName() { return this.name; } @@ -226,7 +234,7 @@ class HookScope extends FunctionScope implements HookState { this.beginTimeout = setTimeout(this.onTimeout, 10000); } - fail(error: Error = undefined) { + fail(error?: Error) { this._status = "fail"; this._error = error; if (this._error) { @@ -273,16 +281,16 @@ export interface TestState { class TestScope extends FunctionScope implements TestState { - private _error: Error; + private _error?: Error; private _status: "started" | "fail" | "pass" = "started"; private conclusionPrinted = false; private beginWithDotDotDot = false; - private beginTimeout: NodeJS.Timeout; - private onTimeout: () => void; + private beginTimeout?: NodeJS.Timeout; + private onTimeout?: () => void; public get status() { return this._status; } - public get error() { return this.error; } + public get error() { return this._error; } public get fullName() { return this.names.join(" "); } public get testName() { return this.names[this.names.length - 1]; } @@ -297,7 +305,7 @@ class TestScope extends FunctionScope implements TestState { this.beginTimeout = setTimeout(this.onTimeout, 10000); } - fail(error: Error = undefined) { + fail(error?: Error) { this._status = "fail"; this._error = error; if (this._error) { @@ -337,18 +345,18 @@ class TestScope extends FunctionScope implements TestState { class TestSkip extends Scope implements TestState { public readonly names: ReadonlyArray; - private readonly source: string; + private readonly source?: string; private sourcePrinted = false; private _status: "skip" = "skip"; - constructor(parent: Scope, name: string, source: string, names: string[]) { + constructor(parent: Scope | undefined, name: string, source: string | undefined, names: string[]) { super(parent, name); this.names = names; this.source = source; } public get status() { return this._status; } - public get error() { return this.error; } + public get error() { return undefined; } public get fullName() { return this.names.join(" "); } public get testName() { return this.names[this.names.length - 1]; } @@ -356,12 +364,13 @@ class TestSkip extends Scope implements TestState { return `${gray("○")} ${this.name}${this.formatSource()}`; } - formatSource(): string { + private formatSource(): string { if (this.sourcePrinted) return ""; this.sourcePrinted = true; + if (!this.source) return ""; try { const base = process.env.INIT_CWD; - return gray(", at " + relative(base, this.source)); + return gray(", at " + relative(base ?? "", this.source)); } catch { return gray(", at " + this.source); } @@ -375,18 +384,19 @@ class TeardownScope extends Scope { } // Type checking is broken -const BaseEnvironment = (NodeEnvironment as any); +// (leave this exactly as-is per your request) +const BaseEnvironment = (((NodeEnvironment as any).default instanceof Function) ? (NodeEnvironment as any).default : NodeEnvironment) as any; class TestTimeout extends Error {} class HookTimeout extends Error {} class TestEnvironment extends BaseEnvironment { - constructor(config, context) { + constructor(config: any, context: any) { super(config, context); } - private static getNameStack(event) { + private static getNameStack(event: any) { let nameStack: string[] = []; @@ -432,13 +442,14 @@ class TestEnvironment extends BaseEnvironment { } } - private hookName(event): string { + private hookName(event: any): string { return event?.hook?.type; } - private source(event): string { + private source(event: any): string | undefined { try { - const hookStack = event?.hook?.asyncError?.stack || event?.test?.asyncError?.stack; + const hookStack: string | undefined = event?.hook?.asyncError?.stack || event?.test?.asyncError?.stack; + if (!hookStack) return undefined; const stackLines = hookStack.split("\n"); if (stackLines.length >= 2) { const line: string = stackLines[1]?.trim(); @@ -449,10 +460,10 @@ class TestEnvironment extends BaseEnvironment { let closeBrace = line.indexOf(")"); if (openBrace != -1 && closeBrace != -1 && openBrace < closeBrace) { - // at _dispatchDescribe (/Users/cankov/git/telerik/roadkill/node_modules/jest-circus/build/index.js:91:26) + // at _dispatchDescribe (/Users/.../node_modules/jest-circus/build/index.js:91:26) path = line.substring(openBrace + 1, closeBrace - 1); } else if (line.startsWith("at ")) { - // at /Users/cankov/git/telerik/roadkill/examples/jest-web/w3schools.test.ts:41:5 + // at /path/to/test.ts:41:5 path = line.substring(3); } else { // play safe @@ -466,15 +477,15 @@ class TestEnvironment extends BaseEnvironment { } } - async handleTestEvent(event, state) { + async handleTestEvent(event: any, state: any) { const nameStack = TestEnvironment.getNameStack(event); - if (event.name == "setup" && this.global.roadkillJestConsoleDefault !== false) { - this.global.console = consoleModule; + if (event.name == "setup" && (this.global as any).roadkillJestConsoleDefault !== false) { + (this.global as any).console = consoleModule; } - if (this.global.roadkillJestLifecycleLogging) { + if ((this.global as any).roadkillJestLifecycleLogging) { console.log(`[JEST] ${this.displayFriendlyEventName(event.name)}${event?.hook?.type ? " " + event?.hook?.type : ""}${nameStack.length ? " (" + nameStack.join(" > ") + ")" : ""}`); } @@ -484,22 +495,22 @@ class TestEnvironment extends BaseEnvironment { if (event.name == "setup") { new RootScope().begin(); - new SetupScope(Scope.top).begin(); + new SetupScope(Scope.top as RootScope).begin(); } else if (event.name == "run_start") { Scope.pop(); // Pops Setup - new RunScope(Scope.top).begin(); + new RunScope(Scope.top as RootScope).begin(); } else if (event.name == "run_describe_start") { - if (event.describeBlock.name == "ROOT_DESCRIBE_BLOCK") { + if (event.describeBlock?.name == "ROOT_DESCRIBE_BLOCK") { } else { - new DescribeScope(Scope.top, nameStack.join(" > ")).begin(); + new DescribeScope(Scope.top!, nameStack.join(" > ")).begin(); } } else if (event.name == "hook_start") { const hookScope = new HookScope( - Scope.top, + Scope.top!, this.hookName(event), this.source(event), nameStack); - this.global["@progress/roadkill/utils:hook"] = hookScope; + (this.global as any)["@progress/roadkill/utils:hook"] = hookScope; hookScope.begin(); } else if (event.name == "hook_success") { (Scope.top as HookScope).pass(); @@ -509,11 +520,11 @@ class TestEnvironment extends BaseEnvironment { Scope.pop(); } else if (event.name == "test_started") { const testScope = new TestScope( - Scope.top, + Scope.top!, nameStack.join(" > "), this.source(event), nameStack); - this.global["@progress/roadkill/utils:test"] = testScope; + (this.global as any)["@progress/roadkill/utils:test"] = testScope; testScope.begin(); } else if (event.name == "test_fn_failure") { const test = Scope.top as TestScope; @@ -523,18 +534,18 @@ class TestEnvironment extends BaseEnvironment { test.pass(); } else if (event.name == "test_done") { const test = (Scope.top as TestScope); - if (test.status == "started") { + if (test?.status == "started") { test.fail(); } Scope.pop(); } else if (event.name == "test_skip") { - const testScope = new TestSkip( - Scope.top, + new TestSkip( + Scope.top!, nameStack.join(" > "), this.source(event), nameStack).event(); } else if (event.name == "run_describe_finish") { - if (event.describeBlock.name == "ROOT_DESCRIBE_BLOCK") { + if (event.describeBlock?.name == "ROOT_DESCRIBE_BLOCK") { } else { Scope.pop(); } @@ -547,32 +558,34 @@ class TestEnvironment extends BaseEnvironment { switch (event.name) { case 'test_start': break; - case 'test_fn_start': - this.global["@progress/roadkill/utils:signal"] = undefined; - this.global.signal = undefined; - const testTimeout = (event?.test?.timeout ?? state?.testTimeout); + case 'test_fn_start': { + (this.global as any)["@progress/roadkill/utils:signal"] = undefined; + (this.global as any).signal = undefined; + const testTimeout = (event?.test?.timeout ?? state?.testTimeout) as number | undefined; if (testTimeout != undefined) { const controller = new AbortController(); - this.global["@progress/roadkill/utils:signal"] = controller.signal; - this.global.signal = controller.signal; + (this.global as any)["@progress/roadkill/utils:signal"] = controller.signal; + (this.global as any).signal = controller.signal; setTimeout(() => { controller.abort(new TestTimeout(`Exceeded timeout of ${testTimeout} ms for a test.`)); }, Math.max(0, testTimeout)); } break; - case 'hook_start': - this.global["@progress/roadkill/utils:signal"] = undefined; - this.global.signal = undefined; - const hookTimeout = (event?.hook?.timeout ?? state?.testTimeout); + } + case 'hook_start': { + (this.global as any)["@progress/roadkill/utils:signal"] = undefined; + (this.global as any).signal = undefined; + const hookTimeout = (event?.hook?.timeout ?? state?.testTimeout) as number | undefined; if (hookTimeout) { const controller = new AbortController(); - this.global["@progress/roadkill/utils:signal"] = controller.signal; - this.global.signal = controller.signal; + (this.global as any)["@progress/roadkill/utils:signal"] = controller.signal; + (this.global as any).signal = controller.signal; setTimeout(() => { controller.abort(new HookTimeout(`Exceeded timeout of ${hookTimeout} ms for a hook.`)); }, Math.max(0, hookTimeout)); } break; + } case 'test_done': break; } diff --git a/packages/@progress/roadkill/tsconfig.json b/packages/@progress/roadkill/tsconfig.json index 4cb6283..2854e73 100644 --- a/packages/@progress/roadkill/tsconfig.json +++ b/packages/@progress/roadkill/tsconfig.json @@ -3,6 +3,7 @@ "target": "ESNext", "module": "Node16", "moduleResolution": "Node16", - "sourceMap": true + "sourceMap": true, + "skipLibCheck": true } } \ No newline at end of file diff --git a/packages/@progress/roadkill/webdriver.ts b/packages/@progress/roadkill/webdriver.ts index a98a29b..966183b 100644 --- a/packages/@progress/roadkill/webdriver.ts +++ b/packages/@progress/roadkill/webdriver.ts @@ -16,7 +16,7 @@ export class WebDriverMethodError extends Error { constructor(error?: string, options?: ErrorOptions, args?: {}) { super(error, options); if (args) - for(const key in args) + for (const key in args) this[key] = args[key]; } } @@ -29,7 +29,7 @@ export class WebDriverRequestError extends Error { if (error.value.stacktrace) Object.defineProperty(this, "stacktrace", { value: error.value.stacktrace, enumerable: false }); if (error.value.data) - this["data"] = error.value.data; + this["data"] = error.value.data; } } } @@ -493,7 +493,7 @@ export class WebDriverClient { public constructor(public readonly options: WebDriverClientOptions, public readonly fetchImplementation: typeof fetch = fetch) { } - get prefix() { return this.options.logPrefix ?? "[WebDriverClient]" } + get prefix() { return this.options.logPrefix ?? "[WebDriverClient]"; } protected log(line: string) { if (this.options?.enableLogging) (this.options.log ?? console.log)(`${this.prefix ? this.prefix + " " : ""}${line}`); @@ -506,11 +506,11 @@ export class WebDriverClient { public async newSession(options: ProcessCapabilities | { capabilities: ValidateCapabilities }, signal?: AbortSignal): Promise { try { const result = await this.request< - ProcessCapabilities | { capabilities: ValidateCapabilities }, - { capabilities: MatchingCapabilities, sessionId: string } - >("POST", "/session", options, signal); + ProcessCapabilities | { capabilities: ValidateCapabilities }, + { capabilities: MatchingCapabilities, sessionId: string } + >("POST", "/session", options, signal); return new Session(this, result.sessionId, result.capabilities); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to create a new session.`, { cause }, { options }); } } @@ -522,7 +522,7 @@ export class WebDriverClient { public async status(signal?: AbortSignal): Promise<{ ready: boolean, message: string, [other: string]: any }> { try { return await this.request<{}, { ready: boolean, message: string }>("GET", "/status", undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError("Failed to retrieve status.", { cause }); } } @@ -537,14 +537,14 @@ export class WebDriverClient { headers["Content-Type"] = "application/json; charset=utf-8"; requestInit.body = JSON.stringify(args, withReplacer(serializer)); } - + const bodyStr = typeof requestInit.body === "string" ? requestInit.body.slice(0, 40) : ""; this.log(`fetch: ${method} ${uri}${bodyStr ? " " + bodyStr : ""}`); const response = await this.fetchImplementation(`${this.options.address}${uri}`, requestInit); this.log(` response: ${method} ${response.status} ${response.statusText} ${uri}${bodyStr ? " " + bodyStr : ""}`); let text = ""; - try { text = await response.text(); } catch {} + try { text = await response.text(); } catch { } if (!response.ok) { if (text) { @@ -569,7 +569,7 @@ export class WebDriverClient { const result = (json as { value: Result }).value; return result; - } catch(cause) { + } catch (cause) { const error = cause instanceof WebDriverRequestError ? cause : new WebDriverRequestError("WebDriver API call failed.", { cause }); error["address"] = this.options.address; error["command"] = `${method} ${uri}`; @@ -637,7 +637,7 @@ export class Session implements Disposable, Serializer { public async getTimeouts(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/timeouts`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get timeouts."`, { cause }); } } @@ -648,21 +648,18 @@ export class Session implements Disposable, Serializer { public async setTimeouts(timeouts: TimeoutsConfiguration, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/timeouts`, timeouts, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to set timeouts.`, { cause }, { timeouts }); } } /** * [10.1 Navigate To](https://www.w3.org/TR/webdriver2/#navigate-to) - * - * The command causes the user agent to navigate the current top-level browsing context to a new location. - * @param url */ public async navigateTo(url: string, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/url`, { url }, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to navigate to ${url}.`, { cause }, { url }); } } @@ -673,86 +670,74 @@ export class Session implements Disposable, Serializer { public async getCurrentUrl(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/url`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get current url.`, { cause }); } } /** * [10.3 Back](https://www.w3.org/TR/webdriver2/#back) - * - * This command causes the browser to traverse one step backward in the joint session history of the current top-level browsing context. This is equivalent to pressing the back button in the browser chrome or invoking window.history.back. */ public async back(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/back`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/back`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to go back.`, { cause }); } } /** * [10.4 Forward](https://www.w3.org/TR/webdriver2/#forward) - * - * This command causes the browser to traverse one step forwards in the joint session history of the current top-level browsing context. This is equivalent to pressing the forward button in the browser chrome or invoking window.history.forward. */ public async forward(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/forward`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/forward`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to go forward.`, { cause }); } } /** * [10.5 Refresh](https://www.w3.org/TR/webdriver2/#refresh) - * - * This command causes the browser to reload the page in the current top-level browsing context. */ public async refresh(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/refresh`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/refresh`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to refresh.`, { cause }); } } /** * [10.6 Get Title](https://www.w3.org/TR/webdriver2/#get-title) - * - * This command returns the document title of the current top-level browsing context, equivalent to calling document.title. */ public async getTitle(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/title`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get title.`, { cause }); } } /** * [11.1 Get Window Handle](https://www.w3.org/TR/webdriver2/#get-window-handle) - * - * Return the window associated with the current top-level browsing context. */ public async getWindow(signal?: AbortSignal): Promise { try { const handle = await this.request<{}, WindowHandle>("GET", `/session/${this.sessionId}/window`, undefined, signal); return new Window(this, handle); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get window.`, { cause }); } } /** * [11.2 Close Window](https://www.w3.org/TR/webdriver2/#close-window) - * - * Close the current top-level browsing context. */ public async closeWindow(signal?: AbortSignal): Promise { try { return await this.request("DELETE", `/session/${this.sessionId}/window`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to close window.`, { cause }); } } @@ -764,7 +749,7 @@ export class Session implements Disposable, Serializer { try { const handles = await this.request<{}, WindowHandle[]>("GET", `/session/${this.sessionId}/window/handles`, undefined, signal); return handles.map(handle => new Window(this, handle)); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get windows.`, { cause }); } } @@ -774,100 +759,96 @@ export class Session implements Disposable, Serializer { */ public async newWindow(type: "tab" | "window" = "tab", signal?: AbortSignal): Promise { try { - const res = await this.request<{ type: "tab" | "window" }, { handle: WindowHandle, type: "tab" | "window"}>("POST", `/session/${this.sessionId}/window/new`, { type }, signal); + const res = await this.request<{ type: "tab" | "window" }, { handle: WindowHandle, type: "tab" | "window" }>("POST", `/session/${this.sessionId}/window/new`, { type }, signal); return new Window(this, res.handle, res.type); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to open a new window.`, { cause }, { type }); } } /** - * [11.6 Switch To Frame](11.6 Switch To Frame) - * - * The Switch To Frame command is used to select the current top-level browsing context or a child browsing context of the current browsing context to use as the current browsing context for subsequent commands. - * - * WebDriver is not bound by the same origin policy, so it is always possible to switch into child browsing contexts, even if they are different origin to the current browsing context. + * [11.6 Switch To Frame](https://www.w3.org/TR/webdriver2/#switch-to-frame) */ - public async switchToFrame(frameId: null | number | ElementId = null, signal?: AbortSignal): Promise { + public async switchToFrame(frame: null | number | Element | WebElementReference, signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/frame`, { id: frameId }, signal); - } catch(cause) { - throw new WebDriverMethodError(`Failed to switch to frame ${frameId}.`, { cause }, { frameId }); + let id: null | number | WebElementReference; + if (frame === null || typeof frame === "number") { + id = frame; + } else if (frame instanceof Element) { + id = { [webElementIdentifier]: frame.elementId }; + } else if (typeof frame === "object" && webElementIdentifier in frame) { + id = frame; + } else { + throw new Error("Invalid frame reference"); + } + return await this.request("POST", `/session/${this.sessionId}/frame`, { id }, signal); + } catch (cause) { + throw new WebDriverMethodError(`Failed to switch to frame.`, { cause }, { frame }); } } /** * [11.7 Switch To Parent Frame](https://www.w3.org/TR/webdriver2/#switch-to-parent-frame) - * - * The Switch to Parent Frame command sets the current browsing context for future commands to the parent of the current browsing context. */ public async switchToParentFrame(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/frame/parent`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/frame/parent`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to switch to parent frame.`, { cause }); } } /** * [11.8.1 Get Window Rect](https://www.w3.org/TR/webdriver2/#get-window-rect) - * - * The Get Window Rect command returns the size and position on the screen of the operating system window corresponding to the current top-level browsing context. */ public async getWindowRect(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/window/rect`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get window rect.`, { cause }); } } /** * [11.8.2 Set Window Rect](https://www.w3.org/TR/webdriver2/#set-window-rect) - * - * The Set Window Rect command alters the size and the position of the operating system window corresponding to the current top-level browsing context. */ public async setWindowRect(windowRect: { x: null | number, y: null | number, width: null | number, height: null | number }, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/window/rect`, windowRect, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to set window rect.`, { cause }, { windowRect }); } } /** * [11.8.3 Maximize Window](https://www.w3.org/TR/webdriver2/#maximize-window) - * - * The Maximize Window command invokes the window manager-specific “maximize” operation, if any, on the window containing the current top-level browsing context. This typically increases the window to the maximum available size without going full-screen. */ public async maximize(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/window/maximize`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/window/maximize`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to maximize.`, { cause }); } } /** * [11.8.4 Minimize Window](https://www.w3.org/TR/webdriver2/#minimize-window) - * - * The Minimize Window command invokes the window manager-specific “minimize” operation, if any, on the window containing the current top-level browsing context. This typically hides the window in the system tray. */ public async minimize(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/window/minimize`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/window/minimize`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to minimize.`, { cause }); } } - + /** * [11.8.5 Fullscreen Window](https://www.w3.org/TR/webdriver2/#fullscreen-window) */ public async fullscreen(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/window/fullscreen`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/window/fullscreen`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to go fullscreen.`, { cause }); } } @@ -878,7 +859,7 @@ export class Session implements Disposable, Serializer { public async findElement(lookup: ElementLookup, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/element`, lookup, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to find element by ${lookup.using} "${lookup.value}".`, { cause }, { lookup }); } } @@ -889,7 +870,7 @@ export class Session implements Disposable, Serializer { public async findElements(lookup: ElementLookup, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/elements`, lookup, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to find elements by ${lookup.using} "${lookup.value}".`, { cause }, { lookup }); } } @@ -900,20 +881,18 @@ export class Session implements Disposable, Serializer { public async getActiveElement(signal?: AbortSignal): Promise { try { return await this.element(await this.request<{}, WebElementReference>("GET", `/session/${this.sessionId}/element/active`, undefined, signal)); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get active element.`, { cause }); } } /** * [13.1 Get Page Source](https://www.w3.org/TR/webdriver2/#get-page-source) - * - * The ***Get Page Source*** command returns a string serialization of the DOM of the current browsing context active document. */ public async getPageSource(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/source`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get page source.`, { cause }); } } @@ -924,7 +903,7 @@ export class Session implements Disposable, Serializer { public async executeScript(script: string, signal?: AbortSignal, ...args: any[]): Promise { try { return await this.request("POST", `/session/${this.sessionId}/execute/sync`, { script, args }, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to execute script.`, { cause }); } } @@ -935,7 +914,7 @@ export class Session implements Disposable, Serializer { public async executeScriptAsync(script: string, signal?: AbortSignal, ...args: any[]): Promise { try { return await this.request("POST", `/session/${this.sessionId}/execute/async`, { script, args }, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to execute script async.`, { cause }); } } @@ -946,9 +925,9 @@ export class Session implements Disposable, Serializer { public async getCookies(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/cookie`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get cookies.`, { cause }); - } + } } /** @@ -957,7 +936,7 @@ export class Session implements Disposable, Serializer { public async getNamedCookie(name: string, signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/cookie/${name}`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get named cookie '${name}'.`, { cause }, { name }); } } @@ -968,7 +947,7 @@ export class Session implements Disposable, Serializer { public async addCookie(cookie: Cookie, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/cookie`, { cookie }, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to add cookie '${cookie.name}'.`, { cause }, { cookie }); } } @@ -979,7 +958,7 @@ export class Session implements Disposable, Serializer { public async deleteCookie(name: string, signal?: AbortSignal): Promise { try { return await this.request("DELETE", `/session/${this.sessionId}/cookie/${name}`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to delete cookie '${name}'.`, { cause }, { name }); } } @@ -990,7 +969,7 @@ export class Session implements Disposable, Serializer { public async deleteAllCookies(signal?: AbortSignal): Promise { try { return await this.request("DELETE", `/session/${this.sessionId}/cookie`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to delete all cookies.`, { cause }); } } @@ -1001,20 +980,18 @@ export class Session implements Disposable, Serializer { public async performActions(actions: ActionSequence[], signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/actions`, { actions }, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to perform actions.`, { cause }); } } /** * [15.8 Release Actions](https://www.w3.org/TR/webdriver2/#release-actions) - * - * The Release Actions command is used to release all the keys and pointer buttons that are currently depressed. This causes events to be fired as if the state was released by an explicit series of actions. It also clears all the internal state of the virtual devices. */ public async releaseActions(signal?: AbortSignal): Promise { try { return await this.request("DELETE", `/session/${this.sessionId}/actions`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to release actions.`, { cause }); } } @@ -1024,8 +1001,8 @@ export class Session implements Disposable, Serializer { */ public async dismissAlert(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/alert/dismiss`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/alert/dismiss`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to dismiss alert.`, { cause }); } } @@ -1035,8 +1012,8 @@ export class Session implements Disposable, Serializer { */ public async acceptAlert(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/alert/accept`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/alert/accept`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to accept alert.`, { cause }); } } @@ -1047,20 +1024,18 @@ export class Session implements Disposable, Serializer { public async getAlertText(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/alert/text`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get alert.`, { cause }); } } /** * [16.4 Send Alert Text](https://www.w3.org/TR/webdriver2/#send-alert-text) - * - * The Send Alert Text command sets the text field of a window.prompt user prompt to the given value. */ public async sendAlertText(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/alert/text`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/alert/text`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to send alert text.`, { cause }); } } @@ -1071,7 +1046,7 @@ export class Session implements Disposable, Serializer { public async takeScreenshot(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/screenshot`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to take screenshot.`, { cause }); } } @@ -1082,7 +1057,7 @@ export class Session implements Disposable, Serializer { public async printPage(printOptions?: PrintOptions, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/print`, printOptions ?? {}, signal) - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError("Failed to print page.", { cause }, printOptions && { printOptions }); } } @@ -1090,14 +1065,14 @@ export class Session implements Disposable, Serializer { /** * Deserialize an {@link Element} by {@link WebElementReference} within this session. */ - public element(elementRef: WebElementReference, signal?: AbortSignal): Element { + public element(elementRef: WebElementReference, _signal?: AbortSignal): Element { return new Element(this, elementRef[webElementIdentifier]); } /** * Deserialize a {@link ShadowRoot} by {@link ShadowRootReference} within this session. */ - public shadowRoot(shadowRootRef: ShadowRootReference, signal?: AbortSignal): ShadowRoot { + public shadowRoot(shadowRootRef: ShadowRootReference, _signal?: AbortSignal): ShadowRoot { return new ShadowRoot(this, shadowRootRef[shadowRootIdentifier]); } } @@ -1117,16 +1092,14 @@ export class Window { public get sessionId() { return this.session.sessionId; } - + /** * [11.3 Switch To Window](https://www.w3.org/TR/webdriver2/#switch-to-window) - * - * Switching window will select the current top-level browsing context used as the target for all subsequent commands. In a tabbed browser, this will typically make the tab containing the browsing context the selected tab. */ public async switchToWindow(signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/window`, { handle: this.handle }, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to switch to window ${this.handle}.`, { cause }); } } @@ -1164,12 +1137,13 @@ export class Element implements WebElementReference { } /** - * [11.6 Switch To Frame](https://www.w3.org/TR/webdriver2/#switch-to-frame) + * Switch to this element’s frame */ public async switchToFrame(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/frame`, { id: this.elementId }, signal); - } catch(cause) { + const id: WebElementReference = { [webElementIdentifier]: this.elementId }; + return await this.request("POST", `/session/${this.sessionId}/frame`, { id }, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to switch to frame from element.`, { cause }); } } @@ -1180,7 +1154,7 @@ export class Element implements WebElementReference { public async findElement(lookup: ElementLookup, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/element/${this.elementId}/element`, lookup, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to find element by ${lookup.using} "${lookup.value}" from element.`, { cause }, { lookup }); } } @@ -1191,7 +1165,7 @@ export class Element implements WebElementReference { public async findElements(lookup: ElementLookup, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/element/${this.elementId}/elements`, lookup, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to find elements by ${lookup.using} "${lookup.value}" from element.`, { cause }, { lookup }); } } @@ -1202,7 +1176,7 @@ export class Element implements WebElementReference { public async shadowRoot(signal?: AbortSignal): Promise { try { return await this.request<{}, ShadowRoot>("GET", `/session/${this.sessionId}/element/${this.elementId}/shadow`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get shadowRoot from element.`, { cause }); } } @@ -1213,45 +1187,40 @@ export class Element implements WebElementReference { public async isSelected(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/element/${this.elementId}/selected`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get isSelected from element.`, { cause }); } } /** * [12.4.2 Get Element Attribute](https://www.w3.org/TR/webdriver2/#get-element-attribute) - * - * Please note that the behavior of this command deviates from the behavior of getAttribute() in [DOM], which in the case of a set boolean attribute would return an empty string. The reason this command returns true as a string is because this evaluates to true in most dynamically typed programming languages, but still preserves the expected type information. */ public async getAttribute(name: string, signal?: AbortSignal): Promise { try { return await this.request<{}, null | string>("GET", `/session/${this.sessionId}/element/${this.elementId}/attribute/${name}`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get attribute ${name} from element.`, { cause }, { name }); } } - /** * [12.4.3 Get Element Property](https://www.w3.org/TR/webdriver2/#get-element-property) */ public async getProperty(name: string, signal?: AbortSignal): Promise { try { return await this.request<{}, any>("GET", `/session/${this.sessionId}/element/${this.elementId}/property/${name}`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get property ${name} from element.`, { cause }, { name }); - } + } } /** * [12.4.4 Get Element CSS Value](https://www.w3.org/TR/webdriver2/#get-element-css-value) - * - * The Get Element Text command intends to return an element’s text “as rendered”. An element’s rendered text is also used for locating a elements by their link text and partial link text. */ public async getCSSValue(name: string, signal?: AbortSignal): Promise { try { return await this.request<{}, string>("GET", `/session/${this.sessionId}/element/${this.elementId}/css/${name}`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get css value ${name} from element.`, { cause }, { name }); } } @@ -1262,7 +1231,7 @@ export class Element implements WebElementReference { public async getText(signal?: AbortSignal): Promise { try { return await this.request<{}, string>("GET", `/session/${this.sessionId}/element/${this.elementId}/text`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get text from element.`, { cause }); } } @@ -1273,26 +1242,18 @@ export class Element implements WebElementReference { public async getTagName(signal?: AbortSignal): Promise { try { return await this.request<{}, string>("GET", `/session/${this.sessionId}/element/${this.elementId}/name`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get tag name from element.`, { cause }); } } /** - * The Get Element Rect command returns the dimensions and coordinates of the given web element. The returned value is a dictionary with the following members: - * - {@link ElementRect.x} - * X axis position of the top-left corner of the web element relative to the current browsing context’s document element in CSS pixels. - * - {@link ElementRect.y} - * Y axis position of the top-left corner of the web element relative to the current browsing context’s document element in CSS pixels. - * - {@link ElementRect.height} - * Height of the web element’s bounding rectangle in CSS pixels. - * - {@link ElementRect.width} - * Width of the web element’s bounding rectangle in CSS pixels. + * The Get Element Rect command returns the dimensions and coordinates of the given web element. */ public async getRect(signal?: AbortSignal): Promise { try { return await this.request<{}, ElementRect>("GET", `/session/${this.sessionId}/element/${this.elementId}/rect`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get rect from element.`, { cause }); } } @@ -1303,7 +1264,7 @@ export class Element implements WebElementReference { public async isEnabled(signal?: AbortSignal): Promise { try { return await this.request<{}, boolean>("GET", `/session/${this.sessionId}/element/${this.elementId}/enabled`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get isEnabled from element.`, { cause }); } } @@ -1314,7 +1275,7 @@ export class Element implements WebElementReference { public async getComputedRole(signal?: AbortSignal): Promise { try { return await this.request<{}, string>("GET", `/session/${this.sessionId}/element/${this.elementId}/computedrole`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get computed role from element.`, { cause }); } } @@ -1325,22 +1286,18 @@ export class Element implements WebElementReference { public async getComputedLabel(signal?: AbortSignal): Promise { try { return await this.request<{}, string>("GET", `/session/${this.sessionId}/element/${this.elementId}/computedlabel`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to get computed label from element.`, { cause }); } } /** * [12.5.1 Element Click](https://www.w3.org/TR/webdriver2/#element-click) - * - * The Element Click command scrolls into view the element if it is not already pointer-interactable, and clicks its in-view center point. - * - * If the element’s center point is obscured by another element, an element click intercepted error is returned. If the element is outside the viewport, an element not interactable error is returned. */ public async click(signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/element/${this.elementId}/click`, {}, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to click element.`, { cause }); } } @@ -1350,8 +1307,8 @@ export class Element implements WebElementReference { */ public async clear(signal?: AbortSignal): Promise { try { - return await this.request("POST", `/session/${this.sessionId}/element/${this.elementId}/clear`, undefined, signal); - } catch(cause) { + return await this.request("POST", `/session/${this.sessionId}/element/${this.elementId}/clear`, {}, signal); + } catch (cause) { throw new WebDriverMethodError(`Failed to clear element.`, { cause }); } } @@ -1362,7 +1319,7 @@ export class Element implements WebElementReference { public async sendKeys(text: string, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/element/${this.elementId}/value`, { text }, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to send text to element.`, { cause }, { text: text?.length > 50 ? text?.substring(0, 50) + "..." : text }); @@ -1375,7 +1332,7 @@ export class Element implements WebElementReference { public async takeScreenshot(signal?: AbortSignal): Promise { try { return await this.request("GET", `/session/${this.sessionId}/element/${this.elementId}/screenshot`, undefined, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to take a screenshot of element.`, { cause }); } } @@ -1408,7 +1365,7 @@ export class ShadowRoot implements ShadowRootReference { public async findElement(lookup: ElementLookup, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/shadow/${this.shadowId}/element`, lookup, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to find element by ${lookup.using} "${lookup.value}" from shadow root.`, { cause }, { lookup }); } } @@ -1419,7 +1376,7 @@ export class ShadowRoot implements ShadowRootReference { public async findElements(lookup: ElementLookup, signal?: AbortSignal): Promise { try { return await this.request("POST", `/session/${this.sessionId}/shadow/${this.shadowId}/elements`, lookup, signal); - } catch(cause) { + } catch (cause) { throw new WebDriverMethodError(`Failed to find elements by ${lookup.using} "${lookup.value}" from shadow root.`, { cause }, { lookup }); } } From 543051fddf76b3d24303409e5a067dffcde1fdc9 Mon Sep 17 00:00:00 2001 From: panayot-cankov Date: Sun, 21 Sep 2025 17:33:15 +0300 Subject: [PATCH 4/6] chore: bump to node 24 --- .github/workflows/CD.yml | 2 +- .github/workflows/CI.yml | 2 +- package-lock.json | 4 ++-- package.json | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index db47e98..5bc95f8 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/setup-node@v3 with: - node-version: ^18.17 + node-version: ^24.1 - name: import secrets id: import-secrets uses: LanceMcCarthy/akeyless-action@v3 diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 9a0da14..066e9ac 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: - node-version: ^18.17 + node-version: ^24.1 - name: install virtual display run: sudo apt-get install xvfb - name: npm install diff --git a/package-lock.json b/package-lock.json index 119c1a0..d55007d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,8 @@ "lerna": "^7.3.0" }, "engines": { - "node": "^18.17.0", - "npm": "^9.9.0" + "node": "^24.1.0", + "npm": "^11.3.0" } }, "examples/jest-web": { diff --git a/package.json b/package.json index 657295f..05caf6d 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "examples/test-website" ], "engines": { - "node": "^18.17.0", - "npm": "^9.9.0" + "node": "^24.1.0", + "npm": "^11.3.0" }, "private": true, "repository": { From 083bc4f613b69a3a2793257a9462b514c2aa43a3 Mon Sep 17 00:00:00 2001 From: panayot-cankov Date: Mon, 22 Sep 2025 19:21:51 +0300 Subject: [PATCH 5/6] feat: example using semantic objects --- examples/test-website/index.sematic.test.ts | 255 +++++++++++++++ examples/test-website/index.ts | 90 +++--- examples/test-website/tsconfig.json | 3 +- packages/@progress/roadkill/semantic.ts | 336 ++++++++++++++------ 4 files changed, 538 insertions(+), 146 deletions(-) create mode 100644 examples/test-website/index.sematic.test.ts diff --git a/examples/test-website/index.sematic.test.ts b/examples/test-website/index.sematic.test.ts new file mode 100644 index 0000000..dfbef13 --- /dev/null +++ b/examples/test-website/index.sematic.test.ts @@ -0,0 +1,255 @@ +// index.semantic.test.ts +import { describe, test, expect, beforeAll, afterAll, afterEach, beforeEach } from "@jest/globals"; +import { mkdir, writeFile } from "fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Express } from "@progress/roadkill/express.js"; +import { getState, step } from "@progress/roadkill/utils.js"; +import { ChromeDriver } from "@progress/roadkill/chromedriver.js"; +import { Session, WebDriverClient, type Element as WebDriverElement } from "@progress/roadkill/webdriver.js"; +import { semantic, discover, SemanticObject, Root, findElementsByCss, type DTO, type DTOOf } from "@progress/roadkill/semantic.js"; + +const enableLogging = false; + +@semantic() +class LoginPage extends SemanticObject { + public readonly titleText: string; + private readonly userInput: WebDriverElement | null; + private readonly passInput: WebDriverElement | null; + private readonly submitBtn: WebDriverElement | null; + + static find(): Array<{ + element: Element; + titleText: string; + userInput: Element | null; + passInput: Element | null; + submitBtn: Element | null; + }> { + return findElementsByCss("main.page-login", el => { + const title = el.querySelector("h1"); + return { + element: el, + titleText: (title?.textContent || "").trim(), + userInput: el.querySelector("#username"), + passInput: el.querySelector("#password"), + submitBtn: el.querySelector("button[type=submit]"), + }; + }); + } + + constructor(s: any, dto: DTOOf) { + super(s, dto); + this.titleText = dto.titleText; + this.userInput = dto.userInput; + this.passInput = dto.passInput; + this.submitBtn = dto.submitBtn; + } + + async login({ username, password }: { username: string; password: string }) { + await this.userInput!.clear(); + await this.userInput!.sendKeys(username); + await this.passInput!.clear(); + await this.passInput!.sendKeys(password); + await this.submitBtn!.click(); + } +} + +@semantic() +class GdprFrame extends SemanticObject { + static find(): DTO<{}>[] { + return findElementsByCss("iframe.overlay-frame", () => ({})); + } + async switchToFrame() { + await this.element!.switchToFrame(); + } +} + +@semantic() +class GdprPanel extends SemanticObject { + public readonly headerText: string; + private readonly acceptBtn: WebDriverElement | null; + + static find(): DTO<{ headerText: string; acceptBtn: Element | null }>[] { + return findElementsByCss("main.page-gdpr", element => ({ + headerText: (element.querySelector("h2")?.textContent || "").trim(), + acceptBtn: element.querySelector("#accept"), + })); + } + + constructor(s: any, dto: DTOOf) { + super(s, dto); + this.headerText = dto.headerText; + this.acceptBtn = dto.acceptBtn; + } + + async accept() { + await this.acceptBtn!.click(); + } +} + +// Topics page container (captures header + subtitle; becomes parent of TopicCard via containment) +@semantic() +class TocPage extends SemanticObject { + public readonly titleText: string; + public readonly subtitleText: string; + public readonly cardCount: number; + + static find(): DTO<{ titleText: string; subtitleText: string; cardCount: number }>[] { + return findElementsByCss("main.page-topics", element => ({ + titleText: (element.querySelector("h1")?.textContent || "").trim(), + subtitleText: (element.querySelector(".muted")?.textContent || "").trim(), + cardCount: element.querySelectorAll("#topics-grid .card").length, + })); + } + + constructor(session: any, dto: DTOOf) { + super(session, dto); + this.titleText = dto.titleText; + this.subtitleText = dto.subtitleText; + this.cardCount = dto.cardCount; + } + + cards(): TopicCard[] { return this.childrenOfType(TopicCard); } +} + +@semantic() +class TopicCard extends SemanticObject { + public readonly title: string; + public readonly description: string; + public readonly href: string; + + static find(): DTO<{ title: string; description: string; href: string }>[] { + return findElementsByCss("#topics-grid .card", element => ({ + title: (element.querySelector("h3")?.textContent || "").trim(), + description: (element.querySelector("p.muted")?.textContent || "").trim(), + href: (element.querySelector("a[href]") as HTMLAnchorElement | null)?.href || "", + })); + } + + constructor(s: any, dto: DTOOf) { + super(s, dto); + this.title = dto.title; + this.description = dto.description; + this.href = dto.href; + } +} + +describe("test-website (semantic objects)", () => { + let chromedriver: ChromeDriver; + let express: Express; + let webdriver: WebDriverClient; + let session: Session; + + beforeAll(async () => { + const { signal } = getState(); + + express = new Express( + Express.npmStart({ cwd: dirname(fileURLToPath(import.meta.url)), enableLogging }), + ); + await express.start(signal); + + chromedriver = new ChromeDriver({ args: ["--port=5033"], enableLogging }); + await chromedriver.start(signal); + + webdriver = new WebDriverClient({ address: chromedriver.address!, enableLogging }); + session = await webdriver.newSession({ + capabilities: { timeouts: { implicit: 2000 } }, + }); + }, 60000); + + beforeEach(async () => { + await session.setTimeouts({ implicit: 2000 }); + }); + + afterEach(async () => { + const { test } = getState(); + if (test && test.status === "fail") { + const screenshot = await session.takeScreenshot(); + const dir = `dist/test/${expect.getState().currentTestName}-semantic`; + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "screenshot.png"), screenshot, { encoding: "base64" }); + } + }); + + afterAll(async () => { + await session?.dispose(); + await chromedriver?.dispose(); + await express?.dispose(); + }, 20000); + + test.only("login + topics using semantic objects", async () => { + + await step("navigate to local test site", () => + session.navigateTo(express.address!) + ); + + let root: Root = await step("discover login page + iframe", async () => { + const r = await discover(session); + expect(r.toXML(" ")).toBe( + ` + + + +` + ); + return r; + }); + + await step("switch to GDPR iframe", async () => { + const login = root.childrenOfType(LoginPage)[0]; + const frame = login.childrenOfType(GdprFrame)[0]; + expect(frame).toBeDefined(); + await frame.switchToFrame(); + }); + + await step("discover & accept GDPR", async () => { + const r = await discover(session); + expect(r.toXML(" ")).toBe( + ` + +` + ); + const panel = r.childrenOfType(GdprPanel)[0]; + await panel.accept(); + }); + + await step("switch back & perform login", async () => { + await session.switchToFrame(null); + const r = await discover(session); + const login = r.childrenOfType(LoginPage)[0]; + await login.login({ username: "admin", password: "1234" }); + }); + + await step("wait for redirect to /toc", async () => { + const deadline = Date.now() + 12_000; + while (Date.now() < deadline) { + if ((await session.getCurrentUrl()).includes("/toc")) return; + await new Promise(r => setTimeout(r, 50)); + } + throw new Error("Timed out waiting for /toc"); + }); + + await step("discover topics & verify snapshot", async () => { + const r = await discover(session); + const xml = r.toXML(" "); + expect(xml).toBe( + ` + + + + + + + +` + ); + }); + + await step("screenshot topics page", async () => { + const screenshot = await session.takeScreenshot(); + const dir = `dist/test/${expect.getState().currentTestName}-semantic`; + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "screenshot.png"), screenshot, { encoding: "base64" }); + }); + }, 30000); +}); diff --git a/examples/test-website/index.ts b/examples/test-website/index.ts index b038a03..d70c168 100644 --- a/examples/test-website/index.ts +++ b/examples/test-website/index.ts @@ -102,29 +102,31 @@ function getCookie(req: Request, name: string) { app.get("/", (req: Request, res: Response) => { const gdprAccepted = getCookie(req, "gdprAccepted") === "true"; const body = ` -
-
-

Roadkill – Test Login

-

Localhost-only demo for WebDriver QA. Use admin / 1234.

-
- -
- - - - -
Submitting simulates a short random delay (500–1500ms) then navigates to Topics.
+
+
+
+

Roadkill – Test Login

+

Localhost-only demo for WebDriver QA. Use admin / 1234.

- - + +
+ + + + +
Submitting simulates a short random delay (500–1500ms) then navigates to Topics.
+
+ +
+
-
-
- - -
+
+ + +
+