diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 000000000..2884135c7 --- /dev/null +++ b/.env.development.example @@ -0,0 +1,18 @@ +# Development Environment Configuration +# Copy this file to .env.development and adjust values as needed + +# Automation mode: 'dev' | 'production' | 'mock' +AUTOMATION_MODE=dev + +# Chrome DevTools settings (for dev mode) +CHROME_DEBUG_PORT=9222 +# CHROME_WS_ENDPOINT=ws://127.0.0.1:9222/devtools/browser/ + +# Shared automation settings +AUTOMATION_TIMEOUT=30000 +RETRY_ATTEMPTS=3 +SCREENSHOT_ON_ERROR=true + +# Start Chrome with debugging enabled: +# /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug +# Or use: npm run chrome:debug \ No newline at end of file diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 000000000..f155d901f --- /dev/null +++ b/.env.test.example @@ -0,0 +1,10 @@ +# Test Environment Configuration +# Copy this file to .env.test and adjust values as needed + +# Use mock adapter for testing (no real browser automation) +AUTOMATION_MODE=mock + +# Test timeouts (can be shorter for faster tests) +AUTOMATION_TIMEOUT=5000 +RETRY_ATTEMPTS=1 +SCREENSHOT_ON_ERROR=false \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 88806d9c1..9456bdc20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "src/apps/*" ], "dependencies": { + "puppeteer-core": "^24.31.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -1214,6 +1215,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", + "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1565,6 +1599,12 @@ "node": ">=14" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1651,7 +1691,7 @@ "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1713,7 +1753,6 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1849,6 +1888,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", @@ -1863,7 +1911,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1904,6 +1951,18 @@ "repeat-string": "^1.6.1" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1911,6 +1970,97 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.1.tgz", + "integrity": "sha512-zGUCsm3yv/ePt2PHNbVxjjn0nNB1MkIaR4wOCxJ2ig5pCf5cCVAYJXVhQg/3OhhJV6DB1ts7Hv0oUaElc2TPQg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.30", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", @@ -1921,6 +2071,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -1978,7 +2137,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -2120,6 +2278,19 @@ "node": ">= 16" } }, + "node_modules/chromium-bidi": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz", + "integrity": "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", @@ -2143,6 +2314,58 @@ "@colors/colors": "1.5.0" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", @@ -2160,7 +2383,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2173,7 +2395,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -2215,11 +2436,19 @@ "dev": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "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" @@ -2320,6 +2549,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -2328,6 +2571,12 @@ "license": "MIT", "optional": true }, + "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==", + "license": "BSD-3-Clause" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -2375,14 +2624,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2491,7 +2738,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2507,6 +2753,49 @@ "node": ">=0.8.0" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2517,6 +2806,24 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -2531,7 +2838,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -2548,11 +2854,16 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -2676,11 +2987,19 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -2692,6 +3011,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -2870,6 +3203,19 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -2884,6 +3230,19 @@ "node": ">=10.19.0" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2917,11 +3276,19 @@ "node": ">=10" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3231,6 +3598,12 @@ "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==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", @@ -3261,7 +3634,6 @@ "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/mz": { @@ -3295,6 +3667,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -3366,7 +3747,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3382,6 +3762,38 @@ "node": ">=8" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3468,7 +3880,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -3524,7 +3935,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3537,17 +3947,68 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, + "node_modules/puppeteer-core": { + "version": "24.31.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.31.0.tgz", + "integrity": "sha512-pnAohhSZipWQoFpXuGV7xCZfaGhqcBR9C4pVrU0QSrcMi7tQMH9J9lDBqBvyMAHQqe8HCARuREqFuVKRQOgTvg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "11.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1521046", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.9", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -3671,6 +4132,15 @@ "node": ">=0.10" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -3878,11 +4348,49 @@ "node": ">=18" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3974,6 +4482,17 @@ "dev": true, "license": "MIT" }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string-argv": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", @@ -3988,7 +4507,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4042,7 +4560,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4052,7 +4569,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4143,6 +4659,68 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar-stream/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -4255,7 +4833,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-fest": { @@ -4271,6 +4848,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4289,7 +4872,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -6043,6 +6626,12 @@ } } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.9.tgz", + "integrity": "sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==", + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6178,9 +6767,29 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "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/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -6191,6 +6800,15 @@ "node": ">=8.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6211,11 +6829,37 @@ "node": ">= 14.6" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", @@ -6247,6 +6891,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index e1e37c0b0..5d32a4cc3 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,12 @@ "test:watch": "vitest watch", "typecheck": "tsc --noEmit", "companion": "npm run companion:build && electron .", - "companion:dev": "node build-main.config.js --watch & vite build --config vite.config.electron.ts --watch", + "companion:dev": "npm run companion:build && (node build-main.config.js --watch & vite build --config vite.config.electron.ts --watch & electron .)", "companion:build": "node build-main.config.js && vite build --config vite.config.electron.ts", - "companion:start": "electron ." + "companion:start": "electron .", + "companion:mock": "AUTOMATION_MODE=mock npm run companion:start", + "companion:devtools": "AUTOMATION_MODE=dev npm run companion:start", + "chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug" }, "devDependencies": { "@cucumber/cucumber": "^11.0.1", @@ -38,6 +41,7 @@ "vitest": "^2.1.8" }, "dependencies": { + "puppeteer-core": "^24.31.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/src/apps/companion/main/di-container.ts b/src/apps/companion/main/di-container.ts index 9d85b7b52..f09057d1f 100644 --- a/src/apps/companion/main/di-container.ts +++ b/src/apps/companion/main/di-container.ts @@ -1,11 +1,42 @@ import { InMemorySessionRepository } from '../../../infrastructure/repositories/InMemorySessionRepository'; import { MockBrowserAutomationAdapter } from '../../../infrastructure/adapters/automation/MockBrowserAutomationAdapter'; +import { BrowserDevToolsAdapter } from '../../../infrastructure/adapters/automation/BrowserDevToolsAdapter'; import { MockAutomationEngineAdapter } from '../../../infrastructure/adapters/automation/MockAutomationEngineAdapter'; import { StartAutomationSessionUseCase } from '../../../packages/application/use-cases/StartAutomationSessionUseCase'; +import { loadAutomationConfig, AutomationMode } from '../../../infrastructure/config'; import type { ISessionRepository } from '../../../packages/application/ports/ISessionRepository'; import type { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation'; import type { IAutomationEngine } from '../../../packages/application/ports/IAutomationEngine'; +/** + * Create browser automation adapter based on configuration mode. + * + * @param mode - The automation mode from configuration + * @returns IBrowserAutomation adapter instance + */ +function createBrowserAutomationAdapter(mode: AutomationMode): IBrowserAutomation { + const config = loadAutomationConfig(); + + switch (mode) { + case 'dev': + return new BrowserDevToolsAdapter({ + debuggingPort: config.devTools?.debuggingPort, + browserWSEndpoint: config.devTools?.browserWSEndpoint, + defaultTimeout: config.defaultTimeout, + }); + + case 'production': + // Production mode will use nut.js adapter in the future + // For now, fall back to mock adapter with a warning + console.warn('Production mode (nut.js) not yet implemented, using mock adapter'); + return new MockBrowserAutomationAdapter(); + + case 'mock': + default: + return new MockBrowserAutomationAdapter(); + } +} + export class DIContainer { private static instance: DIContainer; @@ -13,10 +44,14 @@ export class DIContainer { private browserAutomation: IBrowserAutomation; private automationEngine: IAutomationEngine; private startAutomationUseCase: StartAutomationSessionUseCase; + private automationMode: AutomationMode; private constructor() { + const config = loadAutomationConfig(); + this.automationMode = config.mode; + this.sessionRepository = new InMemorySessionRepository(); - this.browserAutomation = new MockBrowserAutomationAdapter(); + this.browserAutomation = createBrowserAutomationAdapter(config.mode); this.automationEngine = new MockAutomationEngineAdapter( this.browserAutomation, this.sessionRepository @@ -46,4 +81,19 @@ export class DIContainer { public getAutomationEngine(): IAutomationEngine { return this.automationEngine; } + + public getAutomationMode(): AutomationMode { + return this.automationMode; + } + + public getBrowserAutomation(): IBrowserAutomation { + return this.browserAutomation; + } + + /** + * Reset the singleton instance (useful for testing with different configurations). + */ + public static resetInstance(): void { + DIContainer.instance = undefined as unknown as DIContainer; + } } \ No newline at end of file diff --git a/src/apps/companion/main/ipc-handlers.ts b/src/apps/companion/main/ipc-handlers.ts index c67756add..1a214db0b 100644 --- a/src/apps/companion/main/ipc-handlers.ts +++ b/src/apps/companion/main/ipc-handlers.ts @@ -1,4 +1,5 @@ -import { ipcMain, BrowserWindow } from 'electron'; +import { ipcMain } from 'electron'; +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'; import { DIContainer } from './di-container'; import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig'; import { StepId } from '../../../packages/domain/value-objects/StepId'; @@ -9,7 +10,7 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { const sessionRepository = container.getSessionRepository(); const automationEngine = container.getAutomationEngine(); - ipcMain.handle('start-automation', async (_event, config: HostedSessionConfig) => { + ipcMain.handle('start-automation', async (_event: IpcMainInvokeEvent, config: HostedSessionConfig) => { try { const result = await startAutomationUseCase.execute(config); const session = await sessionRepository.findById(result.sessionId); @@ -55,7 +56,7 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { } }); - ipcMain.handle('get-session-status', async (_event, sessionId: string) => { + ipcMain.handle('get-session-status', async (_event: IpcMainInvokeEvent, sessionId: string) => { const session = await sessionRepository.findById(sessionId); if (!session) { return { found: false }; @@ -71,11 +72,11 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { }; }); - ipcMain.handle('pause-automation', async (_event, _sessionId: string) => { + ipcMain.handle('pause-automation', async (_event: IpcMainInvokeEvent, _sessionId: string) => { return { success: false, error: 'Pause not implemented in POC' }; }); - ipcMain.handle('resume-automation', async (_event, _sessionId: string) => { + ipcMain.handle('resume-automation', async (_event: IpcMainInvokeEvent, _sessionId: string) => { return { success: false, error: 'Resume not implemented in POC' }; }); } \ No newline at end of file diff --git a/src/infrastructure/adapters/automation/BrowserDevToolsAdapter.ts b/src/infrastructure/adapters/automation/BrowserDevToolsAdapter.ts new file mode 100644 index 000000000..f6aa9780a --- /dev/null +++ b/src/infrastructure/adapters/automation/BrowserDevToolsAdapter.ts @@ -0,0 +1,462 @@ +import puppeteer, { Browser, Page, CDPSession } from 'puppeteer-core'; +import { StepId } from '../../../packages/domain/value-objects/StepId'; +import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation'; +import { + NavigationResult, + FormFillResult, + ClickResult, + WaitResult, + ModalResult, +} from '../../../packages/application/ports/AutomationResults'; +import { IRacingSelectorMap, getStepSelectors, getStepName } from './selectors/IRacingSelectorMap'; + +/** + * Configuration for connecting to browser via Chrome DevTools Protocol + */ +export interface DevToolsConfig { + /** WebSocket endpoint URL (e.g., ws://127.0.0.1:9222/devtools/browser/...) */ + browserWSEndpoint?: string; + /** Chrome debugging port (default: 9222) */ + debuggingPort?: number; + /** Default timeout for operations in milliseconds (default: 30000) */ + defaultTimeout?: number; + /** Human-like typing delay in milliseconds (default: 50) */ + typingDelay?: number; + /** Whether to wait for network idle after navigation (default: true) */ + waitForNetworkIdle?: boolean; +} + +/** + * BrowserDevToolsAdapter - Real browser automation using Puppeteer-core. + * + * This adapter connects to an existing browser session via Chrome DevTools Protocol (CDP) + * and automates the iRacing hosted session creation workflow. + * + * Key features: + * - Connects to existing browser (doesn't launch new one) + * - Uses IRacingSelectorMap for element location + * - Human-like typing delays for form filling + * - Waits for network idle after navigation + * - Disconnects without closing browser + * + * Usage: + * 1. Start Chrome with remote debugging: + * `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222` + * 2. Navigate to iRacing and log in manually + * 3. Create adapter and connect: + * ``` + * const adapter = new BrowserDevToolsAdapter({ debuggingPort: 9222 }); + * await adapter.connect(); + * ``` + */ +export class BrowserDevToolsAdapter implements IBrowserAutomation { + private browser: Browser | null = null; + private page: Page | null = null; + private config: Required; + private connected: boolean = false; + + constructor(config: DevToolsConfig = {}) { + this.config = { + browserWSEndpoint: config.browserWSEndpoint ?? '', + debuggingPort: config.debuggingPort ?? 9222, + defaultTimeout: config.defaultTimeout ?? 30000, + typingDelay: config.typingDelay ?? 50, + waitForNetworkIdle: config.waitForNetworkIdle ?? true, + }; + } + + /** + * Connect to an existing browser via Chrome DevTools Protocol. + * The browser must be started with --remote-debugging-port flag. + */ + async connect(): Promise { + if (this.connected) { + return; + } + + try { + if (this.config.browserWSEndpoint) { + // Connect using explicit WebSocket endpoint + this.browser = await puppeteer.connect({ + browserWSEndpoint: this.config.browserWSEndpoint, + }); + } else { + // Connect using debugging port - need to fetch endpoint first + const response = await fetch(`http://127.0.0.1:${this.config.debuggingPort}/json/version`); + const data = await response.json(); + const wsEndpoint = data.webSocketDebuggerUrl; + + this.browser = await puppeteer.connect({ + browserWSEndpoint: wsEndpoint, + }); + } + + // Find iRacing tab or use the first available tab + const pages = await this.browser.pages(); + this.page = await this.findIRacingPage(pages) || pages[0]; + + if (!this.page) { + throw new Error('No pages found in browser'); + } + + // Set default timeout + this.page.setDefaultTimeout(this.config.defaultTimeout); + + this.connected = true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to connect to browser: ${errorMessage}`); + } + } + + /** + * Disconnect from the browser without closing it. + * The user can continue using the browser after disconnection. + */ + async disconnect(): Promise { + if (this.browser) { + // Disconnect without closing - user may still use the browser + this.browser.disconnect(); + this.browser = null; + this.page = null; + } + this.connected = false; + } + + /** + * Check if adapter is connected to browser. + */ + isConnected(): boolean { + return this.connected && this.browser !== null && this.page !== null; + } + + /** + * Navigate to a URL and wait for the page to load. + */ + async navigateToPage(url: string): Promise { + this.ensureConnected(); + + const startTime = Date.now(); + + try { + const waitUntil = this.config.waitForNetworkIdle ? 'networkidle2' : 'domcontentloaded'; + await this.page!.goto(url, { waitUntil }); + + const loadTime = Date.now() - startTime; + + return { + success: true, + url, + loadTime, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + success: false, + url, + loadTime: Date.now() - startTime, + error: `Navigation failed: ${errorMessage}`, + }; + } + } + + /** + * Fill a form field with human-like typing delay. + * + * @param fieldName - Field identifier (will be looked up in selector map or used directly) + * @param value - Value to type into the field + */ + async fillFormField(fieldName: string, value: string): Promise { + this.ensureConnected(); + + try { + // Try to find the element + const element = await this.page!.$(fieldName); + + if (!element) { + return { + success: false, + fieldName, + valueSet: '', + error: `Field not found: ${fieldName}`, + }; + } + + // Clear existing value and type new value with human-like delay + await element.click({ clickCount: 3 }); // Select all existing text + await element.type(value, { delay: this.config.typingDelay }); + + return { + success: true, + fieldName, + valueSet: value, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + success: false, + fieldName, + valueSet: '', + error: `Failed to fill field: ${errorMessage}`, + }; + } + } + + /** + * Click an element on the page. + */ + async clickElement(selector: string): Promise { + this.ensureConnected(); + + try { + // Wait for element to be visible and clickable + await this.page!.waitForSelector(selector, { + visible: true, + timeout: this.config.defaultTimeout + }); + + await this.page!.click(selector); + + return { + success: true, + target: selector, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + success: false, + target: selector, + error: `Click failed: ${errorMessage}`, + }; + } + } + + /** + * Wait for an element to appear on the page. + */ + async waitForElement(selector: string, maxWaitMs: number = 5000): Promise { + this.ensureConnected(); + + const startTime = Date.now(); + + try { + await this.page!.waitForSelector(selector, { + timeout: maxWaitMs, + visible: true + }); + + return { + success: true, + target: selector, + waitedMs: Date.now() - startTime, + found: true, + }; + } catch (error) { + return { + success: false, + target: selector, + waitedMs: Date.now() - startTime, + found: false, + error: `Element not found within ${maxWaitMs}ms`, + }; + } + } + + /** + * Handle modal operations for specific workflow steps. + * Modal steps are: 6 (SET_ADMINS), 9 (ADD_CAR), 12 (ADD_TRACK) + */ + async handleModal(stepId: StepId, action: string): Promise { + this.ensureConnected(); + + if (!stepId.isModalStep()) { + return { + success: false, + stepId: stepId.value, + action, + error: `Step ${stepId.value} (${getStepName(stepId.value)}) is not a modal step`, + }; + } + + try { + const stepSelectors = getStepSelectors(stepId.value); + + if (!stepSelectors?.modal) { + return { + success: false, + stepId: stepId.value, + action, + error: `No modal selectors defined for step ${stepId.value}`, + }; + } + + const modalSelectors = stepSelectors.modal; + + switch (action) { + case 'open': + // Wait for and verify modal is open + await this.page!.waitForSelector(modalSelectors.container, { + visible: true, + timeout: this.config.defaultTimeout, + }); + break; + + case 'close': + // Click close button + await this.page!.click(modalSelectors.closeButton); + // Wait for modal to disappear + await this.page!.waitForSelector(modalSelectors.container, { + hidden: true, + timeout: this.config.defaultTimeout, + }); + break; + + case 'search': + // Focus search input if available + if (modalSelectors.searchInput) { + await this.page!.click(modalSelectors.searchInput); + } + break; + + case 'select': + // Click select/confirm button + if (modalSelectors.selectButton) { + await this.page!.click(modalSelectors.selectButton); + } + break; + + default: + return { + success: false, + stepId: stepId.value, + action, + error: `Unknown modal action: ${action}`, + }; + } + + return { + success: true, + stepId: stepId.value, + action, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + success: false, + stepId: stepId.value, + action, + error: `Modal operation failed: ${errorMessage}`, + }; + } + } + + // ============== Helper Methods ============== + + /** + * Find the iRacing page among open browser tabs. + */ + private async findIRacingPage(pages: Page[]): Promise { + for (const page of pages) { + const url = page.url(); + if (url.includes('iracing.com') || url.includes('members-ng.iracing.com')) { + return page; + } + } + return null; + } + + /** + * Ensure adapter is connected before operations. + */ + private ensureConnected(): void { + if (!this.isConnected()) { + throw new Error('Not connected to browser. Call connect() first.'); + } + } + + // ============== Extended Methods for Workflow Automation ============== + + /** + * Navigate to a specific step in the wizard using sidebar navigation. + */ + async navigateToStep(stepId: StepId): Promise { + this.ensureConnected(); + + const startTime = Date.now(); + const stepSelectors = getStepSelectors(stepId.value); + + if (!stepSelectors?.sidebarLink) { + return { + success: false, + url: '', + loadTime: 0, + error: `No sidebar link defined for step ${stepId.value} (${getStepName(stepId.value)})`, + }; + } + + try { + await this.page!.click(stepSelectors.sidebarLink); + + // Wait for step container to be visible + if (stepSelectors.container) { + await this.page!.waitForSelector(stepSelectors.container, { visible: true }); + } + + return { + success: true, + url: this.page!.url(), + loadTime: Date.now() - startTime, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + success: false, + url: this.page!.url(), + loadTime: Date.now() - startTime, + error: `Failed to navigate to step: ${errorMessage}`, + }; + } + } + + /** + * Get the current page URL. + */ + getCurrentUrl(): string { + if (!this.page) { + return ''; + } + return this.page.url(); + } + + /** + * Take a screenshot of the current page (useful for debugging). + */ + async takeScreenshot(path: string): Promise { + this.ensureConnected(); + await this.page!.screenshot({ path, fullPage: true }); + } + + /** + * Get the current page content (useful for debugging). + */ + async getPageContent(): Promise { + this.ensureConnected(); + return await this.page!.content(); + } + + /** + * Wait for network to be idle (no pending requests). + */ + async waitForNetworkIdle(timeout: number = 5000): Promise { + this.ensureConnected(); + await this.page!.waitForNetworkIdle({ timeout }); + } + + /** + * Execute JavaScript in the page context. + */ + async evaluate(fn: () => T): Promise { + this.ensureConnected(); + return await this.page!.evaluate(fn); + } +} \ No newline at end of file diff --git a/src/infrastructure/adapters/automation/MockBrowserAutomationAdapter.ts b/src/infrastructure/adapters/automation/MockBrowserAutomationAdapter.ts index 5eebf9845..7b6a27dce 100644 --- a/src/infrastructure/adapters/automation/MockBrowserAutomationAdapter.ts +++ b/src/infrastructure/adapters/automation/MockBrowserAutomationAdapter.ts @@ -1,6 +1,13 @@ import { StepId } from '../../../packages/domain/value-objects/StepId'; import { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig'; import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation'; +import { + NavigationResult, + FormFillResult, + ClickResult, + WaitResult, + ModalResult, +} from '../../../packages/application/ports/AutomationResults'; interface MockConfig { simulateFailures?: boolean; @@ -19,40 +26,9 @@ interface StepExecutionResult { }; } -interface NavigationResult { - success: boolean; - url: string; - simulatedDelay: number; -} - -interface FormFillResult { - success: boolean; - fieldName: string; - value: string; - simulatedDelay: number; -} - -interface ClickResult { - success: boolean; - selector: string; - simulatedDelay: number; -} - -interface WaitResult { - success: boolean; - selector: string; - simulatedDelay: number; -} - -interface ModalResult { - success: boolean; - stepId: number; - action: string; - simulatedDelay: number; -} - export class MockBrowserAutomationAdapter implements IBrowserAutomation { private config: MockConfig; + private connected: boolean = false; constructor(config: MockConfig = {}) { this.config = { @@ -61,13 +37,25 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation { }; } + async connect(): Promise { + this.connected = true; + } + + async disconnect(): Promise { + this.connected = false; + } + + isConnected(): boolean { + return this.connected; + } + async navigateToPage(url: string): Promise { const delay = this.randomDelay(200, 800); await this.sleep(delay); return { success: true, url, - simulatedDelay: delay, + loadTime: delay, }; } @@ -77,8 +65,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation { return { success: true, fieldName, - value, - simulatedDelay: delay, + valueSet: value, }; } @@ -87,8 +74,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation { await this.sleep(delay); return { success: true, - selector, - simulatedDelay: delay, + target: selector, }; } @@ -99,8 +85,9 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation { return { success: true, - selector, - simulatedDelay: delay, + target: selector, + waitedMs: delay, + found: true, }; } @@ -115,7 +102,6 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation { success: true, stepId: stepId.value, action, - simulatedDelay: delay, }; } diff --git a/src/infrastructure/adapters/automation/index.ts b/src/infrastructure/adapters/automation/index.ts new file mode 100644 index 000000000..13214552e --- /dev/null +++ b/src/infrastructure/adapters/automation/index.ts @@ -0,0 +1,22 @@ +/** + * Automation adapters for browser automation. + * + * Exports: + * - MockBrowserAutomationAdapter: Mock adapter for testing + * - BrowserDevToolsAdapter: Real browser automation via Chrome DevTools Protocol + * - IRacingSelectorMap: CSS selectors for iRacing UI elements + */ + +// Adapters +export { MockBrowserAutomationAdapter } from './MockBrowserAutomationAdapter'; +export { BrowserDevToolsAdapter, DevToolsConfig } from './BrowserDevToolsAdapter'; + +// Selector map and utilities +export { + IRacingSelectorMap, + IRacingSelectorMapType, + StepSelectors, + getStepSelectors, + getStepName, + isModalStep, +} from './selectors/IRacingSelectorMap'; \ No newline at end of file diff --git a/src/infrastructure/adapters/automation/selectors/IRacingSelectorMap.ts b/src/infrastructure/adapters/automation/selectors/IRacingSelectorMap.ts new file mode 100644 index 000000000..7e2828358 --- /dev/null +++ b/src/infrastructure/adapters/automation/selectors/IRacingSelectorMap.ts @@ -0,0 +1,399 @@ +/** + * CSS Selector map for iRacing hosted session workflow. + * Selectors are derived from HTML samples in resources/iracing-hosted-sessions/ + * + * The iRacing UI uses Chakra UI/React with dynamic CSS classes. + * We prefer stable selectors: data-testid, id, aria-labels, role attributes. + */ + +export interface StepSelectors { + /** Primary container/step identifier */ + container?: string; + /** Wizard sidebar navigation link */ + sidebarLink?: string; + /** Wizard top navigation link */ + wizardNav?: string; + /** Form fields for this step */ + fields?: Record; + /** Buttons specific to this step */ + buttons?: Record; + /** Modal selectors if this is a modal step */ + modal?: { + container: string; + closeButton: string; + confirmButton?: string; + searchInput?: string; + resultsList?: string; + selectButton?: string; + }; +} + +export interface IRacingSelectorMapType { + /** Common selectors used across multiple steps */ + common: { + mainModal: string; + modalDialog: string; + modalContent: string; + modalTitle: string; + modalCloseButton: string; + checkoutButton: string; + backButton: string; + nextButton: string; + wizardContainer: string; + wizardSidebar: string; + searchInput: string; + loadingSpinner: string; + }; + /** Step-specific selectors */ + steps: Record; + /** iRacing-specific URLs */ + urls: { + base: string; + hostedRacing: string; + login: string; + }; +} + +/** + * Complete selector map for iRacing hosted session creation workflow. + * + * Steps: + * 1. LOGIN - Login page (handled externally) + * 2. HOSTED_RACING - Navigate to hosted racing section + * 3. CREATE_RACE - Click create race button + * 4. RACE_INFORMATION - Fill session name, password, description + * 5. SERVER_DETAILS - Select server region, launch time + * 6. SET_ADMINS - Admin configuration (modal at step 6) + * 7. TIME_LIMITS - Configure time limits + * 8. SET_CARS - Car selection overview + * 9. ADD_CAR - Add a car (modal at step 9) + * 10. SET_CAR_CLASSES - Configure car classes + * 11. SET_TRACK - Track selection overview + * 12. ADD_TRACK - Add a track (modal at step 12) + * 13. TRACK_OPTIONS - Configure track options + * 14. TIME_OF_DAY - Configure time of day + * 15. WEATHER - Configure weather + * 16. RACE_OPTIONS - Configure race options + * 17. TEAM_DRIVING - Configure team driving + * 18. TRACK_CONDITIONS - Final review (safety checkpoint - no final submit) + */ +export const IRacingSelectorMap: IRacingSelectorMapType = { + common: { + mainModal: '#create-race-modal', + modalDialog: '#create-race-modal-modal-dialog', + modalContent: '#create-race-modal-modal-content', + modalTitle: '[data-testid="modal-title"]', + modalCloseButton: '.modal-header .close, [data-testid="button-close-modal"]', + checkoutButton: '.btn.btn-success', + backButton: '.btn.btn-secondary:has(.icon-caret-left)', + nextButton: '.btn.btn-secondary:has(.icon-caret-right)', + wizardContainer: '#create-race-wizard', + wizardSidebar: '.wizard-sidebar', + searchInput: '.wizard-sidebar input[type="text"][placeholder="Search"]', + loadingSpinner: '.loader-container .loader', + }, + urls: { + base: 'https://members-ng.iracing.com', + hostedRacing: 'https://members-ng.iracing.com/web/racing/hosted', + login: 'https://members-ng.iracing.com/login', + }, + steps: { + // Step 1: LOGIN - External, handled before automation + 1: { + container: '#login-form, .login-container', + fields: { + email: 'input[name="email"], #email', + password: 'input[name="password"], #password', + }, + buttons: { + submit: 'button[type="submit"], .login-button', + }, + }, + + // Step 2: HOSTED_RACING - Navigate to hosted racing page + 2: { + container: '#hosted-sessions, [data-page="hosted"]', + sidebarLink: 'a[href*="/racing/hosted"]', + buttons: { + createRace: '.btn:has-text("Create a Race"), [data-action="create-race"]', + }, + }, + + // Step 3: CREATE_RACE - Click create race to open modal + 3: { + container: '[data-modal-component="ModalCreateRace"]', + buttons: { + createRace: 'button:has-text("Create a Race"), .btn-primary:has-text("Create")', + }, + }, + + // Step 4: RACE_INFORMATION - Fill session name, password, description + 4: { + container: '#set-session-information', + sidebarLink: '#wizard-sidebar-link-set-session-information', + wizardNav: '[data-testid="wizard-nav-set-session-information"]', + fields: { + sessionName: '.form-group:has(label:has-text("Session Name")) input, input[name="sessionName"]', + password: '.form-group:has(label:has-text("Password")) input, input[name="password"]', + description: '.form-group:has(label:has-text("Description")) textarea, textarea[name="description"]', + }, + buttons: { + next: '.wizard-footer .btn:has-text("Server Details")', + }, + }, + + // Step 5: SERVER_DETAILS - Select server region and launch time + 5: { + container: '#set-server-details', + sidebarLink: '#wizard-sidebar-link-set-server-details', + wizardNav: '[data-testid="wizard-nav-set-server-details"]', + fields: { + serverRegion: '.chakra-accordion__button[data-index="0"]', + launchTime: 'input[name="launchTime"], [id*="field-"]:has(+ [placeholder="Now"])', + startNow: '.switch:has(input[value="startNow"])', + }, + buttons: { + next: '.wizard-footer .btn:has-text("Admins")', + back: '.wizard-footer .btn:has-text("Race Information")', + }, + }, + + // Step 6: SET_ADMINS - Admin configuration (modal step) + 6: { + container: '#set-admins', + sidebarLink: '#wizard-sidebar-link-set-admins', + wizardNav: '[data-testid="wizard-nav-set-admins"]', + buttons: { + addAdmin: '.btn:has-text("Add Admin"), .btn-primary:has(.icon-add)', + next: '.wizard-footer .btn:has-text("Time Limit")', + back: '.wizard-footer .btn:has-text("Server Details")', + }, + modal: { + container: '#add-admin-modal, .modal:has([data-modal-component="AddAdmin"])', + closeButton: '.modal .close, [data-testid="button-close-modal"]', + searchInput: 'input[placeholder*="Search"], input[name="adminSearch"]', + resultsList: '.admin-list, .search-results', + selectButton: '.btn:has-text("Select"), .btn-primary:has-text("Add")', + }, + }, + + // Step 7: TIME_LIMITS - Configure time limits + 7: { + container: '#set-time-limit', + sidebarLink: '#wizard-sidebar-link-set-time-limit', + wizardNav: '[data-testid="wizard-nav-set-time-limit"]', + fields: { + practiceLength: 'input[name="practiceLength"]', + qualifyLength: 'input[name="qualifyLength"]', + raceLength: 'input[name="raceLength"]', + warmupLength: 'input[name="warmupLength"]', + }, + buttons: { + next: '.wizard-footer .btn:has-text("Cars")', + back: '.wizard-footer .btn:has-text("Admins")', + }, + }, + + // Step 8: SET_CARS - Car selection overview + 8: { + container: '#set-cars', + sidebarLink: '#wizard-sidebar-link-set-cars', + wizardNav: '[data-testid="wizard-nav-set-cars"]', + buttons: { + addCar: '.btn:has-text("Add Car"), .btn-primary:has(.icon-add)', + next: '.wizard-footer .btn:has-text("Track")', + back: '.wizard-footer .btn:has-text("Time Limit")', + }, + }, + + // Step 9: ADD_CAR - Add a car (modal step) + 9: { + container: '#set-cars', + sidebarLink: '#wizard-sidebar-link-set-cars', + wizardNav: '[data-testid="wizard-nav-set-cars"]', + modal: { + container: '#add-car-modal, .modal:has(.car-list)', + closeButton: '.modal .close, [aria-label="Close"]', + searchInput: 'input[placeholder*="Search"], .car-search input', + resultsList: '.car-list table tbody, .car-grid', + selectButton: '.btn:has-text("Select"), .btn-primary.btn-xs:has-text("Select")', + }, + }, + + // Step 10: SET_CAR_CLASSES - Configure car classes + 10: { + container: '#set-car-classes, #set-cars', + sidebarLink: '#wizard-sidebar-link-set-cars', + wizardNav: '[data-testid="wizard-nav-set-cars"]', + fields: { + carClass: 'select[name="carClass"], .car-class-select', + }, + buttons: { + next: '.wizard-footer .btn:has-text("Track")', + }, + }, + + // Step 11: SET_TRACK - Track selection overview + 11: { + container: '#set-track', + sidebarLink: '#wizard-sidebar-link-set-track', + wizardNav: '[data-testid="wizard-nav-set-track"]', + buttons: { + addTrack: '.btn:has-text("Add Track"), .btn-primary:has(.icon-add)', + next: '.wizard-footer .btn:has-text("Track Options")', + back: '.wizard-footer .btn:has-text("Cars")', + }, + }, + + // Step 12: ADD_TRACK - Add a track (modal step) + 12: { + container: '#set-track', + sidebarLink: '#wizard-sidebar-link-set-track', + wizardNav: '[data-testid="wizard-nav-set-track"]', + modal: { + container: '#add-track-modal, .modal:has(.track-list)', + closeButton: '.modal .close, [aria-label="Close"]', + searchInput: 'input[placeholder*="Search"], .track-search input', + resultsList: '.track-list table tbody, .track-grid', + selectButton: '.btn:has-text("Select"), .btn-primary.btn-xs:has-text("Select")', + }, + }, + + // Step 13: TRACK_OPTIONS - Configure track options + 13: { + container: '#set-track-options', + sidebarLink: '#wizard-sidebar-link-set-track-options', + wizardNav: '[data-testid="wizard-nav-set-track-options"]', + fields: { + trackConfig: 'select[name="trackConfig"]', + pitStalls: 'input[name="pitStalls"]', + }, + buttons: { + next: '.wizard-footer .btn:has-text("Time of Day")', + back: '.wizard-footer .btn:has-text("Track")', + }, + }, + + // Step 14: TIME_OF_DAY - Configure time of day + 14: { + container: '#set-time-of-day', + sidebarLink: '#wizard-sidebar-link-set-time-of-day', + wizardNav: '[data-testid="wizard-nav-set-time-of-day"]', + fields: { + timeOfDay: 'input[name="timeOfDay"], .time-slider', + date: 'input[name="date"], .date-picker', + }, + buttons: { + next: '.wizard-footer .btn:has-text("Weather")', + back: '.wizard-footer .btn:has-text("Track Options")', + }, + }, + + // Step 15: WEATHER - Configure weather + 15: { + container: '#set-weather', + sidebarLink: '#wizard-sidebar-link-set-weather', + wizardNav: '[data-testid="wizard-nav-set-weather"]', + fields: { + weatherType: 'select[name="weatherType"]', + temperature: 'input[name="temperature"]', + humidity: 'input[name="humidity"]', + windSpeed: 'input[name="windSpeed"]', + windDirection: 'input[name="windDirection"]', + }, + buttons: { + next: '.wizard-footer .btn:has-text("Race Options")', + back: '.wizard-footer .btn:has-text("Time of Day")', + }, + }, + + // Step 16: RACE_OPTIONS - Configure race options + 16: { + container: '#set-race-options', + sidebarLink: '#wizard-sidebar-link-set-race-options', + wizardNav: '[data-testid="wizard-nav-set-race-options"]', + fields: { + maxDrivers: 'input[name="maxDrivers"]', + hardcoreIncidents: '.switch:has(input[name="hardcoreIncidents"])', + rollingStarts: '.switch:has(input[name="rollingStarts"])', + fullCourseCautions: '.switch:has(input[name="fullCourseCautions"])', + }, + buttons: { + next: '.wizard-footer .btn:has-text("Track Conditions")', + back: '.wizard-footer .btn:has-text("Weather")', + }, + }, + + // Step 17: TEAM_DRIVING - Configure team driving (if applicable) + 17: { + container: '#set-team-driving', + fields: { + teamDriving: '.switch:has(input[name="teamDriving"])', + minDrivers: 'input[name="minDrivers"]', + maxDrivers: 'input[name="maxDrivers"]', + }, + buttons: { + next: '.wizard-footer .btn:has-text("Track Conditions")', + back: '.wizard-footer .btn:has-text("Race Options")', + }, + }, + + // Step 18: TRACK_CONDITIONS - Final review (safety checkpoint - NO final submit) + 18: { + container: '#set-track-conditions', + sidebarLink: '#wizard-sidebar-link-set-track-conditions', + wizardNav: '[data-testid="wizard-nav-set-track-conditions"]', + fields: { + trackState: 'select[name="trackState"]', + marbles: '.switch:has(input[name="marbles"])', + rubberedTrack: '.switch:has(input[name="rubberedTrack"])', + }, + buttons: { + // NOTE: Checkout button is intentionally NOT included for safety + // The automation should stop here and let the user review/confirm manually + back: '.wizard-footer .btn:has-text("Race Options")', + }, + }, + }, +}; + +/** + * Get selectors for a specific step + */ +export function getStepSelectors(stepId: number): StepSelectors | undefined { + return IRacingSelectorMap.steps[stepId]; +} + +/** + * Check if a step is a modal step (requires opening a secondary dialog) + */ +export function isModalStep(stepId: number): boolean { + return stepId === 6 || stepId === 9 || stepId === 12; +} + +/** + * Get the step name for logging/debugging + */ +export function getStepName(stepId: number): string { + const stepNames: Record = { + 1: 'LOGIN', + 2: 'HOSTED_RACING', + 3: 'CREATE_RACE', + 4: 'RACE_INFORMATION', + 5: 'SERVER_DETAILS', + 6: 'SET_ADMINS', + 7: 'TIME_LIMITS', + 8: 'SET_CARS', + 9: 'ADD_CAR', + 10: 'SET_CAR_CLASSES', + 11: 'SET_TRACK', + 12: 'ADD_TRACK', + 13: 'TRACK_OPTIONS', + 14: 'TIME_OF_DAY', + 15: 'WEATHER', + 16: 'RACE_OPTIONS', + 17: 'TEAM_DRIVING', + 18: 'TRACK_CONDITIONS', + }; + return stepNames[stepId] || `UNKNOWN_STEP_${stepId}`; +} \ No newline at end of file diff --git a/src/infrastructure/config/AutomationConfig.ts b/src/infrastructure/config/AutomationConfig.ts new file mode 100644 index 000000000..eeb403125 --- /dev/null +++ b/src/infrastructure/config/AutomationConfig.ts @@ -0,0 +1,98 @@ +/** + * Automation configuration module for environment-based adapter selection. + * + * This module provides configuration types and loaders for the automation system, + * allowing switching between different adapters based on environment variables. + */ + +export type AutomationMode = 'dev' | 'production' | 'mock'; + +export interface AutomationEnvironmentConfig { + mode: AutomationMode; + + /** Dev mode configuration (Browser DevTools) */ + devTools?: { + browserWSEndpoint?: string; + debuggingPort?: number; + }; + + /** Production mode configuration (nut.js) - stub for future implementation */ + nutJs?: { + windowTitle?: string; + templatePath?: string; + confidence?: number; + }; + + /** Default timeout for automation operations in milliseconds */ + defaultTimeout?: number; + /** Number of retry attempts for failed operations */ + retryAttempts?: number; + /** Whether to capture screenshots on error */ + screenshotOnError?: boolean; +} + +/** + * Load automation configuration from environment variables. + * + * Environment variables: + * - AUTOMATION_MODE: 'dev' | 'production' | 'mock' (default: 'mock') + * - CHROME_DEBUG_PORT: Chrome debugging port (default: 9222) + * - CHROME_WS_ENDPOINT: WebSocket endpoint for Chrome DevTools + * - IRACING_WINDOW_TITLE: Window title for nut.js (default: 'iRacing') + * - TEMPLATE_PATH: Path to template images (default: './resources/templates') + * - OCR_CONFIDENCE: OCR confidence threshold (default: 0.9) + * - AUTOMATION_TIMEOUT: Default timeout in ms (default: 30000) + * - RETRY_ATTEMPTS: Number of retry attempts (default: 3) + * - SCREENSHOT_ON_ERROR: Capture screenshots on error (default: true) + * + * @returns AutomationEnvironmentConfig with parsed environment values + */ +export function loadAutomationConfig(): AutomationEnvironmentConfig { + const modeEnv = process.env.AUTOMATION_MODE; + const mode: AutomationMode = isValidAutomationMode(modeEnv) ? modeEnv : 'mock'; + + return { + mode, + devTools: { + debuggingPort: parseIntSafe(process.env.CHROME_DEBUG_PORT, 9222), + browserWSEndpoint: process.env.CHROME_WS_ENDPOINT, + }, + nutJs: { + windowTitle: process.env.IRACING_WINDOW_TITLE || 'iRacing', + templatePath: process.env.TEMPLATE_PATH || './resources/templates', + confidence: parseFloatSafe(process.env.OCR_CONFIDENCE, 0.9), + }, + defaultTimeout: parseIntSafe(process.env.AUTOMATION_TIMEOUT, 30000), + retryAttempts: parseIntSafe(process.env.RETRY_ATTEMPTS, 3), + screenshotOnError: process.env.SCREENSHOT_ON_ERROR !== 'false', + }; +} + +/** + * Type guard to validate automation mode string. + */ +function isValidAutomationMode(value: string | undefined): value is AutomationMode { + return value === 'dev' || value === 'production' || value === 'mock'; +} + +/** + * Safely parse an integer with a default fallback. + */ +function parseIntSafe(value: string | undefined, defaultValue: number): number { + if (value === undefined || value === '') { + return defaultValue; + } + const parsed = parseInt(value, 10); + return isNaN(parsed) ? defaultValue : parsed; +} + +/** + * Safely parse a float with a default fallback. + */ +function parseFloatSafe(value: string | undefined, defaultValue: number): number { + if (value === undefined || value === '') { + return defaultValue; + } + const parsed = parseFloat(value); + return isNaN(parsed) ? defaultValue : parsed; +} \ No newline at end of file diff --git a/src/infrastructure/config/index.ts b/src/infrastructure/config/index.ts new file mode 100644 index 000000000..3508df142 --- /dev/null +++ b/src/infrastructure/config/index.ts @@ -0,0 +1,9 @@ +/** + * Configuration module exports for infrastructure layer. + */ + +export { + AutomationMode, + AutomationEnvironmentConfig, + loadAutomationConfig, +} from './AutomationConfig'; \ No newline at end of file diff --git a/src/packages/application/ports/AutomationResults.ts b/src/packages/application/ports/AutomationResults.ts new file mode 100644 index 000000000..619501496 --- /dev/null +++ b/src/packages/application/ports/AutomationResults.ts @@ -0,0 +1,30 @@ +export interface AutomationResult { + success: boolean; + error?: string; + metadata?: Record; +} + +export interface NavigationResult extends AutomationResult { + url: string; + loadTime: number; +} + +export interface FormFillResult extends AutomationResult { + fieldName: string; + valueSet: string; +} + +export interface ClickResult extends AutomationResult { + target: string; +} + +export interface WaitResult extends AutomationResult { + target: string; + waitedMs: number; + found: boolean; +} + +export interface ModalResult extends AutomationResult { + stepId: number; + action: string; +} \ No newline at end of file diff --git a/src/packages/application/ports/IBrowserAutomation.ts b/src/packages/application/ports/IBrowserAutomation.ts index 531574bb2..24e18233d 100644 --- a/src/packages/application/ports/IBrowserAutomation.ts +++ b/src/packages/application/ports/IBrowserAutomation.ts @@ -1,9 +1,20 @@ import { StepId } from '../../domain/value-objects/StepId'; +import { + NavigationResult, + FormFillResult, + ClickResult, + WaitResult, + ModalResult, +} from './AutomationResults'; export interface IBrowserAutomation { - navigateToPage(url: string): Promise; - fillFormField(fieldName: string, value: string): Promise; - clickElement(selector: string): Promise; - waitForElement(selector: string, maxWaitMs?: number): Promise; - handleModal(stepId: StepId, action: string): Promise; + navigateToPage(url: string): Promise; + fillFormField(fieldName: string, value: string): Promise; + clickElement(selector: string): Promise; + waitForElement(selector: string, maxWaitMs?: number): Promise; + handleModal(stepId: StepId, action: string): Promise; + + connect?(): Promise; + disconnect?(): Promise; + isConnected?(): boolean; } \ No newline at end of file diff --git a/tests/integration/infrastructure/BrowserDevToolsAdapter.test.ts b/tests/integration/infrastructure/BrowserDevToolsAdapter.test.ts new file mode 100644 index 000000000..810ce27ef --- /dev/null +++ b/tests/integration/infrastructure/BrowserDevToolsAdapter.test.ts @@ -0,0 +1,386 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BrowserDevToolsAdapter, DevToolsConfig } from '../../../src/infrastructure/adapters/automation/BrowserDevToolsAdapter'; +import { StepId } from '../../../src/packages/domain/value-objects/StepId'; +import { + IRacingSelectorMap, + getStepSelectors, + getStepName, + isModalStep, +} from '../../../src/infrastructure/adapters/automation/selectors/IRacingSelectorMap'; + +// Mock puppeteer-core +vi.mock('puppeteer-core', () => { + const mockPage = { + url: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted'), + goto: vi.fn().mockResolvedValue(undefined), + $: vi.fn().mockResolvedValue({ + click: vi.fn().mockResolvedValue(undefined), + type: vi.fn().mockResolvedValue(undefined), + }), + click: vi.fn().mockResolvedValue(undefined), + type: vi.fn().mockResolvedValue(undefined), + waitForSelector: vi.fn().mockResolvedValue(undefined), + setDefaultTimeout: vi.fn(), + screenshot: vi.fn().mockResolvedValue(undefined), + content: vi.fn().mockResolvedValue(''), + waitForNetworkIdle: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(undefined), + }; + + const mockBrowser = { + pages: vi.fn().mockResolvedValue([mockPage]), + disconnect: vi.fn(), + }; + + return { + default: { + connect: vi.fn().mockResolvedValue(mockBrowser), + }, + }; +}); + +// Mock global fetch for CDP endpoint discovery +global.fetch = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ + webSocketDebuggerUrl: 'ws://127.0.0.1:9222/devtools/browser/mock-id', + }), +}); + +describe('BrowserDevToolsAdapter', () => { + let adapter: BrowserDevToolsAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new BrowserDevToolsAdapter({ + debuggingPort: 9222, + defaultTimeout: 5000, + typingDelay: 10, + }); + }); + + afterEach(async () => { + if (adapter.isConnected()) { + await adapter.disconnect(); + } + }); + + describe('instantiation', () => { + it('should create adapter with default config', () => { + const defaultAdapter = new BrowserDevToolsAdapter(); + expect(defaultAdapter).toBeInstanceOf(BrowserDevToolsAdapter); + expect(defaultAdapter.isConnected()).toBe(false); + }); + + it('should create adapter with custom config', () => { + const customConfig: DevToolsConfig = { + debuggingPort: 9333, + defaultTimeout: 10000, + typingDelay: 100, + waitForNetworkIdle: false, + }; + const customAdapter = new BrowserDevToolsAdapter(customConfig); + expect(customAdapter).toBeInstanceOf(BrowserDevToolsAdapter); + }); + + it('should create adapter with explicit WebSocket endpoint', () => { + const wsAdapter = new BrowserDevToolsAdapter({ + browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test-id', + }); + expect(wsAdapter).toBeInstanceOf(BrowserDevToolsAdapter); + }); + }); + + describe('connect/disconnect', () => { + it('should connect to browser via debugging port', async () => { + await adapter.connect(); + expect(adapter.isConnected()).toBe(true); + }); + + it('should disconnect from browser without closing it', async () => { + await adapter.connect(); + expect(adapter.isConnected()).toBe(true); + + await adapter.disconnect(); + expect(adapter.isConnected()).toBe(false); + }); + + it('should handle multiple connect calls gracefully', async () => { + await adapter.connect(); + await adapter.connect(); // Should not throw + expect(adapter.isConnected()).toBe(true); + }); + + it('should handle disconnect when not connected', async () => { + await adapter.disconnect(); // Should not throw + expect(adapter.isConnected()).toBe(false); + }); + }); + + describe('navigateToPage', () => { + beforeEach(async () => { + await adapter.connect(); + }); + + it('should navigate to URL successfully', async () => { + const result = await adapter.navigateToPage('https://members-ng.iracing.com'); + + expect(result.success).toBe(true); + expect(result.url).toBe('https://members-ng.iracing.com'); + expect(result.loadTime).toBeGreaterThanOrEqual(0); + }); + + it('should return error when not connected', async () => { + await adapter.disconnect(); + + await expect(adapter.navigateToPage('https://example.com')) + .rejects.toThrow('Not connected to browser'); + }); + }); + + describe('fillFormField', () => { + beforeEach(async () => { + await adapter.connect(); + }); + + it('should fill form field successfully', async () => { + const result = await adapter.fillFormField('input[name="sessionName"]', 'Test Session'); + + expect(result.success).toBe(true); + expect(result.fieldName).toBe('input[name="sessionName"]'); + expect(result.valueSet).toBe('Test Session'); + }); + + it('should return error for non-existent field', async () => { + // Re-mock to return null for element lookup + const puppeteer = await import('puppeteer-core'); + const mockBrowser = await puppeteer.default.connect({} as any); + const pages = await mockBrowser.pages(); + const mockPage = pages[0] as any; + mockPage.$.mockResolvedValueOnce(null); + + const result = await adapter.fillFormField('input[name="nonexistent"]', 'value'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Field not found'); + }); + }); + + describe('clickElement', () => { + beforeEach(async () => { + await adapter.connect(); + }); + + it('should click element successfully', async () => { + const result = await adapter.clickElement('.btn-primary'); + + expect(result.success).toBe(true); + expect(result.target).toBe('.btn-primary'); + }); + }); + + describe('waitForElement', () => { + beforeEach(async () => { + await adapter.connect(); + }); + + it('should wait for element and find it', async () => { + const result = await adapter.waitForElement('#create-race-modal', 5000); + + expect(result.success).toBe(true); + expect(result.found).toBe(true); + expect(result.target).toBe('#create-race-modal'); + }); + + it('should return not found when element does not appear', async () => { + // Re-mock to throw timeout error + const puppeteer = await import('puppeteer-core'); + const mockBrowser = await puppeteer.default.connect({} as any); + const pages = await mockBrowser.pages(); + const mockPage = pages[0] as any; + mockPage.waitForSelector.mockRejectedValueOnce(new Error('Timeout')); + + const result = await adapter.waitForElement('#nonexistent', 100); + + expect(result.success).toBe(false); + expect(result.found).toBe(false); + }); + }); + + describe('handleModal', () => { + beforeEach(async () => { + await adapter.connect(); + }); + + it('should handle modal for step 6 (SET_ADMINS)', async () => { + const stepId = StepId.create(6); + const result = await adapter.handleModal(stepId, 'open'); + + expect(result.success).toBe(true); + expect(result.stepId).toBe(6); + expect(result.action).toBe('open'); + }); + + it('should handle modal for step 9 (ADD_CAR)', async () => { + const stepId = StepId.create(9); + const result = await adapter.handleModal(stepId, 'close'); + + expect(result.success).toBe(true); + expect(result.stepId).toBe(9); + expect(result.action).toBe('close'); + }); + + it('should handle modal for step 12 (ADD_TRACK)', async () => { + const stepId = StepId.create(12); + const result = await adapter.handleModal(stepId, 'search'); + + expect(result.success).toBe(true); + expect(result.stepId).toBe(12); + }); + + it('should return error for non-modal step', async () => { + const stepId = StepId.create(4); // RACE_INFORMATION is not a modal step + const result = await adapter.handleModal(stepId, 'open'); + + expect(result.success).toBe(false); + expect(result.error).toContain('not a modal step'); + }); + + it('should return error for unknown action', async () => { + const stepId = StepId.create(6); + const result = await adapter.handleModal(stepId, 'unknown_action'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unknown modal action'); + }); + }); +}); + +describe('IRacingSelectorMap', () => { + describe('common selectors', () => { + it('should have all required common selectors', () => { + expect(IRacingSelectorMap.common.mainModal).toBeDefined(); + expect(IRacingSelectorMap.common.modalDialog).toBeDefined(); + expect(IRacingSelectorMap.common.modalContent).toBeDefined(); + expect(IRacingSelectorMap.common.checkoutButton).toBeDefined(); + expect(IRacingSelectorMap.common.wizardContainer).toBeDefined(); + expect(IRacingSelectorMap.common.wizardSidebar).toBeDefined(); + }); + + it('should have iRacing-specific URLs', () => { + expect(IRacingSelectorMap.urls.base).toContain('iracing.com'); + expect(IRacingSelectorMap.urls.hostedRacing).toContain('hosted'); + }); + }); + + describe('step selectors', () => { + it('should have selectors for all 18 steps', () => { + for (let i = 1; i <= 18; i++) { + expect(IRacingSelectorMap.steps[i]).toBeDefined(); + } + }); + + it('should have wizard navigation for most steps', () => { + // Steps that have wizard navigation + const stepsWithWizardNav = [4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16, 18]; + + for (const stepNum of stepsWithWizardNav) { + const selectors = IRacingSelectorMap.steps[stepNum]; + expect(selectors.wizardNav || selectors.sidebarLink).toBeDefined(); + } + }); + + it('should have modal selectors for modal steps (6, 9, 12)', () => { + expect(IRacingSelectorMap.steps[6].modal).toBeDefined(); + expect(IRacingSelectorMap.steps[9].modal).toBeDefined(); + expect(IRacingSelectorMap.steps[12].modal).toBeDefined(); + }); + + it('should NOT have checkout button in step 18 (safety)', () => { + const step18 = IRacingSelectorMap.steps[18]; + expect(step18.buttons?.checkout).toBeUndefined(); + }); + }); + + describe('getStepSelectors', () => { + it('should return selectors for valid step', () => { + const selectors = getStepSelectors(4); + expect(selectors).toBeDefined(); + expect(selectors?.container).toBe('#set-session-information'); + }); + + it('should return undefined for invalid step', () => { + const selectors = getStepSelectors(99); + expect(selectors).toBeUndefined(); + }); + }); + + describe('isModalStep', () => { + it('should return true for modal steps', () => { + expect(isModalStep(6)).toBe(true); + expect(isModalStep(9)).toBe(true); + expect(isModalStep(12)).toBe(true); + }); + + it('should return false for non-modal steps', () => { + expect(isModalStep(1)).toBe(false); + expect(isModalStep(4)).toBe(false); + expect(isModalStep(18)).toBe(false); + }); + }); + + describe('getStepName', () => { + it('should return correct step names', () => { + expect(getStepName(1)).toBe('LOGIN'); + expect(getStepName(4)).toBe('RACE_INFORMATION'); + expect(getStepName(6)).toBe('SET_ADMINS'); + expect(getStepName(9)).toBe('ADD_CAR'); + expect(getStepName(12)).toBe('ADD_TRACK'); + expect(getStepName(18)).toBe('TRACK_CONDITIONS'); + }); + + it('should return UNKNOWN for invalid step', () => { + expect(getStepName(99)).toContain('UNKNOWN'); + }); + }); +}); + +describe('Integration: Adapter with SelectorMap', () => { + let adapter: BrowserDevToolsAdapter; + + beforeEach(async () => { + adapter = new BrowserDevToolsAdapter(); + await adapter.connect(); + }); + + afterEach(async () => { + await adapter.disconnect(); + }); + + it('should use selector map for navigation', async () => { + const selectors = getStepSelectors(4); + expect(selectors?.sidebarLink).toBeDefined(); + + const result = await adapter.clickElement(selectors!.sidebarLink!); + expect(result.success).toBe(true); + }); + + it('should use selector map for form filling', async () => { + const selectors = getStepSelectors(4); + expect(selectors?.fields?.sessionName).toBeDefined(); + + const result = await adapter.fillFormField( + selectors!.fields!.sessionName, + 'My Test Session' + ); + expect(result.success).toBe(true); + }); + + it('should use selector map for modal handling', async () => { + const stepId = StepId.create(9); + const selectors = getStepSelectors(9); + expect(selectors?.modal).toBeDefined(); + + const result = await adapter.handleModal(stepId, 'open'); + expect(result.success).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/integration/infrastructure/MockBrowserAutomationAdapter.test.ts b/tests/integration/infrastructure/MockBrowserAutomationAdapter.test.ts index d1771b795..04edc573f 100644 --- a/tests/integration/infrastructure/MockBrowserAutomationAdapter.test.ts +++ b/tests/integration/infrastructure/MockBrowserAutomationAdapter.test.ts @@ -16,7 +16,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { const result = await adapter.navigateToPage(url); expect(result.success).toBe(true); - expect(result.simulatedDelay).toBeGreaterThan(0); + expect(result.loadTime).toBeGreaterThan(0); }); it('should return navigation URL in result', async () => { @@ -32,8 +32,8 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { const result = await adapter.navigateToPage(url); - expect(result.simulatedDelay).toBeGreaterThanOrEqual(200); - expect(result.simulatedDelay).toBeLessThanOrEqual(800); + expect(result.loadTime).toBeGreaterThanOrEqual(200); + expect(result.loadTime).toBeLessThanOrEqual(800); }); }); @@ -46,7 +46,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { expect(result.success).toBe(true); expect(result.fieldName).toBe(fieldName); - expect(result.value).toBe(value); + expect(result.valueSet).toBe(value); }); it('should simulate typing speed delay', async () => { @@ -55,7 +55,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { const result = await adapter.fillFormField(fieldName, value); - expect(result.simulatedDelay).toBeGreaterThan(0); + expect(result.valueSet).toBeDefined(); }); it('should handle empty field values', async () => { @@ -65,7 +65,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { const result = await adapter.fillFormField(fieldName, value); expect(result.success).toBe(true); - expect(result.value).toBe(''); + expect(result.valueSet).toBe(''); }); }); @@ -76,7 +76,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { const result = await adapter.clickElement(selector); expect(result.success).toBe(true); - expect(result.selector).toBe(selector); + expect(result.target).toBe(selector); }); it('should simulate click delays', async () => { @@ -84,8 +84,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { const result = await adapter.clickElement(selector); - expect(result.simulatedDelay).toBeGreaterThan(0); - expect(result.simulatedDelay).toBeLessThanOrEqual(300); + expect(result.target).toBeDefined(); }); }); @@ -96,7 +95,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { const result = await adapter.waitForElement(selector); expect(result.success).toBe(true); - expect(result.selector).toBe(selector); + expect(result.target).toBe(selector); }); it('should simulate element load time', async () => { @@ -104,8 +103,8 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { const result = await adapter.waitForElement(selector); - expect(result.simulatedDelay).toBeGreaterThanOrEqual(100); - expect(result.simulatedDelay).toBeLessThanOrEqual(1000); + expect(result.waitedMs).toBeGreaterThanOrEqual(100); + expect(result.waitedMs).toBeLessThanOrEqual(1000); }); it('should timeout after maximum wait time', async () => { @@ -164,8 +163,9 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => { const result = await adapter.handleModal(stepId, action); - expect(result.simulatedDelay).toBeGreaterThanOrEqual(200); - expect(result.simulatedDelay).toBeLessThanOrEqual(600); + expect(result.success).toBe(true); + expect(result.stepId).toBe(6); + expect(result.action).toBe(action); }); }); diff --git a/tests/unit/infrastructure/AutomationConfig.test.ts b/tests/unit/infrastructure/AutomationConfig.test.ts new file mode 100644 index 000000000..b3f89666a --- /dev/null +++ b/tests/unit/infrastructure/AutomationConfig.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { loadAutomationConfig, AutomationMode } from '../../../src/infrastructure/config/AutomationConfig'; + +describe('AutomationConfig', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset environment before each test + process.env = { ...originalEnv }; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + }); + + describe('loadAutomationConfig', () => { + describe('default configuration', () => { + it('should return mock mode when AUTOMATION_MODE is not set', () => { + delete process.env.AUTOMATION_MODE; + + const config = loadAutomationConfig(); + + expect(config.mode).toBe('mock'); + }); + + it('should return default devTools configuration', () => { + const config = loadAutomationConfig(); + + expect(config.devTools?.debuggingPort).toBe(9222); + expect(config.devTools?.browserWSEndpoint).toBeUndefined(); + }); + + it('should return default nutJs configuration', () => { + const config = loadAutomationConfig(); + + expect(config.nutJs?.windowTitle).toBe('iRacing'); + expect(config.nutJs?.templatePath).toBe('./resources/templates'); + expect(config.nutJs?.confidence).toBe(0.9); + }); + + it('should return default shared settings', () => { + const config = loadAutomationConfig(); + + expect(config.defaultTimeout).toBe(30000); + expect(config.retryAttempts).toBe(3); + expect(config.screenshotOnError).toBe(true); + }); + }); + + describe('dev mode configuration', () => { + it('should return dev mode when AUTOMATION_MODE=dev', () => { + process.env.AUTOMATION_MODE = 'dev'; + + const config = loadAutomationConfig(); + + expect(config.mode).toBe('dev'); + }); + + it('should parse CHROME_DEBUG_PORT', () => { + process.env.CHROME_DEBUG_PORT = '9333'; + + const config = loadAutomationConfig(); + + expect(config.devTools?.debuggingPort).toBe(9333); + }); + + it('should read CHROME_WS_ENDPOINT', () => { + process.env.CHROME_WS_ENDPOINT = 'ws://127.0.0.1:9222/devtools/browser/abc123'; + + const config = loadAutomationConfig(); + + expect(config.devTools?.browserWSEndpoint).toBe('ws://127.0.0.1:9222/devtools/browser/abc123'); + }); + }); + + describe('production mode configuration', () => { + it('should return production mode when AUTOMATION_MODE=production', () => { + process.env.AUTOMATION_MODE = 'production'; + + const config = loadAutomationConfig(); + + expect(config.mode).toBe('production'); + }); + + it('should parse IRACING_WINDOW_TITLE', () => { + process.env.IRACING_WINDOW_TITLE = 'iRacing Simulator'; + + const config = loadAutomationConfig(); + + expect(config.nutJs?.windowTitle).toBe('iRacing Simulator'); + }); + + it('should parse TEMPLATE_PATH', () => { + process.env.TEMPLATE_PATH = '/custom/templates'; + + const config = loadAutomationConfig(); + + expect(config.nutJs?.templatePath).toBe('/custom/templates'); + }); + + it('should parse OCR_CONFIDENCE', () => { + process.env.OCR_CONFIDENCE = '0.85'; + + const config = loadAutomationConfig(); + + expect(config.nutJs?.confidence).toBe(0.85); + }); + }); + + describe('environment variable parsing', () => { + it('should parse AUTOMATION_TIMEOUT', () => { + process.env.AUTOMATION_TIMEOUT = '60000'; + + const config = loadAutomationConfig(); + + expect(config.defaultTimeout).toBe(60000); + }); + + it('should parse RETRY_ATTEMPTS', () => { + process.env.RETRY_ATTEMPTS = '5'; + + const config = loadAutomationConfig(); + + expect(config.retryAttempts).toBe(5); + }); + + it('should parse SCREENSHOT_ON_ERROR=false', () => { + process.env.SCREENSHOT_ON_ERROR = 'false'; + + const config = loadAutomationConfig(); + + expect(config.screenshotOnError).toBe(false); + }); + + it('should parse SCREENSHOT_ON_ERROR=true', () => { + process.env.SCREENSHOT_ON_ERROR = 'true'; + + const config = loadAutomationConfig(); + + expect(config.screenshotOnError).toBe(true); + }); + + it('should fallback to defaults for invalid integer values', () => { + process.env.CHROME_DEBUG_PORT = 'invalid'; + process.env.AUTOMATION_TIMEOUT = 'not-a-number'; + process.env.RETRY_ATTEMPTS = ''; + + const config = loadAutomationConfig(); + + expect(config.devTools?.debuggingPort).toBe(9222); + expect(config.defaultTimeout).toBe(30000); + expect(config.retryAttempts).toBe(3); + }); + + it('should fallback to defaults for invalid float values', () => { + process.env.OCR_CONFIDENCE = 'invalid'; + + const config = loadAutomationConfig(); + + expect(config.nutJs?.confidence).toBe(0.9); + }); + + it('should fallback to mock mode for invalid AUTOMATION_MODE', () => { + process.env.AUTOMATION_MODE = 'invalid-mode'; + + const config = loadAutomationConfig(); + + expect(config.mode).toBe('mock'); + }); + }); + + describe('full configuration scenario', () => { + it('should load complete dev environment configuration', () => { + process.env.AUTOMATION_MODE = 'dev'; + process.env.CHROME_DEBUG_PORT = '9222'; + process.env.CHROME_WS_ENDPOINT = 'ws://localhost:9222/devtools/browser/test'; + process.env.AUTOMATION_TIMEOUT = '45000'; + process.env.RETRY_ATTEMPTS = '2'; + process.env.SCREENSHOT_ON_ERROR = 'true'; + + const config = loadAutomationConfig(); + + expect(config).toEqual({ + mode: 'dev', + devTools: { + debuggingPort: 9222, + browserWSEndpoint: 'ws://localhost:9222/devtools/browser/test', + }, + nutJs: { + windowTitle: 'iRacing', + templatePath: './resources/templates', + confidence: 0.9, + }, + defaultTimeout: 45000, + retryAttempts: 2, + screenshotOnError: true, + }); + }); + + it('should load complete mock environment configuration', () => { + process.env.AUTOMATION_MODE = 'mock'; + + const config = loadAutomationConfig(); + + expect(config.mode).toBe('mock'); + expect(config.devTools).toBeDefined(); + expect(config.nutJs).toBeDefined(); + }); + }); + }); +}); \ No newline at end of file diff --git a/tsconfig.electron.json b/tsconfig.electron.json index d5a886cf9..d6bb5b9e5 100644 --- a/tsconfig.electron.json +++ b/tsconfig.electron.json @@ -8,6 +8,7 @@ "lib": ["ES2020", "DOM"], "skipLibCheck": true, "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "moduleResolution": "node", "noEmit": false },