feat(tests): Add network request capture and analysis for WDIO tests

Enables capturing all network requests during test execution to validate
      URL allowlists, debug network failures, and verify requests match expected
      patterns from comprehensive URL documentation.

      Implementation uses puppeteer-core to connect directly to Chrome DevTools
      Protocol, supporting both local testing and remote Selenium Grid deployments.

      Key features:
      - Captures URLs, status codes, resource types, and timing for all requests
      - Exports to JSON with comprehensive statistics
      - Analysis tool generates reports in multiple formats (text, CSV, JSON)
      - Configurable grid support via environment variable or custom capability
      - Works with multiremote mode (multiple browser instances)
      - Zero-config for local development (opt-in via CAPTURE_NETWORK=true)

      Added npm scripts:
      - test-network / test-network-single: Run tests with capture enabled
      - analyze-network: Generate reports from captured data

      Dependencies added:
      - puppeteer-core: Direct CDP access for network monitoring
      - wdio-chromedriver-service: Chrome driver with debugging support
This commit is contained in:
Hristo Terezov
2025-11-07 10:37:26 -06:00
parent f9daba728f
commit 7ab6c283a6
7 changed files with 1699 additions and 33 deletions

257
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

461
tests/NETWORK-CAPTURE.md Normal file
View File

@@ -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=<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

View File

@@ -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<string, number>;
/** Requests grouped by resource type. */
byResourceType: Record<string, number>;
/** Requests grouped by HTTP status code. */
byStatus: Record<number, number>;
/** 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<string, INetworkRequest>;
/**
* 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<void>}
*/
async start(): Promise<void> {
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<void>}
*/
async stop(): Promise<void> {
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');
}
}

View File

@@ -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<string, number>;
byResourceType: Record<string, number>;
byStatus: Record<number, number>;
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<string>();
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<string, INetworkRequest[]>} Requests grouped by domain.
*/
getRequestsByDomain(): Record<string, INetworkRequest[]> {
const byDomain: Record<string, INetworkRequest[]> = {};
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<number, INetworkRequest[]>} Requests grouped by status code.
*/
getRequestsByStatus(): Record<number, INetworkRequest[]> {
const byStatus: Record<number, INetworkRequest[]> = {};
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<string, INetworkRequest[]>} Requests grouped by resource type.
*/
getRequestsByResourceType(): Record<string, INetworkRequest[]> {
const byType: Record<string, INetworkRequest[]> = {};
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 <network-capture.json> [options]');
console.error('\nOptions:');
console.error(' --urls <file> Export unique URLs to text file');
console.error(' --csv <file> Export requests to CSV file');
console.error(' --domains <file> 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();
}

View File

@@ -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<any> = [];
/** Network capture instances for each browser (when CAPTURE_NETWORK is enabled). */
const networkCaptures: Map<string, NetworkCapture> = 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) {