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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Selected Element Info
+
+
+
+
+ {{ scope.row.value }}
+ Copy
+
+
+
+
+
+
+
+
+
+
+
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();
+ });
+});