diff --git a/package-lock.json b/package-lock.json index 37775dabe2..80e6c09d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -181,6 +181,7 @@ "patch-package": "6.4.7", "pretty": "2.0.0", "process": "0.11.10", + "puppeteer-core": "24.29.1", "sass": "1.26.8", "style-loader": "3.3.1", "traverse": "0.6.6", @@ -5109,18 +5110,18 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.10.5", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.5.tgz", - "integrity": "sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==", + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", + "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.1", + "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", - "semver": "^7.7.2", - "tar-fs": "^3.0.8", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { @@ -5131,9 +5132,9 @@ } }, "node_modules/@puppeteer/browsers/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -5156,9 +5157,9 @@ "license": "MIT" }, "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -11273,6 +11274,20 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "10.5.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-10.5.1.tgz", + "integrity": "sha512-rlj6OyhKhVTnk4aENcUme3Jl9h+cq4oXu4AzBcvr8RMmT6BR4a3zSNT9dbIfXr9/BS6ibzRyDhowuw4n2GgzsQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/chromium-edge-launcher": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", @@ -19659,6 +19674,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -21441,6 +21463,79 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "24.29.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.29.1.tgz", + "integrity": "sha512-ErJ9qKCK+bdLvBa7QVSQTBSPm8KZbl1yC/WvhrZ0ut27hDf2QBzjDsn1IukzE1i1KtZ7NYGETOV4W1beoo9izA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "10.5.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1521046", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.8", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-core/node_modules/devtools-protocol": { + "version": "0.0.1521046", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", + "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/puppeteer-core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/puppeteer-core/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -24829,9 +24924,9 @@ } }, "node_modules/tar-fs": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.10.tgz", - "integrity": "sha512-C1SwlQGNLe/jPNqapK8epDsXME7CAJR5RL3GcE6KWx1d9OUByzoHVcbu1VPI8tevg9H8Alae0AApHHFGzrD5zA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, "license": "MIT", "dependencies": { @@ -25458,6 +25553,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", @@ -26005,6 +26107,13 @@ "node": ">=18.20.0" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz", + "integrity": "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webdriver/node_modules/ws": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", @@ -27144,6 +27253,16 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zxcvbn": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", @@ -30342,24 +30461,24 @@ } }, "@puppeteer/browsers": { - "version": "2.10.5", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.5.tgz", - "integrity": "sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==", + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", + "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", "dev": true, "requires": { - "debug": "^4.4.1", + "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", - "semver": "^7.7.2", - "tar-fs": "^3.0.8", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "dependencies": { "debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "requires": { "ms": "^2.1.3" @@ -30372,9 +30491,9 @@ "dev": true }, "semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true } } @@ -34734,6 +34853,16 @@ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", "dev": true }, + "chromium-bidi": { + "version": "10.5.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-10.5.1.tgz", + "integrity": "sha512-rlj6OyhKhVTnk4aENcUme3Jl9h+cq4oXu4AzBcvr8RMmT6BR4a3zSNT9dbIfXr9/BS6ibzRyDhowuw4n2GgzsQ==", + "dev": true, + "requires": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + } + }, "chromium-edge-launcher": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", @@ -40731,6 +40860,12 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" }, + "mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true + }, "mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -41963,6 +42098,50 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" }, + "puppeteer-core": { + "version": "24.29.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.29.1.tgz", + "integrity": "sha512-ErJ9qKCK+bdLvBa7QVSQTBSPm8KZbl1yC/WvhrZ0ut27hDf2QBzjDsn1IukzE1i1KtZ7NYGETOV4W1beoo9izA==", + "dev": true, + "requires": { + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "10.5.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1521046", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.8", + "ws": "^8.18.3" + }, + "dependencies": { + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "devtools-protocol": { + "version": "0.0.1521046", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", + "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", + "dev": true + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true + } + } + }, "qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -44282,9 +44461,9 @@ "dev": true }, "tar-fs": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.10.tgz", - "integrity": "sha512-C1SwlQGNLe/jPNqapK8epDsXME7CAJR5RL3GcE6KWx1d9OUByzoHVcbu1VPI8tevg9H8Alae0AApHHFGzrD5zA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, "requires": { "bare-fs": "^4.0.1", @@ -44719,6 +44898,12 @@ "reflect.getprototypeof": "^1.0.6" } }, + "typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true + }, "typescript": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", @@ -45073,6 +45258,12 @@ } } }, + "webdriver-bidi-protocol": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz", + "integrity": "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==", + "dev": true + }, "webdriverio": { "version": "9.16.0", "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.16.0.tgz", @@ -45838,6 +46029,12 @@ } } }, + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true + }, "zxcvbn": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", diff --git a/package.json b/package.json index 11185f2a40..67c71d567b 100644 --- a/package.json +++ b/package.json @@ -187,6 +187,7 @@ "patch-package": "6.4.7", "pretty": "2.0.0", "process": "0.11.10", + "puppeteer-core": "24.29.1", "sass": "1.26.8", "style-loader": "3.3.1", "traverse": "0.6.6", @@ -225,7 +226,12 @@ "test-grid": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.grid.conf.ts", "test-grid-single": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.grid.conf.ts --spec", "test-grid-ff": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.grid.firefox.conf.ts", - "test-grid-ff-single": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.grid.firefox.conf.ts --spec" + "test-grid-ff-single": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.grid.firefox.conf.ts --spec", + "test-network": "CAPTURE_NETWORK=true DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.conf.ts", + "test-network-single": "CAPTURE_NETWORK=true DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.conf.ts --spec", + "test-network-dev": "CAPTURE_NETWORK=true DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.dev.conf.ts", + "test-network-dev-single": "CAPTURE_NETWORK=true DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.dev.conf.ts --spec", + "analyze-network": "npx tsx tests/helpers/networkAnalysis.ts" }, "resolutions": { "@types/react": "17.0.14", diff --git a/tests/NETWORK-CAPTURE-GRID-SETUP.md b/tests/NETWORK-CAPTURE-GRID-SETUP.md new file mode 100644 index 0000000000..c9e35d29b0 --- /dev/null +++ b/tests/NETWORK-CAPTURE-GRID-SETUP.md @@ -0,0 +1,208 @@ +# Network Capture Grid Setup Guide + +Quick reference for configuring network capture with Selenium/WebDriver grid. + +## TL;DR + +**Local testing (default):** No configuration needed ✓ + +**Grid testing:** Set `GRID_NODE_HOSTNAME` environment variable or `custom:nodeHostname` capability + +--- + +## Configuration Matrix + +| Scenario | Configuration | Command Example | +|----------|---------------|-----------------| +| **Local testing** | None (default) | `CAPTURE_NETWORK=true npm run test-dev-single -- tests/specs/2way/audioOnlyTest.spec.ts` | +| **Grid - Single node** | `GRID_NODE_HOSTNAME=node-1.grid.com` | `CAPTURE_NETWORK=true GRID_NODE_HOSTNAME=node-1.grid.com npm run test-grid` | +| **Grid - Multiple nodes** | Custom capability `'custom:nodeHostname'` | See below | + +--- + +## Setup Steps for Grid + +### Step 1: Configure Chrome on Grid Nodes + +Add these Chrome arguments on ALL grid nodes: + +```bash +--remote-debugging-address=0.0.0.0 # Expose debugging on network (not just localhost) +--remote-debugging-port=0 # Auto-assign available port +``` + +**Example Selenium Grid node config:** +```json +{ + "capabilities": [{ + "browserName": "chrome", + "goog:chromeOptions": { + "args": [ + "--remote-debugging-address=0.0.0.0", + "--remote-debugging-port=0" + ] + } + }] +} +``` + +### Step 2: Choose Configuration Method + +#### Method A: Environment Variable (Simple) + +Use when all tests run on the same node: + +```bash +export GRID_NODE_HOSTNAME=node-1.your-grid.com +CAPTURE_NETWORK=true npm run test-grid +``` + +#### Method B: Custom Capability (Advanced) + +Use when tests run on different nodes. Your grid hub/router must set the capability based on which node the browser is assigned to. + +**In your grid hub logic** (pseudocode): +```javascript +// When assigning browser to a node: +if (assignedToNode === 'node-1.your-grid.com') { + capabilities['custom:nodeHostname'] = 'node-1.your-grid.com'; +} else if (assignedToNode === 'node-2.your-grid.com') { + capabilities['custom:nodeHostname'] = 'node-2.your-grid.com'; +} +``` + +**Or in wdio.conf.ts** (if node is known at config time): +```typescript +capabilities: { + browserName: 'chrome', + 'custom:nodeHostname': process.env.NODE_HOSTNAME || 'node-1.your-grid.com' +} +``` + +--- + +## Verification + +When tests start, check console output: + +``` +✓ Local: NetworkCapture: Using local debugger address: localhost:65243 +✓ Grid (env): NetworkCapture: Using grid node hostname from env: node-1.grid.com:65243 +✓ Grid (cap): NetworkCapture: Using grid node hostname from capability: node-1.grid.com:65243 +``` + +If you see connection errors, the hostname/port is wrong or Chrome isn't exposing debugging on the network. + +--- + +## Security Considerations + +**⚠️ IMPORTANT:** Exposing Chrome's debugging port on `0.0.0.0` allows anyone on the network to connect and control the browser. + +**Recommended security measures:** + +1. **Firewall rules:** Only allow connections from test runner IPs + ```bash + # Example iptables rule on grid node: + iptables -A INPUT -p tcp --dport 9222:9322 -s 10.0.1.100 -j ACCEPT + iptables -A INPUT -p tcp --dport 9222:9322 -j DROP + ``` + +2. **VPN/Private network:** Run grid on isolated network + +3. **SSH tunneling:** Tunnel debugging ports through SSH + ```bash + # On test runner machine: + ssh -L 9222:localhost:9222 node-1.your-grid.com + # Then use GRID_NODE_HOSTNAME=localhost in tests + ``` + +4. **Temporary exposure:** Only enable debugging when running network capture tests + +--- + +## Troubleshooting + +### Error: "Chrome debugger address not found in capabilities" + +**Cause:** Chrome didn't start with `--remote-debugging-port` + +**Fix:** Ensure Chrome args include `--remote-debugging-port=0` (should already be in wdio.conf.ts) + +### Error: "Failed to fetch debugger info: 404" or "ECONNREFUSED" + +**Cause:** NetworkCapture can't reach Chrome's debugging port + +**Possible fixes:** +1. Check `GRID_NODE_HOSTNAME` matches actual node hostname +2. Verify Chrome is running with `--remote-debugging-address=0.0.0.0` +3. Check firewall allows connections to debugging port +4. Test manually: `curl http://node-1.your-grid.com:9222/json/version` + +### Error: "webSocketDebuggerUrl not found" + +**Cause:** Chrome's debugging endpoint isn't responding correctly + +**Fix:** +1. Verify Chrome version supports remote debugging +2. Check Chrome didn't crash during startup +3. Try restarting the grid node + +### Tests work locally but fail on grid + +**Cause:** Forgot to set `GRID_NODE_HOSTNAME` or `custom:nodeHostname` + +**Fix:** Add grid configuration (see Step 2 above) + +--- + +## Priority Order (for reference) + +When resolving the debugger address, NetworkCapture checks in this order: + +1. **Custom capability** `'custom:nodeHostname'` ← Highest priority +2. **Environment variable** `GRID_NODE_HOSTNAME` +3. **Default** Use debuggerAddress as-is (localhost) ← Local testing + +This allows you to: +- Use env var for simple setups +- Override per-browser with capability for complex setups +- No config needed for local development + +--- + +## Example: Real Grid Setup + +**Infrastructure:** +- Grid hub: `grid-hub.company.com` +- Node 1: `chrome-node-1.company.com` +- Node 2: `chrome-node-2.company.com` +- Test runner: `test-runner.company.com` + +**Node configuration (both nodes):** +```bash +# Chrome startup args: +--remote-debugging-address=0.0.0.0 +--remote-debugging-port=0 + +# Firewall (allow test runner only): +iptables -A INPUT -p tcp --dport 9222:9322 -s test-runner.company.com -j ACCEPT +iptables -A INPUT -p tcp --dport 9222:9322 -j DROP +``` + +**Test execution:** +```bash +# On test-runner.company.com +# Tests will be distributed across both nodes +export GRID_NODE_HOSTNAME=chrome-node-1.company.com # If all tests go to node 1 +# OR configure custom:nodeHostname capability in grid hub +CAPTURE_NETWORK=true npm run test-grid +``` + +--- + +## Questions? + +- See full documentation: `tests/NETWORK-CAPTURE.md` +- Check implementation: `tests/helpers/NetworkCapture.ts` +- Grid configuration: Your grid provider's documentation diff --git a/tests/NETWORK-CAPTURE.md b/tests/NETWORK-CAPTURE.md new file mode 100644 index 0000000000..6c16618185 --- /dev/null +++ b/tests/NETWORK-CAPTURE.md @@ -0,0 +1,461 @@ +# Network Request Capture for WDIO Tests + +This feature enables capturing and analyzing all network requests made during WebDriverIO test execution. It uses Chrome DevTools Protocol (CDP) to intercept and log network activity. + +## Features + +- **URL Capture**: Records all URLs requested during tests +- **Basic Statistics**: Success/failure counts, status codes, response timing +- **Domain Analysis**: Groups requests by domain +- **Resource Type Tracking**: Categorizes by resource type (Document, Script, XHR, etc.) +- **Cache Detection**: Identifies cached vs. fresh requests +- **Failure Tracking**: Records failed requests with error messages +- **Multiple Export Formats**: JSON, CSV, plain text + +## Requirements + +- **Chrome Browser**: Network capture uses Chrome DevTools Protocol (CDP) +- **puppeteer-core**: Direct CDP access library (already installed) +- **wdio-chromedriver-service**: Chrome browser driver (already configured) +- **Chrome remote debugging**: Enabled via `--remote-debugging-port=0` (already configured) + +**Note**: This implementation uses puppeteer-core to connect directly to Chrome's debugging port, which allows it to work with WebDriverIO's multiremote mode (p1, p2, p3, p4 browser instances). + +## Quick Start + +### Running Tests with Network Capture + +Enable network capture by setting the `CAPTURE_NETWORK=true` environment variable: + +```bash +# Run all tests with network capture +npm run test-network + +# Run single test with network capture +npm run test-network-single -- tests/specs/2way/audioOnlyTest.spec.ts + +# Run development environment test with network capture +npm run test-network-dev-single -- tests/specs/2way/audioOnlyTest.spec.ts +``` + +### Output Files + +Network capture data is saved to `test-results/` directory: + +``` +test-results/ +├── network-p1-0-0-audioOnlyTest.json # Participant 1 capture +├── network-p2-0-0-audioOnlyTest.json # Participant 2 capture +└── ... +``` + +Each file contains: +- `captureDate`: Timestamp of capture +- `stats`: Summary statistics (total, succeeded, failed, by domain, etc.) +- `requests`: Array of all captured requests with details + +### Analyzing Captured Data + +Use the analysis utility to generate reports: + +```bash +# Print summary to console +npm run analyze-network -- test-results/network-p1-0-0-audioOnlyTest.json + +# Export unique URLs to text file +npm run analyze-network -- test-results/network-p1-0-0-audioOnlyTest.json --urls urls.txt + +# Export all requests to CSV +npm run analyze-network -- test-results/network-p1-0-0-audioOnlyTest.json --csv requests.csv + +# Export domain summary to JSON +npm run analyze-network -- test-results/network-p1-0-0-audioOnlyTest.json --domains domains.json + +# Combine multiple exports +npm run analyze-network -- test-results/network-p1-0-0-audioOnlyTest.json \ + --urls urls.txt \ + --csv requests.csv \ + --domains domains.json +``` + +## Analysis Output + +### Console Summary + +The analysis tool prints a comprehensive summary: + +``` +=== Network Capture Analysis === +Capture Date: 2025-11-06T10:30:00.000Z + +--- Summary --- +Total Requests: 127 +Succeeded: 124 (97.6%) +Failed: 3 (2.4%) +Cached: 15 (11.8%) + +--- Requests by Domain (Top 10) --- + localhost:8080: 45 + meet-jit-si-turnrelay.jitsi.net: 12 + alpha.jitsi.net: 8 + ... + +--- Requests by Status Code --- + 200: 110 + 304: 14 + 404: 2 + Failed: 1 + +--- Requests by Resource Type --- + Document: 2 + Script: 35 + XHR: 40 + WebSocket: 5 + ... + +--- Failed Requests (3) --- + [GET] https://example.com/missing.js + Reason: net::ERR_CONNECTION_REFUSED + ... +``` + +### Export Formats + +**URLs Text File** (`--urls`) +``` +https://localhost:8080/config.js +https://localhost:8080/lib-jitsi-meet.js +https://alpha.jitsi.net/http-bind +... +``` + +**CSV File** (`--csv`) +```csv +"URL","Method","Status","Success","ResourceType","FromCache","FailureText" +"https://localhost:8080/config.js","GET","200","true","Script","false","" +... +``` + +**Domain Summary JSON** (`--domains`) +```json +{ + "uniqueDomains": [ + "localhost:8080", + "alpha.jitsi.net", + "meet-jit-si-turnrelay.jitsi.net" + ], + "requestsByDomain": { + "localhost:8080": 45, + "alpha.jitsi.net": 8, + ... + } +} +``` + +## Use Cases + +### 1. Verify URL Allowlist + +Compare captured URLs against expected patterns: + +```bash +npm run test-network-single -- tests/specs/2way/audioOnlyTest.spec.ts +npm run analyze-network -- test-results/network-p1-*.json --urls captured-urls.txt + +# Compare with your allowlist +diff captured-urls.txt expected-urls.txt +``` + +### 2. Debug Network Failures + +Identify failing requests during tests: + +```bash +npm run test-network-dev-single -- tests/specs/failing-test.spec.ts +npm run analyze-network -- test-results/network-*.json +# Check "Failed Requests" section +``` + +### 3. Performance Analysis + +Analyze request patterns and identify bottlenecks: + +```bash +npm run analyze-network -- test-results/network-*.json --csv all-requests.csv +# Import CSV into spreadsheet for timing analysis +``` + +### 4. Validate CSP/CORS + +Ensure all requests succeed and no CORS errors occur: + +```bash +npm run test-network-single -- tests/specs/your-test.spec.ts +npm run analyze-network -- test-results/network-*.json +# Check for CORS-related failures +``` + +### 5. Compare URL Patterns Across Tests + +Capture multiple tests and compare: + +```bash +npm run test-network-single -- tests/specs/2way/audioOnlyTest.spec.ts +npm run test-network-single -- tests/specs/3way/test.spec.ts + +npm run analyze-network -- test-results/network-p1-*-audioOnlyTest.json --urls audio-urls.txt +npm run analyze-network -- test-results/network-p1-*-test.json --urls 3way-urls.txt + +diff audio-urls.txt 3way-urls.txt +``` + +## Programmatic Usage + +### Using NetworkCapture in Custom Scripts + +```typescript +import { NetworkCapture } from './tests/helpers/NetworkCapture'; + +// In your test or script +const capture = new NetworkCapture(browser); +await capture.start(); + +// ... perform actions ... + +await capture.stop(); + +// Get statistics +const stats = capture.getStats(); +console.log(`Total requests: ${stats.total}`); + +// Get failed URLs +const failed = capture.getFailedUrls(); +console.log('Failed:', failed); + +// Export to file +capture.exportToJSON('my-capture.json'); +``` + +### Using NetworkAnalyzer + +```typescript +import { NetworkAnalyzer } from './tests/helpers/networkAnalysis'; + +const analyzer = new NetworkAnalyzer('test-results/network-p1-0-0-test.json'); + +// Get unique domains +const domains = analyzer.getUniqueDomains(); + +// Get failed requests +const failed = analyzer.getFailedRequests(); + +// Export reports +analyzer.printSummary(); +analyzer.exportUrlsToFile('urls.txt'); +analyzer.exportToCSV('requests.csv'); +analyzer.exportDomainSummary('domains.json'); +``` + +## Configuration + +### Environment Variables + +- **`CAPTURE_NETWORK=true`**: Enable network capture (required) +- **`GRID_NODE_HOSTNAME=`**: Grid node hostname for remote testing (optional, see Grid Support below) +- Works with all WDIO configurations: `wdio.conf.ts`, `wdio.dev.conf.ts`, etc. + +### Grid Support + +The network capture feature supports both **local testing** (default) and **remote grid nodes**. + +#### Local Testing (Default) + +No configuration needed. Works out of the box: + +```bash +CAPTURE_NETWORK=true npm run test-dev-single -- tests/specs/2way/audioOnlyTest.spec.ts +``` + +#### Grid Testing Configuration + +When running tests on a Selenium/WebDriver grid with remote nodes, you need to tell NetworkCapture how to reach the remote Chrome instances. + +**Option 1: Environment Variable (Simple - Single Node)** + +Use when all tests run on the same grid node: + +```bash +CAPTURE_NETWORK=true GRID_NODE_HOSTNAME=node-1.your-grid.com npm run test-grid-single -- tests/specs/2way/audioOnlyTest.spec.ts +``` + +**Option 2: Custom Capability (Advanced - Multiple Nodes)** + +Use when tests run on different nodes and you need per-browser hostname: + +```javascript +// In your grid configuration or wdio.conf.ts: +capabilities: { + browserName: 'chrome', + 'goog:chromeOptions': { + args: ['--remote-debugging-port=0', '--remote-debugging-address=0.0.0.0'] + }, + 'custom:nodeHostname': 'node-1.your-grid.com' // Add this +} +``` + +**Priority Order:** +1. Custom capability `'custom:nodeHostname'` (highest priority) +2. Environment variable `GRID_NODE_HOSTNAME` +3. Default: `localhost` (local testing) + +#### Grid Node Requirements + +For grid support, Chrome on nodes must be configured to expose debugging on the network: + +```bash +# In your grid node Chrome configuration: +--remote-debugging-address=0.0.0.0 # Allow connections from network (not just localhost) +--remote-debugging-port=0 # Auto-assign port (already configured) +``` + +**Security Note:** Only expose debugging ports on trusted networks. Consider firewall rules or SSH tunneling for production grids. + +#### Example Grid Setup + +**Scenario:** Tests run on grid with 2 nodes + +**Node Configuration:** +```yaml +# node-1.your-grid.com +chrome_args: + - --remote-debugging-address=0.0.0.0 + - --remote-debugging-port=0 + +# node-2.your-grid.com +chrome_args: + - --remote-debugging-address=0.0.0.0 + - --remote-debugging-port=0 +``` + +**Test Execution:** +```bash +# If all tests run on node-1: +CAPTURE_NETWORK=true GRID_NODE_HOSTNAME=node-1.your-grid.com npm run test-grid + +# If tests distribute across nodes, use custom capability in wdio.conf.ts +# (grid must set 'custom:nodeHostname' based on which node browser is on) +CAPTURE_NETWORK=true npm run test-grid +``` + +#### Verifying Grid Configuration + +Check the console output when tests start: + +``` +✓ Local testing: + NetworkCapture: Using local debugger address: localhost:65243 + +✓ Grid with env variable: + NetworkCapture: Using grid node hostname from env: node-1.your-grid.com:65243 + +✓ Grid with capability: + NetworkCapture: Using grid node hostname from capability: node-1.your-grid.com:65243 +``` + +### Limitations + +- **Chrome Only**: Network capture uses CDP, which is Chrome-specific. Firefox tests are automatically skipped. +- **Performance Overhead**: Minimal impact, but may add 1-2% to test execution time. +- **Storage**: Each test generates 100KB-5MB JSON files depending on number of requests. + +## Troubleshooting + +### Network capture not starting + +**Symptom**: No `network-*.json` files created + +**Solutions**: +1. Verify `CAPTURE_NETWORK=true` is set +2. Check browser is Chrome (not Firefox) +3. Look for errors in test output: "Failed to start network capture" +4. Ensure WebDriverIO config properly loads `NetworkCapture` + +### CDP connection errors + +**Symptom**: "Error: Protocol error: Connection closed" + +**Solutions**: +1. Ensure Chrome/chromedriver versions are compatible +2. Check if Chrome crashed during test (screenshot in test-results/) +3. Try running with headful mode (remove `HEADLESS=true`) + +### Large file sizes + +**Symptom**: JSON files are unexpectedly large (>10MB) + +**Solutions**: +1. Long-running tests capture more requests +2. Consider filtering out noise (e.g., keep-alive pings, polling) +3. Break test into smaller test cases + +### Missing requests + +**Symptom**: Expected URLs not in capture + +**Possible causes**: +1. Requests completed before capture started (start earlier in test) +2. Requests made from service worker (not intercepted by CDP) +3. WebRTC data channels (not HTTP requests) +4. Browser cache prevented request (check `fromCache` field) + +## Architecture + +### NetworkCapture Class + +Located in `tests/helpers/NetworkCapture.ts` + +- Uses puppeteer-core to connect directly to Chrome's debugging port +- Works with WebDriverIO multiremote mode (multiple browser instances) +- Enables CDP Network domain via CDPSession +- Listens to CDP events: + - `Network.requestWillBeSent` - captures outgoing requests + - `Network.responseReceived` - captures responses + - `Network.loadingFailed` - captures failures +- Stores data in memory, exports on demand + +**Why Puppeteer instead of @wdio/devtools-service?** +The @wdio/devtools-service does not support multiremote mode (GitHub issue #5505 since 2020). Using puppeteer-core allows us to establish individual CDP connections for each browser instance (p1, p2, p3, p4). + +### WDIO Integration + +Located in `tests/wdio.conf.ts` + +- **before hook**: Initializes NetworkCapture when `CAPTURE_NETWORK=true` +- **after hook**: Stops capture and exports JSON files +- Creates separate capture instance for each browser (p1, p2, p3, p4) + +### NetworkAnalyzer Class + +Located in `tests/helpers/networkAnalysis.ts` + +- Parses JSON capture files +- Generates statistics and reports +- Exports to multiple formats +- CLI entry point for analysis commands + +## Future Enhancements + +Potential additions: + +- **HAR Export**: Full HTTP Archive format support +- **Request/Response Bodies**: Optional capture of payload data +- **Real-time Filtering**: Exclude domains/patterns during capture +- **Automatic Comparison**: Built-in diff against `COMPREHENSIVE-URL-LIST.md` +- **HTML Reports**: Visual charts and graphs +- **WebDriver Bidi**: Cross-browser support when standardized + +## Related Documentation + +- [COMPREHENSIVE-URL-LIST.md](../COMPREHENSIVE-URL-LIST.md) - Complete list of possible URLs +- [WebDriverIO CDP Docs](https://webdriver.io/docs/api/chromium/#cdp) - CDP API reference +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) - CDP specification diff --git a/tests/helpers/NetworkCapture.ts b/tests/helpers/NetworkCapture.ts new file mode 100644 index 0000000000..34624c87c6 --- /dev/null +++ b/tests/helpers/NetworkCapture.ts @@ -0,0 +1,401 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import puppeteer, { type CDPSession } from 'puppeteer-core'; + +/** + * Represents a captured network request with its metadata. + */ +interface INetworkRequest { + /** Failure reason if request failed. */ + failureText?: string; + /** Whether response came from cache. */ + fromCache?: boolean; + /** HTTP method (GET, POST, etc.). */ + method: string; + /** Unique request identifier from CDP. */ + requestId: string; + /** Type of resource (Document, Stylesheet, XHR, etc.). */ + resourceType?: string; + /** HTTP status code (200, 404, etc.). */ + status?: number; + /** Whether the request completed successfully. */ + success?: boolean; + /** Timestamp when request was initiated (Unix time in ms). */ + timestamp: number; + /** Full URL of the request. */ + url: string; +} + +/** + * Statistics about captured network requests. + */ +interface INetworkStats { + /** Requests grouped by domain. */ + byDomain: Record; + /** Requests grouped by resource type. */ + byResourceType: Record; + /** Requests grouped by HTTP status code. */ + byStatus: Record; + /** Number of requests served from cache. */ + cachedRequests: number; + /** Number of failed requests. */ + failed: number; + /** Number of successful requests. */ + succeeded: number; + /** Total number of requests. */ + total: number; +} + +/** + * Helper class to capture network requests during WDIO tests using Chrome DevTools Protocol via Puppeteer. + * + * This implementation uses puppeteer-core to connect directly to Chrome's debugging port, + * which allows it to work with WebDriverIO's multiremote mode (unlike @wdio/devtools-service). + * + * Usage: + * ```typescript + * const capture = new NetworkCapture(driver); + * await capture.start(); + * // ... perform test actions ... + * await capture.stop(); + * const stats = capture.getStats(); + * capture.exportToJSON('network-capture.json'); + * ``` + */ +export class NetworkCapture { + private cdpClient: CDPSession | null = null; + private driver: WebdriverIO.Browser; + private isCapturing: boolean; + private requests: Map; + + /** + * Creates a new NetworkCapture instance. + * + * @param {WebdriverIO.Browser} driver - WebDriverIO browser instance. + */ + constructor(driver: WebdriverIO.Browser) { + this.driver = driver; + this.requests = new Map(); + this.isCapturing = false; + } + + /** + * Resolves the actual debugger address to connect to. + * Supports both local testing (default) and remote grid nodes. + * + * Configuration options (in priority order): + * 1. Custom capability 'custom:nodeHostname' - explicit node hostname + * 2. Environment variable GRID_NODE_HOSTNAME - static hostname for all nodes + * 3. Default: use debuggerAddress as-is (localhost) - for local testing + * + * @param {string} debuggerAddress - Address from Chrome capabilities (e.g., "localhost:65243"). + * @returns {string} Resolved address to connect to. + * + * @example + * // Local testing (default): + * resolveDebuggerAddress('localhost:65243') → 'localhost:65243' + * + * // Grid with custom capability: + * driver.capabilities['custom:nodeHostname'] = 'node-1.grid.com' + * resolveDebuggerAddress('localhost:65243') → 'node-1.grid.com:65243' + * + * // Grid with environment variable: + * GRID_NODE_HOSTNAME=node-1.grid.com + * resolveDebuggerAddress('localhost:65243') → 'node-1.grid.com:65243' + */ + private resolveDebuggerAddress(debuggerAddress: string): string { + // Option 1: Check for custom capability (highest priority) + const customNodeHost = (this.driver.capabilities as any)['custom:nodeHostname']; + + if (customNodeHost) { + const [ , port ] = debuggerAddress.split(':'); + + console.log(`NetworkCapture: Using grid node hostname from capability: ${customNodeHost}:${port}`); + + return `${customNodeHost}:${port}`; + } + + // Option 2: Check for environment variable + const envNodeHost = process.env.GRID_NODE_HOSTNAME; + + if (envNodeHost) { + const [ , port ] = debuggerAddress.split(':'); + + console.log(`NetworkCapture: Using grid node hostname from env: ${envNodeHost}:${port}`); + + return `${envNodeHost}:${port}`; + } + + // Option 3: Default - use debuggerAddress as-is (local testing) + console.log(`NetworkCapture: Using local debugger address: ${debuggerAddress}`); + + return debuggerAddress; + } + + /** + * Starts capturing network requests via CDP using Puppeteer. + * Connects to Chrome's debugging port and enables the Network domain. + * + * @returns {Promise} + */ + async start(): Promise { + if (this.isCapturing) { + console.warn('NetworkCapture: Already capturing, ignoring start()'); + + return; + } + + try { + // Get the debugging address from Chrome capabilities + const debuggerAddress = this.driver.capabilities['goog:chromeOptions']?.debuggerAddress; + + if (!debuggerAddress) { + throw new Error('Chrome debugger address not found in capabilities'); + } + + // Resolve the actual address to connect to (supports both local and grid setups) + const actualAddress = this.resolveDebuggerAddress(debuggerAddress); + + // Fetch the WebSocket debugger URL from Chrome's /json/version endpoint + const response = await fetch(`http://${actualAddress}/json/version`); + + if (!response.ok) { + throw new Error(`Failed to fetch debugger info: ${response.status} ${response.statusText}`); + } + + const versionInfo = await response.json() as { webSocketDebuggerUrl: string; }; + const wsUrl = versionInfo.webSocketDebuggerUrl; + + if (!wsUrl) { + throw new Error('webSocketDebuggerUrl not found in debugger info'); + } + + // Connect Puppeteer to the existing Chrome instance using the correct WebSocket URL + const browser = await puppeteer.connect({ + browserWSEndpoint: wsUrl, + defaultViewport: null + }); + + // Get the first page/target + const targets = await browser.targets(); + const target = targets.find(t => t.type() === 'page') || targets[0]; + + if (!target) { + throw new Error('No page target found'); + } + + // Create CDP session + this.cdpClient = await target.createCDPSession(); + + // Enable Network domain + await this.cdpClient.send('Network.enable'); + + // Listen for network events + this.cdpClient.on('Network.requestWillBeSent', this.handleRequestWillBeSent.bind(this)); + this.cdpClient.on('Network.responseReceived', this.handleResponseReceived.bind(this)); + this.cdpClient.on('Network.loadingFailed', this.handleLoadingFailed.bind(this)); + + this.isCapturing = true; + console.log('NetworkCapture: Started capturing network requests via Puppeteer CDP'); + } catch (error) { + console.error('NetworkCapture: Failed to start capturing:', error); + throw error; + } + } + + /** + * Stops capturing network requests and closes CDP connection. + * + * @returns {Promise} + */ + async stop(): Promise { + if (!this.isCapturing || !this.cdpClient) { + return; + } + + try { + // Disable Network domain + await this.cdpClient.send('Network.disable'); + + // Detach CDP session + await this.cdpClient.detach(); + + this.cdpClient = null; + this.isCapturing = false; + console.log(`NetworkCapture: Stopped capturing. Total requests: ${this.requests.size}`); + } catch (error) { + console.error('NetworkCapture: Failed to stop capturing:', error); + throw error; + } + } + + /** + * Handles Network.requestWillBeSent CDP events. + * Records initial request data. + * + * @param {any} params - CDP event parameters. + */ + private handleRequestWillBeSent(params: any): void { + const { requestId, request, timestamp, type } = params; + + this.requests.set(requestId, { + failureText: undefined, + fromCache: false, + method: request.method, + requestId, + resourceType: type, + status: undefined, + success: undefined, + timestamp: timestamp * 1000, // CDP uses seconds, convert to ms + url: request.url + }); + } + + /** + * Handles Network.responseReceived CDP events. + * Updates request with response status and cache info. + * + * @param {any} params - CDP event parameters. + */ + private handleResponseReceived(params: any): void { + const { requestId, response } = params; + const request = this.requests.get(requestId); + + if (request) { + request.status = response.status; + request.success = response.status >= 200 && response.status < 400; + request.fromCache = response.fromDiskCache || response.fromServiceWorker || false; + } + } + + /** + * Handles Network.loadingFailed CDP events. + * Marks request as failed and records reason. + * + * @param {any} params - CDP event parameters. + */ + private handleLoadingFailed(params: any): void { + const { requestId, errorText } = params; + const request = this.requests.get(requestId); + + if (request) { + request.success = false; + request.failureText = errorText; + } + } + + /** + * Returns all captured network requests. + * + * @returns {INetworkRequest[]} Array of captured requests. + */ + getRequests(): INetworkRequest[] { + return Array.from(this.requests.values()); + } + + /** + * Returns URLs of all captured requests. + * + * @returns {string[]} Array of URLs. + */ + getUrls(): string[] { + return this.getRequests().map(req => req.url); + } + + /** + * Returns URLs that failed to load. + * + * @returns {string[]} Array of failed URLs with failure reasons. + */ + getFailedUrls(): Array<{ reason?: string; url: string; }> { + return this.getRequests() + .filter(req => req.success === false) + .map(req => ({ reason: req.failureText, url: req.url })); + } + + /** + * Calculates statistics about captured network requests. + * + * @returns {INetworkStats} Statistics object. + */ + getStats(): INetworkStats { + const requests = this.getRequests(); + const stats: INetworkStats = { + byDomain: {}, + byResourceType: {}, + byStatus: {}, + cachedRequests: 0, + failed: 0, + succeeded: 0, + total: requests.length + }; + + for (const request of requests) { + // Count success/failure + if (request.success === true) { + stats.succeeded++; + } else if (request.success === false) { + stats.failed++; + } + + // Group by status code + if (request.status) { + stats.byStatus[request.status] = (stats.byStatus[request.status] || 0) + 1; + } + + // Group by domain + try { + const url = new URL(request.url); + const domain = url.hostname; + + stats.byDomain[domain] = (stats.byDomain[domain] || 0) + 1; + } catch (e) { + // Invalid URL, skip domain grouping + } + + // Group by resource type + if (request.resourceType) { + stats.byResourceType[request.resourceType] + = (stats.byResourceType[request.resourceType] || 0) + 1; + } + + // Count cached requests + if (request.fromCache) { + stats.cachedRequests++; + } + } + + return stats; + } + + /** + * Exports captured requests to a JSON file. + * + * @param {string} filePath - Path where to save the JSON file. + */ + exportToJSON(filePath: string): void { + const data = { + captureDate: new Date().toISOString(), + requests: this.getRequests(), + stats: this.getStats() + }; + + // Ensure directory exists + const dir = path.dirname(filePath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + console.log(`NetworkCapture: Exported ${data.requests.length} requests to ${filePath}`); + } + + /** + * Clears all captured requests. + */ + clear(): void { + this.requests.clear(); + console.log('NetworkCapture: Cleared all captured requests'); + } +} diff --git a/tests/helpers/networkAnalysis.ts b/tests/helpers/networkAnalysis.ts new file mode 100644 index 0000000000..ef0b4308fb --- /dev/null +++ b/tests/helpers/networkAnalysis.ts @@ -0,0 +1,348 @@ +#!/usr/bin/env node + +import * as fs from 'fs'; + +/** + * Represents a captured network request from the NetworkCapture output. + */ +interface INetworkRequest { + failureText?: string; + fromCache?: boolean; + method: string; + requestId: string; + resourceType?: string; + status?: number; + success?: boolean; + timestamp: number; + url: string; +} + +/** + * Statistics about captured network requests. + */ +interface INetworkStats { + byDomain: Record; + byResourceType: Record; + byStatus: Record; + cachedRequests: number; + failed: number; + succeeded: number; + total: number; +} + +/** + * Network capture data structure. + */ +interface ICaptureData { + captureDate: string; + requests: INetworkRequest[]; + stats: INetworkStats; +} + +/** + * Analyzes network capture data and generates reports. + */ +export class NetworkAnalyzer { + private data: ICaptureData; + + /** + * Creates a new NetworkAnalyzer from a JSON file. + * + * @param {string} jsonFilePath - Path to the network capture JSON file. + */ + constructor(jsonFilePath: string) { + if (!fs.existsSync(jsonFilePath)) { + throw new Error(`File not found: ${jsonFilePath}`); + } + + const content = fs.readFileSync(jsonFilePath, 'utf-8'); + + this.data = JSON.parse(content); + } + + /** + * Returns all unique URLs from the capture. + * + * @returns {string[]} Array of unique URLs. + */ + getUniqueUrls(): string[] { + const urls = new Set(this.data.requests.map(req => req.url)); + + return Array.from(urls).sort(); + } + + /** + * Returns all unique domains from the capture. + * + * @returns {string[]} Array of unique domains. + */ + getUniqueDomains(): string[] { + const domains = new Set(); + + for (const request of this.data.requests) { + try { + const url = new URL(request.url); + + domains.add(url.hostname); + } catch (e) { + // Skip invalid URLs + } + } + + return Array.from(domains).sort(); + } + + /** + * Returns requests grouped by domain. + * + * @returns {Record} Requests grouped by domain. + */ + getRequestsByDomain(): Record { + const byDomain: Record = {}; + + for (const request of this.data.requests) { + try { + const url = new URL(request.url); + const domain = url.hostname; + + if (!byDomain[domain]) { + byDomain[domain] = []; + } + byDomain[domain].push(request); + } catch (e) { + // Skip invalid URLs + } + } + + return byDomain; + } + + /** + * Returns failed requests with details. + * + * @returns {INetworkRequest[]} Array of failed requests. + */ + getFailedRequests(): INetworkRequest[] { + return this.data.requests.filter(req => req.success === false); + } + + /** + * Returns requests by HTTP status code. + * + * @returns {Record} Requests grouped by status code. + */ + getRequestsByStatus(): Record { + const byStatus: Record = {}; + + for (const request of this.data.requests) { + if (request.status) { + if (!byStatus[request.status]) { + byStatus[request.status] = []; + } + byStatus[request.status].push(request); + } + } + + return byStatus; + } + + /** + * Returns requests by resource type. + * + * @returns {Record} Requests grouped by resource type. + */ + getRequestsByResourceType(): Record { + const byType: Record = {}; + + for (const request of this.data.requests) { + if (request.resourceType) { + if (!byType[request.resourceType]) { + byType[request.resourceType] = []; + } + byType[request.resourceType].push(request); + } + } + + return byType; + } + + /** + * Prints a summary report to console. + */ + printSummary(): void { + console.log('\n=== Network Capture Analysis ==='); + console.log(`Capture Date: ${this.data.captureDate}`); + console.log('\n--- Summary ---'); + console.log(`Total Requests: ${this.data.stats.total}`); + console.log(`Succeeded: ${this.data.stats.succeeded} (${this.getPercentage(this.data.stats.succeeded, this.data.stats.total)}%)`); + console.log(`Failed: ${this.data.stats.failed} (${this.getPercentage(this.data.stats.failed, this.data.stats.total)}%)`); + console.log(`Cached: ${this.data.stats.cachedRequests} (${this.getPercentage(this.data.stats.cachedRequests, this.data.stats.total)}%)`); + + console.log('\n--- Requests by Domain (Top 10) ---'); + const domainEntries = Object.entries(this.data.stats.byDomain) + .sort(([ , a ], [ , b ]) => b - a) + .slice(0, 10); + + for (const [ domain, count ] of domainEntries) { + console.log(` ${domain}: ${count}`); + } + + console.log('\n--- Requests by Status Code ---'); + const statusEntries = Object.entries(this.data.stats.byStatus) + .sort(([ a ], [ b ]) => parseInt(a) - parseInt(b)); + + for (const [ status, count ] of statusEntries) { + console.log(` ${status}: ${count}`); + } + + console.log('\n--- Requests by Resource Type ---'); + const typeEntries = Object.entries(this.data.stats.byResourceType) + .sort(([ , a ], [ , b ]) => b - a); + + for (const [ type, count ] of typeEntries) { + console.log(` ${type}: ${count}`); + } + + const failedRequests = this.getFailedRequests(); + + if (failedRequests.length > 0) { + console.log(`\n--- Failed Requests (${failedRequests.length}) ---`); + for (const request of failedRequests) { + console.log(` [${request.method}] ${request.url}`); + if (request.failureText) { + console.log(` Reason: ${request.failureText}`); + } + } + } + + console.log('\n'); + } + + /** + * Exports unique URLs to a text file (one per line). + * + * @param {string} outputPath - Path where to save the file. + */ + exportUrlsToFile(outputPath: string): void { + const urls = this.getUniqueUrls(); + + fs.writeFileSync(outputPath, urls.join('\n')); + console.log(`Exported ${urls.length} unique URLs to ${outputPath}`); + } + + /** + * Exports analysis to CSV format. + * + * @param {string} outputPath - Path where to save the CSV file. + */ + exportToCSV(outputPath: string): void { + const headers = [ 'URL', 'Method', 'Status', 'Success', 'ResourceType', 'FromCache', 'FailureText' ]; + const rows = [ headers ]; + + for (const request of this.data.requests) { + rows.push([ + request.url, + request.method, + request.status?.toString() || '', + request.success?.toString() || '', + request.resourceType || '', + request.fromCache?.toString() || '', + request.failureText || '' + ]); + } + + const csvContent = rows.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n'); + + fs.writeFileSync(outputPath, csvContent); + console.log(`Exported ${this.data.requests.length} requests to ${outputPath}`); + } + + /** + * Exports domain summary to JSON. + * + * @param {string} outputPath - Path where to save the JSON file. + */ + exportDomainSummary(outputPath: string): void { + const summary = { + uniqueDomains: this.getUniqueDomains(), + requestsByDomain: Object.fromEntries( + Object.entries(this.data.stats.byDomain) + .sort(([ , a ], [ , b ]) => b - a) + ) + }; + + fs.writeFileSync(outputPath, JSON.stringify(summary, null, 2)); + console.log(`Exported domain summary to ${outputPath}`); + } + + /** + * Helper method to calculate percentage. + * + * @param {number} value - The value. + * @param {number} total - The total. + * @returns {string} Percentage formatted to 1 decimal place. + */ + private getPercentage(value: number, total: number): string { + if (total === 0) { + return '0.0'; + } + + return ((value / total) * 100).toFixed(1); + } +} + +/** + * CLI entry point for network analysis. + */ +function main(): void { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('Usage: networkAnalysis.ts [options]'); + console.error('\nOptions:'); + console.error(' --urls Export unique URLs to text file'); + console.error(' --csv Export requests to CSV file'); + console.error(' --domains Export domain summary to JSON file'); + console.error('\nExample:'); + console.error(' npx tsx tests/helpers/networkAnalysis.ts test-results/network-p1-0-0-audioOnlyTest.json'); + console.error(' npx tsx tests/helpers/networkAnalysis.ts test-results/network-p1-0-0-audioOnlyTest.json --urls urls.txt --csv requests.csv'); + process.exit(1); + } + + const jsonFilePath = args[0]; + const analyzer = new NetworkAnalyzer(jsonFilePath); + + // Print summary to console + analyzer.printSummary(); + + // Handle optional exports + for (let i = 1; i < args.length; i += 2) { + const option = args[i]; + const outputPath = args[i + 1]; + + if (!outputPath) { + console.error(`Missing output path for option ${option}`); + process.exit(1); + } + + switch (option) { + case '--urls': + analyzer.exportUrlsToFile(outputPath); + break; + case '--csv': + analyzer.exportToCSV(outputPath); + break; + case '--domains': + analyzer.exportDomainSummary(outputPath); + break; + default: + console.error(`Unknown option: ${option}`); + process.exit(1); + } + } +} + +// Run if executed directly +if (require.main === module) { + main(); +} diff --git a/tests/wdio.conf.ts b/tests/wdio.conf.ts index de2aa204be..1a4ca40fca 100644 --- a/tests/wdio.conf.ts +++ b/tests/wdio.conf.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import process from 'node:process'; import pretty from 'pretty'; +import { NetworkCapture } from './helpers/NetworkCapture'; import { getTestProperties, loadTestFiles } from './helpers/TestProperties'; import { config as testsConfig } from './helpers/TestsConfig'; import WebhookProxy from './helpers/WebhookProxy'; @@ -36,7 +37,11 @@ const chromeArgs = [ // Avoids - "You are checking for animations on an inactive tab, animations do not run for inactive tabs" // when executing waitForStable() '--disable-renderer-backgrounding', - '--use-file-for-fake-audio-capture=tests/resources/fakeAudioStream.wav' + '--use-file-for-fake-audio-capture=tests/resources/fakeAudioStream.wav', + + // Enable remote debugging for CDP access via Puppeteer (required for NetworkCapture) + // Port 0 means Chrome will choose an available port automatically + '--remote-debugging-port=0' ]; if (process.env.RESOLVER_RULES) { @@ -127,6 +132,9 @@ const TEST_RESULTS_DIR = 'test-results'; const keepAlive: Array = []; +/** Network capture instances for each browser (when CAPTURE_NETWORK is enabled). */ +const networkCaptures: Map = new Map(); + export const config: WebdriverIO.MultiremoteConfig = { runner: 'local', @@ -240,6 +248,19 @@ export const config: WebdriverIO.MultiremoteConfig = { await bInstance.execute(() => console.log(`${new Date().toISOString()} keep-alive`)); }, 20_000)); + // Setup network capture if enabled + if (process.env.CAPTURE_NETWORK === 'true' && !bInstance.isFirefox) { + try { + const capture = new NetworkCapture(bInstance); + + await capture.start(); + networkCaptures.set(`${instance}-${cid}-${testName}`, capture); + console.log(`Network capture started for ${instance}`); + } catch (error) { + console.error(`Failed to start network capture for ${instance}:`, error); + } + } + if (bInstance.isFirefox) { return; } @@ -278,11 +299,35 @@ export const config: WebdriverIO.MultiremoteConfig = { } }, - after() { + async after() { const { ctx }: any = global; ctx?.webhooksProxy?.disconnect(); keepAlive.forEach(clearInterval); + + // Stop network capture and export data + if (networkCaptures.size > 0) { + await Promise.all(Array.from(networkCaptures.entries()).map(async ([ key, capture ]) => { + try { + await capture.stop(); + const outputPath = path.join(TEST_RESULTS_DIR, `network-${key}.json`); + + capture.exportToJSON(outputPath); + + const stats = capture.getStats(); + + console.log(`\nNetwork Capture Summary for ${key}:`); + console.log(` Total requests: ${stats.total}`); + console.log(` Succeeded: ${stats.succeeded}`); + console.log(` Failed: ${stats.failed}`); + console.log(` Cached: ${stats.cachedRequests}`); + } catch (error) { + console.error(`Failed to stop/export network capture for ${key}:`, error); + } + })); + + networkCaptures.clear(); + } }, beforeSession(c, capabilities_, specs_, cid) {