diff --git a/README.md b/README.md index 8fef00b..f9acfbc 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,20 @@ haptest --policy perf_start_hap -i ALL --exclude com.huawei.* com.ohos.* -o out haptest -i com.example.demo --policy static_guided --staticConfig config.json --llm --simk 3 -o out ``` +### 6. Inspect UI hierarchy with the web viewer +1. Ensure your HarmonyOS device or emulator is reachable through `hdc`. +2. Start the UI viewer service (all arguments are optional unless you need to force a specific target): + ``` + haptest ui-viewer + ``` + - `--target`: optional connect key when multiple devices are attached; the service auto-detects when omitted. + - `-p`: HTTP port for the local Express server (default `7789`). + - `-o`: output directory for session artifacts. +3. Browse to `http://localhost:7789/ui-viewer`. +4. Click **Connect Device** to let the backend auto-detect the connected device. Once connected, click **Fetch Current Page** to capture the latest screenshot and hierarchy for the active foreground app. +5. Explore the hierarchy tree, inspect widget metadata, or copy XPath snippets as needed. Use **Fetch Current Page** again any time you want to refresh the view. +6. Press `Ctrl+C` in the terminal to stop the service when finished. + ## Contribution 1. Fork the repository diff --git a/package-lock.json b/package-lock.json index 01de4f1..b24d13a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "bjc": "^1.0.22", "class-transformer": "^0.5.1", "commander": "^12.1.0", + "express": "^4.19.2", "graphology": "^0.25.4", "graphology-shortest-path": "^2.1.0", "image-hash": "^5.3.2", @@ -22,7 +23,6 @@ "moment": "^2.30.1", "openai": "^5.12.2", "promise-socket": "7.0.0", - "request": "^2.88.2", "ts-graphviz": "^2.1.2", "ws": "^8.18.0" }, @@ -31,6 +31,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.5", + "@types/express": "^4.17.21", "@types/node": "^20.14.9", "ts-node": "^10.9.2", "vitest": "^3.2.4" @@ -869,6 +870,17 @@ "@types/node": "*" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", @@ -878,6 +890,16 @@ "@types/deep-eql": "*" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -890,12 +912,45 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/express": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.24.tgz", + "integrity": "sha512-Mbrt4SRlXSTWryOnHAh2d4UQ/E7n9lZyGSi6KgX+4hkuL9soYbLOVXVhnk/ODp12YsGc95f4pOvqywJ6kngUwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/html-escaper": { "version": "3.0.2", "resolved": "https://repo.huaweicloud.com/repository/npm/@types/html-escaper/-/html-escaper-3.0.2.tgz", "integrity": "sha512-A8vk09eyYzk8J/lFO4OUMKCmRN0rRzfZf4n3Olwapgox/PtTiU8zPYlL1UEkJ/WeHvV6v9Xnj3o/705PKz9r4Q==", "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "2.2.0", "resolved": "https://repo.huaweicloud.com/repository/npm/@types/json5/-/json5-2.2.0.tgz", @@ -905,6 +960,13 @@ "json5": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.14.10", "resolved": "https://repo.huaweicloud.com/repository/npm/@types/node/-/node-20.14.10.tgz", @@ -913,6 +975,53 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmmirror.com/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.12", "resolved": "https://repo.huaweicloud.com/repository/npm/@types/ws/-/ws-8.5.12.tgz", @@ -1020,6 +1129,19 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://repo.huaweicloud.com/repository/npm/acorn/-/acorn-8.12.1.tgz", @@ -1074,6 +1196,12 @@ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -1168,6 +1296,60 @@ "bjc": "bin/bjc" } }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -1192,6 +1374,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1201,6 +1392,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -1257,6 +1477,42 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/core-js": { "version": "3.39.0", "resolved": "https://repo.huaweicloud.com/repository/npm/core-js/-/core-js-3.39.0.tgz", @@ -1334,6 +1590,25 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://repo.huaweicloud.com/repository/npm/diff/-/diff-4.0.2.tgz", @@ -1343,6 +1618,20 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -1353,12 +1642,57 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", @@ -1399,6 +1733,12 @@ "@esbuild/win32-x64": "0.25.5" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1408,6 +1748,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1434,6 +1783,82 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmmirror.com/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -1492,6 +1917,39 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/flatted": { "version": "3.3.1", "resolved": "https://repo.huaweicloud.com/repository/npm/flatted/-/flatted-3.3.1.tgz", @@ -1520,6 +1978,24 @@ "node": ">= 0.12" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://repo.huaweicloud.com/repository/npm/fs-extra/-/fs-extra-8.1.0.tgz", @@ -1547,6 +2023,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -1556,6 +2078,18 @@ "assert-plus": "^1.0.0" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://repo.huaweicloud.com/repository/npm/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1636,12 +2170,52 @@ "node": ">=6" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://repo.huaweicloud.com/repository/npm/html-escaper/-/html-escaper-3.0.3.tgz", "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -1657,6 +2231,18 @@ "npm": ">=1.3.7" } }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1690,6 +2276,21 @@ "request": "^2.81.0" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -1808,6 +2409,54 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1868,6 +2517,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -1877,11 +2535,35 @@ "node": "*" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obliterator": { "version": "2.0.4", "resolved": "https://repo.huaweicloud.com/repository/npm/obliterator/-/obliterator-2.0.4.tgz", "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/openai": { "version": "5.12.2", "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", @@ -1903,6 +2585,21 @@ } } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2049,6 +2746,19 @@ "node": ">=10.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -2079,6 +2789,30 @@ "node": ">=0.6" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -2212,6 +2946,147 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://repo.huaweicloud.com/repository/npm/siginfo/-/siginfo-2.0.0.tgz", @@ -2258,6 +3133,15 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", @@ -2370,6 +3254,15 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/token-types": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", @@ -2491,6 +3384,19 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.5.3", "resolved": "https://repo.huaweicloud.com/repository/npm/typescript/-/typescript-5.5.3.tgz", @@ -2516,6 +3422,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2525,6 +3440,15 @@ "punycode": "^2.1.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -2541,6 +3465,15 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", diff --git a/package.json b/package.json index 911399c..0aef056 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "bjc": "^1.0.22", "class-transformer": "^0.5.1", "commander": "^12.1.0", + "express": "^4.19.2", "graphology": "^0.25.4", "graphology-shortest-path": "^2.1.0", "image-hash": "^5.3.2", @@ -33,6 +34,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.5", + "@types/express": "^4.17.21", "@types/node": "^20.14.9", "ts-node": "^10.9.2", "vitest": "^3.2.4" diff --git a/res/ui-viewer/index.html b/res/ui-viewer/index.html new file mode 100644 index 0000000..ff70fad --- /dev/null +++ b/res/ui-viewer/index.html @@ -0,0 +1,133 @@ + + + + + + HapTest UI Viewer + + + + +
+ +
+
+ UI Viewer + v{{version}} +
+ + Platform: HarmonyOS + + + + + + + + + {{ isConnected ? 'Reconnect Device' : 'Connect Device' }} + + + + {{ isConnected ? ('Device: ' + (deviceAlias || 'auto')) : 'Device: Not connected' }} + + + + + Fetch Current Page + + +
+ GitHub + +
+ +
+
+ + +
+ +
+

Selected Element Info

+ + + + + + +
+ +
+
+ +
+

UI hierarchy

