From e4744ce959e3e16064d5cd4c4d530ccc2cf8b1ac Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Wed, 15 Apr 2026 03:25:36 +0300 Subject: [PATCH] feat: add ability to use wsdriver --- package-lock.json | 305 ++++-------- package.json | 6 +- src/browser/browser.ts | 10 +- src/browser/cdp/connection.ts | 571 ++++------------------- src/browser/cdp/constants.ts | 10 - src/browser/cdp/error.ts | 66 +-- src/browser/cdp/utils.ts | 11 - src/browser/existing-browser.ts | 18 + src/browser/wsdriver/compression.ts | 90 ++++ src/browser/wsdriver/constants.ts | 14 + src/browser/wsdriver/debug.ts | 3 + src/browser/wsdriver/error.ts | 29 ++ src/browser/wsdriver/index.ts | 347 ++++++++++++++ src/browser/wsdriver/request.ts | 99 ++++ src/browser/wsdriver/response.ts | 98 ++++ src/browser/wsdriver/types.ts | 99 ++++ src/config/browser-options.js | 2 + src/config/defaults.js | 1 + src/config/types.ts | 1 + src/ws-connection/constants.ts | 13 + src/ws-connection/error.ts | 101 ++++ src/ws-connection/index.ts | 557 ++++++++++++++++++++++ src/ws-connection/utils.ts | 10 + test/src/browser/cdp/connection.ts | 222 +-------- test/src/browser/existing-browser.js | 86 +++- test/src/browser/wsdriver/compression.ts | 63 +++ test/src/browser/wsdriver/index.ts | 185 ++++++++ test/src/browser/wsdriver/request.ts | 64 +++ test/src/browser/wsdriver/response.ts | 53 +++ test/src/ws-connection/index.ts | 397 ++++++++++++++++ 30 files changed, 2567 insertions(+), 964 deletions(-) create mode 100644 src/browser/wsdriver/compression.ts create mode 100644 src/browser/wsdriver/constants.ts create mode 100644 src/browser/wsdriver/debug.ts create mode 100644 src/browser/wsdriver/error.ts create mode 100644 src/browser/wsdriver/index.ts create mode 100644 src/browser/wsdriver/request.ts create mode 100644 src/browser/wsdriver/response.ts create mode 100644 src/browser/wsdriver/types.ts create mode 100644 src/ws-connection/constants.ts create mode 100644 src/ws-connection/error.ts create mode 100644 src/ws-connection/index.ts create mode 100644 src/ws-connection/utils.ts create mode 100644 test/src/browser/wsdriver/compression.ts create mode 100644 test/src/browser/wsdriver/index.ts create mode 100644 test/src/browser/wsdriver/request.ts create mode 100644 test/src/browser/wsdriver/response.ts create mode 100644 test/src/ws-connection/index.ts diff --git a/package-lock.json b/package-lock.json index 5e0543fe6..813f7ca4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@testplane/devtools": "8.32.5", "@testplane/wdio-protocols": "9.4.7", "@testplane/wdio-utils": "9.5.4", - "@testplane/webdriverio": "9.5.24", + "@testplane/webdriverio": "9.5.25", "@vitest/spy": "2.1.4", "buffer-crc32": "1.0.0", "chalk": "2.4.2", @@ -74,7 +74,7 @@ "@rrweb/record": "2.0.0-alpha.18", "@sinonjs/fake-timers": "10.3.0", "@swc/core": "1.13.3", - "@testplane/wdio-types": "9.5.4", + "@testplane/wdio-types": "9.5.5", "@types/babel__code-frame": "7.0.6", "@types/browserify": "12.0.40", "@types/chai": "4.3.4", @@ -88,7 +88,7 @@ "@types/lodash": "4.14.191", "@types/micromatch": "4.0.9", "@types/mocha": "10.0.1", - "@types/node": "18.19.3", + "@types/node": "20.19.39", "@types/proper-lockfile": "4.1.4", "@types/proxyquire": "1.3.28", "@types/set-cookie-parser": "2.4.10", @@ -2298,12 +2298,15 @@ "node": ">=18.0.0" } }, - "node_modules/@testplane/devtools/node_modules/@types/node": { - "version": "20.19.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", - "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", + "node_modules/@testplane/devtools/node_modules/@testplane/wdio-types": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz", + "integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==", "dependencies": { - "undici-types": "~6.21.0" + "@types/node": "^20.1.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@testplane/devtools/node_modules/chalk": { @@ -2329,11 +2332,6 @@ "node": ">=16" } }, - "node_modules/@testplane/devtools/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, "node_modules/@testplane/devtools/node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -2517,6 +2515,17 @@ "node": ">=18.0.0" } }, + "node_modules/@testplane/wdio-config/node_modules/@testplane/wdio-types": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz", + "integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==", + "dependencies": { + "@types/node": "^20.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@testplane/wdio-config/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2620,23 +2629,10 @@ "node": ">=18.0.0" } }, - "node_modules/@testplane/wdio-repl/node_modules/@types/node": { - "version": "20.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", - "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@testplane/wdio-repl/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, "node_modules/@testplane/wdio-types": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz", - "integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==", + "version": "9.5.5", + "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.5.tgz", + "integrity": "sha512-g1vFPN8lfojGHWqQhSMSIfjdL/qiPgk3FGjmvSpTjnkbsNKxoVgRz7O6TeBXBy0h+hEIfXAjWX0utwz2eHjK4Q==", "dependencies": { "@types/node": "^20.1.0" }, @@ -2644,19 +2640,6 @@ "node": ">=18.0.0" } }, - "node_modules/@testplane/wdio-types/node_modules/@types/node": { - "version": "20.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", - "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@testplane/wdio-types/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, "node_modules/@testplane/wdio-utils": { "version": "9.5.4", "resolved": "https://registry.npmjs.org/@testplane/wdio-utils/-/wdio-utils-9.5.4.tgz", @@ -2680,6 +2663,17 @@ "node": ">=18.0.0" } }, + "node_modules/@testplane/wdio-utils/node_modules/@testplane/wdio-types": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz", + "integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==", + "dependencies": { + "@types/node": "^20.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@testplane/wdio-utils/node_modules/decamelize": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.0.tgz", @@ -2703,14 +2697,14 @@ } }, "node_modules/@testplane/webdriver": { - "version": "9.5.12", - "resolved": "https://registry.npmjs.org/@testplane/webdriver/-/webdriver-9.5.12.tgz", - "integrity": "sha512-uZTSO8HW2kFH5MGUVWeLY86e95zj3ZX05tYlA31J5sgij7q71TDRMjGJ7xjjjfMoA2U9dFg6ZlrI/WBwkMSEeg==", + "version": "9.5.15", + "resolved": "https://registry.npmjs.org/@testplane/webdriver/-/webdriver-9.5.15.tgz", + "integrity": "sha512-ARD9MYKjQLKBCvK2snN67QS915/iHuFPkYm7b27VLKuL22OoTPq7Ncy+FxwumqKyrjqiXNoLHWTkFfVzNkR+EA==", "dependencies": { "@testplane/wdio-config": "9.5.3", "@testplane/wdio-logger": "9.4.6", "@testplane/wdio-protocols": "9.4.6", - "@testplane/wdio-types": "9.5.3", + "@testplane/wdio-types": "9.5.5", "@testplane/wdio-utils": "9.5.3", "@types/node": "^20.1.0", "@types/ws": "^8.5.3", @@ -2756,17 +2750,6 @@ "resolved": "https://registry.npmjs.org/@testplane/wdio-protocols/-/wdio-protocols-9.4.6.tgz", "integrity": "sha512-r++AmapEhXpShtNsYbqhX71iZSyUp7YyZV+RoamWVyvgP3ljpeU9cLksz2imyZJ5fCas1qcx9xElPKHRF/zcxw==" }, - "node_modules/@testplane/webdriver/node_modules/@testplane/wdio-types": { - "version": "9.5.3", - "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.3.tgz", - "integrity": "sha512-rubeNOnw0eozqnAoVl6oWEipV8ZeAPCv3UDRyZk/mdu7IBZUPuyJUX3cbiWnj9KCiJeVy3Qy6Ohn7Apod/zzog==", - "dependencies": { - "@types/node": "^20.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@testplane/webdriver/node_modules/@testplane/wdio-utils": { "version": "9.5.3", "resolved": "https://registry.npmjs.org/@testplane/wdio-utils/-/wdio-utils-9.5.3.tgz", @@ -2801,18 +2784,10 @@ "node": ">=18.0.0" } }, - "node_modules/@testplane/webdriver/node_modules/@types/node": { - "version": "20.19.37", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", - "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/@testplane/webdriver/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dependencies": { "balanced-match": "^1.0.0" } @@ -2873,23 +2848,18 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@testplane/webdriver/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, "node_modules/@testplane/webdriverio": { - "version": "9.5.24", - "resolved": "https://registry.npmjs.org/@testplane/webdriverio/-/webdriverio-9.5.24.tgz", - "integrity": "sha512-A/0ZEIChSSxwxfjAd/D+R1WdtahQui8PAdesLIxK+zR6BpqbTTlveUEDuY+EkxNhx1ttGCFCkNMGpCKXOw8/SA==", + "version": "9.5.25", + "resolved": "https://registry.npmjs.org/@testplane/webdriverio/-/webdriverio-9.5.25.tgz", + "integrity": "sha512-yXngYns3uxNXWbVcW1LhpkLclfbFqhRo7nleCrQ8Z/dfw2v28jO+sXAsKikvYZa0jmjbr34ZP7Oz/ZcxHgAseQ==", "dependencies": { "@testplane/wdio-config": "9.5.3", "@testplane/wdio-logger": "9.4.6", "@testplane/wdio-protocols": "9.4.6", "@testplane/wdio-repl": "9.4.4", - "@testplane/wdio-types": "9.5.3", + "@testplane/wdio-types": "9.5.5", "@testplane/wdio-utils": "9.5.3", - "@testplane/webdriver": "9.5.12", + "@testplane/webdriver": "9.5.15", "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", "archiver": "^7.0.1", @@ -2955,17 +2925,6 @@ "resolved": "https://registry.npmjs.org/@testplane/wdio-protocols/-/wdio-protocols-9.4.6.tgz", "integrity": "sha512-r++AmapEhXpShtNsYbqhX71iZSyUp7YyZV+RoamWVyvgP3ljpeU9cLksz2imyZJ5fCas1qcx9xElPKHRF/zcxw==" }, - "node_modules/@testplane/webdriverio/node_modules/@testplane/wdio-types": { - "version": "9.5.3", - "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.3.tgz", - "integrity": "sha512-rubeNOnw0eozqnAoVl6oWEipV8ZeAPCv3UDRyZk/mdu7IBZUPuyJUX3cbiWnj9KCiJeVy3Qy6Ohn7Apod/zzog==", - "dependencies": { - "@types/node": "^20.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@testplane/webdriverio/node_modules/@testplane/wdio-utils": { "version": "9.5.3", "resolved": "https://registry.npmjs.org/@testplane/wdio-utils/-/wdio-utils-9.5.3.tgz", @@ -3000,14 +2959,6 @@ "node": ">=18.0.0" } }, - "node_modules/@testplane/webdriverio/node_modules/@types/node": { - "version": "20.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", - "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/@testplane/webdriverio/node_modules/@types/sinonjs__fake-timers": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", @@ -3102,11 +3053,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@testplane/webdriverio/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, "node_modules/@textlint/ast-node-types": { "version": "12.2.1", "dev": true, @@ -3369,10 +3315,11 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.3", - "license": "MIT", + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/normalize-package-data": { @@ -13529,8 +13476,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "license": "MIT" + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/unicorn-magic": { "version": "0.1.0", @@ -15927,12 +15875,12 @@ "strip-ansi": "^6.0.1" } }, - "@types/node": { - "version": "20.19.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", - "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", + "@testplane/wdio-types": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz", + "integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==", "requires": { - "undici-types": "~6.21.0" + "@types/node": "^20.1.0" } }, "chalk": { @@ -15949,11 +15897,6 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==" }, - "undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, "which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -16079,6 +16022,14 @@ "strip-ansi": "^6.0.1" } }, + "@testplane/wdio-types": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz", + "integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==", + "requires": { + "@types/node": "^20.1.0" + } + }, "brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -16152,44 +16103,14 @@ "integrity": "sha512-+7TSmEnIaovPMysIUFT8Cq/B+t7FQdT+TyMADTnOMwpMZUxJqslT4MUoTnSBIFWsomTfqR7GjLjYRr7oa+3U5A==", "requires": { "@types/node": "^20.1.0" - }, - "dependencies": { - "@types/node": { - "version": "20.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", - "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", - "requires": { - "undici-types": "~6.21.0" - } - }, - "undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - } } }, "@testplane/wdio-types": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz", - "integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==", + "version": "9.5.5", + "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.5.tgz", + "integrity": "sha512-g1vFPN8lfojGHWqQhSMSIfjdL/qiPgk3FGjmvSpTjnkbsNKxoVgRz7O6TeBXBy0h+hEIfXAjWX0utwz2eHjK4Q==", "requires": { "@types/node": "^20.1.0" - }, - "dependencies": { - "@types/node": { - "version": "20.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", - "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", - "requires": { - "undici-types": "~6.21.0" - } - }, - "undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - } } }, "@testplane/wdio-utils": { @@ -16212,6 +16133,14 @@ "wait-port": "^1.1.0" }, "dependencies": { + "@testplane/wdio-types": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz", + "integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==", + "requires": { + "@types/node": "^20.1.0" + } + }, "decamelize": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.0.tgz", @@ -16225,14 +16154,14 @@ } }, "@testplane/webdriver": { - "version": "9.5.12", - "resolved": "https://registry.npmjs.org/@testplane/webdriver/-/webdriver-9.5.12.tgz", - "integrity": "sha512-uZTSO8HW2kFH5MGUVWeLY86e95zj3ZX05tYlA31J5sgij7q71TDRMjGJ7xjjjfMoA2U9dFg6ZlrI/WBwkMSEeg==", + "version": "9.5.15", + "resolved": "https://registry.npmjs.org/@testplane/webdriver/-/webdriver-9.5.15.tgz", + "integrity": "sha512-ARD9MYKjQLKBCvK2snN67QS915/iHuFPkYm7b27VLKuL22OoTPq7Ncy+FxwumqKyrjqiXNoLHWTkFfVzNkR+EA==", "requires": { "@testplane/wdio-config": "9.5.3", "@testplane/wdio-logger": "9.4.6", "@testplane/wdio-protocols": "9.4.6", - "@testplane/wdio-types": "9.5.3", + "@testplane/wdio-types": "9.5.5", "@testplane/wdio-utils": "9.5.3", "@types/node": "^20.1.0", "@types/ws": "^8.5.3", @@ -16271,14 +16200,6 @@ "resolved": "https://registry.npmjs.org/@testplane/wdio-protocols/-/wdio-protocols-9.4.6.tgz", "integrity": "sha512-r++AmapEhXpShtNsYbqhX71iZSyUp7YyZV+RoamWVyvgP3ljpeU9cLksz2imyZJ5fCas1qcx9xElPKHRF/zcxw==" }, - "@testplane/wdio-types": { - "version": "9.5.3", - "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.3.tgz", - "integrity": "sha512-rubeNOnw0eozqnAoVl6oWEipV8ZeAPCv3UDRyZk/mdu7IBZUPuyJUX3cbiWnj9KCiJeVy3Qy6Ohn7Apod/zzog==", - "requires": { - "@types/node": "^20.1.0" - } - }, "@testplane/wdio-utils": { "version": "9.5.3", "resolved": "https://registry.npmjs.org/@testplane/wdio-utils/-/wdio-utils-9.5.3.tgz", @@ -16309,18 +16230,10 @@ } } }, - "@types/node": { - "version": "20.19.37", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", - "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", - "requires": { - "undici-types": "~6.21.0" - } - }, "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "requires": { "balanced-match": "^1.0.0" } @@ -16355,26 +16268,21 @@ "requires": { "brace-expansion": "^2.0.2" } - }, - "undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" } } }, "@testplane/webdriverio": { - "version": "9.5.24", - "resolved": "https://registry.npmjs.org/@testplane/webdriverio/-/webdriverio-9.5.24.tgz", - "integrity": "sha512-A/0ZEIChSSxwxfjAd/D+R1WdtahQui8PAdesLIxK+zR6BpqbTTlveUEDuY+EkxNhx1ttGCFCkNMGpCKXOw8/SA==", + "version": "9.5.25", + "resolved": "https://registry.npmjs.org/@testplane/webdriverio/-/webdriverio-9.5.25.tgz", + "integrity": "sha512-yXngYns3uxNXWbVcW1LhpkLclfbFqhRo7nleCrQ8Z/dfw2v28jO+sXAsKikvYZa0jmjbr34ZP7Oz/ZcxHgAseQ==", "requires": { "@testplane/wdio-config": "9.5.3", "@testplane/wdio-logger": "9.4.6", "@testplane/wdio-protocols": "9.4.6", "@testplane/wdio-repl": "9.4.4", - "@testplane/wdio-types": "9.5.3", + "@testplane/wdio-types": "9.5.5", "@testplane/wdio-utils": "9.5.3", - "@testplane/webdriver": "9.5.12", + "@testplane/webdriver": "9.5.15", "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", "archiver": "^7.0.1", @@ -16425,14 +16333,6 @@ "resolved": "https://registry.npmjs.org/@testplane/wdio-protocols/-/wdio-protocols-9.4.6.tgz", "integrity": "sha512-r++AmapEhXpShtNsYbqhX71iZSyUp7YyZV+RoamWVyvgP3ljpeU9cLksz2imyZJ5fCas1qcx9xElPKHRF/zcxw==" }, - "@testplane/wdio-types": { - "version": "9.5.3", - "resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.3.tgz", - "integrity": "sha512-rubeNOnw0eozqnAoVl6oWEipV8ZeAPCv3UDRyZk/mdu7IBZUPuyJUX3cbiWnj9KCiJeVy3Qy6Ohn7Apod/zzog==", - "requires": { - "@types/node": "^20.1.0" - } - }, "@testplane/wdio-utils": { "version": "9.5.3", "resolved": "https://registry.npmjs.org/@testplane/wdio-utils/-/wdio-utils-9.5.3.tgz", @@ -16463,14 +16363,6 @@ } } }, - "@types/node": { - "version": "20.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", - "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", - "requires": { - "undici-types": "~6.21.0" - } - }, "@types/sinonjs__fake-timers": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", @@ -16529,11 +16421,6 @@ "requires": { "brace-expansion": "^2.0.1" } - }, - "undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" } } }, @@ -16762,9 +16649,11 @@ "dev": true }, "@types/node": { - "version": "18.19.3", + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "requires": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "@types/normalize-package-data": { @@ -23485,7 +23374,9 @@ "integrity": "sha512-d87yk8lqSFUYtR5fTFe2frpkMIrUEz+lgoJmhcL+J3StVl+8fj8ytE4lLnJOTPCE12YbumNGzf4LYsQyusdV5g==" }, "undici-types": { - "version": "5.26.5" + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "unicorn-magic": { "version": "0.1.0", diff --git a/package.json b/package.json index c5d70ead3..0b2c8c6b1 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@testplane/devtools": "8.32.5", "@testplane/wdio-protocols": "9.4.7", "@testplane/wdio-utils": "9.5.4", - "@testplane/webdriverio": "9.5.24", + "@testplane/webdriverio": "9.5.25", "@vitest/spy": "2.1.4", "buffer-crc32": "1.0.0", "chalk": "2.4.2", @@ -125,7 +125,7 @@ "@rrweb/record": "2.0.0-alpha.18", "@sinonjs/fake-timers": "10.3.0", "@swc/core": "1.13.3", - "@testplane/wdio-types": "9.5.4", + "@testplane/wdio-types": "9.5.5", "@types/babel__code-frame": "7.0.6", "@types/browserify": "12.0.40", "@types/chai": "4.3.4", @@ -139,7 +139,7 @@ "@types/lodash": "4.14.191", "@types/micromatch": "4.0.9", "@types/mocha": "10.0.1", - "@types/node": "18.19.3", + "@types/node": "20.19.39", "@types/proper-lockfile": "4.1.4", "@types/proxyquire": "1.3.28", "@types/set-cookie-parser": "2.4.10", diff --git a/src/browser/browser.ts b/src/browser/browser.ts index 20ef3d25b..fa1616fd5 100644 --- a/src/browser/browser.ts +++ b/src/browser/browser.ts @@ -138,13 +138,15 @@ export class Browser { req = this._config[optName](req); } - if (!req.headers!["X-Request-ID"]) { - req.headers!["X-Request-ID"] = `${ + const requestHeaders = req.headers as Record; + + if (!requestHeaders["X-Request-ID"]) { + requestHeaders["X-Request-ID"] = `${ this.state.testXReqId }${X_REQUEST_ID_DELIMITER}${crypto.randomUUID()}`; } - if (!req.headers!["traceparent"] && this.state.traceparent) { - req.headers!["traceparent"] = this.state.traceparent; + if (!requestHeaders["traceparent"] && this.state.traceparent) { + requestHeaders["traceparent"] = this.state.traceparent; } return req; diff --git a/src/browser/cdp/connection.ts b/src/browser/cdp/connection.ts index f8c4f7c50..7b6f80950 100644 --- a/src/browser/cdp/connection.ts +++ b/src/browser/cdp/connection.ts @@ -1,62 +1,60 @@ -import { WebSocket, type RawData } from "ws"; +import type { RawData } from "ws"; +import { inspect } from "node:util"; import { debugCdp } from "./debug"; import { getWsEndpoint } from "./ws-endpoint"; -import { exponentiallyWait, extractRequestIdFromBrokenResponse } from "./utils"; -import { CDPConnectionTerminatedError, CDPError, CDPTimeoutError } from "./error"; +import { extractRequestIdFromBrokenResponse } from "./utils"; +import { + CDPConnectionBreakError, + CDPConnectionEstablishmentError, + CDPConnectionTerminatedError, + CDPConnectionTimeoutError, + CDPError, + CDPRequestError, + CDPRequestTimeoutError, +} from "./error"; import { CDP_CONNECTION_RETRIES, CDP_CONNECTION_RETRY_BASE_DELAY, CDP_CONNECTION_TIMEOUT, - CDP_MAX_REQUEST_ID, CDP_REQUEST_RETRIES, CDP_REQUEST_RETRY_BASE_DELAY, CDP_REQUEST_TIMEOUT, - CDP_ERROR_CODE, - CDP_PING_INTERVAL, - CDP_PING_TIMEOUT, - CDP_PING_MAX_SUBSEQUENT_FAILS, } from "./constants"; import type { CDPEvent, CDPMessage, CDPRequest } from "./types"; import type { Browser } from "../types"; - -enum WsConnectionStatus { - DISCONNECTED, // Not connected, able to connect - CONNECTING, // Connection is being established - CONNECTED, // Connection established - CLOSED, // Connection is disposed and does not require reconnecting -} +import { WsConnection } from "../../ws-connection"; +import { WS_ERROR_CODE } from "../../ws-connection/constants"; +import { WsError } from "../../ws-connection/error"; +import { exponentiallyWait } from "../../ws-connection/utils"; type OnEventMessageFn = (cdpEventMessage: CDPEvent) => unknown; -// Closing WS when its still not connected produces error: -// https://github.com/websockets/ws/blob/86eac5b44ac2bff9087ec40c9bd06bc7b4f0da07/lib/websocket.js#L297-L301 -const closeWsConnection = (ws: WebSocket): void => { - if (ws.readyState !== ws.CONNECTING) { - ws.close(); - } else { - ws.once("open", () => { - ws.close(); - }); - } -}; - export class CDPConnection { public onEventMessage: OnEventMessageFn | null = null; - private readonly _cdpWsEndpoint: string; - private readonly _headers?: Record; - private _onPong: (() => void) | null = null; - private _pingInterval: ReturnType | null = null; - private _pingSubsequentFails = 0; - private _onConnectionCloseFn: (() => void) | null = null; // Defined, if there is connection attempt at the moment - private _wsConnectionStatus: WsConnectionStatus = WsConnectionStatus.DISCONNECTED; - private _wsConnection: WebSocket | null = null; - private _wsConnectionPromise: Promise | null = null; - private _requestId = 0; - private _pendingRequests: Record | CDPError) => void> = {}; + + private readonly _wsConnection: WsConnection, string>; private constructor(cdpWsEndpoint: string, headers?: Record) { - this._cdpWsEndpoint = cdpWsEndpoint; - this._headers = headers; + this._wsConnection = new WsConnection, string>(cdpWsEndpoint, { + headers, + debugFn: debugCdp, + retries: { + count: CDP_CONNECTION_RETRIES, + baseDelay: CDP_CONNECTION_RETRY_BASE_DELAY, + }, + timeouts: { + request: CDP_REQUEST_TIMEOUT, + createSession: CDP_CONNECTION_TIMEOUT, + }, + errors: { + ConnectionEstablishment: CDPConnectionEstablishmentError, + ConnectionBreak: CDPConnectionBreakError, + ConnectionTerminated: CDPConnectionTerminatedError, + ConnectionTimeout: CDPConnectionTimeoutError, + RequestTimeout: CDPRequestError, + }, + onMessage: this._onMessage.bind(this), + }); } /** @description Creates CDPConnection without establishing it */ @@ -73,197 +71,27 @@ export class CDPConnection { return new this(cdpWsEndpoint, headers); } - /** @description Tries to establish ws connection with timeout */ - private async _tryToEstablishWsConnection(endpoint: string): Promise { - return new Promise(resolve => { - try { - const onConnectionCloseFn = (): void => done(new CDPConnectionTerminatedError()); - - if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { - onConnectionCloseFn(); - } else { - this._onConnectionCloseFn = onConnectionCloseFn; - } - - // eslint-disable-next-line - const cdpConnectionInstance = this; - const ws = new WebSocket(endpoint, { headers: this._headers }); - - let isSettled = false; - - const timeoutId = setTimeout(() => { - closeWsConnection(ws); - done( - new CDPTimeoutError({ - message: `Couldn't establish CDP connection to "${endpoint}" in ${CDP_CONNECTION_TIMEOUT}ms`, - }), - ); - }, CDP_CONNECTION_TIMEOUT).unref(); - - const onOpen = (): void => { - done(ws); - }; - - const onError = (error: unknown): void => { - closeWsConnection(ws); - done( - new CDPError({ - message: `Couldn't establish CDP connection to "${endpoint}": ${error}`, - }), - ); - }; - - const onClose = (): void => { - done( - new CDPError({ - message: `CDP connection to "${endpoint}" unexpectedly closed while establishing`, - }), - ); - }; - - ws.on("open", onOpen); - ws.on("error", onError); - ws.on("close", onClose); - - // eslint-disable-next-line no-inner-declarations - function done(result: WebSocket | Error): void { - if (isSettled) { - return; - } - - cdpConnectionInstance._onConnectionCloseFn = null; - isSettled = true; - clearTimeout(timeoutId); - ws.off("open", onOpen); - ws.off("error", onError); - ws.off("close", onClose); - resolve(result); - } - } catch (err) { - resolve(err as Error); - } - }); - } - - /** - * @description creates ws connection with retries or returns existing one - * @note Concurrent requests with same params produce same ws connection - */ - private async _getWsConnection(): Promise { - const ws = this._wsConnection; - - if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { - throw new CDPConnectionTerminatedError({ message: `Session to ${this._cdpWsEndpoint} was closed` }); - } - - if (this._wsConnectionStatus === WsConnectionStatus.CONNECTING && this._wsConnectionPromise) { - return this._wsConnectionPromise; - } - - if (this._wsConnectionStatus === WsConnectionStatus.CONNECTED && ws && ws.readyState === ws.OPEN) { - return ws; - } - - if (this._wsConnectionStatus === WsConnectionStatus.CONNECTED && ws && ws.readyState !== ws.OPEN) { - this._closeWsConnection("CDP connection was in invalid state", WsConnectionStatus.DISCONNECTED); - } - - this._wsConnectionStatus = WsConnectionStatus.CONNECTING; - - this._wsConnectionPromise = (async (): Promise => { - try { - for (let retriesLeft = CDP_CONNECTION_RETRIES; retriesLeft >= 0; retriesLeft--) { - const result = await this._tryToEstablishWsConnection(this._cdpWsEndpoint); - - if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { - if (result instanceof WebSocket) { - closeWsConnection(result); - } - throw new CDPConnectionTerminatedError(); - } - - if (result instanceof WebSocket) { - debugCdp(`Established CDP connection to ${this._cdpWsEndpoint}`); - - this._wsConnection = result; - this._wsConnectionStatus = WsConnectionStatus.CONNECTED; - this._pingHealthCheckStart(); - - const onPing = (): void => result.pong(); - const onMessage = (data: RawData): void => this._onMessage(data); - const onError = (err: Error): void => { - if (result === this._wsConnection) { - this._closeWsConnection( - `An error occured in CDP connection: ${err}`, - WsConnectionStatus.DISCONNECTED, - ); - this._tryToReconnect(); - } - }; - - result.on("ping", onPing); - result.on("message", onMessage); - result.on("error", onError); - result.once("close", () => { - result.off("ping", onPing); - result.off("message", onMessage); - result.off("error", onError); - if (result === this._wsConnection) { - this._closeWsConnection( - "CDP connection was closed unexpectedly", - WsConnectionStatus.DISCONNECTED, - ); - this._tryToReconnect(); - } - }); - - return result; - } - - if (!(result instanceof CDPError) || result instanceof CDPConnectionTerminatedError) { - throw result; - } - - debugCdp(`${result.message}; retries left: ${retriesLeft}`); - - // Intentionally avoiding wait after timeout - if (result instanceof CDPError && !(result instanceof CDPTimeoutError)) { - await exponentiallyWait({ - baseDelay: CDP_CONNECTION_RETRY_BASE_DELAY, - attempt: CDP_CONNECTION_RETRIES - retriesLeft, - }); - } - } - - throw new CDPError({ - message: `Couldn't establish CDP connection to ${this._cdpWsEndpoint} in ${CDP_CONNECTION_RETRIES} retries`, - }); - } catch (err) { - if (this._wsConnectionStatus === WsConnectionStatus.CONNECTING) { - this._wsConnectionStatus = WsConnectionStatus.DISCONNECTED; - this._wsConnectionPromise = null; - } - - throw err; - } finally { - if (this._wsConnectionStatus !== WsConnectionStatus.CONNECTING) { - this._wsConnectionPromise = null; - } - } - })(); - - return this._wsConnectionPromise; + close(): void { + this._wsConnection.close(); } - /** @description Handles websocket incoming messages, resolving pending requests */ private _onMessage(data: RawData): void { const message = data.toString("utf8"); - debugCdp(`< ${message}`); - try { const jsonParsedMessage: CDPMessage = JSON.parse(message); + if (debugCdp.enabled) { + debugCdp( + `< ${inspect(jsonParsedMessage, { + depth: 3, + maxStringLength: 150, + breakLength: Infinity, + compact: true, + })}`, + ); + } + if (!("id" in jsonParsedMessage)) { if (this.onEventMessage) { this.onEventMessage(jsonParsedMessage); @@ -274,152 +102,90 @@ export class CDPConnection { const requestId = jsonParsedMessage.id; - if (!this._pendingRequests[requestId]) { - debugCdp(`Received response to request ${requestId}, which is probably timed out already`); - - return; - } - if ("result" in jsonParsedMessage) { - this._pendingRequests[requestId](jsonParsedMessage.result); + this._wsConnection.provideResponseFor(requestId, jsonParsedMessage.result); } else if ("error" in jsonParsedMessage) { - this._pendingRequests[requestId]( - new CDPError({ + this._wsConnection.provideResponseFor( + requestId, + new CDPRequestError({ message: jsonParsedMessage.error.message, code: jsonParsedMessage.error.code, - requestId: requestId, + requestId, }), ); } else { - this._pendingRequests[requestId]( - new CDPError({ + this._wsConnection.provideResponseFor( + requestId, + new CDPRequestError({ message: "Received malformed response without result", - code: CDP_ERROR_CODE.MALFORMED_RESPONSE, - requestId: requestId, + code: WS_ERROR_CODE.MALFORMED_RESPONSE, + requestId, }), ); } } catch (err) { - debugCdp(`Couldn't process CDP message.\n\tError: ${err}\n\tMessage: "${message}"`); + if (debugCdp.enabled) { + debugCdp(`\u2718 Couldn't process CDP message\n\tError: ${err}\n\tMessage: "${message}"`); + } const requestId = extractRequestIdFromBrokenResponse(message); - if (requestId && this._pendingRequests[requestId]) { - this._pendingRequests[requestId]( - new CDPError({ + if (requestId) { + this._wsConnection.provideResponseFor( + requestId, + new CDPRequestError({ message: "Received malformed response: response is invalid JSON", - code: CDP_ERROR_CODE.MALFORMED_RESPONSE, - requestId: requestId, + code: WS_ERROR_CODE.MALFORMED_RESPONSE, + requestId, }), ); } } } - /** - * @description Produces connection-"uniq" request ids - * @note Theoretically, it can collide, but given "CDP_MAX_REQUEST_ID" is INT32_MAX, it wont - */ - private _getRequestId(): number { - const id = ++this._requestId; - - if (this._requestId >= CDP_MAX_REQUEST_ID) { - this._requestId = 0; - } - - return id; - } - - /** @description establishes ws connection, sends request with timeout and waits for response */ - private async _tryToSendRequest( - method: CDPRequest["method"], - { params, sessionId }: Omit, - ): Promise | CDPError> { - const id = this._getRequestId(); - const ws = await this._getWsConnection(); - const requestMessage = JSON.stringify({ id, sessionId, method, params }); - - if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { - throw new CDPConnectionTerminatedError({ - message: `Couldn't send "${requestMessage}" because CDP connection was manually closed`, - }); - } - - debugCdp(`> ${requestMessage}`); - - return new Promise | CDPError>(resolve => { - const pendingRequests = this._pendingRequests; - - let isSettled = false; - - const onTimeout = setTimeout(() => { - const err = new CDPTimeoutError({ - message: `Timed out while waiting for ${method} for ${CDP_REQUEST_TIMEOUT}ms`, - requestId: id, - }); - - done(err); - }, CDP_REQUEST_TIMEOUT).unref(); - - function done(response: Record | CDPError): void { - if (isSettled) { - return; - } - - isSettled = true; - delete pendingRequests[id]; - clearTimeout(onTimeout); - resolve(response); - } - - pendingRequests[id] = done; - - ws.send(requestMessage, error => { - if (!error) { - return; - } - - done( - new CDPError({ - message: `Couldn't send CDP request "${method}": ${error.message}`, - code: CDP_ERROR_CODE.SEND_FAILED, - requestId: id, - }), - ); - - // Proactively closing connection as "send error" is marker that something bad with connection happened - if (ws === this._wsConnection) { - this._closeWsConnection( - "CDP connection was considered broken as 'send' failed", - WsConnectionStatus.DISCONNECTED, - ); - this._tryToReconnect(); - } - }); - }); - } - /** @description Performs high-level CDP request with retries and timeouts */ async request( method: CDPRequest["method"], { params, sessionId }: Omit = {}, ): Promise { - let result!: T | Error; + let result!: T | WsError; for (let retriesLeft = CDP_REQUEST_RETRIES; retriesLeft >= 0; retriesLeft--) { - result = (await this._tryToSendRequest(method, { params, sessionId })) as T | Error; + const id = this._wsConnection.getRequestId(); + const requestMessage = JSON.stringify({ id, sessionId, method, params }); + + if (debugCdp.enabled) { + debugCdp( + `> ${inspect(requestMessage, { + depth: 3, + maxStringLength: 150, + breakLength: Infinity, + compact: true, + })}`, + ); + } - const noRetriesLeft = retriesLeft <= 0; - const connectionIsClosed = this._wsConnectionStatus === WsConnectionStatus.CLOSED; + result = (await this._wsConnection.makeRequest(id, requestMessage)) as T | WsError; - if (!(result instanceof CDPError) || result.isNonRetryable() || noRetriesLeft || connectionIsClosed) { + if (!(result instanceof WsError) || !result.isRetryable() || retriesLeft <= 0) { break; } - debugCdp(`${result.message}; retries left: ${retriesLeft}`); + if (debugCdp.enabled) { + debugCdp( + `⟳ ${inspect({ + id, + sessionId, + method, + params, + errorMessage: result.message, + retriesLeft: retriesLeft, + })}`, + ); + } // Intentionally avoiding wait after timeout - if (result instanceof CDPError && !(result instanceof CDPTimeoutError)) { + if (!(result instanceof CDPRequestTimeoutError)) { await exponentiallyWait({ baseDelay: CDP_REQUEST_RETRY_BASE_DELAY, attempt: CDP_REQUEST_RETRIES - retriesLeft, @@ -427,147 +193,10 @@ export class CDPConnection { } } - if (result instanceof Error) { + if (result instanceof WsError) { throw result; } return result; } - - private _closeWsConnection( - sessionAbortMessage: string, - status: WsConnectionStatus.CLOSED | WsConnectionStatus.DISCONNECTED, - ): void { - const ws = this._wsConnection; - - if (!ws || this._wsConnectionStatus === WsConnectionStatus.CLOSED) { - this._wsConnection = null; - return; - } - - debugCdp(`${sessionAbortMessage}; endpoint: "${this._cdpWsEndpoint}"`); - - if (status === WsConnectionStatus.CLOSED && this._onConnectionCloseFn) { - this._onConnectionCloseFn(); - } - - this._wsConnection = null; - this._wsConnectionStatus = status; - this._abortPendingRequests(`Request was aborted because ${sessionAbortMessage}`); - this._pingHealthCheckStop(); - - closeWsConnection(ws); - } - - /** - * @description Tries to re-establish connection after network drops - * @note Silently gives up after failed "CDP_CONNECTION_RETRIES" attempts - */ - private _tryToReconnect(): void { - debugCdp(`Trying to reconnect; endpoint: "${this._cdpWsEndpoint}"`); - - this._getWsConnection() - .then(() => debugCdp(`Successfully reconnected to session; endpoint: "${this._cdpWsEndpoint}"`)) - .catch(() => debugCdp(`Couldn't reconnect to session automatically; endpoint: "${this._cdpWsEndpoint}"`)); - } - - /** @description Used to abort all pending requests when connection is closed */ - private _abortPendingRequests(message: string): void { - const pendingRequests = this._pendingRequests; - const pendingRequestIds = Object.keys(pendingRequests).map(Number); - - this._pendingRequests = {}; - - for (const requestId of pendingRequestIds) { - if (pendingRequests[requestId]) { - pendingRequests[requestId]( - new CDPConnectionTerminatedError({ - message, - requestId, - }), - ); - } - } - } - - /** @description Closes websocket connection, terminating all pending requests */ - close(): void { - this._closeWsConnection("Connection was closed manually", WsConnectionStatus.CLOSED); - } - - private _pingHealthCheckStop(): void { - this._pingSubsequentFails = 0; - - if (this._pingInterval) { - clearInterval(this._pingInterval); - } - - if (this._wsConnection && this._onPong) { - this._wsConnection.off("pong", this._onPong); - } - } - - private _isWebSocketActive(ws: WebSocket): boolean { - return Boolean(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING && ws === this._wsConnection); - } - - private _pingHealthCheckStart(): void { - this._pingHealthCheckStop(); - - const ws = this._wsConnection; - - if (!ws || !this._isWebSocketActive(ws)) { - return; - } - - this._pingHealthCheckStop(); - - let isWaitingForPong = false; - let pongTimeout: ReturnType; - - const onPong = (this._onPong = (): void => { - if (isWaitingForPong && this._isWebSocketActive(ws)) { - isWaitingForPong = false; - - debugCdp("< PONG"); - - clearTimeout(pongTimeout); - - this._pingSubsequentFails = 0; - } - }); - - ws.on("pong", onPong); - - const pingInterval = (this._pingInterval = setInterval(() => { - if (!this._isWebSocketActive(ws)) { - clearInterval(pingInterval); - return; - } - - pongTimeout = setTimeout(() => { - if (isWaitingForPong && this._isWebSocketActive(ws)) { - isWaitingForPong = false; - - this._pingSubsequentFails++; - - debugCdp(`Ping failed(${this._pingSubsequentFails} in a row) in ${CDP_PING_TIMEOUT}ms`); - - if (this._pingSubsequentFails >= CDP_PING_MAX_SUBSEQUENT_FAILS) { - this._closeWsConnection( - `CDP connection was considered broken as ${this._pingSubsequentFails} pings failed in a row`, - WsConnectionStatus.DISCONNECTED, - ); - this._tryToReconnect(); - } - } - }, CDP_PING_TIMEOUT).unref(); - - ws.ping(); - - debugCdp("> PING"); - - isWaitingForPong = true; - }, CDP_PING_INTERVAL).unref()); - } } diff --git a/src/browser/cdp/constants.ts b/src/browser/cdp/constants.ts index 9a8862841..a64fd18a1 100644 --- a/src/browser/cdp/constants.ts +++ b/src/browser/cdp/constants.ts @@ -4,13 +4,3 @@ export const CDP_CONNECTION_RETRY_BASE_DELAY = 500; export const CDP_REQUEST_TIMEOUT = 15000; // 15 sec export const CDP_REQUEST_RETRIES = 3; export const CDP_REQUEST_RETRY_BASE_DELAY = 500; -export const CDP_MAX_REQUEST_ID = 2147483647; // INT32_MAX -export const CDP_PING_INTERVAL = 15000; // 15 sec -export const CDP_PING_TIMEOUT = 10000; // 10 sec -export const CDP_PING_MAX_SUBSEQUENT_FAILS = 2; -export const CDP_ERROR_CODE = { - MALFORMED_RESPONSE: -32810, // Custom error code - SEND_FAILED: -32820, // Custom error code - TIMEOUT: -32830, // Custom error code - CONNECTION_TERMINATED: -32840, // Custom error code -} as const; diff --git a/src/browser/cdp/error.ts b/src/browser/cdp/error.ts index 3d9f15ecd..83244477c 100644 --- a/src/browser/cdp/error.ts +++ b/src/browser/cdp/error.ts @@ -1,51 +1,33 @@ -import { CDP_ERROR_CODE } from "./constants"; -import type { CDPRequestId } from "./types"; - -export class CDPError extends Error { - public code?: number; - public requestId?: CDPRequestId; - - constructor({ message, code, requestId }: { message: string; code?: number; requestId?: CDPRequestId }) { - let errorMessage = message; +import { + WsError, + WsConnectionEstablishmentError, + WsConnectionTerminatedError, + WsConnectionBreakError, + WsConnectionTimeoutError, + WsRequestTimeoutError, +} from "../../ws-connection/error"; + +export class CDPError extends WsError { + isRetryable(): boolean { + return false; + } +} - if (code) { - errorMessage += `\n\tErrorCode: ${code}`; - } +export class CDPConnectionEstablishmentError extends WsConnectionEstablishmentError {} +export class CDPConnectionBreakError extends WsConnectionBreakError {} +export class CDPConnectionTerminatedError extends WsConnectionTerminatedError {} +export class CDPConnectionTimeoutError extends WsConnectionTimeoutError {} +export class CDPRequestTimeoutError extends WsRequestTimeoutError {} - if (requestId) { - errorMessage += `\n\tCDP Request ID: ${requestId}`; +export class CDPRequestError extends WsError { + isRetryable(): boolean { + if (!this.code) { + return true; } - super(errorMessage); - - this.name = this.constructor.name; - this.code = code; - this.requestId = requestId; - } - - isNonRetryable(): boolean { // JSON-RPC Protocol Errors // CDP State/Execution Errors // https://www.jsonrpc.org/specification#error_object - return Boolean(this.code && ((this.code >= -32700 && this.code <= -32600) || this.code === -32000)); - } -} - -export class CDPTimeoutError extends CDPError { - constructor({ message, requestId }: { message: string; requestId?: CDPRequestId }) { - super({ message, code: CDP_ERROR_CODE.TIMEOUT, requestId }); - - this.name = this.constructor.name; - } -} - -export class CDPConnectionTerminatedError extends CDPError { - constructor({ - message = "CDP connection was manually closed", - requestId, - }: { message?: string; requestId?: CDPRequestId } = {}) { - super({ message, code: CDP_ERROR_CODE.CONNECTION_TERMINATED, requestId }); - - this.name = this.constructor.name; + return (this.code < -32700 || this.code > -32600) && this.code !== -32000; } } diff --git a/src/browser/cdp/utils.ts b/src/browser/cdp/utils.ts index e11142f79..53d1d2436 100644 --- a/src/browser/cdp/utils.ts +++ b/src/browser/cdp/utils.ts @@ -1,14 +1,3 @@ -export const exponentiallyWait = ({ - baseDelay = 500, - attempt = 0, - factor = 2, - jitter = 100, -}: { baseDelay?: number; attempt?: number; factor?: number; jitter?: number } = {}): Promise => { - const delay = Math.round(baseDelay * factor ** attempt + Math.random() * jitter); - - return new Promise(resolve => setTimeout(resolve, delay).unref()); -}; - export const extractRequestIdFromBrokenResponse = (message: string): number | null => { const idStartMarker = '{"id":'; diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 9ffd82e05..77651c972 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -20,6 +20,7 @@ import { runWithoutHistory } from "./history"; import type { SessionOptions } from "./types"; import { Page } from "puppeteer-core"; import { CDP } from "./cdp"; +import { WSDriverRequestAgent } from "./wsdriver"; import type { ElementReference } from "@testplane/wdio-protocols"; const OPTIONAL_SESSION_OPTS = ["transformRequest", "transformResponse"]; @@ -91,6 +92,7 @@ export class ExistingBrowser extends Browser { protected _calibration?: CalibrationResult; protected _clientBridge?: ClientBridge; protected _cdp: CDP | null = null; + protected _wsDriver: WSDriverRequestAgent | null = null; protected _tags: Set = new Set(); constructor(config: Config, opts: BrowserOpts) { @@ -162,6 +164,7 @@ export class ExistingBrowser extends Browser { quit(): void { this._cdp?.close(); + this._wsDriver?.close(); this._meta = this._initMeta(); } @@ -274,6 +277,21 @@ export class ExistingBrowser extends Browser { requestedCapabilities: sessionOpts.capabilities, }; + if (this._config.useWsDriver && sessionCaps && sessionCaps["se:wsdriver"]) { + const supportsWsDriverV1 = sessionCaps["se:wsdriverVersion"]?.split(", ").includes("1"); + + if (supportsWsDriverV1) { + this._wsDriver = WSDriverRequestAgent.create({ + sessionId, + sessionCaps: sessionCaps as WebdriverIO.Capabilities, + headers: sessionOpts.headers as Record, + browserConfig: this._config, + }); + + opts.customWdRequestAgent = this._wsDriver; + } + } + return attach(opts); } diff --git a/src/browser/wsdriver/compression.ts b/src/browser/wsdriver/compression.ts new file mode 100644 index 000000000..148ff3609 --- /dev/null +++ b/src/browser/wsdriver/compression.ts @@ -0,0 +1,90 @@ +import zlib from "node:zlib"; +import { WsDriverCompression, WsDriverCompressionType } from "./types"; + +export const getDecompressed = async ( + compressedPayload: Buffer, + compressionType: WsDriverCompressionType, +): Promise => { + if (compressionType === WsDriverCompression.None) { + return compressedPayload; + } + + if (compressionType === WsDriverCompression.GZIP) { + return new Promise((resolve, reject) => { + zlib.gunzip(compressedPayload, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + if (compressionType === WsDriverCompression.ZSTD) { + if (!("zstd" in process.versions) || !("zstdDecompress" in zlib)) { + throw new Error("Can't decompress zstd compressed message"); + } + + const typedZstdDecompress = zlib.zstdDecompress as ( + buf: Buffer, + cb: (err: null | Error, result: Buffer) => void, + ) => void; + + return new Promise((resolve, reject) => { + typedZstdDecompress(compressedPayload, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + throw new Error(`Unknown compression type: "${compressionType}"`); +}; + +export const getCompressed = async ( + decompressedPayload: Buffer, + compressionType: WsDriverCompressionType, +): Promise => { + if (compressionType === WsDriverCompression.None) { + return decompressedPayload; + } + + if (compressionType === WsDriverCompression.GZIP) { + return new Promise((resolve, reject) => { + zlib.gzip(decompressedPayload, { level: 4 }, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + if (compressionType === WsDriverCompression.ZSTD) { + if (!("zstd" in process.versions) || !("zstdCompress" in zlib)) { + throw new Error("Can't compress payload with zstd"); + } + + const typedZstdCompress = zlib.zstdCompress as ( + buf: Buffer, + cb: (err: null | Error, result: Buffer) => void, + ) => void; + + return new Promise((resolve, reject) => { + typedZstdCompress(decompressedPayload, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + throw new Error(`Unknown compression type: "${compressionType}"`); +}; diff --git a/src/browser/wsdriver/constants.ts b/src/browser/wsdriver/constants.ts new file mode 100644 index 000000000..03e0359ff --- /dev/null +++ b/src/browser/wsdriver/constants.ts @@ -0,0 +1,14 @@ +export const WSD_CONNECTION_TIMEOUT = 15000; // 15 sec +export const WSD_CONNECTION_RETRIES = 3; +export const WSD_CONNECTION_RETRY_BASE_DELAY = 500; + +export const WSD_REQUEST_RETRIES = 3; +export const WSD_REQUEST_RETRY_BASE_DELAY = 500; + +export const WSD_COMPRESSION_THRESHOLD_BYTES = 1024; +export const WSD_ACCEPT_ENCODING_HEADER = "wsdriver-accept-encoding"; + +export const WSD_COMPRESSION_TYPE = { + ZSTD: "zstd", + GZIP: "gzip", +} as const; diff --git a/src/browser/wsdriver/debug.ts b/src/browser/wsdriver/debug.ts new file mode 100644 index 000000000..6ba4cb6ac --- /dev/null +++ b/src/browser/wsdriver/debug.ts @@ -0,0 +1,3 @@ +import debugModule from "debug"; + +export const debugWSDriver = debugModule("testplane:wsdriver"); diff --git a/src/browser/wsdriver/error.ts b/src/browser/wsdriver/error.ts new file mode 100644 index 000000000..a5096af31 --- /dev/null +++ b/src/browser/wsdriver/error.ts @@ -0,0 +1,29 @@ +import { + WsError, + WsConnectionEstablishmentError, + WsConnectionTerminatedError, + WsTimeoutError, + WsConnectionBreakError, +} from "../../ws-connection/error"; + +export class WSDriverError extends WsError { + isRetryable(): boolean { + return false; + } +} + +export class WSDriverRequestAgentEstablishmentError extends WsConnectionEstablishmentError {} +export class WSDriverRequestAgentBreakError extends WsConnectionBreakError {} +export class WSDriverRequestAgentTerminatedError extends WsConnectionTerminatedError {} +export class WSDriverRequestAgentTimeoutError extends WsTimeoutError {} +export class WSDriverRequestTimeoutError extends WsTimeoutError { + isRetryable(): boolean { + // Webdriverio manages timeouted requests on its own + return false; + } +} +export class WSDriverRequestError extends WsError { + isRetryable(): boolean { + return true; + } +} diff --git a/src/browser/wsdriver/index.ts b/src/browser/wsdriver/index.ts new file mode 100644 index 000000000..08758ef04 --- /dev/null +++ b/src/browser/wsdriver/index.ts @@ -0,0 +1,347 @@ +import { STATUS_CODES } from "http"; +import type { RawData } from "ws"; +import { inspect } from "node:util"; +import { debugWSDriver } from "./debug"; +import { + WSDriverRequestAgentBreakError, + WSDriverRequestAgentEstablishmentError, + WSDriverRequestAgentTerminatedError, + WSDriverRequestAgentTimeoutError, + WSDriverError, + WSDriverRequestError, + WSDriverRequestTimeoutError, +} from "./error"; +import { + WSD_ACCEPT_ENCODING_HEADER, + WSD_COMPRESSION_TYPE, + WSD_CONNECTION_RETRIES, + WSD_CONNECTION_RETRY_BASE_DELAY, + WSD_CONNECTION_TIMEOUT, + WSD_REQUEST_RETRIES, + WSD_REQUEST_RETRY_BASE_DELAY, +} from "./constants"; +import { + IncomingWsDriverMessage, + RequestWsDriverOptions, + RequestWsDriverResponse, + WsDriverCompression, + WsDriverCompressionType, +} from "./types"; +import * as logger from "../../utils/logger"; +import { WsConnection } from "../../ws-connection"; +import { WS_ERROR_CODE } from "../../ws-connection/constants"; +import { WsError } from "../../ws-connection/error"; +import { parseWsDriverIncomingMessage } from "./response"; +import { BrowserConfig } from "../../config/browser-config"; +import { constructWsDriverRequest } from "./request"; +import { exponentiallyWait } from "../../ws-connection/utils"; + +interface WSDriverRequestAgentOptions { + sessionId: string; + headers?: Record; + requestTimeout: number; + clientSupportedCompressionTypes: Array<(typeof WSD_COMPRESSION_TYPE)[keyof typeof WSD_COMPRESSION_TYPE]>; + supportedVersions: Record; +} + +export class WSDriverRequestAgent { + private readonly _wsConnection: WsConnection; + private _clientSupportedCompressionTypes: Array<(typeof WSD_COMPRESSION_TYPE)[keyof typeof WSD_COMPRESSION_TYPE]>; + private _serverSupportedCompressionType?: WsDriverCompressionType; + private _sessionId: string; + private _sessionPrefix: string; + + private constructor( + wsdWsEndpoint: string, + { sessionId, headers, requestTimeout, clientSupportedCompressionTypes }: WSDriverRequestAgentOptions, + ) { + headers ||= {}; + headers[WSD_ACCEPT_ENCODING_HEADER] = clientSupportedCompressionTypes.join(", "); + + this._wsConnection = new WsConnection(wsdWsEndpoint, { + headers, + debugFn: debugWSDriver, + retries: { + count: WSD_CONNECTION_RETRIES, + baseDelay: WSD_CONNECTION_RETRY_BASE_DELAY, + }, + timeouts: { + request: requestTimeout, + createSession: WSD_CONNECTION_TIMEOUT, + }, + errors: { + ConnectionEstablishment: WSDriverRequestAgentEstablishmentError, + ConnectionBreak: WSDriverRequestAgentBreakError, + ConnectionTerminated: WSDriverRequestAgentTerminatedError, + ConnectionTimeout: WSDriverRequestAgentTimeoutError, + RequestTimeout: WSDriverRequestTimeoutError, + }, + onMessage: this._onMessage.bind(this), + }); + + this._clientSupportedCompressionTypes = clientSupportedCompressionTypes; + this._sessionId = sessionId; + this._sessionPrefix = `/session/${sessionId}/`; + } + + /** @description Creates WSDriverRequestAgent without establishing it */ + static create({ + sessionId, + sessionCaps, + headers = {}, + browserConfig, + }: { + sessionId: string; + sessionCaps: WebdriverIO.Capabilities; + headers: Record; + browserConfig: BrowserConfig; + }): WSDriverRequestAgent { + if (!sessionCaps["se:wsdriver"]) { + throw new WSDriverError({ message: "Couldn't determine wsdriver endpoint" }); + } + + if (!sessionCaps["se:wsdriverVersion"]) { + throw new WSDriverError({ message: "Couldn't determine wsdriver supported versions" }); + } + + const wsdriverEndpoint = sessionCaps["se:wsdriver"]; + const wsdriverSupportedVersions = sessionCaps["se:wsdriverVersion"].split(", ").map(Number).filter(Boolean); + + const requestTimeout = browserConfig.httpTimeout; + const supportedVersions = wsdriverSupportedVersions.reduce((acc, val) => { + acc[val] = true; + return acc; + }, {} as Record); + const clientSupportedCompressionTypes = ["zstd" in process.versions ? "zstd" : null, "gzip"].filter( + Boolean, + ) as Array<(typeof WSD_COMPRESSION_TYPE)[keyof typeof WSD_COMPRESSION_TYPE]>; + + return new this(wsdriverEndpoint, { + sessionId, + headers, + requestTimeout, + clientSupportedCompressionTypes, + supportedVersions, + }); + } + + close(): void { + this._wsConnection.close(); + } + + private async _onMessage(data: RawData, isBinary: boolean): Promise { + if (!isBinary) { + this._wsConnection.forceReconnect( + `Unsupported data type: Expected binary, received text: ${inspect(data)}`, + ); + return; + } + + const incomingMessage = await parseWsDriverIncomingMessage(data).catch((err: Error) => err); + + if (!incomingMessage) { + // Valid, but unsupported + return; + } + + if (incomingMessage instanceof Error) { + // Invalid message + this._wsConnection.forceReconnect(incomingMessage.message); + return; + } + + const message = incomingMessage as IncomingWsDriverMessage; + + if (debugWSDriver.enabled) { + const header = message.rawBody.readUint8(1); + + debugWSDriver( + `< ${inspect( + { + sessionId: this._sessionId, + requestId: message.requestId, + header: header.toString(2).padStart(8, "0"), + statusCode: message.statusCode, + body: message.body, + }, + { + depth: 3, + maxStringLength: 150, + breakLength: Infinity, + compact: true, + }, + )}`, + ); + } + + if (message.isProtocolError) { + logger.error("wsdriver: Protocol error occured while parsing message:", message); + this._wsConnection.provideResponseFor( + message.requestId, + new WSDriverRequestError({ + message: "Protocol error: " + inspect(message.body), + requestId: message.requestId, + code: WS_ERROR_CODE.PROTOCOL_ERROR, + }), + ); + this._wsConnection.forceReconnect("Protocol error: " + inspect(message.body)); + return; + } + + if (message.body instanceof Error) { + logger.error("wsdriver: Malformed response:", message.body); + this._wsConnection.provideResponseFor( + message.requestId, + new WSDriverRequestError({ + message: message.body.message, + requestId: message.requestId, + code: WS_ERROR_CODE.MALFORMED_RESPONSE, + }), + ); + } + + this._wsConnection.provideResponseFor(message.requestId, message); + } + + private async _getRequestCompressionType(): Promise { + if (typeof this._serverSupportedCompressionType !== "undefined") { + return this._serverSupportedCompressionType; + } + + const { responseHeaders } = await this._getConnectionProperties(); + + if (!responseHeaders || !responseHeaders[WSD_ACCEPT_ENCODING_HEADER]) { + return (this._serverSupportedCompressionType = WsDriverCompression.None); + } + + const serverAcceptEncodingHeaders = responseHeaders[WSD_ACCEPT_ENCODING_HEADER].concat(", ") as string; + const serverAcceptEncodings = serverAcceptEncodingHeaders.split(", "); + + for (const clientSupportedEncoding of this._clientSupportedCompressionTypes) { + if (serverAcceptEncodings.includes(clientSupportedEncoding)) { + if (clientSupportedEncoding === "zstd") { + return (this._serverSupportedCompressionType = WsDriverCompression.ZSTD); + } else if (clientSupportedEncoding === "gzip") { + return (this._serverSupportedCompressionType = WsDriverCompression.GZIP); + } + } + } + + return (this._serverSupportedCompressionType = WsDriverCompression.None); + } + + private _getConnectionProperties(): ReturnType< + WsConnection["getConnectionProperties"] + > { + return this._wsConnection.getConnectionProperties(); + } + + /** @description Performs high-level WSDriver request with timeout */ + async request(url: URL, options: RequestWsDriverOptions): Promise { + let requestId!: number; + let result!: IncomingWsDriverMessage | WsError; + + for (let retriesLeft = WSD_REQUEST_RETRIES; retriesLeft >= 0; retriesLeft--) { + requestId = this._wsConnection.getRequestId(); + const requestMessage = await constructWsDriverRequest(url, options, { + requestId, + sessionPrefix: this._sessionPrefix, + compressionType: await this._getRequestCompressionType(), + }); + + if (debugWSDriver.enabled) { + const header = requestMessage.readUint8(1); + const commandEndIdx = requestMessage.indexOf(0, 8); + const command = requestMessage.subarray(8, commandEndIdx).toString(); + debugWSDriver( + `> ${inspect( + { + sessionId: this._sessionId, + requestId, + header: header.toString(2).padStart(8, "0"), + method: options.method, + command, + body: options.json, + }, + { + depth: 3, + maxStringLength: 150, + breakLength: Infinity, + compact: true, + }, + )}`, + ); + } + + result = (await this._wsConnection.makeRequest(requestId, requestMessage).catch((err: WsError) => err)) as + | IncomingWsDriverMessage + | WsError; + + if (result instanceof WSDriverRequestTimeoutError) { + const requestError = new Error(result.message); + requestError.stack = result.stack; + // error code should be "ETIMEDOUT" for webdriver to be able to retry + (requestError as { code?: string }).code = "ETIMEDOUT"; + throw requestError; + } + + if (!(result instanceof WsError) || !result.isRetryable() || retriesLeft <= 0) { + break; + } + + if (debugWSDriver.enabled) { + const header = requestMessage.readUint8(1); + const commandEndIdx = requestMessage.indexOf(0, 8); + const command = requestMessage.subarray(8, commandEndIdx).toString(); + debugWSDriver( + `⟳ ${inspect({ + sessionId: this._sessionId, + requestId, + header: header.toString(2).padStart(8, "0"), + method: options.method, + command, + body: options.json, + errorMessage: result.message, + retriesLeft: retriesLeft, + })}`, + ); + } + + await exponentiallyWait({ + baseDelay: WSD_REQUEST_RETRY_BASE_DELAY, + attempt: WSD_REQUEST_RETRIES - retriesLeft, + }); + } + + if (result instanceof WsError) { + throw result; + } + + const response = { + url: url.toString(), + method: options.method!, + requestId, + statusCode: result.statusCode, + statusMessage: STATUS_CODES[result.statusCode] as string, + req: { + id: requestId, + method: options.method!, + path: options.path as string, + host: url.host, + }, + request: { + id: requestId, + options, + requestUrl: url, + }, + ok: result.statusCode >= 200 && result.statusCode < 300, + rawBody: result.rawBody, + body: result.body, + } as RequestWsDriverResponse; + + response.req.res = response; + response.request.response = response; + + return response; + } +} diff --git a/src/browser/wsdriver/request.ts b/src/browser/wsdriver/request.ts new file mode 100644 index 000000000..563baebfc --- /dev/null +++ b/src/browser/wsdriver/request.ts @@ -0,0 +1,99 @@ +/* eslint-disable no-bitwise */ +import { + RequestWsDriverOptions, + WsDriverCompression, + WsDriverCompressionType, + WsDriverMessage, + WsDriverMethod, + WsDriverMethodType, + WsDriverRequestMethodString, +} from "./types"; +import { WSD_COMPRESSION_THRESHOLD_BYTES } from "./constants"; +import { getCompressed } from "./compression"; + +interface RequestOptions { + method?: WsDriverRequestMethodString; + headers?: Record; + json?: Record; +} + +interface ConnectionOptions { + requestId: number; + sessionPrefix: string; + compressionType: WsDriverCompressionType; +} + +const WSDRIVER_VERSION = 1; +// version, header, requestId, requestMethod, command null-terminator +const OUTGOING_MESSAGE_AUXILIARY_BYTES = 1 + 1 + 4 + 2 + 1; + +const constructHeaderByte = (compressionType: WsDriverCompressionType, isJson: boolean): number => { + let headerByte = 0; + + headerByte |= WsDriverMessage.Request << 4; + headerByte |= compressionType << 2; + headerByte |= +isJson << 1; + + return headerByte; +}; + +const getRequestMethod = (method: RequestOptions["method"]): WsDriverMethodType => { + if (!method) { + return WsDriverMethod.get; + } + + const methodLowerCase = method.toLowerCase(); + const encodedMethodType = WsDriverMethod[methodLowerCase as keyof typeof WsDriverMethod]; + + if (typeof encodedMethodType !== "undefined") { + return encodedMethodType; + } + + throw new Error(`Unsupported method: "${method}"`); +}; + +export const constructWsDriverRequest = async ( + url: URL, + requestOptions: RequestWsDriverOptions, + connectionOptions: ConnectionOptions, +): Promise => { + const commandStartIdx = url.pathname.indexOf(connectionOptions.sessionPrefix); + + if (commandStartIdx === -1) { + throw new Error("Not session-related request"); + } + + const command = url.pathname.slice(commandStartIdx + connectionOptions.sessionPrefix.length); + const bodyPayload = requestOptions.json ? Buffer.from(JSON.stringify(requestOptions.json)) : Buffer.alloc(0); + const shouldCompress = + connectionOptions.compressionType !== WsDriverCompression.None && + Buffer.byteLength(bodyPayload) >= WSD_COMPRESSION_THRESHOLD_BYTES; + const compressedPayload = shouldCompress + ? await getCompressed(bodyPayload, connectionOptions.compressionType) + : bodyPayload; + const headerByte = constructHeaderByte( + shouldCompress ? connectionOptions.compressionType : WsDriverCompression.None, + true, + ); + const requestMethod = getRequestMethod(requestOptions.method); + + const resultMessage = Buffer.alloc( + OUTGOING_MESSAGE_AUXILIARY_BYTES + Buffer.byteLength(command) + Buffer.byteLength(compressedPayload), + ); + + let ptr = 0; + + ptr = resultMessage.writeUInt8(WSDRIVER_VERSION); + ptr = resultMessage.writeUint8(headerByte, ptr); + ptr = resultMessage.writeUint32BE(connectionOptions.requestId, ptr); + ptr = resultMessage.writeUint16BE(requestMethod, ptr); + ptr += resultMessage.write(command, ptr, "utf8"); + ptr = resultMessage.writeUint8(0, ptr); + ptr += compressedPayload.copy(resultMessage, ptr); + + if (ptr !== resultMessage.byteLength) { + throw new Error("WSDriver request message construction failed"); + } + + return resultMessage; +}; diff --git a/src/browser/wsdriver/response.ts b/src/browser/wsdriver/response.ts new file mode 100644 index 000000000..9e5761aac --- /dev/null +++ b/src/browser/wsdriver/response.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-bitwise */ +import { inspect } from "util"; +import { RawData } from "ws"; +import { IncomingWsDriverMessage, WsDriverCompressionType, WsDriverMessage, WsDriverMessageType } from "./types"; +import * as logger from "../../utils/logger"; +import { getDecompressed } from "./compression"; +import { debugWSDriver } from "./debug"; + +const WSDRIVER_VERSION = 1; +// version, header, requestId, responseStatus, command null-terminator +const MIN_INCOMING_MESSAGE_LENGTH_BYTES = 1 + 1 + 4 + 2 + 1; + +const rawDataToBuffer = (rawData: RawData): Buffer => { + if (rawData instanceof Buffer) { + return rawData; + } + + if (Array.isArray(rawData)) { + return Buffer.concat(rawData as Uint8Array[]); + } + + return Buffer.from(rawData as Uint8Array); +}; + +// https://github.com/gemini-testing/selenoid/blob/master/wsdriver/ws_req.go#L78-L85 +export const parseWsDriverIncomingMessage = async ( + incomingMessage: RawData, +): Promise => { + const data = rawDataToBuffer(incomingMessage); + + if (data.length < MIN_INCOMING_MESSAGE_LENGTH_BYTES) { + return new Error(`Invalid incoming message "${inspect(incomingMessage)}": too short`); + } + + const messageVersion = data.readUInt8(0); + + if (messageVersion !== WSDRIVER_VERSION) { + logger.warn(`wsdriver: Unexpected message version. Expected '${WSDRIVER_VERSION}', got '${messageVersion}'`); + return null; + } + + const messageHeaders = data.readUInt8(1); + const messageType = (messageHeaders >> 4) as WsDriverMessageType; + const compressionType = ((messageHeaders >> 2) & 0b11) as WsDriverCompressionType; + const isJson = Boolean((messageHeaders >> 1) & 0b1); + const isProtocolError = Boolean(messageHeaders & 0b1); + + if (messageType !== WsDriverMessage.Response) { + logger.warn(`wsdriver: Unexpected message type. Expected '${WsDriverMessage.Response}', got '${messageType}'`); + return null; + } + + const requestId = data.readUInt32BE(2); + const statusCode = data.readUInt16BE(6); + const requestPathEnd = data.indexOf(0, 8); + + if (requestPathEnd === -1) { + return new Error(`Invalid incoming message with id ${requestId}: absent null-terminator for requestPath`); + } + + const requestPath = data.toString("utf8", 8, requestPathEnd); + const rawBody = data.subarray(requestPathEnd + 1); + + let body: Error | string | Record; + + try { + const decompressedBody: Error | Buffer | null = rawBody.byteLength + ? await getDecompressed(rawBody, compressionType).catch(err => err) + : null; + + if (!rawBody.byteLength || !decompressedBody) { + body = rawBody.toString("utf8"); + } else if (decompressedBody instanceof Error) { + body = decompressedBody; + } else if (isJson) { + try { + body = JSON.parse(decompressedBody.toString("utf8")); + } catch (cause) { + debugWSDriver("Couldn't parse incoming JSON payload: %O", cause); + body = decompressedBody.toString("utf8"); + } + } else { + body = decompressedBody.toString("utf8"); + } + } catch (err) { + body = err as Error; + } + + return { + requestId, + statusCode, + isJson, + isProtocolError, + requestPath, + body, + rawBody, + } as IncomingWsDriverMessage; +}; diff --git a/src/browser/wsdriver/types.ts b/src/browser/wsdriver/types.ts new file mode 100644 index 000000000..55e52a2a5 --- /dev/null +++ b/src/browser/wsdriver/types.ts @@ -0,0 +1,99 @@ +// https://github.com/gemini-testing/selenoid/blob/master/wsdriver/ws_req.go#L11-L16 +export const WsDriverMessage = { + Request: 0, + Response: 1, +} as const; + +export type WsDriverMessageType = (typeof WsDriverMessage)[keyof typeof WsDriverMessage]; + +// https://github.com/gemini-testing/selenoid/blob/master/wsdriver/ws_req.go#L18-L24 +export const WsDriverCompression = { + None: 0, + GZIP: 1, + ZSTD: 2, +} as const; + +export type WsDriverCompressionType = (typeof WsDriverCompression)[keyof typeof WsDriverCompression]; + +// https://github.com/gemini-testing/selenoid/blob/master/wsdriver/ws_req.go#L33-L60 +export const WsDriverMethod = { + get: 0, + head: 1, + post: 2, + put: 3, + delete: 4, + connect: 5, + options: 6, + trace: 7, + patch: 8, +} as const; + +export type WsDriverMethodType = (typeof WsDriverMethod)[keyof typeof WsDriverMethod]; + +export type WsDriverRequestMethodString = + | "get" + | "head" + | "post" + | "put" + | "delete" + | "options" + | "trace" + | "patch" + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "HEAD" + | "DELETE" + | "OPTIONS" + | "TRACE"; + +interface IncomingWsDriverGeneralMessage { + requestId: number; + statusCode: number; + isProtocolError: boolean; + requestPath: string; + rawBody: Buffer; +} + +interface IncomingWsDriverJsonMessage extends IncomingWsDriverGeneralMessage { + isJson: true; + body: Error | Record; +} + +interface IncomingWsDriverStringMessage extends IncomingWsDriverGeneralMessage { + isJson: false; + body: Error | string; +} + +export type IncomingWsDriverMessage = IncomingWsDriverJsonMessage | IncomingWsDriverStringMessage; + +export interface RequestWsDriverOptions { + path?: string; + method?: WsDriverRequestMethodString; + json?: Record; +} + +export interface RequestWsDriverResponse { + url: string; + method: string; + requestId: number; + statusCode: number; + statusMessage: string; + req: { + id: number; + method: string; + path: string; + host: string; + res: RequestWsDriverResponse; + }; + request: { + id: number; + options: RequestWsDriverOptions; + requestUrl: URL; + response: RequestWsDriverResponse; + }; + ok: boolean; + rawBody: Buffer; + body: unknown; +} diff --git a/src/config/browser-options.js b/src/config/browser-options.js index 54b8f5087..f679f66ed 100644 --- a/src/config/browser-options.js +++ b/src/config/browser-options.js @@ -400,6 +400,8 @@ function buildBrowserOptions(defaultFactory, extra) { passive: options.boolean("passive"), + useWsDriver: options.boolean("useWsDriver"), + timeTravel: option({ defaultValue: defaultFactory("timeTravel"), parseEnv: JSON.parse, diff --git a/src/config/defaults.js b/src/config/defaults.js index 9ffa93a79..9e1401e36 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -128,6 +128,7 @@ module.exports = { }, }, passive: false, + useWsDriver: false, timeTravel: TimeTravelMode.Off, selectivity: { enabled: false, diff --git a/src/config/types.ts b/src/config/types.ts index a0fd8f359..27f4956c7 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -378,6 +378,7 @@ export interface CommonConfig { headless: "old" | "new" | boolean | null; isolation: boolean; passive: boolean; + useWsDriver: boolean; lastFailed: { only: boolean; diff --git a/src/ws-connection/constants.ts b/src/ws-connection/constants.ts new file mode 100644 index 000000000..b7d0ceada --- /dev/null +++ b/src/ws-connection/constants.ts @@ -0,0 +1,13 @@ +export const WS_MAX_REQUEST_ID = 2147483647; // INT32_MAX +export const WS_PING_INTERVAL = 15000; // 15 sec +export const WS_PING_TIMEOUT = 10000; // 10 sec +export const WS_PING_MAX_SUBSEQUENT_FAILS = 2; +export const WS_ERROR_CODE = { + MALFORMED_RESPONSE: -32810, // Custom error code + SEND_FAILED: -32820, // Custom error code + TIMEOUT: -32830, // Custom error code + CONNECTION_TERMINATED: -32840, // Custom error code + CONNECTION_ESTABLISHMENT: -32850, // Custom error code + CONNECTION_BREAK: -32860, // Custom error code + PROTOCOL_ERROR: -32680, // Custom error code +} as const; diff --git a/src/ws-connection/error.ts b/src/ws-connection/error.ts new file mode 100644 index 000000000..c45338945 --- /dev/null +++ b/src/ws-connection/error.ts @@ -0,0 +1,101 @@ +import { WS_ERROR_CODE } from "./constants"; + +export abstract class WsError extends Error { + public code?: number; + public requestId?: number; + + constructor({ message, code, requestId }: { message: string; code?: number; requestId?: number }) { + let errorMessage = message; + + if (code) { + errorMessage += `\n\tErrorCode: ${code}`; + } + + if (requestId) { + errorMessage += `\n\tRequest ID: ${requestId}`; + } + + super(errorMessage); + + this.name = this.constructor.name; + this.code = code; + this.requestId = requestId; + } + + abstract isRetryable(): boolean; +} + +export class WsConnectionEstablishmentError extends WsError { + constructor({ message, requestId }: { message: string; requestId?: number }) { + super({ message, code: WS_ERROR_CODE.CONNECTION_ESTABLISHMENT, requestId }); + + this.name = this.constructor.name; + } + + isRetryable(): boolean { + return true; + } +} + +export class WsConnectionBreakError extends WsError { + constructor({ message = "WS connection interrupted", requestId }: { message: string; requestId?: number }) { + super({ message, code: WS_ERROR_CODE.CONNECTION_BREAK, requestId }); + + this.name = this.constructor.name; + } + + isRetryable(): boolean { + return true; + } +} + +export class WsConnectionTerminatedError extends WsError { + constructor({ + message = "WS connection was manually closed", + requestId, + }: { message?: string; requestId?: number } = {}) { + super({ message, code: WS_ERROR_CODE.CONNECTION_TERMINATED, requestId }); + + this.name = this.constructor.name; + } + + isRetryable(): boolean { + return false; + } +} + +export class WsTimeoutError extends WsError { + constructor({ message, requestId }: { message: string; requestId?: number }) { + super({ message, code: WS_ERROR_CODE.TIMEOUT, requestId }); + + this.name = this.constructor.name; + } + + isRetryable(): boolean { + return true; + } +} + +export class WsConnectionTimeoutError extends WsTimeoutError { + constructor({ message }: { message: string }) { + super({ message }); + + this.name = this.constructor.name; + } + + isRetryable(): boolean { + return true; + } +} + +export class WsRequestTimeoutError extends WsTimeoutError { + constructor({ message, requestId }: { message: string; requestId?: number }) { + super({ message, requestId }); + + this.name = this.constructor.name; + } + + isRetryable(): boolean { + return true; + } +} diff --git a/src/ws-connection/index.ts b/src/ws-connection/index.ts new file mode 100644 index 000000000..c5c100a49 --- /dev/null +++ b/src/ws-connection/index.ts @@ -0,0 +1,557 @@ +/* eslint-disable new-cap */ +import { IncomingMessage } from "node:http"; +import { WebSocket, type RawData } from "ws"; +import { + WsTimeoutError, + WsConnectionTerminatedError, + WsError, + WsConnectionEstablishmentError, + WsConnectionBreakError, + WsConnectionTimeoutError, + WsRequestTimeoutError, +} from "./error"; +import { exponentiallyWait } from "./utils"; +import { WS_MAX_REQUEST_ID, WS_PING_INTERVAL, WS_PING_TIMEOUT, WS_PING_MAX_SUBSEQUENT_FAILS } from "./constants"; + +enum WsConnectionStatus { + DISCONNECTED, // Not connected, able to connect + CONNECTING, // Connection is being established + CONNECTED, // Connection established + CLOSED, // Connection is disposed and does not require reconnecting +} + +interface WsConnectionRetries { + count: number; + baseDelay: number; + factor?: number; +} + +interface WsConnectionTimeouts { + createSession: number; + request: number; +} + +interface WsConnectionErrors { + ConnectionEstablishment: new ( + ...args: ConstructorParameters + ) => WsConnectionEstablishmentError; + ConnectionTerminated: new ( + ...args: ConstructorParameters + ) => WsConnectionTerminatedError; + ConnectionBreak: new (...args: ConstructorParameters) => WsConnectionBreakError; + ConnectionTimeout: new ( + ...args: ConstructorParameters + ) => WsConnectionTimeoutError; + RequestTimeout: new (...args: ConstructorParameters) => WsRequestTimeoutError; +} + +interface WsConnectionOptions { + headers?: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + debugFn?: (formatter: any, ...args: any[]) => void; + retries?: WsConnectionRetries; + timeouts: WsConnectionTimeouts; + errors: WsConnectionErrors; + onMessage: (data: RawData, isBinary: boolean) => void | Promise; +} + +// Closing WS when its still not connected produces error: +// https://github.com/websockets/ws/blob/86eac5b44ac2bff9087ec40c9bd06bc7b4f0da07/lib/websocket.js#L297-L301 +const closeWsConnection = (ws: WebSocket): void => { + if (ws.readyState !== ws.CONNECTING) { + ws.close(); + } else { + ws.once("open", () => { + ws.close(); + }); + } +}; + +export class WsConnection< + ResponseMessageType = unknown, + RequestMessageType extends string | RawData = string | RawData, +> { + private readonly _endpoint: string; + private readonly _requestHeaders?: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _debugFn: (formatter: any, ...args: any[]) => void; + private readonly _retries: WsConnectionRetries; + private readonly _timeouts: WsConnectionTimeouts; + private readonly _errors: WsConnectionErrors; + private readonly _onMessage: (data: RawData, isBinary: boolean) => void | Promise; + private readonly _onResponseHeaders?: (headers: IncomingMessage["headers"]) => void; + private readonly _onReconnect?: () => void; + private readonly _onDisconnect?: () => void; + private readonly _onClose?: () => void; + private _onPong: (() => void) | null = null; + private _pingShouldSkip = false; + private _pingInterval: ReturnType | null = null; + private _pingSubsequentFails = 0; + private _onConnectionCloseFn: (() => void) | null = null; // Defined, if there is connection attempt at the moment + private _wsConnectionStatus: WsConnectionStatus = WsConnectionStatus.DISCONNECTED; + private _wsConnection: WebSocket | null = null; + private _wsConnectionPromise: Promise | null = null; + private _requestId = 0; + private _pendingRequests: Record void> = {}; + + constructor(endpoint: string, options: WsConnectionOptions) { + const { headers, debugFn, retries, timeouts, errors, onMessage } = options; + + this._endpoint = endpoint; + this._requestHeaders = headers; + this._debugFn = debugFn || ((): void => {}); + this._retries = retries || { count: 3, baseDelay: 500, factor: 2 }; + this._timeouts = timeouts; + this._errors = errors; + this._onMessage = onMessage; + } + + async getConnectionProperties(): Promise<{ responseHeaders?: IncomingMessage["headers"] }> { + await this._getWsConnection(); + + return { + responseHeaders: this._responseHeaders, + }; + } + + private _responseHeaders?: IncomingMessage["headers"]; + + /** @description Tries to establish ws connection with timeout */ + private async _tryToEstablishWsConnection(endpoint: string): Promise { + return new Promise(resolve => { + try { + const onConnectionCloseFn = (): void => done(new this._errors.ConnectionTerminated()); + + if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + onConnectionCloseFn(); + } else { + this._onConnectionCloseFn = onConnectionCloseFn; + } + + // eslint-disable-next-line + const cdpConnectionInstance = this; + const ws = new WebSocket(endpoint, { headers: this._requestHeaders }); + let isSettled = false; + + const timeoutId = setTimeout(() => { + closeWsConnection(ws); + done( + new this._errors.ConnectionTimeout({ + message: `Couldn't establish WS connection to "${endpoint}" in ${this._timeouts.createSession}ms`, + }), + ); + }, this._timeouts.createSession).unref(); + + const onOpen = (): void => { + done(ws); + }; + + const onError = (error: unknown): void => { + closeWsConnection(ws); + done( + new this._errors.ConnectionEstablishment({ + message: `Couldn't establish WS connection to "${endpoint}": ${error}`, + }), + ); + }; + + const onClose = (): void => { + done( + new this._errors.ConnectionEstablishment({ + message: `WS connection to "${endpoint}" unexpectedly closed while establishing`, + }), + ); + }; + + const onUpgrade = (res: IncomingMessage): void => { + this._responseHeaders = res.headers; + this._onResponseHeaders?.(res.headers); + }; + + ws.on("open", onOpen); + ws.on("error", onError); + ws.on("close", onClose); + ws.on("upgrade", onUpgrade); + + // eslint-disable-next-line no-inner-declarations + function done(result: WebSocket | Error): void { + if (isSettled) { + return; + } + + cdpConnectionInstance._onConnectionCloseFn = null; + isSettled = true; + clearTimeout(timeoutId); + ws.off("open", onOpen); + ws.off("error", onError); + ws.off("close", onClose); + ws.off("upgrade", onUpgrade); + resolve(result); + } + } catch (err) { + resolve(err as Error); + } + }); + } + + /** + * @description creates ws connection with retries or returns existing one + * @note Concurrent requests with same params produce same ws connection + */ + private async _getWsConnection(): Promise { + const ws = this._wsConnection; + + if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + throw new this._errors.ConnectionTerminated({ message: `Session to ${this._endpoint} was closed` }); + } + + if (this._wsConnectionStatus === WsConnectionStatus.CONNECTING && this._wsConnectionPromise) { + return this._wsConnectionPromise; + } + + if (this._wsConnectionStatus === WsConnectionStatus.CONNECTED && ws && ws.readyState === ws.OPEN) { + return ws; + } + + if (this._wsConnectionStatus === WsConnectionStatus.CONNECTED && ws && ws.readyState !== ws.OPEN) { + this._closeWsConnection("WS connection was in invalid state", WsConnectionStatus.DISCONNECTED); + } + + this._wsConnectionStatus = WsConnectionStatus.CONNECTING; + + this._wsConnectionPromise = (async (): Promise => { + try { + for (let retriesLeft = this._retries.count || 0; retriesLeft >= 0; retriesLeft--) { + const result = await this._tryToEstablishWsConnection(this._endpoint); + + if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + if (result instanceof WebSocket) { + closeWsConnection(result); + } + + throw new this._errors.ConnectionTerminated(); + } + + if (result instanceof WebSocket) { + this._debugFn(`\u2713 Established WS connection to ${this._endpoint}`); + + this._wsConnection = result; + this._wsConnectionStatus = WsConnectionStatus.CONNECTED; + this._pingHealthCheckStart(); + + const onPing = (): void => result.pong(); + const onMessage = (data: RawData, isBinary: boolean): void | Promise => + this._onMessage(data, isBinary); + const onError = (err: Error): void => { + if (result === this._wsConnection) { + this._closeWsConnection( + `An error occured in WS connection: ${err}`, + WsConnectionStatus.DISCONNECTED, + ); + this._tryToReconnect(); + } + }; + + result.on("ping", onPing); + result.on("message", onMessage); + result.on("error", onError); + result.once("close", () => { + result.off("ping", onPing); + result.off("message", onMessage); + result.off("error", onError); + + if (result === this._wsConnection) { + this._closeWsConnection( + "WS connection was closed unexpectedly", + WsConnectionStatus.DISCONNECTED, + ); + this._tryToReconnect(); + } + }); + + return result; + } + + if (!(result instanceof WsError) || !result.isRetryable()) { + throw result; + } + + this._debugFn(`⟳ ${result.message}; retries left: ${retriesLeft}`); + + // Intentionally avoiding wait after timeout + if (result instanceof WsError && !(result instanceof WsTimeoutError)) { + await exponentiallyWait({ + baseDelay: this._retries.baseDelay, + attempt: this._retries.count - retriesLeft, + factor: this._retries.factor, + }); + } + } + + throw new this._errors.ConnectionEstablishment({ + message: `Couldn't establish WS connection to ${this._endpoint} in ${this._retries.count} retries`, + }); + } catch (err) { + if (this._wsConnectionStatus === WsConnectionStatus.CONNECTING) { + this._wsConnectionStatus = WsConnectionStatus.DISCONNECTED; + this._wsConnectionPromise = null; + } + + throw err; + } finally { + if (this._wsConnectionStatus !== WsConnectionStatus.CONNECTING) { + this._wsConnectionPromise = null; + } + } + })(); + + return this._wsConnectionPromise; + } + + /** @description Produces connection-"uniq" request ids */ + getRequestId(): number { + const id = ++this._requestId; + + if (this._requestId >= WS_MAX_REQUEST_ID) { + this._requestId = 0; + } + + return id; + } + + /** @description Performs WS request with timeout */ + async makeRequest(requestId: number, requestMessage: RequestMessageType): Promise { + const ws = await this._getWsConnection(); + + if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + throw new this._errors.ConnectionTerminated({ + requestId, + message: `Couldn't send request because WS connection was manually closed`, + }); + } + + return new Promise(resolve => { + const pendingRequests = this._pendingRequests; + let isSettled = false; + + const onTimeout = setTimeout(() => { + const err = new this._errors.RequestTimeout({ + message: `Timed out while waiting for request in ${this._timeouts.request}ms`, + requestId, + }); + + done(err); + }, this._timeouts.request).unref(); + + function done(response: ResponseMessageType | WsError): void { + if (isSettled) { + return; + } + + isSettled = true; + delete pendingRequests[requestId]; + clearTimeout(onTimeout); + resolve(response); + } + + pendingRequests[requestId] = done; + + ws.send(requestMessage, error => { + if (!error) { + this._pingShouldSkip = true; + return; + } + + done( + new this._errors.ConnectionBreak({ + message: `Couldn't send WS request: ${error.message}`, + requestId, + }), + ); + + // Proactively closing connection as "send error" is marker that something bad with connection happened + if (ws === this._wsConnection) { + this._closeWsConnection( + "WS connection was considered broken as 'send' failed", + WsConnectionStatus.DISCONNECTED, + ); + this._tryToReconnect(); + } + }); + }); + } + + provideResponseFor(requestId: number, data: ResponseMessageType | WsError): void { + if (!this._pendingRequests[requestId]) { + this._debugFn(`! Received response to request ${requestId}, which is probably timed out already`); + return; + } + + this._pendingRequests[requestId](data); + } + + forceReconnect(sessionAbortMessage: string): void { + this._closeWsConnection(sessionAbortMessage, WsConnectionStatus.DISCONNECTED); + this._tryToReconnect(); + } + + /** @description Used to abort all pending requests when connection is closed */ + private _abortPendingRequests(message: string, isTerminated: boolean): void { + const pendingRequests = this._pendingRequests; + const pendingRequestIds = Object.keys(pendingRequests).map(Number); + + this._pendingRequests = {}; + + for (const requestId of pendingRequestIds) { + if (pendingRequests[requestId]) { + pendingRequests[requestId]( + isTerminated + ? new this._errors.ConnectionTerminated({ + message, + requestId, + }) + : new this._errors.ConnectionBreak({ + message, + requestId, + }), + ); + } + } + } + + private _closeWsConnection( + sessionAbortMessage: string, + status: WsConnectionStatus.CLOSED | WsConnectionStatus.DISCONNECTED, + ): void { + const ws = this._wsConnection; + + if (!ws || this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + this._wsConnection = null; + return; + } + + this._debugFn(`\u2718 ${sessionAbortMessage}; endpoint: "${this._endpoint}"`); + + const isClosing = status === WsConnectionStatus.CLOSED; + + if (isClosing && this._onConnectionCloseFn) { + this._onConnectionCloseFn(); + } + + this._wsConnection = null; + this._wsConnectionStatus = status; + this._abortPendingRequests(`Request was aborted because ${sessionAbortMessage}`, isClosing); + this._pingHealthCheckStop(); + + if (isClosing) { + this._onClose?.(); + } else { + this._onDisconnect?.(); + } + + closeWsConnection(ws); + } + + /** + * @description Tries to re-establish connection after network drops + * @note Silently gives up after failed retries attempts + */ + private _tryToReconnect(): void { + this._debugFn(`⟳ Trying to reconnect; endpoint: "${this._endpoint}"`); + + this._getWsConnection() + .then(() => { + this._onReconnect?.(); + this._debugFn(`\u2713 Successfully reconnected to session; endpoint: "${this._endpoint}"`); + }) + .catch(() => + this._debugFn(`\u2718 Couldn't reconnect to session automatically; endpoint: "${this._endpoint}"`), + ); + } + + /** @description Closes websocket connection, terminating all pending requests */ + close(): void { + this._closeWsConnection("Connection was closed manually", WsConnectionStatus.CLOSED); + } + + private _pingHealthCheckStop(): void { + this._pingSubsequentFails = 0; + + if (this._pingInterval) { + clearInterval(this._pingInterval); + } + + if (this._wsConnection && this._onPong) { + this._wsConnection.off("pong", this._onPong); + } + } + + private _isWebSocketActive(ws: WebSocket): boolean { + return Boolean(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING && ws === this._wsConnection); + } + + private _pingHealthCheckStart(): void { + this._pingHealthCheckStop(); + + const ws = this._wsConnection; + + if (!ws || !this._isWebSocketActive(ws)) { + return; + } + + this._pingHealthCheckStop(); + + let isWaitingForPong = false; + let pongTimeout: ReturnType; + + const onPong = (this._onPong = (): void => { + if (isWaitingForPong && this._isWebSocketActive(ws)) { + isWaitingForPong = false; + + this._debugFn("< PONG"); + + clearTimeout(pongTimeout); + + this._pingSubsequentFails = 0; + } + }); + + ws.on("pong", onPong); + + const pingInterval = (this._pingInterval = setInterval(() => { + if (!this._isWebSocketActive(ws)) { + clearInterval(pingInterval); + return; + } + + if (this._pingShouldSkip) { + this._pingShouldSkip = false; + return; + } + + pongTimeout = setTimeout(() => { + if (isWaitingForPong && this._isWebSocketActive(ws)) { + isWaitingForPong = false; + + this._pingSubsequentFails++; + + this._debugFn(`! Ping failed(${this._pingSubsequentFails} in a row) in ${WS_PING_TIMEOUT}ms`); + + if (this._pingSubsequentFails >= WS_PING_MAX_SUBSEQUENT_FAILS) { + this._closeWsConnection( + `WS connection was considered broken as ${this._pingSubsequentFails} pings failed in a row`, + WsConnectionStatus.DISCONNECTED, + ); + this._tryToReconnect(); + } + } + }, WS_PING_TIMEOUT).unref(); + + ws.ping(); + + this._debugFn("> PING"); + + isWaitingForPong = true; + }, WS_PING_INTERVAL).unref()); + } +} diff --git a/src/ws-connection/utils.ts b/src/ws-connection/utils.ts new file mode 100644 index 000000000..db3dbc605 --- /dev/null +++ b/src/ws-connection/utils.ts @@ -0,0 +1,10 @@ +export const exponentiallyWait = ({ + baseDelay = 500, + attempt = 0, + factor = 2, + jitter = 100, +}: { baseDelay?: number; attempt?: number; factor?: number; jitter?: number } = {}): Promise => { + const delay = Math.round(baseDelay * factor ** attempt + Math.random() * jitter); + + return new Promise(resolve => setTimeout(resolve, delay).unref()); +}; diff --git a/test/src/browser/cdp/connection.ts b/test/src/browser/cdp/connection.ts index 9c37aad08..bb433d268 100644 --- a/test/src/browser/cdp/connection.ts +++ b/test/src/browser/cdp/connection.ts @@ -2,8 +2,7 @@ import { WebSocket, WebSocketServer } from "ws"; import sinon, { SinonStub, SinonFakeTimers } from "sinon"; import proxyquire from "proxyquire"; import { CDPConnection } from "src/browser/cdp/connection"; -import { CDPError, CDPConnectionTerminatedError } from "src/browser/cdp/error"; -import { CDP_MAX_REQUEST_ID } from "src/browser/cdp/constants"; +import { CDPError, CDPRequestError } from "src/browser/cdp/error"; import type { CDPEvent, CDPErrorResponse, CDPRequest, CDPResponse } from "src/browser/cdp/types"; import type { Browser } from "src/browser/types"; @@ -122,8 +121,10 @@ describe('"CDPConnection"', () => { CDPConnectionProxied = proxyquire("src/browser/cdp/connection", { "./ws-endpoint": { getWsEndpoint: getWsEndpointStub }, - "./utils": { + "../../ws-connection/utils": { exponentiallyWait: exponentiallyWaitStub, + }, + "./utils": { extractRequestIdFromBrokenResponse: extractRequestIdFromBrokenResponseStub, }, "./debug": { debugCdp: debugCdpStub }, @@ -179,32 +180,7 @@ describe('"CDPConnection"', () => { params: { message: "serverErrorMessage" }, }); - await assert.isRejected(requestPromise, CDPError, "serverErrorMessage"); - }); - - it("should generate unique request IDs", async () => { - const promise1 = connection.request("Successful.Method", { params: { test: "1" } }); - const promise2 = connection.request("Successful.Method", { params: { test: "2" } }); - - const [result1, result2] = await Promise.all([promise1, promise2]); - - // Verify both requests completed successfully - assert.deepEqual(result1, { id: 1, params: { test: "1" } }); - assert.deepEqual(result2, { id: 2, params: { test: "2" } }); - }); - - it("should wrap request ID at maximum value", async () => { - // Set the connection's request ID to near maximum - Object.defineProperty(connection, "_requestId", { value: CDP_MAX_REQUEST_ID - 1 }); - - const promise1 = connection.request("Successful.Method", { params: { test: "1" } }); - const promise2 = connection.request("Successful.Method", { params: { test: "2" } }); - - const [result1, result2] = await Promise.all([promise1, promise2]); - - // Verify both requests completed successfully - assert.deepEqual(result1, { id: CDP_MAX_REQUEST_ID, params: { test: "1" } }); - assert.deepEqual(result2, { id: 1, params: { test: "2" } }); + await assert.isRejected(requestPromise, CDPRequestError, "serverErrorMessage"); }); it("should handle request with sessionId", async () => { @@ -222,96 +198,6 @@ describe('"CDPConnection"', () => { }); }); - // Error codes outside the range -32700 to -32600 and not -32000 are retryable - describe("request retries", () => { - let connection: CDPConnection; - - beforeEach(async () => { - connection = await CDPConnectionProxied.create(mockBrowser); - }); - - afterEach(async () => { - connection.close(); - }); - - it("should retry requests on retryable errors", async () => { - const response = await connection.request("Flaky.Method", { - params: { respondAfter: 3, errorCode: -1000 }, - }); - - // Verify that exponentiallyWait was called for retries - assert.callCount(exponentiallyWaitStub, 3); - assert.equal(wsServer?.requestsCounter, 4); - assert.deepEqual(response, { id: 4, params: { respondAfter: 3, errorCode: -1000 } }); - }); - - it("should stop retrying after maximum attempts", async () => { - // All attempts will fail with retryable error - const requestPromise = connection.request("Unsuccessful.Method", { - params: { message: "Persistent error", code: -30000 }, - }); - - await assert.isRejected(requestPromise, CDPError, "Persistent error"); - assert.equal(wsServer?.requestsCounter, 4); - }); - - it("should not retry non-retryable errors", async () => { - const requestPromise = connection.request("Unsuccessful.Method", { - params: { message: "Persistent error", code: -32000 }, - }); - - await assert.isRejected(requestPromise, CDPError, "Persistent error"); - assert.equal(wsServer?.requestsCounter, 1); - }); - }); - - describe("connection establishment and retries", () => { - let connection: CDPConnection; - - beforeEach(async () => { - connection = await CDPConnectionProxied.create(mockBrowser); - }); - - afterEach(() => { - connection.close(); - }); - - it("should establish connection successfully", async () => { - // Make a request to verify connection is established - const result = await connection.request("Successful.Method", { params: { test: "value" } }); - - assert.deepEqual(result, { id: 1, params: { test: "value" } }); - assert.calledWith(getWsEndpointStub, mockBrowser); - }); - - it("should fail after maximum connection retries", async () => { - // Always return non-existent port - getWsEndpointStub.resolves("ws://localhost:32105"); - - const connection = await CDPConnectionProxied.create(mockBrowser); - const requestPromise = connection.request("Runtime.enable"); - - try { - await assert.isRejected(requestPromise, CDPError, "Couldn't establish CDP connection"); - } finally { - connection.close(); - } - }); - - it("should connect once on multiple requests", async () => { - const [r1, r2, r3] = await Promise.all([ - connection.request("Successful.Method"), - connection.request("Successful.Method"), - connection.request("Successful.Method"), - ]); - - assert.deepEqual(r1, { id: 1 }); - assert.deepEqual(r2, { id: 2 }); - assert.deepEqual(r3, { id: 3 }); - assert.equal(wsServer?.connectionsCounter, 1); - }); - }); - describe("event handling", () => { let connection: CDPConnection; @@ -319,8 +205,6 @@ describe('"CDPConnection"', () => { connection = await CDPConnectionProxied.create(mockBrowser); await connection.request("Successful.Method"); // Establishes connection - - Object.defineProperty(connection, "_requestId", { value: 0 }); }); afterEach(async () => { @@ -365,28 +249,7 @@ describe('"CDPConnection"', () => { const result = await connection.request("Successful.Method", { params: {} }); // Should handle malformed JSON gracefully and still process valid requests - assert.deepEqual(result, { id: 1, params: {} }); - }); - - it("should retry + handle malformed response with extractable request ID", async () => { - let brokenRequestIdCounter = 1; - - extractRequestIdFromBrokenResponseStub.callsFake(() => brokenRequestIdCounter++); - - const serverWs = getWsServerConnection(); - - let requestsCount = 0; - // Override server's message handler to send malformed response - serverWs.removeAllListeners("message"); - serverWs.on("message", () => { - requestsCount++; - // Send malformed JSON with extractable request ID - serverWs.send('{"id":1,"invalid"}'); - }); - - const requestPromise = connection.request("Successful.Method"); - await assert.isRejected(requestPromise, CDPError, "Received malformed response: response is invalid JSON"); - assert.equal(requestsCount, 4); + assert.deepEqual(result, { id: 2, params: {} }); }); it("should ignore responses for unknown request IDs", async () => { @@ -398,78 +261,7 @@ describe('"CDPConnection"', () => { // Make a normal request - should work despite the unknown response const result = await connection.request("Successful.Method", { params: { test: "value" } }); - assert.deepEqual(result, { id: 1, params: { test: "value" } }); - }); - }); - - describe("connection management and reconnection", () => { - let connection: CDPConnection; - - beforeEach(async () => { - connection = await CDPConnectionProxied.create(mockBrowser); - - await connection.request("Successful.Method"); // Establishes connection - - Object.defineProperty(connection, "_requestId", { value: 0 }); - }); - - afterEach(async () => { - connection.close(); - }); - - it("should close connection and abort pending requests", async () => { - await wsServer?.waitForConnection; - - const requestPromise = connection.request("Timeout.Method", { params: {} }); - - // Close connection manually before timeout - connection.close(); - - await assert.isRejected(requestPromise, CDPConnectionTerminatedError); - }); - - it("should prevent new requests after close", async () => { - connection.close(); - - const requestPromise = connection.request("Runtime.enable"); - - await assert.isRejected(requestPromise, CDPConnectionTerminatedError); - }); - - it("should reuse existing connection for multiple requests", async () => { - const promise1 = connection.request("Successful.Method"); - const promise2 = connection.request("Successful.Method"); - - const [result1, result2] = await Promise.all([promise1, promise2]); - - assert.deepEqual(result1, { id: 1 }); - assert.deepEqual(result2, { id: 2 }); - }); - - it("should handle connection drop and reconnect", async () => { - const result1 = await connection.request("Successful.Method"); - assert.deepEqual(result1, { id: 1 }); - - // Simulate connection drop - getWsServerConnection().close(); - - // Make second request (should trigger reconnection) - const result2 = await connection.request("Successful.Method"); - assert.deepEqual(result2, { id: 3 }); - }); - - it("should handle connection termination and reconnect", async () => { - const result1 = await connection.request("Successful.Method"); - assert.deepEqual(result1, { id: 1 }); - - // Simulate connection error - getWsServerConnection().terminate(); - - // Make request (should trigger reconnection) - // Request is retried because of termination - const result2 = await connection.request("Successful.Method"); - assert.deepEqual(result2, { id: 3 }); - assert.calledOnce(exponentiallyWaitStub); + assert.deepEqual(result, { id: 2, params: { test: "value" } }); }); }); }); diff --git a/test/src/browser/existing-browser.js b/test/src/browser/existing-browser.js index b1ee35478..902cfe39c 100644 --- a/test/src/browser/existing-browser.js +++ b/test/src/browser/existing-browser.js @@ -22,7 +22,14 @@ describe("ExistingBrowser", () => { const sandbox = sinon.createSandbox(); let session; let ExistingBrowser; - let webdriverioAttachStub, clientBridgeBuildStub, loggerWarnStub, initCommandHistoryStub, runGroupStub, CDPStub; + let webdriverioAttachStub, + clientBridgeBuildStub, + loggerWarnStub, + initCommandHistoryStub, + runGroupStub, + CDPStub, + WSDriverRequestAgentStub, + WSDriverRequestAgentCreateStub; const mkBrowser_ = (configOpts, opts) => { return mkExistingBrowser_(configOpts, opts, ExistingBrowser); @@ -52,6 +59,8 @@ describe("ExistingBrowser", () => { loggerWarnStub = sandbox.stub(); initCommandHistoryStub = sandbox.stub(); runGroupStub = sandbox.stub(); + WSDriverRequestAgentStub = { request: sandbox.stub() }; + WSDriverRequestAgentCreateStub = sandbox.stub().returns(WSDriverRequestAgentStub); CDPStub = { target: { @@ -91,6 +100,11 @@ describe("ExistingBrowser", () => { create: sandbox.stub().resolves(CDPStub), }, }, + "./wsdriver": { + WSDriverRequestAgent: { + create: WSDriverRequestAgentCreateStub, + }, + }, }).ExistingBrowser; }); @@ -772,6 +786,76 @@ describe("ExistingBrowser", () => { }); }); + describe("useWsDriver", () => { + it("should not set customWdRequestAgent if useWsDriver is false", async () => { + const sessionCaps = { "se:wsdriver": "ws://grid.url/session/test", "se:wsdriverVersion": "1" }; + const browser = mkBrowser_({ useWsDriver: false }); + + await initBrowser_(browser, { sessionCaps }); + + const attachOpts = webdriverioAttachStub.lastCall.args[0]; + assert.isUndefined(attachOpts.customWdRequestAgent); + }); + + it("should not set customWdRequestAgent if se:wsdriver capability is not present", async () => { + const sessionCaps = { browserName: "chrome" }; + const browser = mkBrowser_({ useWsDriver: true }); + + await initBrowser_(browser, { sessionCaps }); + + const attachOpts = webdriverioAttachStub.lastCall.args[0]; + assert.isUndefined(attachOpts.customWdRequestAgent); + }); + + it("should not set customWdRequestAgent if se:wsdriverVersion does not include version 1", async () => { + const sessionCaps = { "se:wsdriver": "ws://grid.url/session/test", "se:wsdriverVersion": "2" }; + const browser = mkBrowser_({ useWsDriver: true }); + + await initBrowser_(browser, { sessionCaps }); + + const attachOpts = webdriverioAttachStub.lastCall.args[0]; + assert.isUndefined(attachOpts.customWdRequestAgent); + }); + + it("should set customWdRequestAgent if useWsDriver is true and se:wsdriverVersion includes version 1", async () => { + const sessionCaps = { "se:wsdriver": "ws://grid.url/session/test", "se:wsdriverVersion": "1" }; + const sessionOpts = { headers: { "X-Custom-Header": "test" } }; + const browser = mkBrowser_({ useWsDriver: true }); + + await initBrowser_(browser, { sessionCaps, sessionOpts }); + + const attachOpts = webdriverioAttachStub.lastCall.args[0]; + assert.isDefined(attachOpts.customWdRequestAgent); + assert.calledOnceWith(WSDriverRequestAgentCreateStub, { + sessionId: sinon.match.any, + sessionCaps: { "se:wsdriver": "ws://grid.url/session/test", "se:wsdriverVersion": "1" }, + headers: { "X-Custom-Header": "test" }, + browserConfig: browser.config, + }); + }); + + it("should set customWdRequestAgent if se:wsdriverVersion includes multiple versions with version 1", async () => { + const sessionCaps = { "se:wsdriver": "ws://grid.url/session/test", "se:wsdriverVersion": "0, 1, 2" }; + const browser = mkBrowser_({ useWsDriver: true }); + + await initBrowser_(browser, { sessionCaps }); + + const attachOpts = webdriverioAttachStub.lastCall.args[0]; + assert.isDefined(attachOpts.customWdRequestAgent); + assert.calledOnce(WSDriverRequestAgentCreateStub); + }); + + it("should not set customWdRequestAgent if se:wsdriverVersion is undefined", async () => { + const sessionCaps = { "se:wsdriver": true }; + const browser = mkBrowser_({ useWsDriver: true }); + + await initBrowser_(browser, { sessionCaps }); + + const attachOpts = webdriverioAttachStub.lastCall.args[0]; + assert.isUndefined(attachOpts.customWdRequestAgent); + }); + }); + describe("prepareScreenshot", () => { it("should prepare screenshot", async () => { const clientBridge = stubClientBridge_(); diff --git a/test/src/browser/wsdriver/compression.ts b/test/src/browser/wsdriver/compression.ts new file mode 100644 index 000000000..b04325df8 --- /dev/null +++ b/test/src/browser/wsdriver/compression.ts @@ -0,0 +1,63 @@ +import { getCompressed, getDecompressed } from "src/browser/wsdriver/compression"; +import { WsDriverCompression } from "src/browser/wsdriver/types"; + +describe("wsdriver/compression", () => { + const payload = Buffer.from("hello world".repeat(256)); + + describe("getCompressed", () => { + it("should return same payload for None compression", async () => { + const result = await getCompressed(payload, WsDriverCompression.None); + assert.strictEqual(result, payload); + }); + + it("should compress with GZIP", async () => { + const result = await getCompressed(payload, WsDriverCompression.GZIP); + assert.notStrictEqual(result, payload); + assert.isTrue(result.length > 0); + assert.isTrue(result.length < payload.length); + }); + + it("should compress with ZSTD", async () => { + // Skip on node < 22 + if (!("zstd" in process.versions)) { + return; + } + const result = await getCompressed(payload, WsDriverCompression.ZSTD); + assert.notStrictEqual(result, payload); + assert.isTrue(result.length > 0); + assert.isTrue(result.length < payload.length); + }); + + it("should throw on unknown compression", async () => { + await assert.isRejected(getCompressed(payload, 999 as any), /Unknown compression type/); + }); + }); + + describe("getDecompressed", () => { + it("should return same payload for None compression", async () => { + const result = await getDecompressed(payload, WsDriverCompression.None); + assert.strictEqual(result, payload); + }); + + it("should decompress GZIP", async () => { + const compressed = await getCompressed(payload, WsDriverCompression.GZIP); + const result = await getDecompressed(compressed, WsDriverCompression.GZIP); + assert.deepEqual(result, payload); + assert.isTrue(result.length > compressed.length); + }); + + it("should decompress ZSTD", async () => { + if (!("zstd" in process.versions)) { + return; + } + const compressed = await getCompressed(payload, WsDriverCompression.ZSTD); + const result = await getDecompressed(compressed, WsDriverCompression.ZSTD); + assert.deepEqual(result, payload); + assert.isTrue(result.length > compressed.length); + }); + + it("should throw on unknown compression", async () => { + await assert.isRejected(getDecompressed(payload, 999 as any), /Unknown compression type/); + }); + }); +}); diff --git a/test/src/browser/wsdriver/index.ts b/test/src/browser/wsdriver/index.ts new file mode 100644 index 000000000..bacfe3f19 --- /dev/null +++ b/test/src/browser/wsdriver/index.ts @@ -0,0 +1,185 @@ +/* eslint-disable no-bitwise */ +import { WebSocket, WebSocketServer } from "ws"; +import sinon, { SinonStub, SinonFakeTimers } from "sinon"; +import proxyquire from "proxyquire"; +import { WSDriverRequestAgent } from "src/browser/wsdriver"; +import { WSDriverError } from "src/browser/wsdriver/error"; +import { WsDriverMessage, WsDriverCompression } from "src/browser/wsdriver/types"; +import { BrowserConfig } from "src/config/browser-config"; + +const STUB_SERVER_PORT = 50124; + +type StubWebSocketServer = WebSocketServer & { + waitForConnection: Promise; + connectionsCounter: number; + requestsCounter: number; + closeConnections: () => void; +}; + +let wsServer: StubWebSocketServer | null = null; + +const createWsServer = (): StubWebSocketServer => { + const wss = new WebSocketServer({ port: STUB_SERVER_PORT }) as StubWebSocketServer; + + let resolveHangingPromise: ((ws: WebSocket) => void) | null = null; + let connectionsCounter = 0; + let requestsCounter = 0; + + const hangingPromise = new Promise(resolve => { + resolveHangingPromise = resolve; + }); + + Object.defineProperty(wss, "waitForConnection", { value: hangingPromise }); + Object.defineProperty(wss, "connectionsCounter", { get: () => connectionsCounter }); + Object.defineProperty(wss, "requestsCounter", { get: () => requestsCounter }); + Object.defineProperty(wss, "closeConnections", { value: () => wss.clients.forEach(ws => ws.close()) }); + + wss.on("connection", ws => { + connectionsCounter++; + resolveHangingPromise?.(ws); + + ws.on("ping", () => ws.pong()); + ws.on("message", data => { + requestsCounter++; + + const buffer = data as Buffer; + const requestId = buffer.readUInt32BE(2); + const commandEnd = buffer.indexOf(0, 8); + const command = buffer.toString("utf8", 8, commandEnd); + + if (command === "success") { + const body = Buffer.from(JSON.stringify({ value: "ok" })); + const response = Buffer.alloc(8 + command.length + 1 + body.length); + response.writeUInt8(1, 0); + response.writeUInt8((WsDriverMessage.Response << 4) | (WsDriverCompression.None << 2) | 2, 1); + response.writeUInt32BE(requestId, 2); + response.writeUInt16BE(200, 6); + Buffer.from(command).copy(response, 8); + response.writeUInt8(0, 8 + command.length); + body.copy(response, 8 + command.length + 1); + ws.send(response); + } else if (command === "protocol-error") { + const body = Buffer.from("error"); + const response = Buffer.alloc(8 + command.length + 1 + body.length); + response.writeUInt8(1, 0); + response.writeUInt8((WsDriverMessage.Response << 4) | (WsDriverCompression.None << 2) | 1, 1); // isProtocolError = 1 + response.writeUInt32BE(requestId, 2); + response.writeUInt16BE(500, 6); + Buffer.from(command).copy(response, 8); + response.writeUInt8(0, 8 + command.length); + body.copy(response, 8 + command.length + 1); + ws.send(response); + } + }); + }); + + return wss; +}; + +describe('"WSDriverRequestAgent"', () => { + const sandbox = sinon.createSandbox(); + let clock: SinonFakeTimers; + let exponentiallyWaitStub: SinonStub; + let WSDriverRequestAgentProxied: typeof WSDriverRequestAgent; + + const mockBrowserConfig = { + httpTimeout: 3000, + } as BrowserConfig; + + beforeEach(() => { + wsServer = createWsServer(); + clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + exponentiallyWaitStub = sandbox.stub().resolves(); + + WSDriverRequestAgentProxied = proxyquire("src/browser/wsdriver", { + "../../ws-connection/utils": { + exponentiallyWait: exponentiallyWaitStub, + }, + "../../utils/logger": { + error: sandbox.stub(), + warn: sandbox.stub(), + info: sandbox.stub(), + }, + }).WSDriverRequestAgent; + }); + + afterEach(() => { + clock.restore(); + sandbox.restore(); + wsServer?.closeConnections(); + wsServer?.close(); + }); + + describe("create", () => { + it("should create WSDriverRequestAgent instance", () => { + const connection = WSDriverRequestAgentProxied.create({ + sessionId: "123", + sessionCaps: { "se:wsdriver": `ws://localhost:${STUB_SERVER_PORT}`, "se:wsdriverVersion": "1" }, + headers: {}, + browserConfig: mockBrowserConfig, + }); + + assert.instanceOf(connection, WSDriverRequestAgentProxied); + }); + + it("should throw error if wsdriver endpoint is missing", () => { + assert.throws( + () => + WSDriverRequestAgentProxied.create({ + sessionId: "123", + sessionCaps: { "se:wsdriverVersion": "1" }, + headers: {}, + browserConfig: mockBrowserConfig, + }), + WSDriverError, + "Couldn't determine wsdriver endpoint", + ); + }); + + it("should throw error if wsdriver version is missing", () => { + assert.throws( + () => + WSDriverRequestAgentProxied.create({ + sessionId: "123", + sessionCaps: { "se:wsdriver": `ws://localhost:${STUB_SERVER_PORT}` }, + headers: {}, + browserConfig: mockBrowserConfig, + }), + WSDriverError, + "Couldn't determine wsdriver supported versions", + ); + }); + }); + + describe("request handling", () => { + let connection: WSDriverRequestAgent; + + beforeEach(async () => { + connection = WSDriverRequestAgentProxied.create({ + sessionId: "123", + sessionCaps: { "se:wsdriver": `ws://localhost:${STUB_SERVER_PORT}`, "se:wsdriverVersion": "1" }, + headers: {}, + browserConfig: mockBrowserConfig, + }); + }); + + afterEach(() => { + connection.close(); + }); + + it("should send request and receive successful response", async () => { + const url = new URL(`http://localhost/session/123/success`); + const response = await connection.request(url, { method: "GET" }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { value: "ok" }); + }); + + it("should handle protocol error", async () => { + const url = new URL(`http://localhost/session/123/protocol-error`); + const requestPromise = connection.request(url, { method: "GET" }); + + await assert.isRejected(requestPromise, /Protocol error/); + }); + }); +}); diff --git a/test/src/browser/wsdriver/request.ts b/test/src/browser/wsdriver/request.ts new file mode 100644 index 000000000..db28c07ac --- /dev/null +++ b/test/src/browser/wsdriver/request.ts @@ -0,0 +1,64 @@ +import { constructWsDriverRequest } from "src/browser/wsdriver/request"; +import { WsDriverCompression } from "src/browser/wsdriver/types"; +import { WSD_COMPRESSION_THRESHOLD_BYTES } from "src/browser/wsdriver/constants"; + +describe("wsdriver/request", () => { + it("should construct request without compression", async () => { + const url = new URL("http://localhost/session/123/element"); + const options = { method: "POST", json: { using: "css selector", value: ".test" } }; + const connectionOptions = { + requestId: 1, + sessionPrefix: "/session/123/", + compressionType: WsDriverCompression.None, + }; + + const result = await constructWsDriverRequest(url, options as any, connectionOptions); + + assert.equal(result.readUInt8(0), 1); // version + assert.equal(result.readUInt8(1), 0b00000010); // header: Request (0), None (0), isJson (1) + assert.equal(result.readUInt32BE(2), 1); // requestId + assert.equal(result.readUInt16BE(6), 2); // method POST + + const commandEnd = result.indexOf(0, 8); + const command = result.toString("utf8", 8, commandEnd); + assert.equal(command, "element"); + + const body = result.subarray(commandEnd + 1).toString("utf8"); + assert.equal(body, JSON.stringify(options.json)); + }); + + it("should construct request with compression if body is large enough", async () => { + const url = new URL("http://localhost/session/123/element"); + const largeString = "a".repeat(WSD_COMPRESSION_THRESHOLD_BYTES); + const options = { method: "POST", json: { value: largeString } }; + const connectionOptions = { + requestId: 1, + sessionPrefix: "/session/123/", + compressionType: WsDriverCompression.GZIP, + }; + + const result = await constructWsDriverRequest(url, options as any, connectionOptions); + + assert.equal(result.readUInt8(0), 1); // version + assert.equal(result.readUInt8(1), 0b00000110); // header: Request (0), GZIP (1), isJson (1) + + const commandEnd = result.indexOf(0, 8); + const body = result.subarray(commandEnd + 1); + assert.notEqual(body.toString("utf8"), JSON.stringify(options.json)); // Should be compressed + }); + + it("should throw if not session-related request", async () => { + const url = new URL("http://localhost/status"); + const options = { method: "GET" }; + const connectionOptions = { + requestId: 1, + sessionPrefix: "/session/123/", + compressionType: WsDriverCompression.None, + }; + + await assert.isRejected( + constructWsDriverRequest(url, options as any, connectionOptions), + /Not session-related request/, + ); + }); +}); diff --git a/test/src/browser/wsdriver/response.ts b/test/src/browser/wsdriver/response.ts new file mode 100644 index 000000000..05d1dc40d --- /dev/null +++ b/test/src/browser/wsdriver/response.ts @@ -0,0 +1,53 @@ +/* eslint-disable no-bitwise */ +import { parseWsDriverIncomingMessage } from "src/browser/wsdriver/response"; +import { WsDriverCompression, WsDriverMessage } from "src/browser/wsdriver/types"; + +describe("wsdriver/response", () => { + it("should parse valid uncompressed JSON response", async () => { + const body = Buffer.from(JSON.stringify({ value: "test" })); + const command = Buffer.from("element"); + const message = Buffer.alloc(8 + command.length + 1 + body.length); + + message.writeUInt8(1, 0); // version + message.writeUInt8((WsDriverMessage.Response << 4) | (WsDriverCompression.None << 2) | 2, 1); // header: Response, None, isJson + message.writeUInt32BE(123, 2); // requestId + message.writeUInt16BE(200, 6); // status + command.copy(message, 8); + message.writeUInt8(0, 8 + command.length); + body.copy(message, 8 + command.length + 1); + + const result = await parseWsDriverIncomingMessage(message); + + assert.deepEqual(result, { + requestId: 123, + statusCode: 200, + isJson: true, + isProtocolError: false, + requestPath: "element", + body: { value: "test" }, + rawBody: body, + }); + }); + + it("should return Error if message is too short", async () => { + const message = Buffer.alloc(5); + const result = await parseWsDriverIncomingMessage(message); + assert.instanceOf(result, Error); + assert.match((result as Error).message, /too short/); + }); + + it("should return null if version is unexpected", async () => { + const message = Buffer.alloc(20); + message.writeUInt8(2, 0); // version 2 + const result = await parseWsDriverIncomingMessage(message); + assert.isNull(result); + }); + + it("should return null if message type is not Response", async () => { + const message = Buffer.alloc(20); + message.writeUInt8(1, 0); // version 1 + message.writeUInt8(WsDriverMessage.Request << 4, 1); // Request type + const result = await parseWsDriverIncomingMessage(message); + assert.isNull(result); + }); +}); diff --git a/test/src/ws-connection/index.ts b/test/src/ws-connection/index.ts new file mode 100644 index 000000000..19729f1c3 --- /dev/null +++ b/test/src/ws-connection/index.ts @@ -0,0 +1,397 @@ +import { WebSocket, WebSocketServer } from "ws"; +import sinon, { SinonStub, SinonFakeTimers } from "sinon"; +import proxyquire from "proxyquire"; +import { WsConnection } from "src/ws-connection"; +import { + WsConnectionTerminatedError, + WsError, + WsConnectionEstablishmentError, + WsConnectionBreakError, + WsConnectionTimeoutError, + WsRequestTimeoutError, +} from "src/ws-connection/error"; +import { WS_MAX_REQUEST_ID } from "src/ws-connection/constants"; + +const STUB_SERVER_PORT = 50123; + +type StubWebSocketServer = WebSocketServer & { + waitForConnection: Promise; + connectionsCounter: number; + requestsCounter: number; + closeConnections: () => void; +}; + +let wsServerConnection: WebSocket | null = null; +let wsServer: StubWebSocketServer | null = null; + +const createWsServer = (): StubWebSocketServer => { + const wss = new WebSocketServer({ port: STUB_SERVER_PORT }) as StubWebSocketServer; + + let resolveHangingPromise: ((ws: WebSocket) => void) | null = null; + let connectionsCounter = 0; + let requestsCounter = 0; + + const hangingPromise = new Promise(resolve => { + resolveHangingPromise = resolve; + }); + + Object.defineProperty(wss, "waitForConnection", { value: hangingPromise }); + Object.defineProperty(wss, "connectionsCounter", { get: () => connectionsCounter }); + Object.defineProperty(wss, "requestsCounter", { get: () => requestsCounter }); + Object.defineProperty(wss, "closeConnections", { value: () => wss.clients.forEach(ws => ws.close()) }); + + wss.on("connection", ws => { + connectionsCounter++; + wsServerConnection = ws; + resolveHangingPromise?.(ws); + + let flakyMethodCounter = 0; + + ws.on("ping", () => ws.pong()); + ws.on("message", data => { + requestsCounter++; + + const { id, method, params } = JSON.parse(data.toString("utf8")); + + switch (method) { + case "Successful.Method": + ws.send(JSON.stringify({ id, result: { id, params } })); + break; + + case "Unsuccessful.Method": + ws.send( + JSON.stringify({ + id, + error: { + id, + code: params && "code" in params ? params.code : 0, + message: params && "message" in params ? params.message : "", + }, + }), + ); + break; + + case "Flaky.Method": { + const respondAfter = params && "respondAfter" in params ? Number(params.respondAfter) : 3; + const message = params && "errorMessage" in params ? params.errorMessage : "error"; + const code = params && "errorCode" in params ? Number(params.errorCode) : -32000; + const dontAnswer = params && "dontAnswer" in params; + + if (flakyMethodCounter++ === respondAfter) { + ws.send(JSON.stringify({ id, result: { id, params } })); + } else if (!dontAnswer) { + ws.send(JSON.stringify({ id, error: { id, code, message } })); + } + + break; + } + } + }); + }); + + return wss; +}; + +const getWsServerConnection = (): WebSocket => { + if (!wsServerConnection) { + throw Error("Connection is not established"); + } + + return wsServerConnection; +}; + +class TestRequestError extends WsError { + constructor(opts: { message: string; code?: number; requestId?: number }) { + super(opts); + this.name = "TestRequestError"; + } + isRetryable(): boolean { + return this.code !== -32000; + } +} + +class TestWsConnection extends WsConnection, string> { + constructor(endpoint: string) { + super(endpoint, { + retries: { count: 3, baseDelay: 100 }, + timeouts: { request: 3000, createSession: 3000 }, + errors: { + ConnectionEstablishment: WsConnectionEstablishmentError, + ConnectionBreak: WsConnectionBreakError, + ConnectionTerminated: WsConnectionTerminatedError, + ConnectionTimeout: WsConnectionTimeoutError, + RequestTimeout: WsRequestTimeoutError, + }, + onMessage: data => { + const message = data.toString("utf8"); + const jsonParsedMessage = JSON.parse(message); + const requestId = jsonParsedMessage.id; + + if ("result" in jsonParsedMessage) { + this.provideResponseFor(requestId, jsonParsedMessage.result); + } else if ("error" in jsonParsedMessage) { + this.provideResponseFor( + requestId, + new TestRequestError({ + message: jsonParsedMessage.error.message, + code: jsonParsedMessage.error.code, + requestId, + }), + ); + } + }, + }); + } + + async request(method: string, params?: Record): Promise { + let result!: T | WsError; + + for (let retriesLeft = 3; retriesLeft >= 0; retriesLeft--) { + const id = this.getRequestId(); + const requestMessage = JSON.stringify({ id, method, params }); + + result = (await this.makeRequest(id, requestMessage)) as T | WsError; + + if (!(result instanceof WsError) || !result.isRetryable() || retriesLeft <= 0) { + break; + } + } + + if (result instanceof WsError) { + throw result; + } + + return result; + } +} + +describe('"WsConnection"', () => { + const sandbox = sinon.createSandbox(); + let clock: SinonFakeTimers; + let exponentiallyWaitStub: SinonStub; + let WsConnectionProxied: typeof WsConnection; + let TestWsConnectionProxied: typeof TestWsConnection; + + const mockEndpoint = `ws://localhost:${STUB_SERVER_PORT}`; + + beforeEach(() => { + wsServer = createWsServer(); + clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + exponentiallyWaitStub = sandbox.stub().resolves(); + + WsConnectionProxied = proxyquire("src/ws-connection", { + "./utils": { + exponentiallyWait: exponentiallyWaitStub, + }, + }).WsConnection; + + TestWsConnectionProxied = class extends WsConnectionProxied, string> { + constructor(endpoint: string) { + super(endpoint, { + retries: { count: 3, baseDelay: 100 }, + timeouts: { request: 3000, createSession: 3000 }, + errors: { + ConnectionEstablishment: WsConnectionEstablishmentError, + ConnectionBreak: WsConnectionBreakError, + ConnectionTerminated: WsConnectionTerminatedError, + ConnectionTimeout: WsConnectionTimeoutError, + RequestTimeout: WsRequestTimeoutError, + }, + onMessage: data => { + const message = data.toString("utf8"); + const jsonParsedMessage = JSON.parse(message); + const requestId = jsonParsedMessage.id; + + if ("result" in jsonParsedMessage) { + this.provideResponseFor(requestId, jsonParsedMessage.result); + } else if ("error" in jsonParsedMessage) { + this.provideResponseFor( + requestId, + new TestRequestError({ + message: jsonParsedMessage.error.message, + code: jsonParsedMessage.error.code, + requestId, + }), + ); + } + }, + }); + } + + async request(method: string, params?: Record): Promise { + let result!: T | WsError; + + for (let retriesLeft = 3; retriesLeft >= 0; retriesLeft--) { + const id = this.getRequestId(); + const requestMessage = JSON.stringify({ id, method, params }); + + result = (await this.makeRequest(id, requestMessage)) as T | WsError; + + if (!(result instanceof WsError) || !result.isRetryable() || retriesLeft <= 0) { + break; + } + + if (!(result instanceof WsRequestTimeoutError)) { + await exponentiallyWaitStub(); + } + } + + if (result instanceof WsError) { + throw result; + } + + return result; + } + } as unknown as typeof TestWsConnection; + }); + + afterEach(() => { + clock.restore(); + sandbox.restore(); + wsServer?.closeConnections(); + wsServer?.close(); + }); + + describe("request handling", () => { + let connection: TestWsConnection; + + beforeEach(() => { + connection = new TestWsConnectionProxied(mockEndpoint); + }); + + afterEach(() => { + connection.close(); + }); + + it("should generate unique request IDs", async () => { + const promise1 = connection.request("Successful.Method", { test: "1" }); + const promise2 = connection.request("Successful.Method", { test: "2" }); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + assert.deepEqual(result1, { id: 1, params: { test: "1" } }); + assert.deepEqual(result2, { id: 2, params: { test: "2" } }); + }); + + it("should wrap request ID at maximum value", async () => { + Object.defineProperty(connection, "_requestId", { value: WS_MAX_REQUEST_ID - 1 }); + + const promise1 = connection.request("Successful.Method", { test: "1" }); + const promise2 = connection.request("Successful.Method", { test: "2" }); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + assert.deepEqual(result1, { id: WS_MAX_REQUEST_ID, params: { test: "1" } }); + assert.deepEqual(result2, { id: 1, params: { test: "2" } }); + }); + }); + + describe("connection establishment and retries", () => { + let connection: TestWsConnection; + + beforeEach(() => { + connection = new TestWsConnectionProxied(mockEndpoint); + }); + + afterEach(() => { + connection.close(); + }); + + it("should establish connection successfully", async () => { + const result = await connection.request("Successful.Method", { test: "value" }); + + assert.deepEqual(result, { id: 1, params: { test: "value" } }); + }); + + it("should fail after maximum connection retries", async () => { + const badConnection = new TestWsConnectionProxied("ws://localhost:32105"); + const requestPromise = badConnection.request("Successful.Method"); + + try { + await assert.isRejected( + requestPromise, + WsConnectionEstablishmentError, + "Couldn't establish WS connection", + ); + } finally { + badConnection.close(); + } + }); + + it("should connect once on multiple requests", async () => { + const [r1, r2, r3] = await Promise.all([ + connection.request("Successful.Method"), + connection.request("Successful.Method"), + connection.request("Successful.Method"), + ]); + + assert.deepEqual(r1, { id: 1 }); + assert.deepEqual(r2, { id: 2 }); + assert.deepEqual(r3, { id: 3 }); + assert.equal(wsServer?.connectionsCounter, 1); + }); + }); + + describe("connection management and reconnection", () => { + let connection: TestWsConnection; + + beforeEach(async () => { + connection = new TestWsConnectionProxied(mockEndpoint); + await connection.request("Successful.Method"); + Object.defineProperty(connection, "_requestId", { value: 0 }); + }); + + afterEach(() => { + connection.close(); + }); + + it("should close connection and abort pending requests", async () => { + await wsServer?.waitForConnection; + + const requestPromise = connection.request("Flaky.Method", { dontAnswer: true }); + + connection.close(); + + await assert.isRejected(requestPromise, WsConnectionTerminatedError); + }); + + it("should prevent new requests after close", async () => { + connection.close(); + + const requestPromise = connection.request("Successful.Method"); + + await assert.isRejected(requestPromise, WsConnectionTerminatedError); + }); + + it("should reuse existing connection for multiple requests", async () => { + const promise1 = connection.request("Successful.Method"); + const promise2 = connection.request("Successful.Method"); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + assert.deepEqual(result1, { id: 1 }); + assert.deepEqual(result2, { id: 2 }); + }); + + it("should handle connection drop and reconnect", async () => { + const result1 = await connection.request("Successful.Method"); + assert.deepEqual(result1, { id: 1 }); + + getWsServerConnection().close(); + + const result2 = await connection.request("Successful.Method"); + assert.deepEqual(result2, { id: 3 }); + }); + + it("should handle connection termination and reconnect", async () => { + const result1 = await connection.request("Successful.Method"); + assert.deepEqual(result1, { id: 1 }); + + getWsServerConnection().terminate(); + + const result2 = await connection.request("Successful.Method"); + assert.deepEqual(result2, { id: 3 }); + assert.calledOnce(exponentiallyWaitStub); + }); + }); +});