+ + +
+ + +
+
+
+
+ + + + + + + diff --git a/res/ui-viewer/static/css/style.css b/res/ui-viewer/static/css/style.css new file mode 100644 index 0000000..567e271 --- /dev/null +++ b/res/ui-viewer/static/css/style.css @@ -0,0 +1,214 @@ +body, html { + height: 100%; + margin: 0; +} +.el-button { + background-color: #3679E3 !important; + border-color: #3679E3 !important; + color: white !important; +} +.el-button:hover { + background-color: #3966C3 !important; + border-color: #3966C3 !important; + color: white !important; +} + +.el-select .el-input__inner:focus { + border-color: #3966C3 !important; +} +.el-select .el-input__inner::placeholder { + color: #a5a2a2; +} +.el-select-dropdown, +.el-select .el-input__inner, +.el-select-dropdown .el-select-dropdown__item { + background-color: #212933 !important; + color: #FDFDFD !important; + border-color: #212933 !important; +} + +.custom-input .el-input__inner { + border-color: #2D3744; +} +.custom-input .el-input__inner::placeholder { + color: #a5a2a2; +} +.custom-input .el-input__inner:focus, +.custom-input .el-input__inner:hover { + border-color: #3966C3; +} + + +#app { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} +.header { + height: 65px; + display: flex; + align-items: center; + padding: 0 20px; + background-color: #222222; + border-bottom: #333844 1px solid; + color: #fff; +} +.main { + display: flex; + width: 100%; + height: calc(100% - 65px); +} + +.left, .center, .right { + display: flex; + flex-direction: column; +} +.left { + width: 25%; + background-color: #212933; + border-right: #333844 1px solid; + justify-content: center; + align-items: center; + position: relative; +} +#screenshotCanvas, #hierarchyCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} +.center { + width: 30%; + background-color: #212933; +} + +.right { + width: 45%; + padding-right: 12px; + background-color: #212933; + min-width: 0; +} + +.divider { + width: 1.5px; + background-color: #333844; + cursor: ew-resize; + height: 100%; + transition: background-color 0.3s; +} +.divider-hover, .divider-dragging { + background-color: #3679E3; + width: 3px; +} + +.region-title { + font-weight: bold; + margin-left: 15px; + margin-bottom: 10px; +} + +.custom-table { + width: 100%; + border-bottom: none !important; + overflow: auto; +} + +.hierarchy-tree-wrapper { + flex: 1; + min-height: 0; + overflow: auto; +} + +.custom-tree { + display: inline-block; + min-width: 100%; + white-space: nowrap; + padding-bottom: 8px; +} + +.custom-tree .el-tree-node__content { + display: inline-flex; + align-items: center; + width: auto; +} + +.custom-tree .el-tree-node__label { + overflow: visible !important; + text-overflow: unset !important; + white-space: nowrap; + flex: 0 0 auto; +} + +.custom-table .el-table__row { + margin-bottom: 0; +} + +.custom-table .el-table__cell { + padding: 5px; + border-right: 1px solid #333844; + border-bottom: 1px solid #333844; +} + +.custom-table .el-table__body-wrapper { + border-bottom: none !important; +} + +.custom-table .el-table__row:last-child .el-table__cell { + border-bottom: none !important; +} + +.custom-table .el-table__header-wrapper, +.custom-table .el-table__body-wrapper { + width: 100% !important; +} + +.custom-table .el-table__header, +.custom-table .el-table__body { + width: 100% !important; +} + +.attr-button { + font-size: 12px; + padding: 2px 3px; + margin-left: 5px; +} + +code { + background-color: #2D3740; + padding: 3px 5px; + border-radius: 4px; + font-family: monospace; +} + +.custom-link { + color: #BBBBBB; + text-decoration: none !important; +} +.custom-link:hover { + color: white; + text-decoration: underline; + } + +.loading { + position: relative; + width: 30px; + height: 30px; + border: 2px solid #000; + border-top-color: rgba(0, 0, 0, 0.2); + border-right-color: rgba(0, 0, 0, 0.2); + border-bottom-color: rgba(0, 0, 0, 0.2); + border-radius: 100%; + + animation: circle infinite 0.75s linear; +} + +@keyframes circle { + 0% { + transform: rotate(0); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/res/ui-viewer/static/js/api.js b/res/ui-viewer/static/js/api.js new file mode 100644 index 0000000..f024880 --- /dev/null +++ b/res/ui-viewer/static/js/api.js @@ -0,0 +1,70 @@ +import { API_HOST } from './config.js'; + +const PLATFORM_PATH = 'harmony'; + +async function checkResponse(response) { + if (!response.ok) { + const text = await response.text(); + try { + const payload = JSON.parse(text); + if (payload && payload.message) { + throw new Error(payload.message); + } + } catch (_ignored) { + // ignore JSON parse errors, fall back to raw text + } + const message = text || `Server error: ${response.status}`; + throw new Error(message); + } + return response.json(); +} + +export async function getVersion() { + const response = await fetch(`${API_HOST}version`); + return checkResponse(response); +} + +export async function listDevices() { + const response = await fetch(`${API_HOST}${PLATFORM_PATH}/devices`); + return checkResponse(response); +} + +export async function connectDevice(connectKey, bundleName) { + const payload = {}; + if (bundleName) { + payload.bundleName = bundleName; + } + if (connectKey) { + payload.connectKey = connectKey; + } + const body = Object.keys(payload).length ? JSON.stringify(payload) : '{}'; + const response = await fetch(`${API_HOST}${PLATFORM_PATH}/connect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body, + }); + return checkResponse(response); +} + +export async function fetchScreenshot() { + const response = await fetch(`${API_HOST}${PLATFORM_PATH}/screenshot`); + return checkResponse(response); +} + +export async function fetchHierarchy() { + const response = await fetch(`${API_HOST}${PLATFORM_PATH}/hierarchy`); + return checkResponse(response); +} + +export async function fetchXpathLite(nodeId) { + const response = await fetch(`${API_HOST}${PLATFORM_PATH}/hierarchy/xpathLite`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ node_id: nodeId }), + }); + return checkResponse(response); +} diff --git a/res/ui-viewer/static/js/config.js b/res/ui-viewer/static/js/config.js new file mode 100644 index 0000000..ede8069 --- /dev/null +++ b/res/ui-viewer/static/js/config.js @@ -0,0 +1 @@ +export const API_HOST = '/api/'; diff --git a/res/ui-viewer/static/js/index.js b/res/ui-viewer/static/js/index.js new file mode 100644 index 0000000..4063854 --- /dev/null +++ b/res/ui-viewer/static/js/index.js @@ -0,0 +1,647 @@ +import { saveToLocalStorage, getFromLocalStorage, copyToClipboard } from './utils.js'; +import { getVersion, connectDevice as connectDeviceApi, fetchScreenshot, fetchHierarchy, fetchXpathLite, listDevices } from './api.js'; + +new Vue({ + el: '#app', + data() { + return { + version: '', + deviceAlias: '', + devices: [], + selectedDevice: getFromLocalStorage('selectedDevice', ''), + isLoadingDevices: false, + isConnected: false, + isConnecting: false, + isDumping: false, + + packageName: '', + activityName: '', + pagePath: '', + updatedAt: '', + displaySize: [0, 0], + scale: 1, + + screenshotTransform: { scale: 1, offsetX: 0, offsetY: 0 }, + jsonHierarchy: null, + treeData: [], + hoveredNode: null, + selectedNode: null, + nodeIndex: Object.create(null), + xpathLite: '//', + mouseClickCoordinatesPercent: null, + + nodeFilterText: '', + defaultTreeProps: { + children: 'children', + label(data) { + if (!data) { + return ''; + } + const suffix = data.text || data.id || data.name || ''; + return data._type ? `${data._type}${suffix ? ' - ' + suffix : ''}` : suffix; + }, + }, + centerWidth: 480, + isDividerHovered: false, + isDragging: false, + }; + }, + computed: { + selectedNodeDetails() { + const defaults = this.getDefaultNodeDetails(); + if (!this.selectedNode) { + return defaults; + } + + const node = this.selectedNode; + const details = []; + + const append = (key, value) => { + if (value === undefined || value === null || value === '') { + return; + } + details.push({ key, value }); + }; + + append('componentPath', node.componentPath); + append('xpathLite', node.xpath || this.xpathLite); + append('type', node._type); + append('id', node.id); + append('name', node.name); + append('text', node.text); + append('hint', node.hint); + append('rect', `(${node.rect.x}, ${node.rect.y}, ${node.rect.width}, ${node.rect.height})`); + append('clickable', node.clickable); + append('enabled', node.enabled); + append('focusable', node.focusable); + append('focused', node.focused); + append('scrollable', node.scrollable); + append('longClickable', node.longClickable); + append('selected', node.selected); + append('debugLine', node.debugLine); + + return [...defaults, ...details]; + } + }, + watch: { + nodeFilterText(val) { + if (this.$refs.treeRef) { + this.$refs.treeRef.filter(val); + } + }, + selectedDevice(val) { + saveToLocalStorage('selectedDevice', val || ''); + if (this.isConnected && val !== this.deviceAlias) { + this.isConnected = false; + this.deviceAlias = ''; + } + this.packageName = ''; + this.activityName = ''; + this.pagePath = ''; + this.updatedAt = ''; + this.displaySize = [0, 0]; + this.jsonHierarchy = null; + this.treeData = []; + this.selectedNode = null; + this.hoveredNode = null; + this.xpathLite = '//'; + this.nodeIndex = Object.create(null); + this.mouseClickCoordinatesPercent = null; + this.screenshotTransform = { scale: 1, offsetX: 0, offsetY: 0 }; + saveToLocalStorage('cachedScreenshot', ''); + this.renderHierarchy(); + if (this.$refs.treeRef && typeof this.$refs.treeRef.setCurrentKey === 'function') { + this.$refs.treeRef.setCurrentKey(null); + } + if (this.$el) { + const screenshotCanvas = this.$el.querySelector('#screenshotCanvas'); + if (screenshotCanvas) { + const ctx = screenshotCanvas.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, screenshotCanvas.width, screenshotCanvas.height); + } + } + } + } + }, + created() { + this.fetchVersion(); + this.loadDevices(); + }, + mounted() { + this.loadCachedScreenshot(); + const canvas = this.$el ? this.$el.querySelector('#hierarchyCanvas') : null; + if (canvas) { + this._onHierarchyMouseMove = this.onMouseMove.bind(this); + this._onHierarchyMouseClick = this.onMouseClick.bind(this); + this._onHierarchyMouseLeave = this.onMouseLeave.bind(this); + + canvas.addEventListener('mousemove', this._onHierarchyMouseMove); + canvas.addEventListener('click', this._onHierarchyMouseClick); + canvas.addEventListener('mouseleave', this._onHierarchyMouseLeave); + } + + this._onDocumentDrag = this.onDrag.bind(this); + this._onDocumentMouseUp = this.stopDrag.bind(this); + + this.setupCanvasResolution('#screenshotCanvas'); + this.setupCanvasResolution('#hierarchyCanvas'); + }, + beforeDestroy() { + const canvas = this.$el ? this.$el.querySelector('#hierarchyCanvas') : null; + if (canvas) { + if (this._onHierarchyMouseMove) { + canvas.removeEventListener('mousemove', this._onHierarchyMouseMove); + } + if (this._onHierarchyMouseClick) { + canvas.removeEventListener('click', this._onHierarchyMouseClick); + } + if (this._onHierarchyMouseLeave) { + canvas.removeEventListener('mouseleave', this._onHierarchyMouseLeave); + } + } + if (this._onDocumentDrag) { + document.removeEventListener('mousemove', this._onDocumentDrag); + } + if (this._onDocumentMouseUp) { + document.removeEventListener('mouseup', this._onDocumentMouseUp); + } + }, + methods: { + async loadDevices(options = {}) { + if (this.isLoadingDevices) { + return; + } + const silent = options.silent === true; + this.isLoadingDevices = true; + try { + const response = await listDevices(); + if (response.success) { + const devices = Array.isArray(response.data) ? response.data : []; + this.devices = devices; + if (!this.selectedDevice && devices.length === 1) { + this.selectedDevice = devices[0].serial; + } else if (this.selectedDevice) { + const exists = devices.some((item) => item && item.serial === this.selectedDevice); + if (!exists) { + this.selectedDevice = devices.length === 1 ? devices[0].serial : ''; + } + } + if (!silent && devices.length === 0) { + this.$message({ + showClose: true, + message: 'No connected devices detected. Please connect a device with "hdc" and refresh.', + type: 'warning' + }); + } + } else { + throw new Error(response.message || 'Failed to list devices'); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!silent) { + this.$message({ showClose: true, message: `Error: ${message}`, type: 'error' }); + } else { + console.error(err); + } + } finally { + this.isLoadingDevices = false; + } + }, + formatDeviceOption(device) { + if (!device) { + return ''; + } + if (typeof device === 'string') { + return device; + } + const parts = []; + if (device.transport) { + parts.push(device.transport); + } + if (device.state) { + parts.push(device.state); + } + if (device.host && device.host !== 'localhost') { + parts.push(device.host); + } + return parts.length ? `${device.serial} (${parts.join(', ')})` : device.serial; + }, + onDeviceDropdownVisible(visible) { + if (visible) { + this.loadDevices(); + } + }, + async fetchVersion() { + try { + const response = await getVersion(); + this.version = response.data; + } catch (err) { + console.error(err); + } + }, + async connectDevice(options = {}) { + const { silent = false } = options; + if (this.isConnecting) { + return; + } + if (!this.selectedDevice) { + if (!silent) { + this.$message({ showClose: true, message: 'Please select a device first', type: 'warning' }); + } + return; + } + this.isConnecting = true; + try { + await this.loadDevices({ silent: true }); + const available = this.devices.some((item) => item && item.serial === this.selectedDevice); + if (!available) { + throw new Error('Selected device is no longer available. Please refresh the list and select again.'); + } + const response = await connectDeviceApi(this.selectedDevice); + if (response.success) { + this.isConnected = true; + const alias = response.data && response.data.alias ? response.data.alias : this.selectedDevice; + this.deviceAlias = alias; + await this.screenshotAndDumpHierarchy(); + if (!silent) { + this.$message({ showClose: true, message: 'Device connected', type: 'success' }); + } + } else { + throw new Error(response.message || 'Connect failed'); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!silent) { + this.$message({ showClose: true, message: `Error: ${message}`, type: 'error' }); + } else { + console.error(err); + } + } finally { + this.isConnecting = false; + } + }, + async screenshotAndDumpHierarchy() { + if (!this.isConnected) { + this.$message({ showClose: true, message: 'Please connect a device first', type: 'warning' }); + return; + } + this.isDumping = true; + try { + await this.fetchScreenshot(); + await this.fetchHierarchy(); + } catch (err) { + this.$message({ showClose: true, message: `Error: ${err.message}`, type: 'error' }); + } finally { + this.isDumping = false; + } + }, + async fetchScreenshot() { + try { + const response = await fetchScreenshot(); + if (response.success) { + const base64Data = response.data; + this.renderScreenshot(base64Data); + saveToLocalStorage('cachedScreenshot', base64Data); + } else { + throw new Error(response.message || 'Fetch screenshot failed'); + } + } catch (error) { + console.error(error); + throw error; + } + }, + async fetchHierarchy() { + try { + const response = await fetchHierarchy(); + if (response.success) { + const ret = response.data; + this.packageName = ret.packageName || this.packageName || ''; + this.activityName = ret.activityName || ''; + this.pagePath = ret.pagePath || ''; + this.updatedAt = ret.updatedAt || new Date().toISOString(); + this.displaySize = ret.windowSize || [0, 0]; + this.scale = ret.scale || 1; + this.jsonHierarchy = ret.jsonHierarchy; + this.treeData = this.jsonHierarchy ? [this.jsonHierarchy] : []; + + this.hoveredNode = null; + this.selectedNode = null; + this.nodeIndex = this.createNodeIndex(this.jsonHierarchy); + if (this.$refs.treeRef && this.$refs.treeRef.setCurrentKey) { + this.$refs.treeRef.setCurrentKey(null); + } + this.xpathLite = '//'; + + this.renderHierarchy(); + } else { + throw new Error(response.message || 'Fetch hierarchy failed'); + } + } catch (error) { + console.error(error); + throw error; + } + }, + getDefaultNodeDetails() { + return [ + { key: 'device', value: this.deviceAlias || '-' }, + { key: 'bundleName', value: this.packageName || '-' }, + { key: 'abilityName', value: this.activityName || '-' }, + { key: 'pagePath', value: this.pagePath || '-' }, + { key: 'updatedAt', value: this.updatedAt || '-' }, + { key: 'displaySize', value: `(${this.displaySize[0]}, ${this.displaySize[1]})` }, + ]; + }, + createNodeIndex(root) { + const index = Object.create(null); + const traverse = (node) => { + if (!node || !node._id) { + return; + } + index[node._id] = node; + if (Array.isArray(node.children)) { + node.children.forEach(traverse); + } + }; + if (root) { + traverse(root); + } + return index; + }, + loadCachedScreenshot() { + const cachedScreenshot = getFromLocalStorage('cachedScreenshot', null); + if (cachedScreenshot) { + this.renderScreenshot(cachedScreenshot); + } + }, + renderScreenshot(base64Data) { + const img = new Image(); + img.src = `data:image/png;base64,${base64Data}`; + img.onload = () => { + const canvas = this.$el.querySelector('#screenshotCanvas'); + const ctx = canvas.getContext('2d'); + + const { clientWidth: canvasWidth, clientHeight: canvasHeight } = canvas; + + this.setupCanvasResolution('#screenshotCanvas'); + + const { width: imgWidth, height: imgHeight } = img; + const scale = Math.min(canvasWidth / imgWidth, canvasHeight / imgHeight); + const x = (canvasWidth - imgWidth * scale) / 2; + const y = (canvasHeight - imgHeight * scale) / 2; + + this.screenshotTransform = { scale, offsetX: x, offsetY: y }; + + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.drawImage(img, x, y, imgWidth * scale, imgHeight * scale); + + this.setupCanvasResolution('#hierarchyCanvas'); + this.renderHierarchy(); + }; + }, + renderHierarchy() { + if (!this.$el) { + return; + } + const canvas = this.$el.querySelector('#hierarchyCanvas'); + if (!canvas) { + return; + } + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + if (!this.jsonHierarchy) { + return; + } + + const { scale, offsetX, offsetY } = this.screenshotTransform; + + const drawNode = (node) => { + if (node.rect) { + const { x, y, width, height } = node.rect; + const scaledX = x * scale + offsetX; + const scaledY = y * scale + offsetY; + const scaledWidth = width * scale; + const scaledHeight = height * scale; + + ctx.save(); + + if (this.selectedNode && node._id === this.selectedNode._id) { + ctx.setLineDash([]); + ctx.strokeStyle = '#409EFF'; + ctx.lineWidth = 2; + ctx.fillStyle = 'rgba(64, 158, 255, 0.18)'; + ctx.fillRect(scaledX, scaledY, scaledWidth, scaledHeight); + } else if (this.hoveredNode && node._id === this.hoveredNode._id) { + ctx.setLineDash([]); + ctx.strokeStyle = '#67C23A'; + ctx.lineWidth = 1.5; + ctx.fillStyle = 'rgba(103, 194, 58, 0.18)'; + ctx.fillRect(scaledX, scaledY, scaledWidth, scaledHeight); + } else { + ctx.setLineDash([2, 6]); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)'; + ctx.lineWidth = 0.7; + } + + ctx.strokeRect(scaledX, scaledY, scaledWidth, scaledHeight); + ctx.restore(); + } + + if (node.children) { + node.children.forEach(drawNode); + } + }; + + drawNode(this.jsonHierarchy); + }, + setupCanvasResolution(selector) { + const canvas = this.$el.querySelector(selector); + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + }, + findSmallestNode(node, mouseX, mouseY, scale, offsetX, offsetY) { + let smallestNode = null; + + const checkNode = (current) => { + if (current.rect) { + const { x, y, width, height } = current.rect; + const scaledX = x * scale + offsetX; + const scaledY = y * scale + offsetY; + const scaledWidth = width * scale; + const scaledHeight = height * scale; + + if ( + mouseX >= scaledX && + mouseY >= scaledY && + mouseX <= scaledX + scaledWidth && + mouseY <= scaledY + scaledHeight + ) { + if (!smallestNode || width * height < smallestNode.rect.width * smallestNode.rect.height) { + smallestNode = current; + } + } + } + + if (current.children) { + current.children.forEach(checkNode); + } + }; + + checkNode(node); + return smallestNode; + }, + onMouseMove(event) { + if (!this.jsonHierarchy) { + return; + } + const canvas = this.$el.querySelector('#hierarchyCanvas'); + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + const { scale, offsetX, offsetY } = this.screenshotTransform; + const hoveredNode = this.findSmallestNode(this.jsonHierarchy, mouseX, mouseY, scale, offsetX, offsetY); + const canonicalHovered = hoveredNode ? ((this.nodeIndex && this.nodeIndex[hoveredNode._id]) || hoveredNode) : null; + if (canonicalHovered !== this.hoveredNode) { + this.hoveredNode = canonicalHovered; + this.renderHierarchy(); + } + }, + async onMouseClick(event) { + if (!this.jsonHierarchy) { + return; + } + const canvas = this.$el.querySelector('#hierarchyCanvas'); + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + const { scale, offsetX, offsetY } = this.screenshotTransform; + + const percentX = mouseX / canvas.width; + const percentY = mouseY / canvas.height; + this.mouseClickCoordinatesPercent = `(${percentX.toFixed(2)}, ${percentY.toFixed(2)})`; + + const selectedNode = this.findSmallestNode(this.jsonHierarchy, mouseX, mouseY, scale, offsetX, offsetY); + if (selectedNode) { + await this.handleSelectNode(selectedNode, { source: 'canvas' }); + } + }, + onMouseLeave() { + if (this.hoveredNode) { + this.hoveredNode = null; + this.renderHierarchy(); + } + }, + async handleTreeNodeClick(node) { + await this.handleSelectNode(node, { source: 'tree' }); + }, + async handleSelectNode(node, options = {}) { + if (!node || !node._id) { + return; + } + const { source = 'external' } = options; + const canonicalNode = (this.nodeIndex && this.nodeIndex[node._id]) || node; + this.selectedNode = canonicalNode; + this.hoveredNode = null; + const shouldScrollTree = source !== 'tree'; + this.syncTreeSelection(canonicalNode._id, { ensureVisible: shouldScrollTree }); + try { + await this.fetchXpathLite(canonicalNode._id); + if (this.selectedNode && this.selectedNode._id === canonicalNode._id) { + this.selectedNode.xpath = this.xpathLite; + } + } catch (err) { + console.error(err); + } + this.renderHierarchy(); + }, + async fetchXpathLite(nodeId) { + try { + const response = await fetchXpathLite(nodeId); + if (response.success) { + this.xpathLite = response.data; + } else { + throw new Error(response.message || 'Fetch xpath failed'); + } + } catch (err) { + console.error(err); + this.xpathLite = '//'; + } + }, + filterNode(value, data) { + if (!value) return true; + if (!data) return false; + const candidates = [data._type, data.text, data.id, data.name, data.componentPath]; + return candidates.some((field) => field && field.toString().indexOf(value) !== -1); + }, + copyValue(value) { + const success = copyToClipboard(value); + this.$message({ showClose: true, message: success ? 'Copied' : 'Copy failed', type: success ? 'success' : 'error' }); + }, + startDrag() { + this.isDragging = true; + if (this._onDocumentDrag) { + document.addEventListener('mousemove', this._onDocumentDrag); + } + if (this._onDocumentMouseUp) { + document.addEventListener('mouseup', this._onDocumentMouseUp); + } + }, + onDrag(event) { + const leftWidth = this.$el.querySelector('.left').offsetWidth; + this.centerWidth = Math.max(360, event.clientX - leftWidth); + }, + stopDrag() { + this.isDragging = false; + if (this._onDocumentDrag) { + document.removeEventListener('mousemove', this._onDocumentDrag); + } + if (this._onDocumentMouseUp) { + document.removeEventListener('mouseup', this._onDocumentMouseUp); + } + }, + hoverDivider() { + this.isDividerHovered = true; + }, + leaveDivider() { + this.isDividerHovered = false; + }, + syncTreeSelection(nodeId, options = {}) { + const { ensureVisible = true } = options; + const tree = this.$refs.treeRef; + if (!tree || typeof tree.setCurrentKey !== 'function') { + return; + } + if (nodeId === undefined || nodeId === null) { + tree.setCurrentKey(null); + return; + } + const currentKey = typeof tree.getCurrentKey === 'function' ? tree.getCurrentKey() : null; + if (currentKey !== nodeId) { + tree.setCurrentKey(nodeId); + } + if (ensureVisible) { + this.$nextTick(() => { + const wrapper = this.$el.querySelector('.hierarchy-tree-wrapper'); + if (!wrapper) { + return; + } + const target = + wrapper.querySelector('.el-tree-node.is-current > .el-tree-node__content') || + wrapper.querySelector('.el-tree-node.is-current'); + if (target && typeof target.scrollIntoView === 'function') { + try { + target.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); + } catch (err) { + target.scrollIntoView(); + } + } + }); + } + } + } +}); diff --git a/res/ui-viewer/static/js/utils.js b/res/ui-viewer/static/js/utils.js new file mode 100644 index 0000000..653abf5 --- /dev/null +++ b/res/ui-viewer/static/js/utils.js @@ -0,0 +1,31 @@ +export function saveToLocalStorage(key, value) { + localStorage.setItem(key, value); +} + +export function getFromLocalStorage(key, defaultValue) { + const stored = localStorage.getItem(key); + return stored !== null ? stored : defaultValue; +} + +export function copyToClipboard(value) { + if (typeof value === 'object') { + value = JSON.stringify(value, null, 2); + } + + if (value === null || value === undefined) { + value = ''; + } + + const textarea = document.createElement('textarea'); + textarea.value = value; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + return true; + } catch (err) { + return false; + } finally { + document.body.removeChild(textarea); + } +} diff --git a/src/cli/cli.ts b/src/cli/cli.ts index ee382bc..5d78b72 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,72 +1,136 @@ -/* - * Copyright (c) 2024 Huawei Device Co., Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { program } from 'commander'; -import { Fuzz } from '../runner/fuzz'; -import path from 'path'; -import fs from 'fs'; -import { HapTestLogger, LOG_LEVEL } from '../utils/logger'; -import { FuzzOptions } from '../runner/fuzz_options'; -import { EnvChecker } from './env_checker'; -import { getLogger } from 'log4js'; -const logger = getLogger(); - -(async function (): Promise { - let packageCfg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), { encoding: 'utf-8' })); - program - .name(packageCfg.name) - .version(packageCfg.version) - .option('-i --hap ', 'HAP bundle name or HAP file path or HAP project source root') - .option('-o --output ', 'output dir', 'out') - .option('--policy ', 'policy name', 'manu') - .option('-t --target [connectkey]', 'hdc connectkey', undefined) - .option('-c --coverage', 'enable coverage', false) - .option('--report [report root]', 'report root') - .option('--debug', 'debug log level', false) - .option('--exclude [excludes...]', 'exclude bundle name') - .option('--llm', 'start llm policy', false) - .option('--simK ', '', '8') - .option('--staticConfig ', '静态引导策略配置文件路径') - .parse(); - let options = program.opts(); - let logLevel = LOG_LEVEL.INFO; - if (options.debug) { - logLevel = LOG_LEVEL.DEBUG; - } - - let fuzzOption: FuzzOptions = { - connectkey: options.target, - hap: options.hap, - policyName: options.policy, - output: options.output, - coverage: options.coverage, - reportRoot: options.report, - excludes: options.exclude, - llm: options.llm, - simK: options.simK, - staticConfig: options.staticConfig, - }; - - HapTestLogger.configure(path.join(options.output, 'haptest.log'), logLevel); - logger.info(`haptest start by args ${JSON.stringify(options)}.`); - - let envChecker = new EnvChecker(fuzzOption); - envChecker.check(); - - let fuzz = new Fuzz(fuzzOption); - await fuzz.start(); - logger.info('stop fuzz.'); - process.exit(); -})(); +/* + * Copyright (c) 2024 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import { program } from 'commander'; +import { getLogger } from 'log4js'; +import { Fuzz } from '../runner/fuzz'; +import { FuzzOptions } from '../runner/fuzz_options'; +import { EnvChecker } from './env_checker'; +import { HapTestLogger, LOG_LEVEL } from '../utils/logger'; +import { startUIViewerServer } from '../ui/ui_viewer_server'; + +const logger = getLogger(); + +interface BaseOptions { + debug?: boolean; +} + +const parsePackageConfig = () => { + return JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), { encoding: 'utf-8' })); +}; + +function resolveLogLevel(opts: BaseOptions): LOG_LEVEL { + return opts.debug ? LOG_LEVEL.DEBUG : LOG_LEVEL.INFO; +} + +async function runFuzzCommand(options: any): Promise { + const outputDir = path.resolve(options.output ?? 'out'); + const logLevel = resolveLogLevel(options); + + HapTestLogger.configure(path.join(outputDir, 'haptest.log'), logLevel); + logger.info(`haptest start by args ${JSON.stringify(options)}.`); + + const fuzzOption: FuzzOptions = { + connectkey: options.target, + hap: options.hap, + policyName: options.policy, + output: outputDir, + coverage: options.coverage, + reportRoot: options.report, + excludes: options.exclude, + llm: options.llm, + simK: options.simK, + staticConfig: options.staticConfig, + }; + + const envChecker = new EnvChecker(fuzzOption); + envChecker.check(); + + const fuzz = new Fuzz(fuzzOption); + await fuzz.start(); + logger.info('stop fuzz.'); + process.exit(); +} + +async function runUIViewerCommand(options: any, version: string): Promise { + const outputDir = path.resolve(options.output ?? 'out'); + fs.mkdirSync(outputDir, { recursive: true }); + + const logLevel = resolveLogLevel(options); + const port = Number(options.port); + if (Number.isNaN(port) || port <= 0) { + throw new Error(`Invalid port: ${options.port}`); + } + + HapTestLogger.configure(path.join(outputDir, 'haptest.log'), logLevel); + const targetLabel = options.target ?? 'auto'; + logger.info(`haptest ui-viewer start with target=${targetLabel}, port=${port}`); + + await startUIViewerServer({ + connectKey: options.target, + outputDir, + port, + logLevel, + version, + }); +} + +(async function (): Promise { + const packageCfg = parsePackageConfig(); + + program.name(packageCfg.name).version(packageCfg.version); + + program + .command('ui-viewer') + .description('Start the HapTest UI viewer web service') + .option('-t, --target [connectkey]', 'hdc connectkey') + .option('-p, --port ', 'http port', '7789') + .option('-o, --output ', 'output dir', 'out/ui-viewer') + .option('--debug', 'debug log level', false) + .action(async (cmdOptions) => { + try { + await runUIViewerCommand(cmdOptions, packageCfg.version); + } catch (err) { + logger.error('Failed to start ui-viewer command.', err); + process.exit(1); + } + }); + + program + .description('HapTest fuzz runner') + .option('-i --hap ', 'HAP bundle name or HAP file path or HAP project source root') + .option('-o --output ', 'output dir', 'out') + .option('--policy ', 'policy name', 'manu') + .option('-t --target [connectkey]', 'hdc connectkey', undefined) + .option('-c --coverage', 'enable coverage', false) + .option('--report [report root]', 'report root') + .option('--debug', 'debug log level', false) + .option('--exclude [excludes...]', 'exclude bundle name') + .option('--llm', 'start llm policy', false) + .option('--simK ', '', '8') + .option('--staticConfig ', 'Path to static configuration file') + .action(async (cmdOptions) => { + try { + await runFuzzCommand(cmdOptions); + } catch (err) { + logger.error('haptest fuzz command failed.', err); + process.exit(1); + } + }); + + await program.parseAsync(process.argv); +})(); diff --git a/src/device/device.ts b/src/device/device.ts index 997aeff..5dda806 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -17,7 +17,9 @@ import fs from 'fs'; import { Event } from '../event/event'; import { KeyCode } from '../model/key_code'; import { Hap, HapRunningState } from '../model/hap'; -import { BACKGROUND_PAGE, Page, STOP_PAGE } from '../model/page'; +import { BACKGROUND_PAGE, Page, STOP_PAGE } from '../model/page'; +import { Component } from '../model/component'; +import { ViewTree } from '../model/viewtree'; import { Point } from '../model/point'; import { Hdc } from './hdc'; import path from 'path'; @@ -33,8 +35,8 @@ import { getLogger } from 'log4js'; import moment from 'moment'; import { ArkUIInspector } from './arkui_inspector'; import { TouchEvent } from '../event/ui_event'; -import { ArkUiDriver } from './uidriver/arkui_driver'; -import { buildDriverImpl } from './uidriver/build'; +import { ArkUiDriver } from './uidriver/arkui_driver'; +import { buildDriverImpl, DriverContext } from './uidriver/build'; import { Gesture } from '../event/gesture'; import { PageBuilder } from '../model/builder/page_builder'; const logger = getLogger(); @@ -49,7 +51,7 @@ export class Device implements EventSimulator { private options: FuzzOptions; private arkuiInspector: ArkUIInspector; private lastFaultlogs: Set; - private driver?: ArkUiDriver; + private driverCtx?: DriverContext; constructor(options: FuzzOptions) { this.options = options; @@ -65,21 +67,34 @@ export class Device implements EventSimulator { this.lastFaultlogs = this.collectFaultLogger(); } - async connect(hap: Hap) { - // install hap - this.installHap(hap); - if (this.options.coverage) { - this.coverage = new Coverage(this, hap, this.options.sourceRoot!); - this.coverage.startBftp(); - } - - this.driver = await buildDriverImpl(this); - this.displaySize = await this.driver.getDisplaySize(); - } - - getDriver(): ArkUiDriver { - return this.driver!; - } + async connect(hap: Hap) { + await this.teardownDriver(); + // install hap + this.installHap(hap); + if (this.options.coverage) { + this.coverage = new Coverage(this, hap, this.options.sourceRoot!); + this.coverage.startBftp(); + } + + this.driverCtx = await buildDriverImpl(this); + this.displaySize = await this.driverCtx.driver.getDisplaySize(); + } + + getDriver(): ArkUiDriver { + return this.driverCtx!.driver; + } + + async disconnect(): Promise { + await this.teardownDriver(); + if (this.options.coverage && this.coverage) { + try { + this.coverage.stopBftp(); + } catch { + // ignore + } + } + this.coverage = undefined; + } /** * Get output path @@ -184,16 +199,22 @@ export class Device implements EventSimulator { */ async dumpViewTree(): Promise { let retryCnt = 5; - while (retryCnt-- >= 0) { - let layout = await this.driver!.dumpLayout(); - let pages = PageBuilder.buildPagesFromJson(JSON.stringify(layout)); - // if exist keyboard then close and dump again. - if (this.closeKeyboard(pages)) { - // for sleep - this.hdc.getDeviceUdid(); - continue; - } - pages.sort((a: Page, b: Page) => { + let attempt = 0; + while (retryCnt-- >= 0) { + attempt += 1; + let layout = await this.driverCtx!.driver.dumpLayout(); + let pages = PageBuilder.buildPagesFromLayout(layout); + logger.debug( + `dumpViewTree attempt=${attempt} layoutType=${layout ? typeof layout : 'undefined'} pages=${pages.length}` + ); + // if exist keyboard then close and dump again. + if (this.closeKeyboard(pages)) { + logger.info('Keyboard detected during dumpViewTree, sending hide event and retrying.'); + // for sleep + this.hdc.getDeviceUdid(); + continue; + } + pages.sort((a: Page, b: Page) => { return b.getRoot().getHeight() - a.getRoot().getHeight(); }); @@ -201,8 +222,9 @@ export class Device implements EventSimulator { return pages[0]; } } - throw new Error('Device->dumpViewTree fail.'); - } + logger.warn('Device->dumpViewTree returned empty layout after retries. Returning fallback page.'); + return this.createFallbackPage(); + } /** * Detect keyboard and close it. @@ -249,7 +271,7 @@ export class Device implements EventSimulator { * @param point */ async click(point: Point): Promise { - await this.driver?.click(point.x, point.y); + await this.driverCtx?.driver?.click(point.x, point.y); } /** @@ -257,7 +279,7 @@ export class Device implements EventSimulator { * @param point */ async doubleClick(point: Point): Promise { - await this.driver?.doubleClick(point.x, point.y); + await this.driverCtx?.driver?.doubleClick(point.x, point.y); } /** @@ -265,7 +287,7 @@ export class Device implements EventSimulator { * @param point */ async longClick(point: Point): Promise { - await this.driver?.longClick(point.x, point.y); + await this.driverCtx?.driver?.longClick(point.x, point.y); } /** @@ -274,7 +296,7 @@ export class Device implements EventSimulator { * @param text */ async inputText(point: Point, text: string): Promise { - await this.driver?.inputText(point, text); + await this.driverCtx?.driver?.inputText(point, text); } /** @@ -285,7 +307,7 @@ export class Device implements EventSimulator { * @param step swipe step size */ async fling(from: Point, to: Point, step: number = 50, speed: number = 600): Promise { - await this.driver?.fling(from, to, step, speed); + await this.driverCtx?.driver?.fling(from, to, step, speed); } /** @@ -295,7 +317,7 @@ export class Device implements EventSimulator { * @param speed value range [200-40000] */ async swipe(from: Point, to: Point, speed: number = 600) { - await this.driver?.swipe(from.x, from.y, to.x, to.y, speed); + await this.driverCtx?.driver?.swipe(from.x, from.y, to.x, to.y, speed); } /** @@ -305,7 +327,7 @@ export class Device implements EventSimulator { * @param speed value range [200-40000] */ async drag(from: Point, to: Point, speed: number = 600) { - await this.driver?.drag(from.x, from.y, to.x, to.y, speed); + await this.driverCtx?.driver?.drag(from.x, from.y, to.x, to.y, speed); } /** @@ -316,14 +338,14 @@ export class Device implements EventSimulator { */ async inputKey(key0: KeyCode, key1?: KeyCode, key2?: KeyCode) { if (!key1) { - await this.driver?.triggerKey(key0); + await this.driverCtx?.driver?.triggerKey(key0); } else { - await this.driver?.triggerCombineKeys(key0, key1, key2); + await this.driverCtx?.driver?.triggerCombineKeys(key0, key1, key2); } } async injectGesture(gestures: Gesture[], speed: number) { - await this.driver?.injectGesture(gestures, speed); + await this.driverCtx?.driver?.injectGesture(gestures, speed); } /** @@ -352,20 +374,24 @@ export class Device implements EventSimulator { * @param hap * @returns */ - async getCurrentPage(hap: Hap): Promise { - let page = await this.dumpViewTree(); - if (this.options.sourceRoot) { - let inspector = await this.dumpInspector(hap.bundleName); - page.mergeInspector(inspector.layout); - } - - // set hap running state - if (page.getBundleName() === hap.bundleName) { - let snapshot = this.getSnapshot(true); - page.setSnapshot(snapshot); - return page; - } - + async getCurrentPage(hap: Hap): Promise { + let page = await this.dumpViewTree(); + const pageBundleName = page.getBundleName(); + if (!hap.bundleName) { + hap.bundleName = pageBundleName; + } + if (this.options.sourceRoot) { + let inspector = await this.dumpInspector(hap.bundleName); + page.mergeInspector(inspector.layout); + } + + // set hap running state + if (pageBundleName === hap.bundleName) { + let snapshot = this.getSnapshot(true); + page.setSnapshot(snapshot); + return page; + } + let runningState = this.getHapRunningState(hap); let snapshot = this.getSnapshot(false); if (runningState === HapRunningState.STOP) { @@ -383,29 +409,64 @@ export class Device implements EventSimulator { * Get current device state * @returns */ - getSnapshot(onForeground: boolean): Snapshot { - let screen = this.capScreen(); - let faultlogs = this.collectFaultLogger(); - let diffLogs = new Set(); - for (const log of faultlogs) { - if (!this.lastFaultlogs.has(log)) { - diffLogs.add(log); - } - } - this.lastFaultlogs = faultlogs; - - return new Snapshot( - this, - screen, - diffLogs, - this.coverage ? this.coverage.getCoverageFile(onForeground) : undefined - ); - } - - /** - * Install hap to device - * @param hap hap file - */ + getSnapshot(onForeground: boolean): Snapshot { + let screen = this.capScreen(); + let faultlogs = this.collectFaultLogger(); + let diffLogs = new Set(); + for (const log of faultlogs) { + if (!this.lastFaultlogs.has(log)) { + diffLogs.add(log); + } + } + this.lastFaultlogs = faultlogs; + + return new Snapshot( + this, + screen, + diffLogs, + this.coverage ? this.coverage.getCoverageFile(onForeground) : undefined + ); + } + + private createFallbackPage(): Page { + const root = new Component(); + root.type = 'Empty'; + const tree = new ViewTree(root); + return new Page(tree, '', '', ''); + } + + private async teardownDriver(): Promise { + if (!this.driverCtx) { + return; + } + + const ctx = this.driverCtx; + this.driverCtx = undefined; + this.displaySize = undefined; + + try { + await ctx.driver.free(); + } catch { + // ignore driver cleanup errors + } + + try { + await ctx.rpc.close(); + } catch { + // ignore rpc cleanup errors + } + + try { + await ctx.agent.stop(); + } catch { + // ignore agent cleanup errors + } + } + + /** + * Install hap to device + * @param hap hap file + */ installHap(hap: Hap) { if (hap.hapFile) { this.hdc.installHap(hap.hapFile); diff --git a/src/device/hdc.ts b/src/device/hdc.ts index 3c5823d..02d8aea 100644 --- a/src/device/hdc.ts +++ b/src/device/hdc.ts @@ -13,7 +13,7 @@ * limitations under the License. */ -import { spawn, spawnSync, SpawnSyncReturns } from 'child_process'; +import { spawn, spawnSync, SpawnSyncReturns } from 'child_process'; import path from 'path'; import { convertStr2RunningState, Hap, HapRunningState } from '../model/hap'; import { HdcCmdError } from '../error/error'; @@ -21,24 +21,32 @@ import { getLogger } from 'log4js'; const logger = getLogger(); -export const NEWLINE = /\r\n|\n/; -const MEMDUMPER = '/data/local/tmp/memdumper'; - -export class Hdc { - private connectkey: string | undefined; - - constructor(connectkey: string | undefined = undefined) { - this.connectkey = connectkey; - this.initDeviceEnv(); - } - - private initDeviceEnv(): void { - if (!this.hasFile(MEMDUMPER)) { - let memdumpFile = path.join(__dirname, '..', '..', 'res/memdumper/memdumper'); - this.sendFile(memdumpFile, MEMDUMPER); - this.excuteShellCommandSync(`chmod +x ${MEMDUMPER}`); - } - } +export const NEWLINE = /\r\n|\n/; +const MEMDUMPER = '/data/local/tmp/memdumper'; + +export interface HdcTargetInfo { + serial: string; + transport: string; + state: string; + host: string; + type: string; +} + +export class Hdc { + private connectkey: string | undefined; + + constructor(connectkey: string | undefined = undefined) { + this.connectkey = connectkey; + this.initDeviceEnv(); + } + + private initDeviceEnv(): void { + if (!this.hasFile(MEMDUMPER)) { + let memdumpFile = path.join(__dirname, '..', '..', 'res/memdumper/memdumper'); + this.sendFile(memdumpFile, MEMDUMPER); + this.excuteShellCommandSync(`chmod +x ${MEMDUMPER}`); + } + } sendFile(local: string, remote: string): number { let output = this.excuteSync('file', 'send', local, remote); @@ -387,11 +395,11 @@ export class Hdc { return this.excute('shell', ...args); } - async excute(command: string, ...params: string[]): Promise { - return new Promise((resolve) => { - let args: string[] = []; - if (this.connectkey) { - args.push(...['-t', this.connectkey]); + async excute(command: string, ...params: string[]): Promise { + return new Promise((resolve) => { + let args: string[] = []; + if (this.connectkey) { + args.push(...['-t', this.connectkey]); } args.push(...[command, ...params]); logger.debug(`hdc excute: ${JSON.stringify(args)}`); @@ -414,7 +422,49 @@ export class Hdc { if (code !== 0) { logger.debug(`hdc process exited with code ${code}`); } - }); - }); - } -} + }); + }); + } + + static listTargets(): HdcTargetInfo[] { + const result = spawnSync('hdc', ['list', 'targets', '-v'], { encoding: 'utf-8', shell: true }); + if (result.error) { + throw new Error(`Failed to execute hdc: ${result.error.message}`); + } + + const stderr = (result.stderr || '').trim(); + const stdout = (result.stdout || '').trim(); + if (!stdout && stderr) { + throw new Error(stderr); + } + + const entries: HdcTargetInfo[] = []; + const lines = stdout.split(NEWLINE); + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + continue; + } + const lower = line.toLowerCase(); + if (lower.startsWith('list targets') || lower.startsWith('total')) { + continue; + } + if (line.startsWith('[Fail]')) { + throw new Error(line); + } + const tokens = line.split(/\s+/).filter((token) => token.length > 0); + if (tokens.length < 3) { + continue; + } + const [serial, transport = '', state = '', host = '', type = ''] = tokens; + entries.push({ + serial, + transport, + state, + host, + type, + }); + } + return entries; + } +} diff --git a/src/device/uidriver/build.ts b/src/device/uidriver/build.ts index a83b2e7..a13332a 100644 --- a/src/device/uidriver/build.ts +++ b/src/device/uidriver/build.ts @@ -13,26 +13,30 @@ * limitations under the License. */ -import { Device } from '../device'; -import { ArkUiDriver } from './arkui_driver'; -import { Gesture } from '../../event/gesture'; -import { HypiumRpc } from './hypium_rpc'; -import { PointerMatrix } from './pointer_matrix'; -import { UitestAgent } from './uitest_agent'; - -export async function buildDriverImpl(device: Device): Promise { - // set uitest agent mode - let agent = new UitestAgent(device); - await agent.start(); - - // create rpc - let rpc = new HypiumRpc(); - await rpc.connect(agent.getHostPort()); - - let driver = new ArkUiDriver(rpc); - await driver.create(); - return driver; -} +import { Device } from '../device'; +import { ArkUiDriver } from './arkui_driver'; +import { Gesture } from '../../event/gesture'; +import { HypiumRpc } from './hypium_rpc'; +import { PointerMatrix } from './pointer_matrix'; +import { UitestAgent } from './uitest_agent'; + +export interface DriverContext { + driver: ArkUiDriver; + rpc: HypiumRpc; + agent: UitestAgent; +} + +export async function buildDriverImpl(device: Device): Promise { + let agent = new UitestAgent(device); + await agent.start(); + + let rpc = new HypiumRpc(); + await rpc.connect(agent.getHostPort()); + + let driver = new ArkUiDriver(rpc); + await driver.create(); + return { driver, rpc, agent }; +} export async function buildPointerMetrix(rpc: HypiumRpc, gestures: Gesture[], speed: number = 2000): Promise { let matrix = new PointerMatrix(rpc); diff --git a/src/device/uidriver/hypium_rpc.ts b/src/device/uidriver/hypium_rpc.ts index 375f36f..64728ed 100644 --- a/src/device/uidriver/hypium_rpc.ts +++ b/src/device/uidriver/hypium_rpc.ts @@ -27,18 +27,20 @@ export class HypiumRpc { this.connected = false; } - async connect(port: number, address: string = '127.0.0.1'): Promise { - this.socket.setTimeout(this.timeout); - await this.socket.connect(port, address); - this.socket.setTimeout(0); - return this.connected; - } - - async close() { - if (this.connected) { - await this.socket.close(); - } - } + async connect(port: number, address: string = '127.0.0.1'): Promise { + this.socket.setTimeout(this.timeout); + await this.socket.connect(port, address); + this.socket.setTimeout(0); + this.connected = true; + return this.connected; + } + + async close() { + if (this.connected) { + await this.socket.close(); + this.connected = false; + } + } async request(method: string, params: any): Promise { // if (!this.connected) { diff --git a/src/device/uidriver/uitest_agent.ts b/src/device/uidriver/uitest_agent.ts index c801a0d..43acac9 100644 --- a/src/device/uidriver/uitest_agent.ts +++ b/src/device/uidriver/uitest_agent.ts @@ -35,17 +35,29 @@ export class UitestAgent { return this.hostPort; } - async start() { - if (this.isRunning()) { - return; - } - + async start() { + if (this.isRunning()) { + return; + } + this.installAgentSo(); this.hdc.excuteShellCommandSync('/system/bin/uitest start-daemon singleness &'); this.hostPort = await hostUnusedPort(); - this.hdc.fportRm(`tcp:${this.hostPort}`, `tcp:${RPC_PORT}`); - this.hdc.fport(`tcp:${this.hostPort}`, `tcp:${RPC_PORT}`); - } + this.hdc.fportRm(`tcp:${this.hostPort}`, `tcp:${RPC_PORT}`); + this.hdc.fport(`tcp:${this.hostPort}`, `tcp:${RPC_PORT}`); + } + + async stop() { + if (this.hostPort === -1) { + return; + } + try { + this.hdc.fportRm(`tcp:${this.hostPort}`, `tcp:${RPC_PORT}`); + } catch (err) { + // ignore + } + this.hostPort = -1; + } private installAgentSo(): void { let deviceAgentFile = '/data/local/tmp/agent.so'; diff --git a/src/model/builder/page_builder.ts b/src/model/builder/page_builder.ts index dff2335..4e57709 100644 --- a/src/model/builder/page_builder.ts +++ b/src/model/builder/page_builder.ts @@ -65,38 +65,83 @@ interface DumpLayoutNode { children: DumpLayoutNode[]; } -export class PageBuilder { - static buildPagesFromJson(json: string): Page[] { - let layout: DumpLayoutNode = JSON.parse(json, (key: string, value: any) => { - if (BOOLEAN_TYPE_KEYS.has(key)) { - return value === 'true'; - } else if (POINT_TYPE_KEYS.has(key)) { - let points: Point[] = []; - for (let point of value.split('][')) { - let [x, y] = point.replace('[', '').replace(']', '').split(','); - if (x && y) { - points.push({ x: Number(x), y: Number(y) }); - } +interface NormalizedPageEntry { + node: DumpLayoutNode; + metaSources: any[]; +} + +function parsePointTokens(value: string): Point[] { + const points: Point[] = []; + const tokens = value.split(']['); + for (const token of tokens) { + const cleaned = token.replace('[', '').replace(']', ''); + if (!cleaned) { + continue; + } + const [x, y] = cleaned.split(','); + if (x !== undefined && y !== undefined) { + const px = Number(x); + const py = Number(y); + if (!Number.isNaN(px) && !Number.isNaN(py)) { + points.push({ x: px, y: py }); + } + } + } + return points; +} + +function normalizePointValue(value: any): Point[] { + if (Array.isArray(value)) { + const points: Point[] = []; + for (const entry of value) { + if (Array.isArray(entry) && entry.length >= 2) { + const [x, y] = entry; + points.push({ x: Number(x), y: Number(y) }); + } else if (entry && typeof entry === 'object') { + const x = 'x' in entry ? Number(entry.x) : Number((entry as any)[0]); + const y = 'y' in entry ? Number(entry.y) : Number((entry as any)[1]); + if (!Number.isNaN(x) && !Number.isNaN(y)) { + points.push({ x, y }); } - return points; } - return value; - }); + } + return points; + } + if (typeof value === 'string') { + return parsePointTokens(value); + } + return []; +} - if (layout === null) { - return []; +function dumpLayoutReviver(key: string, value: any): any { + if (BOOLEAN_TYPE_KEYS.has(key)) { + if (typeof value === 'boolean') { + return value; } + if (typeof value === 'string') { + return value === 'true'; + } + return Boolean(value); + } + if (POINT_TYPE_KEYS.has(key)) { + return normalizePointValue(value); + } + return value; +} - let pages: Page[] = []; - for (let child of layout.children) { - pages.push( - new Page( - PageBuilder.buildViewTree(child), - child.attributes.abilityName!, - child.attributes.bundleName!, - child.attributes.pagePath! - ) - ); +export class PageBuilder { + static buildPagesFromJson(json: string): Page[] { + return this.buildPagesFromLayout(json); + } + + static buildPagesFromLayout(layoutInput: unknown): Page[] { + const layout = this.decodeLayout(layoutInput); + const normalized = this.normalizePages(layout); + + const pages: Page[] = []; + for (const entry of normalized) { + const meta = this.extractMeta(entry.metaSources); + pages.push(new Page(this.buildViewTree(entry.node), meta.abilityName, meta.bundleName, meta.pagePath)); } return pages; @@ -106,6 +151,169 @@ export class PageBuilder { return this.buildPagesFromJson(fs.readFileSync(layoutFile, 'utf-8')); } + private static decodeLayout(layoutInput: unknown): any { + if (layoutInput === null || layoutInput === undefined) { + return null; + } + if (typeof layoutInput === 'string') { + const trimmed = layoutInput.trim(); + if (!trimmed) { + return null; + } + return JSON.parse(trimmed, dumpLayoutReviver); + } + if (typeof layoutInput === 'object') { + try { + return JSON.parse(JSON.stringify(layoutInput), dumpLayoutReviver); + } catch { + return null; + } + } + return null; + } + + private static normalizePages(layout: any): NormalizedPageEntry[] { + if (!layout) { + return []; + } + + if (Array.isArray(layout)) { + return layout.flatMap((item) => this.normalizePages(item)); + } + + if (layout.data) { + return this.normalizePages(layout.data); + } + + if (layout.result) { + return this.normalizePages(layout.result); + } + + const result: NormalizedPageEntry[] = []; + const pushNode = (node: any, metaSources: any[]) => { + if (!node || typeof node !== 'object') { + return; + } + if (!node.attributes) { + return; + } + if (!Array.isArray(node.children)) { + node.children = []; + } + const sources = metaSources.filter(Boolean); + result.push({ node: node as DumpLayoutNode, metaSources: sources }); + }; + + if (Array.isArray(layout.children) && layout.children.length > 0) { + for (const child of layout.children) { + pushNode(child, [child?.attributes, layout?.attributes, layout?.metadata, layout]); + } + return result; + } + + if (Array.isArray(layout.pages) && layout.pages.length > 0) { + for (const page of layout.pages) { + if (page?.root) { + pushNode(page.root, [page.root?.attributes, page?.metadata, page, layout?.metadata, layout]); + } else { + pushNode(page, [page?.attributes, page?.metadata, layout?.metadata, layout]); + } + } + return result; + } + + if (Array.isArray(layout.windows) && layout.windows.length > 0) { + for (const win of layout.windows) { + if (win?.root) { + pushNode(win.root, [win.root?.attributes, win?.metadata, win, layout?.metadata, layout]); + } + } + if (result.length > 0) { + return result; + } + } + + if (layout.root) { + pushNode(layout.root, [layout.root?.attributes, layout?.metadata, layout]); + if (result.length > 0) { + return result; + } + } + + pushNode(layout, [layout?.attributes, layout?.metadata, layout]); + if (result.length === 0) { + const fallback = this.findFirstNode(layout); + if (fallback) { + result.push(fallback); + } + } + return result; + } + + private static extractMeta(metaSources: any[]): { abilityName: string; bundleName: string; pagePath: string } { + return { + abilityName: this.extractMetaValue(metaSources, 'abilityName'), + bundleName: this.extractMetaValue(metaSources, 'bundleName'), + pagePath: this.extractMetaValue(metaSources, 'pagePath'), + }; + } + + private static findFirstNode(value: any, ancestors: any[] = []): NormalizedPageEntry | null { + if (!value || typeof value !== 'object') { + return null; + } + if (Array.isArray(value)) { + for (const item of value) { + const found = this.findFirstNode(item, ancestors); + if (found) { + return found; + } + } + return null; + } + + const metaSources = [value?.attributes, value?.metadata, value, ...ancestors].filter(Boolean); + if (value.attributes && (Array.isArray(value.children) || value.children === undefined)) { + if (!Array.isArray(value.children)) { + value.children = Array.isArray(value.children) ? value.children : []; + } + return { node: value as DumpLayoutNode, metaSources }; + } + + for (const key of Object.keys(value)) { + if (key === 'attributes' || key === 'metadata') { + continue; + } + const found = this.findFirstNode(value[key], metaSources); + if (found) { + return found; + } + } + + return null; + } + + private static extractMetaValue(metaSources: any[], key: string): string { + const normalizedKey = key.toLowerCase(); + for (const source of metaSources) { + if (!source || typeof source !== 'object') { + continue; + } + if (source[key] !== undefined && source[key] !== null && source[key] !== '') { + return String(source[key]); + } + for (const prop of Object.keys(source)) { + if (prop.toLowerCase() === normalizedKey) { + const value = source[prop]; + if (value !== undefined && value !== null && value !== '') { + return String(value); + } + } + } + } + return ''; + } + static buildComponent(node: DumpLayoutNode, parent: Component | null = null): Component { let component = new Component(); component.bounds = node.attributes.bounds; diff --git a/src/ui/hierarchy_builder.ts b/src/ui/hierarchy_builder.ts new file mode 100644 index 0000000..3ea3284 --- /dev/null +++ b/src/ui/hierarchy_builder.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2024 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { randomUUID } from 'crypto'; +import { Component } from '../model/component'; +import { Point } from '../model/point'; + +export interface HierarchyRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface HierarchyNode { + _id: string; + _parentId: string | null; + index: number; + text: string; + id: string; + name?: string; + hint?: string; + _type: string; + description?: string; + checkable: boolean; + clickable: boolean; + enabled: boolean; + focusable: boolean; + focused: boolean; + scrollable: boolean; + longClickable: boolean; + selected: boolean; + rect: HierarchyRect; + bounds: Point[]; + debugLine?: string; + componentPath: string; + xpath: string; + children: HierarchyNode[]; +} + +export interface HierarchyTree { + root: HierarchyNode; + map: Map; +} + +function toRect(bounds?: Point[]): HierarchyRect { + if (!bounds || bounds.length < 2) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + const first = bounds[0]; + const second = bounds[1]; + const minX = Math.min(first.x, second.x); + const minY = Math.min(first.y, second.y); + return { + x: minX, + y: minY, + width: Math.abs(second.x - first.x), + height: Math.abs(second.y - first.y), + }; +} + +function cloneBounds(bounds?: Point[]): Point[] { + if (!bounds) { + return []; + } + return bounds.map((point) => ({ x: point.x, y: point.y })); +} + +export function buildHierarchy(rootComponent: Component): HierarchyTree { + const map = new Map(); + + const visit = (component: Component, parentId: string | null, index: number, parentPath: string): HierarchyNode => { + const id = randomUUID(); + const rect = toRect(component.bounds); + const type = component.type ?? ''; + const siblingIndex = index + 1; + const componentPath = parentPath.length > 0 ? `${parentPath} > ${type || 'Node'}[${siblingIndex}]` : `${type || 'Node'}[1]`; + + const node: HierarchyNode = { + _id: id, + _parentId: parentId, + index, + text: component.text ?? '', + id: component.id ?? '', + name: component.name ?? undefined, + hint: component.hint ?? undefined, + _type: type, + description: component.hint ?? undefined, + checkable: component.checkable ?? false, + clickable: component.clickable ?? false, + enabled: component.enabled ?? false, + focusable: component.clickable ?? false, + focused: component.focused ?? false, + scrollable: component.scrollable ?? false, + longClickable: component.longClickable ?? false, + selected: component.selected ?? false, + rect, + bounds: cloneBounds(component.bounds), + debugLine: component.debugLine ?? undefined, + componentPath, + xpath: '', + children: [], + }; + + map.set(id, node); + node.children = component.children.map((child, childIndex) => visit(child, id, childIndex, componentPath)); + return node; + }; + + const rootNode = visit(rootComponent, null, 0, ''); + return { root: rootNode, map }; +} + +export function generateXPathLite(nodeId: string, tree: HierarchyTree): string { + const { map } = tree; + const node = map.get(nodeId); + if (!node) { + return '//'; + } + + const segments: string[] = []; + let current: HierarchyNode | undefined = node; + while (current) { + const parentId = current._parentId; + const type = current._type || 'Node'; + if (!parentId) { + segments.unshift(`${type}[1]`); + break; + } + const parent = map.get(parentId); + if (!parent) { + segments.unshift(`${type}[1]`); + break; + } + const siblings = parent.children.filter((item) => item._type === current!._type); + const position = siblings.indexOf(current) + 1; + segments.unshift(`${type}[${position > 0 ? position : 1}]`); + current = parent; + } + + return `//${segments.join('/')}`; +} diff --git a/src/ui/ui_viewer_server.ts b/src/ui/ui_viewer_server.ts new file mode 100644 index 0000000..a74d05f --- /dev/null +++ b/src/ui/ui_viewer_server.ts @@ -0,0 +1,717 @@ +/* + * Copyright (c) 2024 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import express, { Request, Response } from 'express'; +import { getLogger } from 'log4js'; +import { Device } from '../device/device'; +import { Hdc, HdcTargetInfo } from '../device/hdc'; +import { Hap } from '../model/hap'; +import { FuzzOptions } from '../runner/fuzz_options'; +import { LOG_LEVEL } from '../utils/logger'; +import { HierarchyTree, buildHierarchy, generateXPathLite } from './hierarchy_builder'; +import { Snapshot } from '../model/snapshot'; +import { Page } from '../model/page'; + +const logger = getLogger('haptest-ui-viewer'); +const DEVICE_CLEANUP_INTERVAL = 60 * 1000; + +interface CachedDeviceEntry { + key: string; + device: Device; + lastUsed: number; + refCount: number; + connectPromise: Promise | null; + ready: boolean; +} + +class DevicePool { + private static entries: Map = new Map(); + private static cleanupTimer: NodeJS.Timeout | null = null; + private static lastReleaseTimestamps: Map = new Map(); + private static readonly MIN_RECONNECT_DELAY_MS = 750; + private static readonly DEVICE_INACTIVE_TTL = 60 * 1000; + + private static makeKey(connectKey: string | undefined, outputDir: string): string { + return `${connectKey ?? 'auto'}|${outputDir}`; + } + + static async acquire(options: FuzzOptions, bundleName?: string): Promise<{ key: string; device: Device }> { + const key = this.makeKey(options.connectkey as string | undefined, options.output); + logger.info( + `[DevicePool] acquire requested | key=${key} | connectKey=${options.connectkey ?? 'auto'} | output=${options.output}` + ); + let entry = this.entries.get(key); + const now = Date.now(); + const lastRelease = this.lastReleaseTimestamps.get(key); + if (lastRelease) { + const elapsed = now - lastRelease; + if (elapsed < this.MIN_RECONNECT_DELAY_MS) { + const delay = this.MIN_RECONNECT_DELAY_MS - elapsed; + logger.info(`[DevicePool] reconnection delay ${delay}ms enforced for key=${key}`); + await sleep(delay); + } + } + + if (!entry) { + const device = new Device(options); + logger.info(`[DevicePool] creating new device entry for key=${key}`); + entry = { + key, + device, + lastUsed: Date.now(), + refCount: 0, + connectPromise: null, + ready: false, + }; + this.entries.set(key, entry); + entry.connectPromise = (async () => { + try { + const initialHap = new Hap(); + initialHap.bundleName = bundleName ?? ''; + logger.info(`[DevicePool] connecting device key=${key} with bundle=${initialHap.bundleName || 'auto'}`); + await device.connect(initialHap); + entry.ready = true; + logger.info(`[DevicePool] device key=${key} connected`); + } catch (err) { + this.entries.delete(key); + logger.error(`[DevicePool] device key=${key} failed to connect: ${String(err)}`); + throw err; + } finally { + entry.connectPromise = null; + entry.lastUsed = Date.now(); + } + })(); + } else { + logger.info( + `[DevicePool] reusing cached device key=${key} (refCount=${entry.refCount}, ready=${entry.ready})` + ); + } + + entry.refCount += 1; + logger.info(`[DevicePool] device key=${key} refCount incremented to ${entry.refCount}`); + this.startCleanupLoop(); + + if (entry.connectPromise) { + try { + await entry.connectPromise; + } catch (err) { + entry.refCount = Math.max(0, entry.refCount - 1); + logger.error(`[DevicePool] connect promise failed for key=${key}: ${String(err)}`); + throw err; + } + } + + entry.lastUsed = Date.now(); + return { key, device: entry.device }; + } + + static async release( + key: string | undefined, + reason: string = 'release', + options: { force?: boolean } = {} + ): Promise { + if (!key) { + return; + } + const entry = this.entries.get(key); + if (!entry) { + logger.warn(`[DevicePool] release requested for missing key=${key}`); + return; + } + if (entry.refCount > 0) { + entry.refCount -= 1; + } + entry.lastUsed = Date.now(); + logger.info( + `[DevicePool] release key=${key} | reason=${reason} | refCount=${entry.refCount} | force=${Boolean( + options.force + )}` + ); + if (entry.connectPromise) { + try { + await entry.connectPromise; + } catch (err) { + logger.warn(`[DevicePool] connect promise rejection during release key=${key}: ${String(err)}`); + } + } + if (entry.refCount <= 0) { + this.lastReleaseTimestamps.set(key, entry.lastUsed); + if (options.force) { + await this.disposeEntry(key, entry, reason); + return; + } + } + this.startCleanupLoop(); + } + + static touch(key: string | undefined): void { + if (!key) { + return; + } + const entry = this.entries.get(key); + if (entry) { + entry.lastUsed = Date.now(); + } + } + + private static startCleanupLoop(): void { + if (this.cleanupTimer) { + return; + } + this.cleanupTimer = setInterval(() => { + this.runCleanup().catch((err) => { + logger.warn(`DevicePool cleanup failed: ${String(err)}`); + }); + }, DEVICE_CLEANUP_INTERVAL); + } + + private static async runCleanup(): Promise { + const now = Date.now(); + for (const [key, entry] of this.entries.entries()) { + if ( + entry.refCount === 0 && + entry.ready && + !entry.connectPromise && + now - entry.lastUsed > this.DEVICE_INACTIVE_TTL + ) { + await this.disposeEntry(key, entry, 'cleanup'); + } + } + + if (this.entries.size === 0 && this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } + + private static async disposeEntry(key: string, entry: CachedDeviceEntry, reason: string): Promise { + if (!this.entries.has(key)) { + logger.debug(`[DevicePool] disposeEntry key=${key} already removed`); + } + this.entries.delete(key); + try { + logger.info(`[DevicePool] disposing device key=${key} | reason=${reason}`); + const disconnect = (entry.device as unknown as { disconnect?: () => Promise | void }).disconnect; + if (typeof disconnect === 'function') { + await disconnect.call(entry.device); + logger.info(`[DevicePool] device key=${key} disconnected`); + } else { + logger.debug(`[DevicePool] device key=${key} has no disconnect method, skipping teardown`); + } + } catch (err) { + logger.warn(`[DevicePool] failed to disconnect device key=${key} | reason=${reason} | error=${String(err)}`); + } + } +} + +interface ApiResponse { + success: boolean; + data: T | null; + message: string | null; +} + +interface UIViewerServerOptions { + bundleName?: string; + connectKey?: string; + outputDir: string; + port: number; + logLevel: LOG_LEVEL; + version: string; +} + +interface HierarchyResponse { + jsonHierarchy: any; + activityName: string; + packageName: string; + pagePath: string; + windowSize: [number, number]; + scale: number; + updatedAt: string; +} + +const success = (data: T): ApiResponse => ({ success: true, data, message: null }); +const failure = (message: string): ApiResponse => ({ success: false, data: null, message }); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class UIViewerSession { + private requestedBundleName?: string; + private connectKey?: string; + private readonly outputDir: string; + private device?: Device; + private deviceCacheKey?: string; + private hap?: Hap; + private lastPage?: Page; + private lastSnapshot?: Snapshot; + private lastScreenshotBase64?: string; + private hierarchy?: HierarchyTree; + private refreshing: Promise | null; + private connectPromise: Promise | null; + + constructor(bundleName: string | undefined, connectKey: string | undefined, outputDir: string) { + this.requestedBundleName = bundleName?.trim() || undefined; + this.connectKey = connectKey?.trim() || undefined; + this.outputDir = outputDir; + this.refreshing = null; + this.connectPromise = null; + } + + private describeSession(): string { + return `connectKey=${this.connectKey ?? 'auto'}, bundle=${this.requestedBundleName ?? 'auto'}, output=${this.outputDir}`; + } + + private logInfo(message: string): void { + logger.info(`[UIViewerSession] ${message} | ${this.describeSession()}`); + } + + private logDebug(message: string): void { + logger.debug(`[UIViewerSession] ${message} | ${this.describeSession()}`); + } + + private logWarn(message: string, err?: unknown): void { + if (err) { + logger.warn(`[UIViewerSession] ${message} | ${this.describeSession()} | error=${String(err)}`); + } else { + logger.warn(`[UIViewerSession] ${message} | ${this.describeSession()}`); + } + } + + private invalidateCache(): void { + this.lastPage = undefined; + this.lastSnapshot = undefined; + this.lastScreenshotBase64 = undefined; + this.hierarchy = undefined; + this.refreshing = null; + } + + private async drainRefresh(): Promise { + const pending = this.refreshing; + if (!pending) { + return; + } + try { + await pending; + } catch { + // ignore refresh failure + } finally { + if (this.refreshing === pending) { + this.refreshing = null; + } + } + } + + updateBundleName(bundleName?: string) { + const normalized = bundleName?.trim(); + if (!normalized) { + if (this.requestedBundleName) { + this.requestedBundleName = undefined; + if (this.hap) { + this.hap.bundleName = ''; + } + this.logInfo('Cleared bundle name'); + this.invalidateCache(); + } + return; + } + + if (normalized === this.requestedBundleName) { + return; + } + + this.requestedBundleName = normalized; + this.logInfo(`Updated bundle name to ${normalized}`); + if (this.hap) { + this.hap.bundleName = normalized; + } + this.invalidateCache(); + } + + private async updateConnectKey(connectKey?: string): Promise { + const normalized = connectKey ? connectKey.trim() : undefined; + if (normalized === this.connectKey) { + return; + } + this.logInfo(`Updating connect key to ${normalized ?? 'auto'}`); + if (this.connectPromise) { + try { + await this.connectPromise; + } catch { + // ignore errors from previous connection attempt + } + } + await this.drainRefresh(); + await this.disposeDevice('connect-key-change'); + this.connectKey = normalized; + } + + private async disposeDevice(reason: string = 'session-dispose'): Promise { + await this.drainRefresh(); + if (this.deviceCacheKey) { + this.logDebug(`Disposing device cache with key=${this.deviceCacheKey} reason=${reason}`); + const force = reason === 'session-dispose' || reason === 'acquire-failed'; + await DevicePool.release(this.deviceCacheKey, reason, { force }); + } + this.device = undefined; + this.hap = undefined; + this.deviceCacheKey = undefined; + this.invalidateCache(); + } + + private ensureConnectKeyResolved(): void { + if (this.connectKey && this.connectKey.length > 0) { + return; + } + let targets: HdcTargetInfo[]; + try { + targets = Hdc.listTargets(); + } catch (err) { + throw this.normalizeConnectionError(err); + } + const connected = targets.filter((item) => item.state.toLowerCase() === 'connected'); + if (connected.length === 0) { + throw new Error( + 'No connected devices detected. Please ensure HDC is installed and a device is connected (run "hdc list targets -v").' + ); + } + if (connected.length > 1) { + throw new Error('Multiple connected devices detected. Please select a target device before connecting.'); + } + this.connectKey = connected[0].serial; + } + + private normalizeConnectionError(err: unknown): Error { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + if ( + lower.includes('enoent') || + lower.includes('not recognized') || + lower.includes('command not found') || + lower.includes('hdc: not found') + ) { + return new Error('Unable to execute "hdc". Please install HDC and ensure it is available in your PATH.'); + } + if (lower.includes('need connect-key') || lower.includes('please confirm a device')) { + return new Error('Multiple devices detected. Please select a target device before connecting.'); + } + return err instanceof Error ? err : new Error(message); + } + + getConnectKey(): string | undefined { + return this.connectKey; + } + + async listDevices(): Promise { + try { + return Hdc.listTargets(); + } catch (err) { + throw this.normalizeConnectionError(err); + } + } + + getTargetAlias(): string { + if (this.connectKey) { + return this.connectKey; + } + if (this.device) { + try { + return this.device.getUdid(); + } catch (err) { + logger.warn(`Failed to get device udid: ${String(err)}`); + } + } + return 'local-device'; + } + + private buildFuzzOptions(): FuzzOptions { + return { + connectkey: this.connectKey as any, + hap: this.requestedBundleName ?? '', + policyName: 'ui-viewer', + output: this.outputDir, + coverage: false, + reportRoot: undefined, + excludes: undefined, + llm: false, + simK: 8, + staticConfig: undefined, + }; + } + + private async ensureConnected(): Promise { + if (this.device && this.hap) { + DevicePool.touch(this.deviceCacheKey); + this.logDebug('Device already connected, reusing existing session'); + return; + } + + if (this.connectPromise) { + await this.connectPromise; + return; + } + + this.connectPromise = (async () => { + try { + this.ensureConnectKeyResolved(); + const fuzzOptions = this.buildFuzzOptions(); + this.logInfo(`Acquiring device from pool`); + const { key, device } = await DevicePool.acquire(fuzzOptions, this.requestedBundleName); + this.deviceCacheKey = key; + this.device = device; + this.hap = new Hap(); + this.hap.bundleName = this.requestedBundleName ?? ''; + DevicePool.touch(this.deviceCacheKey); + this.logInfo(`Device acquired with cacheKey=${key}`); + } catch (err) { + this.logWarn('Failed to acquire device', err); + await this.disposeDevice('acquire-failed'); + throw this.normalizeConnectionError(err); + } finally { + this.connectPromise = null; + } + })(); + + await this.connectPromise; + } + + async ensureDeviceConnected(bundleName?: string, connectKey?: string): Promise { + if (bundleName !== undefined) { + this.updateBundleName(bundleName); + } + if (connectKey !== undefined) { + await this.updateConnectKey(connectKey); + } + this.logInfo('ensureDeviceConnected invoked'); + await this.ensureConnected(); + } + + private async innerRefresh(): Promise { + await this.ensureConnected(); + DevicePool.touch(this.deviceCacheKey); + if (!this.device || !this.hap) { + throw new Error('device is not ready.'); + } + + this.hap.bundleName = this.requestedBundleName ?? ''; + this.logInfo('Refreshing device snapshot'); + const page = await this.device.getCurrentPage(this.hap); + const snapshot = page.getSnapshot(); + if (!snapshot) { + throw new Error('Snapshot unavailable from device.'); + } + + const screenshotBase64 = this.loadScreenshot(snapshot.screenCapPath); + this.lastPage = page; + this.lastSnapshot = snapshot; + this.lastScreenshotBase64 = screenshotBase64; + this.hierarchy = buildHierarchy(page.getRoot()); + this.logInfo('Device snapshot refreshed successfully'); + } + + private loadScreenshot(screenCapPath: string): string { + const buffer = fs.readFileSync(screenCapPath); + try { + fs.unlinkSync(screenCapPath); + } catch (err) { + logger.warn(`Failed to remove screenshot file ${screenCapPath}: ${String(err)}`); + } + return buffer.toString('base64'); + } + + async refresh(): Promise { + if (this.refreshing) { + return this.refreshing; + } + + this.refreshing = this.innerRefresh() + .catch((err) => { + logger.error('Refresh device snapshot failed.', err); + throw err; + }) + .finally(() => { + this.refreshing = null; + }); + + return this.refreshing; + } + + async ensureHierarchyReady(): Promise { + if (!this.lastPage || !this.hierarchy || !this.lastSnapshot) { + await this.refresh(); + } + } + + async getScreenshot(): Promise { + await this.refresh(); + if (!this.lastScreenshotBase64) { + throw new Error('Screenshot not available.'); + } + return this.lastScreenshotBase64; + } + + async getHierarchy(): Promise { + await this.ensureHierarchyReady(); + if (!this.lastPage || !this.hierarchy || !this.lastSnapshot) { + throw new Error('Hierarchy not available.'); + } + + return { + jsonHierarchy: this.hierarchy.root, + activityName: this.lastPage.getAbilityName(), + packageName: this.lastPage.getBundleName(), + pagePath: this.lastPage.getPagePath(), + windowSize: [this.lastSnapshot.screenWidth, this.lastSnapshot.screenHeight], + scale: 1, + updatedAt: new Date().toISOString(), + }; + } + + async getXPathLite(nodeId: string): Promise { + await this.ensureHierarchyReady(); + if (!this.hierarchy) { + throw new Error('Hierarchy not available.'); + } + return generateXPathLite(nodeId, this.hierarchy); + } +} + +export async function startUIViewerServer(options: UIViewerServerOptions): Promise { + const app = express(); + app.use(express.json({ limit: '5mb' })); + + const session = new UIViewerSession(options.bundleName, options.connectKey, options.outputDir); + + app.get('/api/version', (_req: Request, res: Response) => { + res.json(success(options.version)); + }); + + app.get('/api/health', (_req: Request, res: Response) => { + res.json(success('ok')); + }); + + app.get('/api/harmony/devices', async (_req: Request, res: Response) => { + try { + const devices = await session.listDevices(); + res.json(success(devices)); + } catch (err) { + logger.error('Failed to list harmony devices.', err); + res.status(500).json(failure(err instanceof Error ? err.message : String(err))); + } + }); + + app.get('/api/harmony/serials', async (_req: Request, res: Response) => { + try { + const devices = await session.listDevices(); + res.json(success(devices.map((item) => item.serial))); + } catch (err) { + logger.error('Failed to list harmony serials.', err); + res.status(500).json(failure(err instanceof Error ? err.message : String(err))); + } + }); + + const connectHandler = async (req: Request, res: Response) => { + try { + const { bundleName, connectKey } = req.body ?? {}; + await session.ensureDeviceConnected(bundleName, connectKey); + res.json(success({ alias: session.getTargetAlias(), connectKey: session.getConnectKey() })); + } catch (err) { + logger.error('Failed to connect device.', err); + res.status(500).json(failure(err instanceof Error ? err.message : String(err))); + } + }; + + const screenshotHandler = async (_req: Request, res: Response) => { + try { + const base64 = await session.getScreenshot(); + res.json(success(base64)); + } catch (err) { + logger.error('Failed to fetch screenshot.', err); + res.status(500).json(failure(err instanceof Error ? err.message : String(err))); + } + }; + + const hierarchyHandler = async (_req: Request, res: Response) => { + try { + const data = await session.getHierarchy(); + res.json(success(data)); + } catch (err) { + logger.error('Failed to fetch hierarchy.', err); + res.status(500).json(failure(err instanceof Error ? err.message : String(err))); + } + }; + + app.post('/api/harmony/connect', connectHandler); + app.post('/api/harmony/:serial/connect', connectHandler); + + app.get('/api/harmony/screenshot', screenshotHandler); + app.get('/api/harmony/:serial/screenshot', screenshotHandler); + + app.get('/api/harmony/hierarchy', hierarchyHandler); + app.get('/api/harmony/:serial/hierarchy', hierarchyHandler); + + app.post('/api/harmony/hierarchy/xpathLite', async (req: Request, res: Response) => { + try { + const nodeId = req.body?.node_id; + if (!nodeId) { + res.status(400).json(failure('node_id is required.')); + return; + } + const xpath = await session.getXPathLite(nodeId); + res.json(success(xpath)); + } catch (err) { + logger.error('Failed to fetch xpath.', err); + res.status(500).json(failure(err instanceof Error ? err.message : String(err))); + } + }); + + const staticRoot = path.join(__dirname, '../../res/ui-viewer'); + const staticDir = path.join(staticRoot, 'static'); + if (fs.existsSync(staticDir)) { + app.use('/static', express.static(staticDir)); + } else { + logger.warn(`Static directory ${staticDir} not found. UI assets may be unavailable.`); + } + + const indexFile = path.join(staticRoot, 'index.html'); + app.get('/', (_req: Request, res: Response) => { + if (fs.existsSync(indexFile)) { + res.sendFile(indexFile); + } else { + res.status(404).send('index.html not found'); + } + }); + + app.get('/ui-viewer', (_req: Request, res: Response) => { + if (fs.existsSync(indexFile)) { + res.sendFile(indexFile); + } else { + res.status(404).send('index.html not found'); + } + }); + + return new Promise((resolve, reject) => { + const server = app.listen(options.port, () => { + logger.info(`haptest ui-viewer listening on http://localhost:${options.port}`); + resolve(); + }); + server.on('error', (err) => { + logger.error('haptest ui-viewer server error.', err); + reject(err); + }); + }); +} diff --git a/test/unit/hdc.test.ts b/test/unit/hdc.test.ts index 27db81c..e002163 100644 --- a/test/unit/hdc.test.ts +++ b/test/unit/hdc.test.ts @@ -19,8 +19,17 @@ import * as path from 'path'; import fs from 'fs'; describe('hdc Test', async () => { - let hdc = new Hdc(); + let initSpy: ReturnType; + let hdc: Hdc; + beforeEach(() => { + initSpy = vi.spyOn(Hdc.prototype as any, 'initDeviceEnv').mockImplementation(() => {}); + hdc = new Hdc(); + }); + + afterEach(() => { + initSpy.mockRestore(); + }); it('test getForegroundProcess', async () => { const MOCK_SHELL_OUTPUT_GetForegroundProcess = fs.readFileSync(path.join(__dirname, '../resource/aa_dump.txt'), { encoding: 'utf-8', diff --git a/test/unit/page_builder_normalize.test.ts b/test/unit/page_builder_normalize.test.ts new file mode 100644 index 0000000..fdb9d10 --- /dev/null +++ b/test/unit/page_builder_normalize.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { PageBuilder } from '../../src/model/builder/page_builder'; + +describe('PageBuilder layout normalization', () => { + const baseAttributes = { + abilityName: 'MainAbility', + bundleName: 'com.example.demo', + pagePath: '/Main', + accessibilityId: '', + bounds: [ + [0, 0], + [100, 200], + ], + origBounds: [ + [0, 0], + [100, 200], + ], + checkable: false, + checked: false, + clickable: false, + description: '', + enabled: true, + focused: false, + hashcode: 'root', + hint: '', + hostWindowId: '1', + id: 'root', + key: 'root-key', + longClickable: false, + scrollable: false, + selected: false, + text: 'Root', + type: 'RootNode', + visible: true, + }; + + const childAttributes = { + ...baseAttributes, + bounds: [ + [10, 20], + [60, 80], + ], + origBounds: [ + [10, 20], + [60, 80], + ], + id: 'child', + key: 'child-key', + text: 'Child', + type: 'Text', + }; + + it('parses layout object wrapped in data->windows->root', () => { + const layout = { + data: { + windows: [ + { + metadata: { + abilityName: 'MainAbility', + bundleName: 'com.example.demo', + pagePath: '/Main', + }, + root: { + attributes: baseAttributes, + children: [ + { + attributes: childAttributes, + children: [], + }, + ], + }, + }, + ], + }, + }; + + const pages = PageBuilder.buildPagesFromLayout(layout); + + expect(pages.length).toBe(1); + const page = pages[0]; + expect(page.getBundleName()).toBe('com.example.demo'); + expect(page.getAbilityName()).toBe('MainAbility'); + expect(page.getPagePath()).toBe('/Main'); + + const root = page.getRoot(); + expect(root.bounds).toEqual([ + { x: 0, y: 0 }, + { x: 100, y: 200 }, + ]); + expect(root.children.length).toBe(1); + expect(root.children[0].bounds).toEqual([ + { x: 10, y: 20 }, + { x: 60, y: 80 }, + ]); + }); + + it('parses layout when provided as stringified JSON', () => { + const stringified = JSON.stringify({ + data: { + pages: [ + { + metadata: { + abilityName: 'SecondAbility', + bundleName: 'com.example.demo', + pagePath: '/Second', + }, + root: { + attributes: { + ...baseAttributes, + abilityName: 'SecondAbility', + pagePath: '/Second', + bounds: [ + [5, 5], + [50, 50], + ], + origBounds: [ + [5, 5], + [50, 50], + ], + id: 'root-2', + key: 'root-key-2', + text: 'Root2', + }, + children: [], + }, + }, + ], + }, + }); + + const pages = PageBuilder.buildPagesFromLayout(stringified); + expect(pages.length).toBe(1); + const page = pages[0]; + expect(page.getBundleName()).toBe('com.example.demo'); + expect(page.getAbilityName()).toBe('SecondAbility'); + expect(page.getPagePath()).toBe('/Second'); + expect(page.getRoot().bounds).toEqual([ + { x: 5, y: 5 }, + { x: 50, y: 50 }, + ]); + }); +}); diff --git a/test/unit/ui_viewer_server.test.ts b/test/unit/ui_viewer_server.test.ts new file mode 100644 index 0000000..ca00dae --- /dev/null +++ b/test/unit/ui_viewer_server.test.ts @@ -0,0 +1,359 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { HierarchyTree } from '../../src/ui/hierarchy_builder'; +import type { LOG_LEVEL } from '../../src/utils/logger'; + +type RouteHandler = (req: any, res: any) => any; + +interface ExpressMockState { + getHandlers: Map; + postHandlers: Map; + jsonArgs: unknown[]; + staticArgs: string[]; + listenPort?: number; + errorListener?: (err: unknown) => void; +} + +const expressState: ExpressMockState = { + getHandlers: new Map(), + postHandlers: new Map(), + jsonArgs: [], + staticArgs: [], + listenPort: undefined, + errorListener: undefined, +}; + +const resetExpressState = () => { + expressState.getHandlers.clear(); + expressState.postHandlers.clear(); + expressState.jsonArgs = []; + expressState.staticArgs = []; + expressState.listenPort = undefined; + expressState.errorListener = undefined; +}; + +vi.mock('express', () => { + const jsonMock = vi.fn((options?: unknown) => { + expressState.jsonArgs.push(options); + return () => undefined; + }); + + const staticMock = vi.fn((dir: string) => { + expressState.staticArgs.push(dir); + return `static:${dir}`; + }); + + const expressMock = vi.fn(() => { + const app = { + use: vi.fn(() => app), + get: vi.fn((route: string, handler: RouteHandler) => { + expressState.getHandlers.set(route, handler); + return app; + }), + post: vi.fn((route: string, handler: RouteHandler) => { + expressState.postHandlers.set(route, handler); + return app; + }), + listen: vi.fn((port: number, callback?: () => void) => { + expressState.listenPort = port; + callback?.(); + return { + on: vi.fn((event: string, listener: (err: unknown) => void) => { + if (event === 'error') { + expressState.errorListener = listener; + } + }), + }; + }), + }; + return app; + }); + + (expressMock as unknown as { json: typeof jsonMock }).json = jsonMock; + (expressMock as unknown as { static: typeof staticMock }).static = staticMock; + + return { default: expressMock }; +}); + +interface MockSnapshot { + screenCapPath: string; + screenWidth: number; + screenHeight: number; +} + +class MockPage { + constructor( + private readonly snapshot: MockSnapshot, + private readonly root: any, + private readonly abilityName: string, + private readonly bundleName: string, + private readonly pagePath: string + ) {} + + getSnapshot(): MockSnapshot { + return this.snapshot; + } + + getRoot(): any { + return this.root; + } + + getAbilityName(): string { + return this.abilityName; + } + + getBundleName(): string { + return this.bundleName; + } + + getPagePath(): string { + return this.pagePath; + } +} + +interface PageFactoryArgs { + bundleName: string; +} + +let currentPageFactory: ((args: PageFactoryArgs) => MockPage) | null = null; +const requestedBundleNames: string[] = []; +const connectedHaps: any[] = []; + +class MockDeviceImpl { + constructor(public readonly options: unknown) {} + + async connect(hap: any): Promise { + connectedHaps.push(hap); + } + + async getCurrentPage(hap: any): Promise { + requestedBundleNames.push(hap.bundleName); + if (!currentPageFactory) { + throw new Error('Page factory is not configured.'); + } + const page = currentPageFactory({ bundleName: hap.bundleName }); + if (!hap.bundleName) { + hap.bundleName = page.getBundleName(); + } + return page; + } + + getUdid(): string { + return 'mock-udid'; + } +} + +const deviceConstructor = vi.fn((options: unknown) => new MockDeviceImpl(options)); + +vi.mock('../../src/device/device', () => ({ + Device: deviceConstructor, +})); + +let mockHierarchyTree: HierarchyTree = { + root: { _id: 'root-node', children: [] } as any, + map: new Map(), +}; + +const buildHierarchyMock = vi.fn(() => mockHierarchyTree); +const generateXPathLiteMock = vi.fn(() => '//MockNode[2]'); + +vi.mock('../../src/ui/hierarchy_builder', () => ({ + buildHierarchy: buildHierarchyMock, + generateXPathLite: generateXPathLiteMock, +})); + +const successResult = (data: unknown) => ({ success: true, data, message: null }); +const failureResult = (message: string) => ({ success: false, data: null, message }); + +const createMockResponse = () => { + const res: any = { + statusCode: 200, + json: vi.fn((payload: unknown) => { + res.payload = payload; + return res; + }), + status: vi.fn((code: number) => { + res.statusCode = code; + return res; + }), + send: vi.fn((payload: unknown) => { + res.payload = payload; + return res; + }), + sendFile: vi.fn((filePath: string) => { + res.sentFile = filePath; + return res; + }), + }; + return res; +}; + +let startUIViewerServer: typeof import('../../src/ui/ui_viewer_server').startUIViewerServer; +let hdcModule: typeof import('../../src/device/hdc'); +let tempOutputDir: string; +let pageSequence = 0; + +const resetMocks = async () => { + resetExpressState(); + requestedBundleNames.length = 0; + connectedHaps.length = 0; + deviceConstructor.mockClear(); + buildHierarchyMock.mockClear(); + generateXPathLiteMock.mockClear(); + currentPageFactory = null; + pageSequence = 0; + vi.clearAllMocks(); + vi.resetModules(); + startUIViewerServer = (await import('../../src/ui/ui_viewer_server')).startUIViewerServer; + hdcModule = await import('../../src/device/hdc'); +}; + +const createMockPage = (dir: string, overrides: Partial<{ abilityName: string; bundleName: string; pagePath: string }> = {}) => { + pageSequence += 1; + const screenPath = path.join(dir, `screen-${pageSequence}.png`); + const fileContent = `image-content-${pageSequence}`; + fs.writeFileSync(screenPath, Buffer.from(fileContent)); + const snapshot: MockSnapshot = { + screenCapPath: screenPath, + screenWidth: 1280, + screenHeight: 720, + }; + return new MockPage( + snapshot, + { _id: 'root-node', children: [] } as any, + overrides.abilityName || 'MainAbility', + overrides.bundleName || 'com.example.app', + overrides.pagePath || 'pages/Main' + ); +}; + +const getRouteHandler = (method: 'get' | 'post', route: string): RouteHandler => { + const handler = method === 'get' ? expressState.getHandlers.get(route) : expressState.postHandlers.get(route); + if (!handler) { + throw new Error(`Route handler for [${method.toUpperCase()} ${route}] not registered.`); + } + return handler; +}; + +beforeEach(async () => { + tempOutputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uiviewer-test-')); + mockHierarchyTree = { + root: { _id: 'root', componentPath: 'Node[1]', children: [] } as any, + map: new Map([ + [ + 'root', + { + _id: 'root', + componentPath: 'Node[1]', + children: [], + } as any, + ], + ]), + }; + await resetMocks(); +}); + +afterEach(() => { + if (tempOutputDir && fs.existsSync(tempOutputDir)) { + fs.rmSync(tempOutputDir, { recursive: true, force: true }); + } +}); + +describe('startUIViewerServer', () => { + it('registers routes and serves happy-path UI viewer workflow', async () => { + const options = { + bundleName: 'com.example.app', + connectKey: 'device-123', + outputDir: tempOutputDir, + port: 7789, + logLevel: 'INFO' as LOG_LEVEL, + version: '1.2.3', + }; + + currentPageFactory = ({ bundleName }) => createMockPage(tempOutputDir, { bundleName }); + const listTargetsSpy = vi + .spyOn(hdcModule.Hdc, 'listTargets') + .mockReturnValue([ + { serial: options.connectKey, transport: 'usb', state: 'device', host: 'localhost', type: 'phone' }, + ]); + + await startUIViewerServer(options); + + expect(expressState.listenPort).toBe(options.port); + expect(deviceConstructor).not.toHaveBeenCalled(); + expect(expressState.jsonArgs[0]).toEqual({ limit: '5mb' }); + + const healthRes = createMockResponse(); + getRouteHandler('get', '/api/health')({}, healthRes); + expect(healthRes.json).toHaveBeenCalledWith(successResult('ok')); + + const versionRes = createMockResponse(); + getRouteHandler('get', '/api/version')({}, versionRes); + expect(versionRes.json).toHaveBeenCalledWith(successResult(options.version)); + + const serialsRes = createMockResponse(); + await getRouteHandler('get', '/api/harmony/serials')({}, serialsRes); + const serialsPayload = serialsRes.json.mock.calls[0][0]; + expect(serialsPayload.success).toBe(true); + expect(Array.isArray(serialsPayload.data)).toBe(true); + expect(serialsPayload.data).toContain(options.connectKey); + + const connectRes = createMockResponse(); + await getRouteHandler('post', '/api/harmony/connect')({ body: {} }, connectRes); + expect(deviceConstructor).toHaveBeenCalledTimes(1); + expect(connectedHaps).toHaveLength(1); + expect(connectRes.json).toHaveBeenCalledWith( + successResult({ alias: 'device-123', connectKey: 'device-123' }) + ); + + const screenshotRes = createMockResponse(); + await getRouteHandler('get', '/api/harmony/screenshot')({}, screenshotRes); + const expectedBase64 = Buffer.from('image-content-1').toString('base64'); + expect(screenshotRes.json).toHaveBeenCalledWith(successResult(expectedBase64)); + + const screenshotFilePath = path.join(tempOutputDir, 'screen-1.png'); + expect(fs.existsSync(screenshotFilePath)).toBe(false); + + expect(requestedBundleNames).toEqual(['com.example.app']); + + const hierarchyRes = createMockResponse(); + await getRouteHandler('get', '/api/harmony/hierarchy')({}, hierarchyRes); + expect(hierarchyRes.json).toHaveBeenCalledWith( + successResult({ + jsonHierarchy: mockHierarchyTree.root, + activityName: 'MainAbility', + packageName: 'com.example.app', + pagePath: 'pages/Main', + windowSize: [1280, 720], + scale: 1, + updatedAt: expect.any(String), + }) + ); + expect(buildHierarchyMock).toHaveBeenCalled(); + + const xpathRes = createMockResponse(); + await getRouteHandler('post', '/api/harmony/hierarchy/xpathLite')( + { body: { node_id: 'root' } }, + xpathRes + ); + expect(generateXPathLiteMock).toHaveBeenCalledWith('root', mockHierarchyTree); + expect(xpathRes.json).toHaveBeenCalledWith(successResult('//MockNode[2]')); + + listTargetsSpy.mockRestore(); + + const missingNodeRes = createMockResponse(); + await getRouteHandler('post', '/api/harmony/hierarchy/xpathLite')({ body: {} }, missingNodeRes); + expect(missingNodeRes.status).toHaveBeenCalledWith(400); + expect(missingNodeRes.json).toHaveBeenCalledWith(failureResult('node_id is required.')); + + const indexRes = createMockResponse(); + getRouteHandler('get', '/')( {}, indexRes ); + expect(indexRes.sendFile).toHaveBeenCalled(); + + const fallbackRes = createMockResponse(); + getRouteHandler('get', '/ui-viewer')( {}, fallbackRes ); + expect(fallbackRes.sendFile).toHaveBeenCalled(); + }); +});