diff --git a/README.md b/README.md
index 86bd3b5..3fc6de6 100644
--- a/README.md
+++ b/README.md
@@ -482,6 +482,19 @@ ID Name Platform Type
5A5FCFCA-27EC-4D1B-B412-BAE629154EE0 iPhone 17 Pro ios simulator booted
```
+### `mobilewright inspect`
+
+Open the Mobilewright Inspector — a browser-based UI showing a live screenshot of your connected device alongside every element and its best locator.
+
+```bash
+npx mobilewright inspect
+npx mobilewright inspect --port 4621 # use a specific port (default: 4621)
+```
+
+The Inspector opens automatically in your browser. Select a device from the picker at the top, then click **Refresh** or enable **Auto refresh**. Click any row in the element list to highlight its bounding box on the screenshot. Elements that share a locator with another element get a `dup` badge.
+
+Locator priority matches what mobilewright uses: `getByTestId` > `getByRole` > `getByLabel` > `getByText`.
+
### `mobilewright screenshot`
Capture a screenshot of a connected device. Auto-starts mobilecli if it isn't running.
diff --git a/package-lock.json b/package-lock.json
index 7e8c176..f8f78b8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1129,6 +1129,10 @@
"resolved": "packages/driver-mobilenext",
"link": true
},
+ "node_modules/@mobilewright/inspector": {
+ "resolved": "packages/inspector",
+ "link": true
+ },
"node_modules/@mobilewright/protocol": {
"resolved": "packages/protocol",
"link": true
@@ -1173,6 +1177,27 @@
"eslint": "^9.0.0 || ^10.0.0"
}
},
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@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/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -1197,6 +1222,38 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+ "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1221,6 +1278,41 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@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.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -1464,6 +1556,19 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1514,6 +1619,43 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
@@ -1527,6 +1669,59 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/bundle-name": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+ "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "run-applescript": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@@ -1536,6 +1731,46 @@
"node": ">=20"
}
},
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/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.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1575,6 +1810,55 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/default-browser": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
+ "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
+ "license": "MIT",
+ "dependencies": {
+ "bundle-name": "^4.1.0",
+ "default-browser-id": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+ "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1584,6 +1868,65 @@
"node": ">=8"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/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/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -1626,6 +1969,12 @@
"@esbuild/win32-x64": "0.28.1"
}
},
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -1832,6 +2181,58 @@
"node": ">=0.10.0"
}
},
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1884,6 +2285,27 @@
"node": ">=16.0.0"
}
},
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -1922,6 +2344,24 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1937,6 +2377,52 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -1950,29 +2436,131 @@
"node": ">=10.13.0"
}
},
- "node_modules/ignore": {
- "version": "7.0.5",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
- "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
- "dev": true,
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
- "node": ">= 4"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
- "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
- "dev": true,
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
- "node": ">=0.8.19"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
@@ -1993,6 +2581,45 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-wsl": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2061,6 +2688,61 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -2103,6 +2785,66 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/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/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/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/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/open": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
+ "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
+ "license": "MIT",
+ "dependencies": {
+ "default-browser": "^5.2.1",
+ "define-lazy-prop": "^3.0.0",
+ "is-inside-container": "^1.0.0",
+ "wsl-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2153,6 +2895,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2173,6 +2924,16 @@
"node": ">=8"
}
},
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
@@ -2240,6 +3001,19 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/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/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2250,6 +3024,84 @@
"node": ">=6"
}
},
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/run-applescript": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+ "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -2262,6 +3114,57 @@
"node": ">=10"
}
},
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -2329,6 +3232,87 @@
"node": ">=8"
}
},
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "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.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -2346,6 +3330,15 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -2398,6 +3391,37 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2419,6 +3443,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/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",
@@ -2429,6 +3462,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -2455,6 +3497,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
@@ -2476,6 +3524,21 @@
}
}
},
+ "node_modules/wsl-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
+ "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-wsl": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -2542,6 +3605,23 @@
"node": ">=18"
}
},
+ "packages/inspector": {
+ "name": "@mobilewright/inspector",
+ "version": "0.0.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@mobilewright/core": "^0.0.1",
+ "@mobilewright/protocol": "^0.0.1",
+ "express": "^5.2.1",
+ "open": "^10.2.0"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.6"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"packages/mobilewright": {
"version": "0.0.1",
"license": "Apache-2.0",
@@ -2549,9 +3629,11 @@
"@mobilewright/core": "^0.0.1",
"@mobilewright/driver-mobilecli": "^0.0.1",
"@mobilewright/driver-mobilenext": "^0.0.1",
+ "@mobilewright/inspector": "^0.0.1",
"@mobilewright/protocol": "^0.0.1",
"commander": "^14.0.3",
"debug": "^4.4.3",
+ "open": "^10.2.0",
"playwright": "1.58.2",
"ws": "^8.18.0"
},
diff --git a/packages/inspector/package.json b/packages/inspector/package.json
new file mode 100644
index 0000000..fd03b46
--- /dev/null
+++ b/packages/inspector/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@mobilewright/inspector",
+ "version": "0.0.1",
+ "description": "Browser-based element inspector for mobilewright",
+ "homepage": "https://mobilewright.dev",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ },
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsc -b",
+ "prepublishOnly": "tsc -b",
+ "test": "playwright test --config=../../tests/mobilewright.config.ts"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/mobile-next/mobilewright.git",
+ "directory": "packages/inspector"
+ },
+ "files": [
+ "dist",
+ "public"
+ ],
+ "dependencies": {
+ "@mobilewright/core": "^0.0.1",
+ "@mobilewright/protocol": "^0.0.1",
+ "express": "^5.2.1",
+ "open": "^10.2.0"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.6"
+ }
+}
diff --git a/packages/inspector/public/css/app.css b/packages/inspector/public/css/app.css
new file mode 100644
index 0000000..24c80c2
--- /dev/null
+++ b/packages/inspector/public/css/app.css
@@ -0,0 +1,453 @@
+/* ---- Theme variables ---- */
+
+:root, [data-theme="void"] {
+ --c-bg: #1a1a1a;
+ --c-surface: #252525;
+ --c-surface2: #1e1e1e;
+ --c-pane-bg: #111111;
+ --c-border: #333333;
+ --c-border2: #2a2a2a;
+ --c-text: #e0e0e0;
+ --c-text-muted: #888888;
+ --c-text-dim: #555555;
+ --c-text-strong: #ffffff;
+ --c-accent: #4a9eff;
+ --c-accent-dim: rgba(74,158,255,.15);
+ --c-accent-hover: rgba(74,158,255,.3);
+ --c-selected: #3ddc6e;
+ --c-selected-bg: #1a3020;
+ --c-selected-dim: rgba(50,205,90,.3);
+ --c-row-hover: #2a2a2a;
+ --c-status-loading: #4a9eff;
+ --c-status-error: #e05050;
+ --c-dup: #c8a030;
+ --c-badge-testid-bg: #1a3d1a; --c-badge-testid: #6dbb6d;
+ --c-badge-role-bg: #1a2d4a; --c-badge-role: #6aaeff;
+ --c-badge-label-bg: #3a2d00; --c-badge-label: #c8a030;
+ --c-badge-text-bg: #2d1a2d; --c-badge-text: #bb7dbb;
+ --c-badge-none-bg: #2a2a2a; --c-badge-none: #666666;
+}
+
+[data-theme="aurora"] {
+ --c-bg: #0d1117;
+ --c-surface: #161b22;
+ --c-surface2: #0d1117;
+ --c-pane-bg: #090d13;
+ --c-border: #21262d;
+ --c-border2: #1c2128;
+ --c-text: #cdd9e5;
+ --c-text-muted: #768390;
+ --c-text-dim: #444c56;
+ --c-text-strong: #e6edf3;
+ --c-accent: #58efb0;
+ --c-accent-dim: rgba(88,239,176,.12);
+ --c-accent-hover: rgba(88,239,176,.25);
+ --c-selected: #d2a8ff;
+ --c-selected-bg: #1f1040;
+ --c-selected-dim: rgba(210,168,255,.25);
+ --c-row-hover: #1c2128;
+ --c-status-loading: #58efb0;
+ --c-status-error: #ff7b72;
+ --c-dup: #ffa657;
+ --c-badge-testid-bg: #04281c; --c-badge-testid: #58efb0;
+ --c-badge-role-bg: #1a1040; --c-badge-role: #d2a8ff;
+ --c-badge-label-bg: #2d1f00; --c-badge-label: #ffa657;
+ --c-badge-text-bg: #2d1030; --c-badge-text: #f778ba;
+ --c-badge-none-bg: #21262d; --c-badge-none: #444c56;
+}
+
+[data-theme="crimson"] {
+ --c-bg: #100808;
+ --c-surface: #1a0e0e;
+ --c-surface2: #140a0a;
+ --c-pane-bg: #0c0606;
+ --c-border: #2d1414;
+ --c-border2: #1f0f0f;
+ --c-text: #e8d4d4;
+ --c-text-muted: #8a6060;
+ --c-text-dim: #4a2828;
+ --c-text-strong: #f5e8e8;
+ --c-accent: #e84040;
+ --c-accent-dim: rgba(232,64,64,.12);
+ --c-accent-hover: rgba(232,64,64,.25);
+ --c-selected: #f0b040;
+ --c-selected-bg: #281a00;
+ --c-selected-dim: rgba(240,176,64,.25);
+ --c-row-hover: #1f1010;
+ --c-status-loading: #e84040;
+ --c-status-error: #ff7070;
+ --c-dup: #f0b040;
+ --c-badge-testid-bg: #280808; --c-badge-testid: #e84040;
+ --c-badge-role-bg: #280e00; --c-badge-role: #f0b040;
+ --c-badge-label-bg: #1e1000; --c-badge-label: #d4903a;
+ --c-badge-text-bg: #1e0808; --c-badge-text: #c06060;
+ --c-badge-none-bg: #1f0f0f; --c-badge-none: #4a2828;
+}
+
+[data-theme="arctic"] {
+ --c-bg: #eceff4;
+ --c-surface: #e5e9f0;
+ --c-surface2: #f0f4f8;
+ --c-pane-bg: #dde3ee;
+ --c-border: #c8d0dc;
+ --c-border2: #d8dee9;
+ --c-text: #2e3440;
+ --c-text-muted: #4c566a;
+ --c-text-dim: #9aa0ad;
+ --c-text-strong: #2e3440;
+ --c-accent: #5e81ac;
+ --c-accent-dim: rgba(94,129,172,.15);
+ --c-accent-hover: rgba(94,129,172,.25);
+ --c-selected: #4c9a5e;
+ --c-selected-bg: #d8efdf;
+ --c-selected-dim: rgba(76,154,94,.2);
+ --c-row-hover: #dde3ee;
+ --c-status-loading: #5e81ac;
+ --c-status-error: #bf616a;
+ --c-dup: #d08770;
+ --c-badge-testid-bg: #d8efdf; --c-badge-testid: #2d7a3a;
+ --c-badge-role-bg: #dae4f0; --c-badge-role: #2e5280;
+ --c-badge-label-bg: #f0e6d0; --c-badge-label: #b45309;
+ --c-badge-text-bg: #ede0f0; --c-badge-text: #6b3a80;
+ --c-badge-none-bg: #e5e9f0; --c-badge-none: #9aa0ad;
+}
+
+[data-theme="synthwave"] {
+ --c-bg: #0d0020;
+ --c-surface: #150030;
+ --c-surface2: #100028;
+ --c-pane-bg: #080015;
+ --c-border: #2a0050;
+ --c-border2: #1a0038;
+ --c-text: #e0d0ff;
+ --c-text-muted: #8060a0;
+ --c-text-dim: #402060;
+ --c-text-strong: #f0e0ff;
+ --c-accent: #ff2d78;
+ --c-accent-dim: rgba(255,45,120,.12);
+ --c-accent-hover: rgba(255,45,120,.25);
+ --c-selected: #00ffcc;
+ --c-selected-bg: #00281e;
+ --c-selected-dim: rgba(0,255,204,.2);
+ --c-row-hover: #1a0038;
+ --c-status-loading: #ff2d78;
+ --c-status-error: #ff6060;
+ --c-dup: #ffd700;
+ --c-badge-testid-bg: #0a2818; --c-badge-testid: #00ffcc;
+ --c-badge-role-bg: #280040; --c-badge-role: #bf80ff;
+ --c-badge-label-bg: #281800; --c-badge-label: #ffd700;
+ --c-badge-text-bg: #280020; --c-badge-text: #ff2d78;
+ --c-badge-none-bg: #1a0038; --c-badge-none: #402060;
+}
+
+/* ---- Reset ---- */
+
+*, *::before, *::after { box-sizing: border-box; }
+[hidden] { display: none !important; }
+
+body {
+ font-family: system-ui, -apple-system, sans-serif;
+ font-size: 13px;
+ margin: 0;
+ background: var(--c-bg);
+ color: var(--c-text);
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ---- Header ---- */
+
+header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 14px;
+ background: var(--c-surface);
+ border-bottom: 1px solid var(--c-border);
+ flex-shrink: 0;
+}
+
+header h1 {
+ font-size: 14px;
+ font-weight: 600;
+ margin: 0;
+ letter-spacing: 0.03em;
+ color: var(--c-text-strong);
+}
+
+#device-picker select,
+#auto-refresh-interval,
+#theme-select {
+ background: var(--c-bg);
+ color: var(--c-text);
+ border: 1px solid var(--c-border);
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-size: 12px;
+ cursor: pointer;
+ outline: none;
+}
+
+#device-picker select:focus,
+#auto-refresh-interval:focus,
+#theme-select:focus {
+ border-color: var(--c-accent);
+}
+
+#auto-refresh-interval:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+#refresh-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.auto-refresh-label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ color: var(--c-text-muted);
+}
+
+#refresh-btn {
+ background: var(--c-surface2);
+ color: var(--c-text);
+ border: 1px solid var(--c-text-dim);
+ border-radius: 4px;
+ padding: 4px 12px;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+#refresh-btn:hover { background: var(--c-row-hover); }
+#refresh-btn:active { background: var(--c-bg); }
+#refresh-btn:disabled { opacity: 0.4; cursor: default; }
+
+#theme-controls {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+#theme-controls label {
+ font-size: 11px;
+ color: var(--c-text-muted);
+}
+
+#status-bar {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--c-text-dim);
+}
+
+#status-bar.error { color: var(--c-status-error); }
+#status-bar.loading { color: var(--c-status-loading); }
+
+/* ---- Main layout ---- */
+
+main {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* ---- Screenshot pane ---- */
+
+#screenshot-pane {
+ flex: 0 0 auto;
+ min-width: 300px;
+ max-width: 50%;
+ position: relative;
+ overflow: hidden;
+ border-right: 1px solid var(--c-border);
+ background: var(--c-pane-bg);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 16px;
+}
+
+#screenshot-container {
+ position: relative;
+ display: inline-block;
+}
+
+#no-device-msg {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 40px 24px;
+ text-align: center;
+ color: var(--c-text-dim);
+}
+
+.placeholder-icon {
+ width: 64px;
+ height: 64px;
+ color: var(--c-text-dim);
+ margin-bottom: 4px;
+ transition: color 0.3s;
+}
+
+#no-device-msg.loading .placeholder-icon {
+ color: var(--c-accent);
+ animation: placeholder-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes placeholder-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+}
+
+.placeholder-title {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--c-text-muted);
+}
+
+.placeholder-sub {
+ margin: 0;
+ font-size: 12px;
+ color: var(--c-text-dim);
+ max-width: 200px;
+ line-height: 1.5;
+}
+
+#screenshot-img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+#highlight-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ overflow: visible;
+}
+
+.highlight-rect {
+ fill: var(--c-accent-dim);
+ stroke: var(--c-accent);
+ stroke-width: 2;
+ pointer-events: all;
+ cursor: pointer;
+ transition: fill 0.1s;
+}
+
+.highlight-rect:hover,
+.highlight-rect.hovered {
+ fill: var(--c-accent-hover);
+}
+
+.highlight-rect.selected {
+ fill: var(--c-selected-dim);
+ stroke: var(--c-selected);
+ stroke-width: 2.5;
+}
+
+/* ---- Elements pane ---- */
+
+#elements-pane {
+ flex: 1;
+ overflow: auto;
+ background: var(--c-surface2);
+}
+
+#elements-list {
+ padding: 0;
+}
+
+.element-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--c-border2);
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.element-row:hover { background: var(--c-row-hover); }
+
+.element-row.selected {
+ background: var(--c-selected-bg);
+ border-left: 3px solid var(--c-selected);
+ padding-left: 9px;
+}
+
+.element-row.no-locator { opacity: 0.5; }
+
+.locator-badge {
+ display: inline-block;
+ font-size: 10px;
+ font-weight: 600;
+ padding: 2px 6px;
+ border-radius: 3px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ flex-shrink: 0;
+ min-width: 52px;
+ text-align: center;
+}
+
+.badge-testId { background: var(--c-badge-testid-bg); color: var(--c-badge-testid); }
+.badge-role { background: var(--c-badge-role-bg); color: var(--c-badge-role); }
+.badge-label { background: var(--c-badge-label-bg); color: var(--c-badge-label); }
+.badge-text { background: var(--c-badge-text-bg); color: var(--c-badge-text); }
+.badge-none { background: var(--c-badge-none-bg); color: var(--c-badge-none); }
+
+.locator-value {
+ font-family: 'SF Mono', Menlo, Monaco, monospace;
+ font-size: 12px;
+ color: var(--c-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+}
+
+.element-type {
+ font-size: 10px;
+ color: var(--c-text-dim);
+ flex-shrink: 0;
+}
+
+.duplicate-warning {
+ font-size: 10px;
+ color: var(--c-dup);
+ flex-shrink: 0;
+}
+
+.vis-btn {
+ background: none;
+ border: none;
+ color: var(--c-text-dim);
+ cursor: pointer;
+ font-size: 13px;
+ padding: 0 2px;
+ flex-shrink: 0;
+ line-height: 1;
+}
+
+.vis-btn:hover { color: var(--c-text-muted); }
+
+.element-row.element-hidden { opacity: 0.4; }
+.element-row.element-hidden .vis-btn { color: var(--c-text-muted); }
+
+.pane-message {
+ color: var(--c-text-dim);
+ padding: 40px 20px;
+ text-align: center;
+}
diff --git a/packages/inspector/public/index.html b/packages/inspector/public/index.html
new file mode 100644
index 0000000..21fd7d9
--- /dev/null
+++ b/packages/inspector/public/index.html
@@ -0,0 +1,67 @@
+
+
+
+
+ Mobilewright Inspector
+
+
+
+
+ Mobilewright Inspector
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a device
+
Pick a connected device or simulator above
+
+
![Device screenshot]()
+
+
+
+
+
+
+
+
+
diff --git a/packages/inspector/public/js/app.js b/packages/inspector/public/js/app.js
new file mode 100644
index 0000000..3587a39
--- /dev/null
+++ b/packages/inspector/public/js/app.js
@@ -0,0 +1,572 @@
+// Mobilewright Inspector frontend. No framework, no build step.
+
+// ---- Pure locator utilities ----
+
+const DEFAULT_HIDDEN_TESTIDS = new Set(['android:id/content'])
+
+function locatorKey(locator) {
+ if (locator.kind === 'role') return `role:${locator.value}:${locator.name ?? ''}`
+ return `${locator.kind}:${locator.value}`
+}
+
+function escQ(s) {
+ return s
+ .replace(/\\/g, '\\\\')
+ .replace(/'/g, "\\'")
+ .replace(/\r/g, '\\r')
+ .replace(/\n/g, '\\n')
+ .replace(/\u2028/g, '\\u2028')
+ .replace(/\u2029/g, '\\u2029')
+}
+
+function locatorLabel(locator) {
+ if (locator.kind === 'testId') return `getByTestId('${escQ(locator.value)}')`
+ if (locator.kind === 'role') {
+ return locator.name
+ ? `getByRole('${escQ(locator.value)}', { name: '${escQ(locator.name)}' })`
+ : `getByRole('${escQ(locator.value)}')`
+ }
+ if (locator.kind === 'label') return `getByLabel('${escQ(locator.value)}')`
+ if (locator.kind === 'text') return `getByText('${escQ(locator.value)}')`
+ return ''
+}
+
+function buildDuplicateSet(elements) {
+ const counts = new Map()
+ for (const el of elements) {
+ if (!el.locator) continue
+ const key = locatorKey(el.locator)
+ counts.set(key, (counts.get(key) ?? 0) + 1)
+ }
+ const dupes = new Set()
+ for (const [k, n] of counts) if (n > 1) dupes.add(k)
+ return dupes
+}
+
+// ---- ScreenshotPane ----
+// Owns the screenshot image, SVG highlight overlay, and placeholder state.
+
+class ScreenshotPane {
+ #img
+ #overlay
+ #placeholder
+ #placeholderTitle
+ #placeholderSub
+ #logicalWidth = 0
+ #logicalHeight = 0
+ #elements = []
+ #hiddenIndices = new Set()
+ #selectedIndex = null
+ #onClickCb = null
+ #screenshotPane // cached pane element for #constrainSize
+ // O(1) lookup from element index to its SVG rect; rebuilt on each renderHighlights call.
+ #rectByIndex = new Map()
+
+ constructor() {
+ this.#img = document.getElementById('screenshot-img')
+ this.#overlay = document.getElementById('highlight-overlay')
+ this.#placeholder = document.getElementById('no-device-msg')
+ this.#placeholderTitle = document.getElementById('placeholder-title')
+ this.#placeholderSub = document.getElementById('placeholder-sub')
+ this.#screenshotPane = document.getElementById('screenshot-pane')
+ window.addEventListener('resize', () => this.#constrainSize())
+ }
+
+ onElementClick(cb) { this.#onClickCb = cb }
+
+ get isScreenshotHidden() { return this.#img.hidden }
+
+ showPlaceholder(title, sub = '', loading = false) {
+ this.#placeholder.hidden = false
+ this.#img.hidden = true
+ this.#overlay.setAttribute('hidden', '')
+ this.#placeholderTitle.textContent = title
+ this.#placeholderSub.textContent = sub
+ this.#placeholder.classList.toggle('loading', loading)
+ }
+
+ showScreenshot(dataUrl, logicalWidth = 0, logicalHeight = 0) {
+ this.#logicalWidth = logicalWidth
+ this.#logicalHeight = logicalHeight
+ this.#placeholder.hidden = true
+ this.#placeholder.classList.remove('loading')
+ this.#img.hidden = false
+ this.#overlay.removeAttribute('hidden')
+ this.#img.src = dataUrl
+ this.#img.onload = () => this.#constrainSize()
+ }
+
+ renderHighlights(elements, hiddenIndices, selectedIndex) {
+ this.#elements = elements
+ this.#hiddenIndices = hiddenIndices
+ this.#selectedIndex = selectedIndex
+ this.#buildRects()
+ }
+
+ setSelectedIndex(index) {
+ // O(1): deselect old rect, select new one directly via index map.
+ this.#rectByIndex.get(this.#selectedIndex)?.classList.remove('selected')
+ this.#selectedIndex = index
+ this.#rectByIndex.get(index)?.classList.add('selected')
+ }
+
+ #buildRects() {
+ this.#overlay.innerHTML = ''
+ this.#rectByIndex.clear()
+ for (const el of this.#visibleElements()) {
+ const { x, y, width, height } = el.bounds
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', x)
+ rect.setAttribute('y', y)
+ rect.setAttribute('width', width)
+ rect.setAttribute('height', height)
+ rect.classList.add('highlight-rect')
+ if (el.index === this.#selectedIndex) rect.classList.add('selected')
+ rect.addEventListener('click', () => this.#onClickCb?.(el.index))
+ rect.addEventListener('mouseenter', () => rect.classList.add('hovered'))
+ rect.addEventListener('mouseleave', () => rect.classList.remove('hovered'))
+ this.#rectByIndex.set(el.index, rect)
+ this.#overlay.appendChild(rect)
+ }
+ }
+
+ #visibleElements() {
+ return this.#elements.filter(el =>
+ el.bounds &&
+ el.isVisible &&
+ !this.#hiddenIndices.has(el.index) &&
+ el.bounds.width > 0 &&
+ el.bounds.height > 0
+ )
+ }
+
+ #constrainSize() {
+ if (this.#img.hidden) return
+ const cs = getComputedStyle(this.#screenshotPane)
+ const availH = this.#screenshotPane.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
+ this.#img.style.maxHeight = availH + 'px'
+ const vw = this.#logicalWidth || this.#img.naturalWidth
+ const vh = this.#logicalHeight || this.#img.naturalHeight
+ this.#overlay.setAttribute('width', this.#img.offsetWidth)
+ this.#overlay.setAttribute('height', this.#img.offsetHeight)
+ this.#overlay.setAttribute('viewBox', `0 0 ${vw} ${vh}`)
+ }
+}
+
+// ---- ElementsPane ----
+// Owns the element list: rendering rows, selection state, visibility toggles.
+
+class ElementsPane {
+ #list
+ #onClickCb = null
+ #onToggleCb = null
+ // O(1) lookup from element index to its row element; rebuilt on each render call.
+ #rowByIndex = new Map()
+ #selectedRow = null
+
+ constructor() {
+ this.#list = document.getElementById('elements-list')
+ this.#list.setAttribute('role', 'list')
+ }
+
+ onElementClick(cb) { this.#onClickCb = cb }
+ onToggleHidden(cb) { this.#onToggleCb = cb }
+
+ render(elements, hiddenIndices) {
+ this.#list.innerHTML = ''
+ this.#rowByIndex.clear()
+ this.#selectedRow = null
+ if (elements.length === 0) {
+ const msg = document.createElement('div')
+ msg.className = 'pane-message'
+ msg.textContent = 'No elements'
+ this.#list.appendChild(msg)
+ return
+ }
+ const dupes = buildDuplicateSet(elements)
+ for (const el of elements) {
+ const row = this.#buildRow(el, hiddenIndices, dupes)
+ this.#rowByIndex.set(el.index, row)
+ this.#list.appendChild(row)
+ }
+ }
+
+ setSelectedIndex(index) {
+ // O(1): deselect previous row, select new one directly via index map.
+ this.#selectedRow?.classList.remove('selected')
+ this.#selectedRow = this.#rowByIndex.get(index) ?? null
+ if (this.#selectedRow) {
+ this.#selectedRow.classList.add('selected')
+ this.#selectedRow.scrollIntoView({ block: 'nearest' })
+ }
+ }
+
+ updateRowVisibility(index, hidden) {
+ const row = this.#rowByIndex.get(index)
+ if (!row) return
+ row.classList.toggle('element-hidden', hidden)
+ const btn = row.querySelector('.vis-btn')
+ if (btn) {
+ btn.textContent = hidden ? '○' : '◉'
+ btn.title = hidden ? 'Show on screenshot' : 'Hide from screenshot'
+ btn.setAttribute('aria-label', hidden ? 'Show on screenshot' : 'Hide from screenshot')
+ btn.setAttribute('aria-pressed', String(!hidden))
+ }
+ }
+
+ #buildRow(el, hiddenIndices, dupes) {
+ const row = document.createElement('div')
+ row.className = 'element-row'
+ row.setAttribute('role', 'listitem')
+ row.tabIndex = 0
+ row.dataset.index = el.index
+ if (!el.locator) row.classList.add('no-locator')
+ if (hiddenIndices.has(el.index)) row.classList.add('element-hidden')
+
+ const locLabel = el.locator ? locatorLabel(el.locator) : null
+ row.setAttribute('aria-label', locLabel ?? `${el.type ?? 'unknown'} (no locator)`)
+
+ const badge = document.createElement('span')
+ badge.className = `locator-badge badge-${el.locator?.kind ?? 'none'}`
+ badge.textContent = el.locator?.kind ?? 'none'
+ row.appendChild(badge)
+
+ const value = document.createElement('span')
+ value.className = 'locator-value'
+ value.textContent = locLabel ?? '(no locator)'
+ if (locLabel) value.title = locLabel
+ row.appendChild(value)
+
+ if (el.locator && dupes.has(locatorKey(el.locator))) {
+ const warn = document.createElement('span')
+ warn.className = 'duplicate-warning'
+ warn.textContent = 'dup'
+ warn.title = 'Multiple elements share this locator'
+ row.appendChild(warn)
+ }
+
+ const type = document.createElement('span')
+ type.className = 'element-type'
+ type.textContent = el.type ?? ''
+ row.appendChild(type)
+
+ const isHidden = hiddenIndices.has(el.index)
+ const visBtn = document.createElement('button')
+ visBtn.className = 'vis-btn'
+ visBtn.textContent = isHidden ? '○' : '◉'
+ visBtn.title = isHidden ? 'Show on screenshot' : 'Hide from screenshot'
+ visBtn.setAttribute('aria-label', isHidden ? 'Show on screenshot' : 'Hide from screenshot')
+ visBtn.setAttribute('aria-pressed', String(!isHidden))
+ visBtn.addEventListener('click', e => { e.stopPropagation(); this.#onToggleCb?.(el.index) })
+ row.appendChild(visBtn)
+
+ row.addEventListener('click', () => this.#onClickCb?.(el.index))
+ row.addEventListener('keydown', e => {
+ if (e.target !== row) return
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ this.#onClickCb?.(el.index)
+ }
+ })
+ return row
+ }
+}
+
+// ---- Inspector ----
+// Orchestrates device management, the refresh cycle, and shared selection/visibility state.
+
+class Inspector {
+ #state = {
+ devices: [],
+ activeId: null,
+ elements: [],
+ selectedIndex: null,
+ logicalWidth: 0,
+ logicalHeight: 0,
+ hiddenIndices: new Set(),
+ }
+ // Persists user visibility overrides across refreshes: locatorKey -> 'hidden' | 'visible'.
+ // Pruned on each refresh to keys present in the new element list.
+ #userOverrides = new Map()
+ #autoRefreshTimer = null
+ #tickInFlight = false
+ #refreshInFlight = false
+ #connectInFlight = false
+ #consecutiveErrors = 0
+ static #MAX_CONSECUTIVE_ERRORS = 3
+
+ #screenshotPane = new ScreenshotPane()
+ #elementsPane = new ElementsPane()
+ #deviceSelect = document.getElementById('device-select')
+ #refreshBtn = document.getElementById('refresh-btn')
+ #autoRefreshToggle = document.getElementById('auto-refresh-toggle')
+ #autoRefreshInterval = document.getElementById('auto-refresh-interval')
+ #statusBar = document.getElementById('status-bar')
+
+ constructor() {
+ this.#screenshotPane.onElementClick(i => this.#selectElement(i))
+ this.#elementsPane.onElementClick(i => this.#selectElement(i))
+ this.#elementsPane.onToggleHidden(i => this.#toggleHidden(i))
+
+ this.#refreshBtn.addEventListener('click', () => this.refresh())
+ this.#deviceSelect.addEventListener('change', () => {
+ const opt = this.#deviceSelect.selectedOptions[0]
+ if (!opt?.value) return
+ const device = this.#state.devices.find(d => d.id === opt.value)
+ if (device) this.#connectDevice(device)
+ })
+ this.#autoRefreshToggle.addEventListener('change', () => this.#applyAutoRefresh())
+ this.#autoRefreshInterval.addEventListener('change', () => this.#applyAutoRefresh())
+
+ this.#loadDevices()
+ }
+
+ async refresh() {
+ if (!this.#state.activeId) return
+ if (this.#refreshInFlight) return
+ this.#refreshInFlight = true
+ this.#refreshBtn.disabled = true
+
+ try {
+ const res = await fetch('/api/inspect')
+ if (res.status === 503) return // another inspect in flight, skip this tick — no state changed yet
+ this.#setStatus('Loading...', 'loading')
+ if (this.#screenshotPane.isScreenshotHidden) {
+ this.#screenshotPane.showPlaceholder('Loading screenshot...', '', true)
+ }
+ if (res.status === 409) {
+ this.#state.activeId = null
+ this.#state.elements = []
+ this.#state.hiddenIndices = new Set()
+ this.#state.selectedIndex = null
+ this.#screenshotPane.showPlaceholder('Device disconnected', 'Select a device to continue')
+ this.#elementsPane.render([], new Set())
+ this.#screenshotPane.renderHighlights([], new Set(), null)
+ this.#setStatus('Device disconnected', 'error')
+ this.#renderDevicePicker()
+ return
+ }
+ if (!res.ok) {
+ const err = await res.json()
+ throw new Error(err.error ?? res.statusText)
+ }
+ const data = await res.json()
+ this.#state.elements = data.elements ?? []
+ this.#state.selectedIndex = null
+ this.#state.logicalWidth = data.screen?.width ?? 0
+ this.#state.logicalHeight = data.screen?.height ?? 0
+
+ // Prune overrides for locator keys no longer present in the new element list.
+ const liveKeys = new Set(
+ this.#state.elements.filter(el => el.locator).map(el => locatorKey(el.locator))
+ )
+ for (const k of this.#userOverrides.keys()) {
+ if (!liveKeys.has(k)) this.#userOverrides.delete(k)
+ }
+
+ this.#state.hiddenIndices = this.#computeHiddenIndices()
+
+ this.#screenshotPane.showScreenshot(data.screenshot, this.#state.logicalWidth, this.#state.logicalHeight)
+ this.#elementsPane.render(this.#state.elements, this.#state.hiddenIndices)
+ this.#screenshotPane.renderHighlights(this.#state.elements, this.#state.hiddenIndices, null)
+ this.#setStatus(`${this.#state.elements.length} elements`)
+ this.#consecutiveErrors = 0
+ } catch (err) {
+ this.#consecutiveErrors++
+ this.#setStatus('Refresh failed: ' + err.message, 'error')
+ if (this.#screenshotPane.isScreenshotHidden) {
+ this.#screenshotPane.showPlaceholder('Could not load screenshot', err.message)
+ }
+ if (this.#consecutiveErrors >= Inspector.#MAX_CONSECUTIVE_ERRORS) {
+ this.#stopAutoRefresh()
+ this.#setStatus(`Auto-refresh stopped after ${this.#consecutiveErrors} consecutive failures`, 'error')
+ }
+ } finally {
+ this.#refreshInFlight = false
+ this.#refreshBtn.disabled = false
+ }
+ }
+
+ async #loadDevices() {
+ try {
+ await this.#fetchDevices()
+ if (this.#state.activeId) {
+ await this.refresh()
+ }
+ } catch (err) {
+ this.#setStatus('Could not load devices: ' + err.message, 'error')
+ }
+ }
+
+ async #fetchDevices() {
+ const res = await fetch('/api/devices')
+ if (!res.ok) throw new Error(`Device list failed: ${res.status}`)
+ const data = await res.json()
+ const prevActiveId = this.#state.activeId
+ this.#state.devices = data.devices ?? []
+ this.#state.activeId = data.activeId ?? null
+ if (this.#state.activeId !== prevActiveId) this.#userOverrides.clear()
+ this.#renderDevicePicker()
+ if (prevActiveId && !this.#state.activeId) {
+ this.#state.elements = []
+ this.#state.selectedIndex = null
+ this.#state.hiddenIndices = new Set()
+ this.#elementsPane.render([], new Set())
+ this.#screenshotPane.showPlaceholder('Device disconnected', 'Select a device to continue')
+ this.#setStatus('Device disconnected', 'error')
+ }
+ }
+
+ #renderDevicePicker() {
+ const currentIds = [...this.#deviceSelect.options].filter(o => o.value).map(o => o.value)
+ const newIds = this.#state.devices.map(d => d.id)
+ const sameList = currentIds.length === newIds.length && currentIds.every((id, i) => id === newIds[i])
+ if (sameList && this.#deviceSelect.value === (this.#state.activeId ?? '')) return
+
+ this.#deviceSelect.innerHTML = ''
+ if (this.#state.devices.length === 0) {
+ const opt = document.createElement('option')
+ opt.value = ''
+ opt.textContent = 'No devices connected'
+ this.#deviceSelect.appendChild(opt)
+ return
+ }
+ if (!this.#state.activeId) {
+ const placeholder = document.createElement('option')
+ placeholder.value = ''
+ placeholder.textContent = 'Select a device'
+ placeholder.disabled = true
+ placeholder.selected = true
+ this.#deviceSelect.appendChild(placeholder)
+ }
+ for (const d of this.#state.devices) {
+ const opt = document.createElement('option')
+ opt.value = d.id
+ opt.dataset.platform = d.platform
+ opt.textContent = `${d.name} (${d.platform}, ${d.type})`
+ if (d.id === this.#state.activeId) opt.selected = true
+ this.#deviceSelect.appendChild(opt)
+ }
+ }
+
+ async #connectDevice(device) {
+ if (this.#connectInFlight) return
+ this.#connectInFlight = true
+ this.#setStatus('Connecting...', 'loading')
+ this.#screenshotPane.showPlaceholder('Connecting...', device.name ?? device.id, true)
+ this.#refreshBtn.disabled = true
+ this.#deviceSelect.disabled = true
+ try {
+ const res = await fetch(`/api/devices/${encodeURIComponent(device.id)}/select`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ platform: device.platform }),
+ })
+ if (!res.ok) {
+ const err = await res.json()
+ throw new Error(err.error ?? res.statusText)
+ }
+ this.#state.activeId = device.id
+ this.#userOverrides.clear()
+ this.#renderDevicePicker()
+ await this.refresh()
+ } catch (err) {
+ this.#setStatus('Connect failed: ' + err.message, 'error')
+ this.#screenshotPane.showPlaceholder('Connect failed', err.message)
+ this.#renderDevicePicker()
+ } finally {
+ this.#connectInFlight = false
+ this.#deviceSelect.disabled = false
+ this.#refreshBtn.disabled = false
+ }
+ }
+
+ #selectElement(index) {
+ this.#state.selectedIndex = index
+ this.#screenshotPane.setSelectedIndex(index)
+ this.#elementsPane.setSelectedIndex(index)
+ }
+
+ #toggleHidden(index) {
+ const el = this.#state.elements.find(el => el.index === index)
+ const key = el?.locator ? locatorKey(el.locator) : null
+ const nowHidden = this.#state.hiddenIndices.has(index)
+ // Only persist overrides for unique locator keys — shared keys would affect all duplicates.
+ const isDupe = key ? buildDuplicateSet(this.#state.elements).has(key) : false
+
+ if (nowHidden) {
+ this.#state.hiddenIndices.delete(index)
+ if (key && !isDupe) this.#userOverrides.set(key, 'visible')
+ } else {
+ this.#state.hiddenIndices.add(index)
+ if (key && !isDupe) this.#userOverrides.set(key, 'hidden')
+ }
+
+ this.#screenshotPane.renderHighlights(this.#state.elements, this.#state.hiddenIndices, this.#state.selectedIndex)
+ this.#elementsPane.updateRowVisibility(index, this.#state.hiddenIndices.has(index))
+ }
+
+ #computeHiddenIndices() {
+ const dupes = buildDuplicateSet(this.#state.elements)
+ return new Set(
+ this.#state.elements
+ .filter(el => {
+ const key = el.locator ? locatorKey(el.locator) : null
+ const override = key && !dupes.has(key) ? this.#userOverrides.get(key) : undefined
+ if (override === 'visible') return false
+ if (override === 'hidden') return true
+ return el.locator?.kind === 'testId' && DEFAULT_HIDDEN_TESTIDS.has(el.locator.value)
+ })
+ .map(el => el.index)
+ )
+ }
+
+ #applyAutoRefresh() {
+ clearInterval(this.#autoRefreshTimer)
+ this.#autoRefreshTimer = null
+ this.#autoRefreshInterval.disabled = !this.#autoRefreshToggle.checked
+ this.#consecutiveErrors = 0 // reset when user manually reconfigures auto-refresh
+ if (this.#autoRefreshToggle.checked) {
+ const ms = Number(this.#autoRefreshInterval.value)
+ this.#autoRefreshTimer = setInterval(async () => {
+ if (this.#tickInFlight) return
+ this.#tickInFlight = true
+ try {
+ await this.#fetchDevices().catch(() => {})
+ await this.refresh()
+ } finally {
+ this.#tickInFlight = false
+ }
+ }, ms)
+ }
+ }
+
+ // Called from the error path only — unregisters the timer and unchecks the toggle.
+ #stopAutoRefresh() {
+ clearInterval(this.#autoRefreshTimer)
+ this.#autoRefreshTimer = null
+ this.#autoRefreshToggle.checked = false
+ this.#autoRefreshInterval.disabled = true
+ }
+
+ #setStatus(msg, type = '') {
+ this.#statusBar.textContent = msg
+ this.#statusBar.className = type
+ }
+}
+
+// ---- Theme ----
+
+function applyTheme(name) {
+ document.documentElement.setAttribute('data-theme', name)
+ localStorage.setItem('mobilewright-inspector-theme', name)
+ const sel = document.getElementById('theme-select')
+ if (sel) sel.value = name
+}
+
+document.getElementById('theme-select')?.addEventListener('change', e => applyTheme(e.target.value))
+
+// ---- Bootstrap ----
+
+applyTheme(localStorage.getItem('mobilewright-inspector-theme') || 'void')
+new Inspector()
diff --git a/packages/inspector/src/index.ts b/packages/inspector/src/index.ts
new file mode 100644
index 0000000..1914cb6
--- /dev/null
+++ b/packages/inspector/src/index.ts
@@ -0,0 +1,75 @@
+import express from 'express';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import http from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { DeviceManager, type MobilewrightLauncher } from './lib/device-manager.js';
+import { createDevicesRouter } from './routes/devices.js';
+import { createInspectRouter } from './routes/inspect.js';
+
+export type { MobilewrightLauncher };
+
+/** Options passed to {@link start}. */
+export interface InspectorOptions {
+ /** iOS launcher from mobilewright. */
+ ios: MobilewrightLauncher;
+ /** Android launcher from mobilewright. */
+ android: MobilewrightLauncher;
+ /** HTTP port to listen on. Defaults to 4621. */
+ port?: number;
+}
+
+/** Handle returned by {@link start} to retrieve the server URL and shut it down. */
+export interface InspectorServer {
+ /** The URL the inspector is listening on, e.g. `http://localhost:4621`. */
+ url: string;
+ /** Gracefully close the device connection and stop the HTTP server. */
+ close: () => Promise;
+}
+
+/**
+ * Start the Mobilewright Inspector HTTP server.
+ * Pass the ios and android launcher objects from mobilewright so the inspector
+ * can list and connect to devices without a circular dependency.
+ */
+export async function start({ ios, android, port = 4621 }: InspectorOptions): Promise {
+ const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
+
+ const app = express();
+ app.use(express.json());
+ app.use(express.static(publicDir));
+ app.get('/health', (_req, res) => res.json({ ok: true }));
+
+ const deviceManager = new DeviceManager({ ios, android });
+ app.use('/api/devices', createDevicesRouter(deviceManager));
+ app.use('/api', createInspectRouter(deviceManager));
+
+ // 4-argument signature is required by Express to treat this as an error handler
+ app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+ res.status(500).json({ error: err.message });
+ });
+
+ const server = http.createServer(app);
+
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(port, '127.0.0.1', () => resolve());
+ });
+
+ const assignedPort = (server.address() as AddressInfo).port;
+ const url = `http://127.0.0.1:${assignedPort}`;
+
+ /** Gracefully drain the device connection and shut down the HTTP server. */
+ async function close(): Promise {
+ await new Promise(resolve => {
+ server.close(() => resolve());
+ server.closeAllConnections();
+ });
+ await Promise.race([
+ deviceManager.close().catch(() => {}),
+ new Promise(r => setTimeout(r, 3000)),
+ ]);
+ }
+
+ return { url, close };
+}
diff --git a/packages/inspector/src/lib/device-manager.test.ts b/packages/inspector/src/lib/device-manager.test.ts
new file mode 100644
index 0000000..8dca901
--- /dev/null
+++ b/packages/inspector/src/lib/device-manager.test.ts
@@ -0,0 +1,191 @@
+import { test, expect } from '@playwright/test';
+import type { Device } from '@mobilewright/core';
+import type { DeviceInfo } from '@mobilewright/protocol';
+import { DeviceManager, DeviceError } from './device-manager.js';
+
+type FakeDevice = Pick & { screen: object };
+
+function fakeDevice(overrides: Partial = {}): FakeDevice {
+ return { screen: {}, close: async () => {}, ...overrides };
+}
+
+interface FakeLauncherOpts {
+ devices?: DeviceInfo[]
+ device?: FakeDevice | null
+}
+
+function makeLauncher({ devices = [], device = null }: FakeLauncherOpts = {}) {
+ return {
+ devices: async () => devices,
+ launch: async () => (device ?? fakeDevice()) as unknown as Device,
+ };
+}
+
+// ---- listDevices ----
+
+test.describe('DeviceManager.listDevices', () => {
+ test('returns combined ios and android devices', async () => {
+ const dm = new DeviceManager({
+ ios: makeLauncher({ devices: [{ id: 'sim-1', name: 'iPhone 15' }] as DeviceInfo[] }),
+ android: makeLauncher({ devices: [{ id: 'emu-1', name: 'Pixel 7' }] as DeviceInfo[] }),
+ });
+ const devices = await dm.listDevices();
+ expect(devices.length).toBe(2);
+ expect(devices.some(d => d.id === 'sim-1')).toBe(true);
+ expect(devices.some(d => d.id === 'emu-1')).toBe(true);
+ });
+
+ test('tags ios devices with platform=ios', async () => {
+ const dm = new DeviceManager({
+ ios: makeLauncher({ devices: [{ id: 'sim-1', name: 'iPhone 15' }] as DeviceInfo[] }),
+ android: makeLauncher({ devices: [] }),
+ });
+ const devices = await dm.listDevices();
+ expect(devices[0].platform).toBe('ios');
+ });
+
+ test('tags android devices with platform=android', async () => {
+ const dm = new DeviceManager({
+ ios: makeLauncher({ devices: [] }),
+ android: makeLauncher({ devices: [{ id: 'emu-1', name: 'Pixel 7' }] as DeviceInfo[] }),
+ });
+ const devices = await dm.listDevices();
+ expect(devices[0].platform).toBe('android');
+ });
+
+ test('tolerates ios failure, still returns android devices', async () => {
+ const dm = new DeviceManager({
+ ios: { devices: async () => { throw new Error('ios dead'); }, launch: async () => { throw new Error(); } },
+ android: makeLauncher({ devices: [{ id: 'emu-1', name: 'Pixel 7' }] as DeviceInfo[] }),
+ });
+ const devices = await dm.listDevices();
+ expect(devices.length).toBe(1);
+ expect(devices[0].id).toBe('emu-1');
+ });
+
+ test('tolerates android failure, still returns ios devices', async () => {
+ const dm = new DeviceManager({
+ ios: makeLauncher({ devices: [{ id: 'sim-1', name: 'iPhone 15' }] as DeviceInfo[] }),
+ android: { devices: async () => { throw new Error('android dead'); }, launch: async () => { throw new Error(); } },
+ });
+ const devices = await dm.listDevices();
+ expect(devices.length).toBe(1);
+ expect(devices[0].id).toBe('sim-1');
+ });
+
+ test('returns empty array when both platforms fail', async () => {
+ const dm = new DeviceManager({
+ ios: { devices: async () => { throw new Error('dead'); }, launch: async () => { throw new Error(); } },
+ android: { devices: async () => { throw new Error('dead'); }, launch: async () => { throw new Error(); } },
+ });
+ const devices = await dm.listDevices();
+ expect(devices).toEqual([]);
+ });
+});
+
+// ---- select ----
+
+test.describe('DeviceManager.select', () => {
+ test('sets device and deviceInfo after connect', async () => {
+ const launched = fakeDevice();
+ const dm = new DeviceManager({
+ ios: { devices: async () => [], launch: async () => launched as unknown as Device },
+ android: { devices: async () => [], launch: async () => { throw new Error(); } },
+ });
+ await dm.select('sim-1', 'ios');
+ expect(dm.device).toBe(launched);
+ expect(dm.deviceInfo).toEqual({ id: 'sim-1', platform: 'ios' });
+ });
+
+ test('throws DeviceError(blocked) when inspect in flight', async () => {
+ const dm = new DeviceManager({
+ ios: { devices: async () => [], launch: async () => fakeDevice() as unknown as Device },
+ android: { devices: async () => [], launch: async () => { throw new Error(); } },
+ });
+ dm.beginInspect();
+ const err = await dm.select('sim-1', 'ios').catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(DeviceError);
+ expect((err as DeviceError).code).toBe('blocked');
+ });
+
+ test('throws DeviceError(in_progress) when select already running', async () => {
+ let resolveLaunch!: (d: Device) => void;
+ const dm = new DeviceManager({
+ ios: { devices: async () => [], launch: () => new Promise(r => { resolveLaunch = r; }) },
+ android: { devices: async () => [], launch: async () => { throw new Error(); } },
+ });
+ const first = dm.select('sim-1', 'ios');
+ const err = await dm.select('sim-2', 'ios').catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(DeviceError);
+ expect((err as DeviceError).code).toBe('in_progress');
+ resolveLaunch(fakeDevice() as unknown as Device);
+ await first;
+ });
+
+ test('wraps launcher errors in DeviceError(connect_failed)', async () => {
+ const dm = new DeviceManager({
+ ios: { devices: async () => [], launch: async () => { throw new Error('timeout'); } },
+ android: { devices: async () => [], launch: async () => { throw new Error(); } },
+ });
+ const err = await dm.select('sim-1', 'ios').catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(DeviceError);
+ expect((err as DeviceError).code).toBe('connect_failed');
+ });
+
+ test('closes previous device before connecting new one', async () => {
+ let firstClosed = false;
+ const first = fakeDevice({ close: async () => { firstClosed = true; } });
+ const second = fakeDevice();
+ const dm = new DeviceManager({
+ ios: {
+ devices: async () => [],
+ launch: async (opts: { deviceId: string }) =>
+ (opts.deviceId === 'sim-2' ? second : first) as unknown as Device,
+ },
+ android: { devices: async () => [], launch: async () => { throw new Error(); } },
+ });
+ await dm.select('sim-1', 'ios');
+ await dm.select('sim-2', 'ios');
+ expect(firstClosed).toBe(true);
+ expect(dm.device).toBe(second);
+ });
+});
+
+// ---- beginInspect / endInspect ----
+
+test.describe('DeviceManager.beginInspect / endInspect', () => {
+ test('beginInspect returns true when idle', () => {
+ const dm = new DeviceManager({ ios: makeLauncher(), android: makeLauncher() });
+ expect(dm.beginInspect()).toBe(true);
+ });
+
+ test('beginInspect returns false when already in flight', () => {
+ const dm = new DeviceManager({ ios: makeLauncher(), android: makeLauncher() });
+ dm.beginInspect();
+ expect(dm.beginInspect()).toBe(false);
+ });
+
+ test('beginInspect returns true after endInspect', () => {
+ const dm = new DeviceManager({ ios: makeLauncher(), android: makeLauncher() });
+ dm.beginInspect();
+ dm.endInspect();
+ expect(dm.beginInspect()).toBe(true);
+ });
+});
+
+// ---- DeviceError ----
+
+test.describe('DeviceError', () => {
+ test('is instanceof Error', () => {
+ expect(new DeviceError('msg', 'blocked')).toBeInstanceOf(Error);
+ });
+
+ test('has name=DeviceError', () => {
+ expect(new DeviceError('msg', 'blocked').name).toBe('DeviceError');
+ });
+
+ test('has code property', () => {
+ expect(new DeviceError('msg', 'blocked').code).toBe('blocked');
+ expect(new DeviceError('msg', 'in_progress').code).toBe('in_progress');
+ });
+});
diff --git a/packages/inspector/src/lib/device-manager.ts b/packages/inspector/src/lib/device-manager.ts
new file mode 100644
index 0000000..7f71cd8
--- /dev/null
+++ b/packages/inspector/src/lib/device-manager.ts
@@ -0,0 +1,160 @@
+import type { Device } from '@mobilewright/core';
+import type { DeviceInfo } from '@mobilewright/protocol';
+import { logger } from './logger.js';
+
+/** Platform launcher injected from mobilewright to avoid a circular dependency. */
+export interface MobilewrightLauncher {
+ /** List all connected/booted devices for this platform. */
+ devices(): Promise;
+ /** Launch and connect to a specific device by id. */
+ launch(opts: { deviceId: string; autoStart?: boolean; autoAppLaunch?: boolean }): Promise;
+}
+
+/** Discriminated union of error codes thrown by DeviceManager. */
+export type DeviceErrorCode = 'blocked' | 'in_progress' | 'not_found' | 'connect_failed';
+
+/** Structured error thrown by DeviceManager for expected failure modes. */
+export class DeviceError extends Error {
+ /** Machine-readable error code indicating the failure reason. */
+ readonly code: DeviceErrorCode;
+
+ /** @param message Human-readable description. @param code Machine-readable failure reason. */
+ constructor(message: string, code: DeviceErrorCode) {
+ super(message);
+ this.name = 'DeviceError';
+ this.code = code;
+ }
+}
+
+/** DeviceInfo tagged with its platform, returned by listDevices(). */
+export type TaggedDeviceInfo = DeviceInfo & { platform: 'ios' | 'android' };
+
+/** Minimal device identity record held by DeviceManager while a device is active. */
+export type DeviceInfoRecord = { id: string; platform: 'ios' | 'android' };
+
+/**
+ * Manages a single active device connection shared across the inspect lifecycle.
+ * Coordinates concurrent select() and inspect operations via in-flight flags.
+ */
+export class DeviceManager {
+ /** iOS launcher instance. */
+ #ios: MobilewrightLauncher;
+ /** Android launcher instance. */
+ #android: MobilewrightLauncher;
+ /** Currently connected device driver, or null when no device is selected. */
+ #activeDevice: Device | null = null;
+ /** Identity of the currently connected device, or null when no device is selected. */
+ #activeDeviceInfo: DeviceInfoRecord | null = null;
+ /** True while an inspect operation is in progress; blocks concurrent select(). */
+ #inspectInFlight = false;
+ /** True while a select() call is awaiting launcher.launch(); blocks concurrent select(). */
+ #selecting = false;
+ /** True after close() is called; prevents new connections after shutdown. */
+ #closed = false;
+
+ /** @param ios iOS launcher from mobilewright. @param android Android launcher from mobilewright. */
+ constructor({ ios, android }: { ios: MobilewrightLauncher; android: MobilewrightLauncher }) {
+ this.#ios = ios;
+ this.#android = android;
+ }
+
+ /**
+ * List all connected/booted devices across both platforms.
+ * Each platform is queried independently so a failure on one does not hide the other.
+ */
+ async listDevices(): Promise {
+ const [iosResult, androidResult] = await Promise.allSettled([
+ this.#ios.devices(),
+ this.#android.devices(),
+ ]);
+ if (iosResult.status === 'rejected') logger.warn(`iOS device list failed: ${iosResult.reason?.message}`);
+ if (androidResult.status === 'rejected') logger.warn(`Android device list failed: ${androidResult.reason?.message}`);
+ return [
+ ...(iosResult.status === 'fulfilled' ? iosResult.value.map(d => ({ ...d, platform: 'ios' as const })) : []),
+ ...(androidResult.status === 'fulfilled' ? androidResult.value.map(d => ({ ...d, platform: 'android' as const })) : []),
+ ];
+ }
+
+ /**
+ * Connect to a device, closing any previous connection first.
+ * Throws DeviceError if an inspect is in flight, a select is already in progress,
+ * or the previous device cannot be cleanly disconnected.
+ */
+ async select(deviceId: string, platform: 'ios' | 'android'): Promise {
+ if (this.#closed) throw new DeviceError('DeviceManager is closed', 'connect_failed');
+ if (this.#inspectInFlight) throw new DeviceError('Device switch blocked: inspect in progress', 'blocked');
+ if (this.#selecting) throw new DeviceError('Device switch already in progress', 'in_progress');
+
+ this.#selecting = true;
+ try {
+ if (this.#activeDevice) {
+ logger.info(`Closing previous device ${this.#activeDeviceInfo?.id}`);
+ try {
+ await this.#activeDevice.close();
+ this.#activeDevice = null;
+ this.#activeDeviceInfo = null;
+ } catch (err) {
+ logger.error(`Failed to close device ${this.#activeDeviceInfo?.id}: ${(err as Error).message}`);
+ throw new DeviceError((err as Error).message, 'connect_failed');
+ }
+ }
+ logger.info(`Connecting to ${platform} device ${deviceId}`);
+ const launcher = platform === 'ios' ? this.#ios : this.#android;
+ const launched = await launcher.launch({ deviceId, autoStart: true, autoAppLaunch: false });
+ if (this.#closed) {
+ try { await launched.close(); } catch {}
+ throw new DeviceError('DeviceManager closed during connect', 'connect_failed');
+ }
+ this.#activeDevice = launched;
+ this.#activeDeviceInfo = { id: deviceId, platform };
+ logger.info(`Connected to ${deviceId}`);
+ return this.#activeDevice;
+ } catch (err) {
+ if (err instanceof DeviceError) throw err;
+ logger.error(`Failed to connect to ${deviceId}: ${(err as Error).message}`);
+ throw new DeviceError((err as Error).message, 'connect_failed');
+ } finally {
+ this.#selecting = false;
+ }
+ }
+
+ /**
+ * Mark the start of an inspect operation.
+ * Returns false if an inspect is already in flight or a device switch is in progress.
+ */
+ beginInspect(): boolean {
+ if (this.#inspectInFlight || this.#selecting) return false;
+ this.#inspectInFlight = true;
+ return true;
+ }
+
+ /** Clear the inspect-in-flight flag set by beginInspect(). */
+ endInspect(): void {
+ this.#inspectInFlight = false;
+ }
+
+ /**
+ * Close the active device connection. Safe to call with no active device.
+ * Throws if the underlying driver close fails; state is only cleared on success.
+ */
+ async close(): Promise {
+ this.#closed = true;
+ if (this.#activeDevice) {
+ logger.info(`Closing device ${this.#activeDeviceInfo?.id}`);
+ try {
+ await this.#activeDevice.close();
+ this.#activeDevice = null;
+ this.#activeDeviceInfo = null;
+ } catch (err) {
+ logger.error(`Failed to close device ${this.#activeDeviceInfo?.id}: ${(err as Error).message}`);
+ throw new DeviceError((err as Error).message, 'connect_failed');
+ }
+ }
+ }
+
+ /** The currently connected device, or null if none selected. */
+ get device(): Device | null { return this.#activeDevice; }
+
+ /** Id and platform of the currently connected device, or null if none selected. */
+ get deviceInfo(): DeviceInfoRecord | null { return this.#activeDeviceInfo; }
+}
diff --git a/packages/inspector/src/lib/locator-derivation.test.ts b/packages/inspector/src/lib/locator-derivation.test.ts
new file mode 100644
index 0000000..7613e77
--- /dev/null
+++ b/packages/inspector/src/lib/locator-derivation.test.ts
@@ -0,0 +1,247 @@
+import { test, expect } from '@playwright/test';
+import type { ViewNode } from '@mobilewright/protocol';
+import { deriveLocator, deriveElementList } from './locator-derivation.js';
+
+function node(overrides: Partial = {}): ViewNode {
+ return {
+ type: 'statictext',
+ isVisible: true,
+ isEnabled: true,
+ bounds: { x: 0, y: 0, width: 100, height: 30 },
+ children: [],
+ ...overrides,
+ } as ViewNode;
+}
+
+function roleName(locator: ReturnType): string | undefined {
+ return locator?.kind === 'role' ? locator.name : undefined;
+}
+
+// ---- Priority order ----
+
+test.describe('deriveLocator — priority order', () => {
+ test('testId (identifier) beats role, label, text', () => {
+ expect(
+ deriveLocator(node({ type: 'button', identifier: 'my-id', label: 'Label', text: 'Text' })),
+ ).toEqual({ kind: 'testId', value: 'my-id' });
+ });
+
+ test('testId (resourceId) beats role, label, text', () => {
+ expect(
+ deriveLocator(node({ resourceId: 'com.example:id/btn', label: 'Tap me' })),
+ ).toEqual({ kind: 'testId', value: 'com.example:id/btn' });
+ });
+
+ test('identifier takes precedence over resourceId', () => {
+ expect(
+ deriveLocator(node({ identifier: 'first', resourceId: 'second' })),
+ ).toEqual({ kind: 'testId', value: 'first' });
+ });
+
+ test('role beats label and text', () => {
+ const result = deriveLocator(node({ type: 'button', label: 'Tap', text: 'Tap me' }));
+ expect(result?.kind).toBe('role');
+ expect(result?.value).toBe('button');
+ });
+
+ test('label beats text', () => {
+ expect(
+ deriveLocator(node({ type: 'unknown', label: 'My Label', text: 'My Text' })),
+ ).toEqual({ kind: 'label', value: 'My Label' });
+ });
+
+ test('text used when no label and no role', () => {
+ expect(
+ deriveLocator(node({ type: 'unknown', text: 'Hello' })),
+ ).toEqual({ kind: 'text', value: 'Hello' });
+ });
+
+ test('value used as text fallback', () => {
+ expect(
+ deriveLocator(node({ type: 'unknown', value: 'typed' })),
+ ).toEqual({ kind: 'text', value: 'typed' });
+ });
+
+ test('returns null when nothing available', () => {
+ expect(deriveLocator(node({ type: 'unknown' }))).toBeNull();
+ });
+});
+
+// ---- Role: name field ----
+
+test.describe('deriveLocator — role name', () => {
+ test('role includes name from label', () => {
+ expect(
+ deriveLocator(node({ type: 'button', label: 'Submit' })),
+ ).toEqual({ kind: 'role', value: 'button', name: 'Submit' });
+ });
+
+ test('role includes name from text when no label', () => {
+ expect(
+ deriveLocator(node({ type: 'button', text: 'OK' })),
+ ).toEqual({ kind: 'role', value: 'button', name: 'OK' });
+ });
+
+ test('role with no label or text has undefined name', () => {
+ expect(
+ deriveLocator(node({ type: 'button' })),
+ ).toEqual({ kind: 'role', value: 'button', name: undefined });
+ });
+});
+
+// ---- Role type mapping ----
+
+const roleMappingCases: [string, string][] = [
+ ['button', 'button'],
+ ['imagebutton', 'button'],
+ ['textfield', 'textfield'],
+ ['securetextfield', 'textfield'],
+ ['edittext', 'textfield'],
+ ['searchfield', 'textfield'],
+ ['reactedittext', 'textfield'],
+ ['statictext', 'text'],
+ ['textview', 'text'],
+ ['text', 'text'],
+ ['image', 'image'],
+ ['imageview', 'image'],
+ ['reactimageview', 'image'],
+ ['switch', 'switch'],
+ ['toggle', 'switch'],
+ ['checkbox', 'checkbox'],
+ ['slider', 'slider'],
+ ['seekbar', 'slider'],
+ ['table', 'list'],
+ ['collectionview', 'list'],
+ ['listview', 'list'],
+ ['recyclerview', 'list'],
+ ['scrollview', 'list'],
+ ['reactscrollview', 'list'],
+ ['cell', 'listitem'],
+ ['linearlayout', 'listitem'],
+ ['relativelayout', 'listitem'],
+ ['tab', 'tab'],
+ ['tabbar', 'tab'],
+ ['link', 'link'],
+ ['navigationbar', 'header'],
+ ['toolbar', 'header'],
+ ['header', 'header'],
+];
+
+test.describe('deriveLocator — role type mapping', () => {
+ for (const [type, expectedRole] of roleMappingCases) {
+ test(`${type} -> ${expectedRole}`, () => {
+ const result = deriveLocator(node({ type }));
+ expect(result?.kind).toBe('role');
+ expect(result?.value).toBe(expectedRole);
+ });
+ }
+
+ test('unknown type does not get a role', () => {
+ expect(deriveLocator(node({ type: 'unknownwidget' }))?.kind).not.toBe('role');
+ });
+
+ test('other type does not map to listitem', () => {
+ const result = deriveLocator(node({ type: 'other' }));
+ expect(result?.value).not.toBe('listitem');
+ });
+});
+
+// ---- Case-insensitive type ----
+
+test.describe('deriveLocator — case insensitive type', () => {
+ test('BUTTON maps to button role', () => {
+ const result = deriveLocator(node({ type: 'BUTTON' }));
+ expect(result?.kind).toBe('role');
+ expect(result?.value).toBe('button');
+ });
+
+ test('MixedCase maps correctly', () => {
+ const result = deriveLocator(node({ type: 'StaticText' }));
+ expect(result?.kind).toBe('role');
+ expect(result?.value).toBe('text');
+ });
+});
+
+// ---- reactviewgroup special case ----
+
+test.describe('deriveLocator — reactviewgroup', () => {
+ test('clickable=true -> button role', () => {
+ expect(
+ deriveLocator(node({ type: 'reactviewgroup', raw: { clickable: 'true' } })),
+ ).toEqual({ kind: 'role', value: 'button', name: undefined });
+ });
+
+ test('accessible=true -> button role', () => {
+ expect(
+ deriveLocator(node({ type: 'reactviewgroup', raw: { accessible: 'true' } })),
+ ).toEqual({ kind: 'role', value: 'button', name: undefined });
+ });
+
+ test('clickable=false falls through to label', () => {
+ expect(
+ deriveLocator(node({ type: 'reactviewgroup', label: 'wrapper', raw: { clickable: 'false' } })),
+ ).toEqual({ kind: 'label', value: 'wrapper' });
+ });
+
+ test('no raw prop falls through to label', () => {
+ expect(
+ deriveLocator(node({ type: 'reactviewgroup', label: 'wrapper' })),
+ ).toEqual({ kind: 'label', value: 'wrapper' });
+ });
+
+ test('non-clickable with no label returns null', () => {
+ expect(deriveLocator(node({ type: 'reactviewgroup' }))).toBeNull();
+ });
+});
+
+// ---- deriveElementList ----
+
+test.describe('deriveElementList', () => {
+ test('empty input returns empty array', () => {
+ expect(deriveElementList([])).toEqual([]);
+ });
+
+ test('flattens nested tree depth-first', () => {
+ const roots = [
+ node({ type: 'table', identifier: 'list', children: [
+ node({ type: 'cell', label: 'Row 1', children: [] }),
+ node({ type: 'cell', label: 'Row 2', children: [] }),
+ ] }),
+ ];
+ const result = deriveElementList(roots);
+ expect(result.length).toBe(3);
+ expect(result[0].locator?.kind).toBe('testId');
+ expect(result[0].locator?.value).toBe('list');
+ expect(result[1].locator?.kind).toBe('role');
+ expect(result[1].locator?.value).toBe('listitem');
+ expect(roleName(result[1].locator)).toBe('Row 1');
+ expect(roleName(result[2].locator)).toBe('Row 2');
+ });
+
+ test('includes nodes with no locator', () => {
+ const result = deriveElementList([node({ type: 'unknown' })]);
+ expect(result.length).toBe(1);
+ expect(result[0].locator).toBeNull();
+ });
+
+ test('each entry has node and locator fields', () => {
+ const root = node({ type: 'button', label: 'Go' });
+ const result = deriveElementList([root]);
+ expect('node' in result[0]).toBe(true);
+ expect('locator' in result[0]).toBe(true);
+ expect(result[0].node).toBe(root);
+ });
+
+ test('deeply nested tree flattened correctly', () => {
+ const deep = node({ type: 'button', label: 'Deep', children: [] });
+ const mid = node({ type: 'statictext', text: 'Mid', children: [deep] });
+ const root = node({ identifier: 'root', children: [mid] });
+ const result = deriveElementList([root]);
+ expect(result.length).toBe(3);
+ expect(result[0].locator?.value).toBe('root');
+ expect(result[1].locator?.kind).toBe('role');
+ expect(roleName(result[1].locator)).toBe('Mid');
+ expect(result[2].locator?.kind).toBe('role');
+ expect(roleName(result[2].locator)).toBe('Deep');
+ });
+});
diff --git a/packages/inspector/src/lib/locator-derivation.ts b/packages/inspector/src/lib/locator-derivation.ts
new file mode 100644
index 0000000..102a276
--- /dev/null
+++ b/packages/inspector/src/lib/locator-derivation.ts
@@ -0,0 +1,86 @@
+// Derives the best mobilewright locator for every node in a ViewNode tree.
+// Priority: Test ID > Role > Label > Text. Mirrors @mobilewright/core query-engine.ts.
+// If mobilewright changes matching rules, update ROLE_TYPE_MAP below to stay in sync.
+
+import type { ViewNode } from '@mobilewright/protocol';
+
+/** Maps mobilewright role names to the node type strings that resolve to each role. */
+const ROLE_TYPE_MAP: Record = {
+ button: ['button', 'imagebutton'],
+ textfield: ['textfield', 'securetextfield', 'edittext', 'searchfield', 'reactedittext'],
+ text: ['statictext', 'textview', 'text', 'reacttextview'],
+ image: ['image', 'imageview', 'reactimageview'],
+ switch: ['switch', 'toggle'],
+ checkbox: ['checkbox'],
+ slider: ['slider', 'seekbar'],
+ list: ['table', 'collectionview', 'listview', 'recyclerview', 'scrollview', 'reactscrollview'],
+ listitem: ['cell', 'linearlayout', 'relativelayout'],
+ tab: ['tab', 'tabbar'],
+ link: ['link'],
+ header: ['navigationbar', 'toolbar', 'header'],
+};
+
+/** Discriminated union of the four locator strategies mobilewright supports. */
+export type Locator =
+ | { kind: 'testId'; value: string }
+ | { kind: 'role'; value: string; name: string | undefined }
+ | { kind: 'label'; value: string }
+ | { kind: 'text'; value: string };
+
+/** Map node.type to a mobilewright role string. Returns null for unmapped types. */
+function deriveRole(node: ViewNode): string | null {
+ const type = (node.type ?? '').toLowerCase();
+
+ if (type === 'reactviewgroup') {
+ const isClickable = node.raw?.['clickable'] === 'true' || node.raw?.['accessible'] === 'true';
+ return isClickable ? 'button' : null;
+ }
+
+ for (const [role, types] of Object.entries(ROLE_TYPE_MAP)) {
+ if (types.includes(type)) return role;
+ }
+ return null;
+}
+
+/**
+ * Derive the best mobilewright locator for a single ViewNode.
+ * Returns null when no supported locator field is present.
+ */
+export function deriveLocator(node: ViewNode): Locator | null {
+ const testId = node.identifier || node.resourceId;
+ if (testId) return { kind: 'testId', value: testId };
+
+ const role = deriveRole(node);
+ if (role) {
+ const name = node.label || node.text || undefined;
+ return { kind: 'role', value: role, name };
+ }
+
+ if (node.label) return { kind: 'label', value: node.label };
+
+ const text = node.text ?? (node.value != null ? String(node.value) : undefined);
+ if (text) return { kind: 'text', value: text };
+
+ return null;
+}
+
+/**
+ * Flatten a ViewNode tree depth-first and annotate each node with its best locator.
+ * Nodes with no locatable field are included with locator: null.
+ */
+export function deriveElementList(
+ roots: ViewNode[],
+): Array<{ node: ViewNode; locator: Locator | null }> {
+ const result: Array<{ node: ViewNode; locator: Locator | null }> = [];
+
+ /** Depth-first recursive walk, pushing each visited node to result. */
+ function walk(nodes: ViewNode[]): void {
+ for (const node of nodes) {
+ result.push({ node, locator: deriveLocator(node) });
+ if (node.children?.length) walk(node.children);
+ }
+ }
+
+ walk(roots);
+ return result;
+}
diff --git a/packages/inspector/src/lib/logger.ts b/packages/inspector/src/lib/logger.ts
new file mode 100644
index 0000000..2c2cb27
--- /dev/null
+++ b/packages/inspector/src/lib/logger.ts
@@ -0,0 +1,15 @@
+/** Write a single timestamped log line to stderr. */
+function log(level: string, msg: string): void {
+ process.stderr.write(`[${new Date().toISOString()}] ${level} ${msg}\n`);
+}
+
+/** Structured logger that writes timestamped lines to stderr. */
+export const logger = {
+ /** Log an informational message. */
+ info: (msg: string) => log('INFO ', msg),
+ /** Log a warning. */
+ warn: (msg: string) => log('WARN ', msg),
+ /** Log an error. */
+ error: (msg: string) => log('ERROR', msg),
+};
+
diff --git a/packages/inspector/src/routes.test.ts b/packages/inspector/src/routes.test.ts
new file mode 100644
index 0000000..7e94eac
--- /dev/null
+++ b/packages/inspector/src/routes.test.ts
@@ -0,0 +1,211 @@
+import { test, expect } from '@playwright/test';
+import http from 'node:http';
+import type { AddressInfo } from 'node:net';
+import express from 'express';
+import { DeviceManager } from './lib/device-manager.js';
+import { createDevicesRouter } from './routes/devices.js';
+import { createInspectRouter } from './routes/inspect.js';
+
+// ---- minimal HTTP helpers ----
+
+interface HttpResponse {
+ status: number
+ body: unknown
+}
+
+function request(method: string, url: string, body: unknown = null): Promise {
+ return new Promise((resolve, reject) => {
+ const payload = body != null ? JSON.stringify(body) : null;
+ const parsed = new URL(url);
+ const req = http.request({
+ hostname: parsed.hostname,
+ port: parsed.port,
+ path: parsed.pathname + parsed.search,
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(payload ? { 'Content-Length': String(Buffer.byteLength(payload)) } : {}),
+ },
+ }, res => {
+ let data = '';
+ res.on('data', (chunk: string) => { data += chunk; });
+ res.on('end', () => {
+ try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(data) }); }
+ catch { resolve({ status: res.statusCode ?? 0, body: data }); }
+ });
+ });
+ req.on('error', reject);
+ req.setTimeout(5000, () => { req.destroy(new Error(`${method} ${parsed.pathname} timed out`)); });
+ if (payload) req.write(payload);
+ req.end();
+ });
+}
+
+const get = (url: string) => request('GET', url);
+const post = (url: string, b: unknown) => request('POST', url, b);
+
+// ---- test server setup ----
+
+function buildApp(dm: DeviceManager) {
+ const app = express();
+ app.use(express.json());
+ app.get('/health', (_req, res) => res.json({ ok: true }));
+ app.use('/api/devices', createDevicesRouter(dm));
+ app.use('/api', createInspectRouter(dm));
+ app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+ res.status(500).json({ error: err.message });
+ });
+ return app;
+}
+
+async function startServer(dm: DeviceManager): Promise<{ server: http.Server; base: string }> {
+ return new Promise((resolve, reject) => {
+ const server = buildApp(dm).listen(0);
+ server.once('error', reject);
+ server.once('listening', () => {
+ const { port } = server.address() as AddressInfo;
+ resolve({ server, base: `http://localhost:${port}` });
+ });
+ });
+}
+
+// ---- GET /health ----
+
+test.describe('GET /health', () => {
+ let server: http.Server;
+ let base: string;
+
+ test.beforeAll(async () => {
+ const dm = new DeviceManager({
+ ios: { devices: async () => [], launch: async () => { throw new Error(); } },
+ android: { devices: async () => [], launch: async () => { throw new Error(); } },
+ })
+ ;({ server, base } = await startServer(dm));
+ });
+
+ test.afterAll(() => new Promise(resolve => server.close(() => resolve())));
+
+ test('returns 200 { ok: true }', async () => {
+ const { status, body } = await get(`${base}/health`);
+ expect(status).toBe(200);
+ expect((body as { ok: boolean }).ok).toBe(true);
+ });
+});
+
+// ---- GET /api/devices ----
+
+test.describe('GET /api/devices', () => {
+ let server: http.Server;
+ let base: string;
+
+ test.beforeAll(async () => {
+ const dm = new DeviceManager({
+ ios: { devices: async () => [{ id: 'sim-1', name: 'iPhone 15' } as never], launch: async () => { throw new Error(); } },
+ android: { devices: async () => [{ id: 'emu-1', name: 'Pixel 7' } as never], launch: async () => { throw new Error(); } },
+ })
+ ;({ server, base } = await startServer(dm));
+ });
+
+ test.afterAll(() => new Promise(resolve => server.close(() => resolve())));
+
+ test('returns combined device list with null activeId', async () => {
+ const { status, body } = await get(`${base}/api/devices`);
+ const b = body as { devices: { id: string }[]; activeId: null };
+ expect(status).toBe(200);
+ expect(Array.isArray(b.devices)).toBe(true);
+ expect(b.devices.length).toBe(2);
+ expect(b.devices.some(d => d.id === 'sim-1')).toBe(true);
+ expect(b.devices.some(d => d.id === 'emu-1')).toBe(true);
+ expect(b.activeId).toBeNull();
+ });
+});
+
+// ---- POST /api/devices/:id/select ----
+
+test.describe('POST /api/devices/:id/select', () => {
+ let server: http.Server;
+ let base: string;
+
+ test.beforeAll(async () => {
+ const dm = new DeviceManager({
+ ios: {
+ devices: async () => [{ id: 'sim-1', name: 'iPhone 15' } as never],
+ launch: async () => ({ screen: {}, close: async () => {} }) as never,
+ },
+ android: { devices: async () => [], launch: async () => { throw new Error(); } },
+ })
+ ;({ server, base } = await startServer(dm));
+ });
+
+ test.afterAll(() => new Promise(resolve => server.close(() => resolve())));
+
+ test('returns 400 when platform is missing', async () => {
+ const { status } = await post(`${base}/api/devices/sim-1/select`, {});
+ expect(status).toBe(400);
+ });
+
+ test('returns 400 when platform is invalid', async () => {
+ const { status } = await post(`${base}/api/devices/sim-1/select`, { platform: 'windows' });
+ expect(status).toBe(400);
+ });
+
+ test('returns 404 when device id is unknown', async () => {
+ const { status } = await post(`${base}/api/devices/unknown/select`, { platform: 'ios' });
+ expect(status).toBe(404);
+ });
+
+ test('returns 200 when device exists and connect succeeds', async () => {
+ const { status, body } = await post(`${base}/api/devices/sim-1/select`, { platform: 'ios' });
+ expect(status).toBe(200);
+ expect((body as { ok: boolean }).ok).toBe(true);
+ });
+});
+
+// ---- GET /api/inspect — no device selected ----
+
+test.describe('GET /api/inspect — no device selected', () => {
+ let server: http.Server;
+ let base: string;
+
+ test.beforeAll(async () => {
+ const dm = new DeviceManager({
+ ios: { devices: async () => [], launch: async () => { throw new Error(); } },
+ android: { devices: async () => [], launch: async () => { throw new Error(); } },
+ })
+ ;({ server, base } = await startServer(dm));
+ });
+
+ test.afterAll(() => new Promise(resolve => server.close(() => resolve())));
+
+ test('returns 409 when no device is connected', async () => {
+ const { status } = await get(`${base}/api/inspect`);
+ expect(status).toBe(409);
+ });
+});
+
+// ---- GET /api/inspect — inspect already in progress ----
+
+test.describe('GET /api/inspect — inspect already in progress', () => {
+ let server: http.Server;
+ let base: string;
+
+ test.beforeAll(async () => {
+ const dm = new DeviceManager({
+ ios: {
+ devices: async () => [{ id: 'sim-1', name: 'iPhone 15' } as never],
+ launch: async () => ({ screen: {}, close: async () => {} }) as never,
+ },
+ android: { devices: async () => [], launch: async () => { throw new Error(); } },
+ })
+ ;({ server, base } = await startServer(dm));
+ await dm.select('sim-1', 'ios');
+ dm.beginInspect();
+ });
+
+ test.afterAll(() => new Promise(resolve => server.close(() => resolve())));
+
+ test('returns 503 when inspect is already in flight', async () => {
+ const { status } = await get(`${base}/api/inspect`);
+ expect(status).toBe(503);
+ });
+});
diff --git a/packages/inspector/src/routes/devices.ts b/packages/inspector/src/routes/devices.ts
new file mode 100644
index 0000000..e70226d
--- /dev/null
+++ b/packages/inspector/src/routes/devices.ts
@@ -0,0 +1,46 @@
+import { Router } from 'express';
+import { DeviceError, DeviceManager } from '../lib/device-manager.js';
+
+/**
+ * Express router for device listing and selection.
+ * GET /api/devices, POST /api/devices/:id/select
+ */
+export function createDevicesRouter(deviceManager: DeviceManager) {
+ const router = Router();
+
+ // GET /api/devices
+ router.get('/', async (_req, res) => {
+ try {
+ const devices = await deviceManager.listDevices();
+ res.json({ devices, activeId: deviceManager.deviceInfo?.id ?? null });
+ } catch (err) {
+ res.status(500).json({ error: (err as Error).message });
+ }
+ });
+
+ // POST /api/devices/:id/select body: { platform: 'ios' | 'android' }
+ router.post('/:id/select', async (req, res) => {
+ const { id } = req.params;
+ const { platform } = (req.body ?? {}) as { platform?: string };
+
+ if (platform !== 'ios' && platform !== 'android') {
+ res.status(400).json({ error: 'platform must be \'ios\' or \'android\'' });
+ return;
+ }
+
+ try {
+ const devices = await deviceManager.listDevices();
+ if (!devices.some(d => d.id === id && d.platform === platform)) {
+ res.status(404).json({ error: `Device '${id}' not found` });
+ return;
+ }
+ await deviceManager.select(id, platform);
+ res.json({ ok: true });
+ } catch (err) {
+ const status = err instanceof DeviceError && (err.code === 'blocked' || err.code === 'in_progress') ? 409 : 500;
+ res.status(status).json({ error: (err as Error).message });
+ }
+ });
+
+ return router;
+}
diff --git a/packages/inspector/src/routes/inspect.ts b/packages/inspector/src/routes/inspect.ts
new file mode 100644
index 0000000..08fc2bf
--- /dev/null
+++ b/packages/inspector/src/routes/inspect.ts
@@ -0,0 +1,92 @@
+import { Router } from 'express';
+import type { Device } from '@mobilewright/core';
+import type { ViewNode, ScreenSize } from '@mobilewright/protocol';
+import { deriveElementList } from '../lib/locator-derivation.js';
+import { logger } from '../lib/logger.js';
+import { DeviceManager } from '../lib/device-manager.js';
+
+const MAX_RETRIES = 3;
+const RETRY_DELAY_MS = 1500;
+const ATTEMPT_TIMEOUT_MS = 10_000;
+
+/**
+ * Express router for the inspect endpoint.
+ * GET /api/inspect — returns screenshot + element list from the same device moment.
+ */
+export function createInspectRouter(deviceManager: DeviceManager) {
+ const router = Router();
+
+ // GET /api/inspect
+ // Returns screenshot + element list from the same moment (no drift).
+ router.get('/inspect', async (_req, res) => {
+ const device = deviceManager.device;
+ if (!device) {
+ res.status(409).json({ error: 'No device selected' });
+ return;
+ }
+ if (!deviceManager.beginInspect()) {
+ res.status(503).json({ error: 'Inspect already in progress' });
+ return;
+ }
+
+ try {
+ const { screenshotBuffer, tree, size } = await attemptWithRetry(device);
+
+ const elements = deriveElementList(tree).map(({ node, locator }, index) => ({
+ index,
+ type: node.type,
+ label: node.label ?? null,
+ text: node.text ?? null,
+ bounds: node.bounds,
+ isVisible: node.isVisible,
+ locator,
+ }));
+
+ res.json({
+ screenshot: `data:image/png;base64,${screenshotBuffer.toString('base64')}`,
+ screen: { width: size.width, height: size.height },
+ elements,
+ });
+ } catch (err) {
+ logger.error(`Inspect failed: ${(err as Error).message}`);
+ res.status(500).json({ error: (err as Error).message });
+ } finally {
+ deviceManager.endInspect();
+ }
+ });
+
+ return router;
+}
+
+/** Run screenshot + viewTree + screenSize together, retrying up to MAX_RETRIES times on failure. */
+async function attemptWithRetry(device: Device): Promise<{ screenshotBuffer: Buffer; tree: ViewNode[]; size: ScreenSize }> {
+ let lastErr: unknown;
+ for (let i = 0; i < MAX_RETRIES; i++) {
+ try {
+ return await withTimeout(
+ Promise.all([device.screen.screenshot(), device.screen.viewTree(), device.screenSize()]).then(
+ ([screenshotBuffer, tree, size]) => ({ screenshotBuffer, tree, size }),
+ ),
+ ATTEMPT_TIMEOUT_MS,
+ `Device operation timed out after ${ATTEMPT_TIMEOUT_MS}ms`,
+ );
+ } catch (err) {
+ lastErr = err;
+ logger.warn(`Inspect attempt ${i + 1}/${MAX_RETRIES} failed: ${(err as Error).message}`);
+ if (i < MAX_RETRIES - 1) await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
+ }
+ }
+ throw lastErr;
+}
+
+/**
+ * Race promise against a ms deadline. Rejects with message on timeout.
+ * Note: cannot cancel the underlying promise; it continues running until the driver times out.
+ */
+function withTimeout(promise: Promise, ms: number, message: string): Promise {
+ let timer: ReturnType;
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }),
+ ]).finally(() => clearTimeout(timer));
+}
diff --git a/packages/inspector/tsconfig.json b/packages/inspector/tsconfig.json
new file mode 100644
index 0000000..5b4344c
--- /dev/null
+++ b/packages/inspector/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.ts"],
+ "references": [
+ { "path": "../protocol" },
+ { "path": "../mobilewright-core" }
+ ]
+}
diff --git a/packages/mobilewright/package.json b/packages/mobilewright/package.json
index c209c0e..b665334 100644
--- a/packages/mobilewright/package.json
+++ b/packages/mobilewright/package.json
@@ -37,8 +37,10 @@
"@mobilewright/core": "^0.0.1",
"@mobilewright/driver-mobilecli": "^0.0.1",
"@mobilewright/driver-mobilenext": "^0.0.1",
+ "@mobilewright/inspector": "^0.0.1",
"@mobilewright/protocol": "^0.0.1",
"commander": "^14.0.3",
+ "open": "^10.2.0",
"debug": "^4.4.3",
"playwright": "1.58.2",
"ws": "^8.18.0"
diff --git a/packages/mobilewright/src/cli.ts b/packages/mobilewright/src/cli.ts
index 41eb6dc..89f5d4d 100644
--- a/packages/mobilewright/src/cli.ts
+++ b/packages/mobilewright/src/cli.ts
@@ -302,6 +302,35 @@ program
if (checks.some(c => c.status === 'error')) process.exitCode = 1;
});
+// ── inspect ────────────────────────────────────────────────────────────
+program
+ .command('inspect')
+ .description('open the Mobilewright Inspector in your browser')
+ .option('-p, --port ', 'port to listen on', '4621')
+ .action(async (opts: { port: string }) => {
+ const { start } = await import('@mobilewright/inspector');
+ const { default: open } = await import('open');
+ const { ios, android } = await import('./launchers.js');
+
+ const port = Number(opts.port);
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
+ console.error(`error: --port must be a valid port number, got: ${opts.port}`);
+ process.exit(1);
+ }
+
+ const inspector = await start({ ios, android, port });
+ console.log(`Mobilewright Inspector running at ${inspector.url}`);
+ try { await open(inspector.url); } catch { /* no browser available; URL already printed */ }
+
+ async function shutdown(): Promise {
+ await inspector.close();
+ process.exit(0);
+ }
+
+ process.on('SIGINT', () => { void shutdown(); });
+ process.on('SIGTERM', () => { void shutdown(); });
+ });
+
// ── init ───────────────────────────────────────────────────────────────
program
.command('init')
diff --git a/packages/mobilewright/tsconfig.json b/packages/mobilewright/tsconfig.json
index 975a413..10f63fb 100644
--- a/packages/mobilewright/tsconfig.json
+++ b/packages/mobilewright/tsconfig.json
@@ -9,6 +9,7 @@
{ "path": "../protocol" },
{ "path": "../mobilewright-core" },
{ "path": "../driver-mobilecli" },
- { "path": "../driver-mobilenext" }
+ { "path": "../driver-mobilenext" },
+ { "path": "../inspector" }
]
}
diff --git a/tsconfig.json b/tsconfig.json
index 833453f..95b5110 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -24,6 +24,7 @@
{ "path": "packages/mobilewright-core" },
{ "path": "packages/driver-mobilecli" },
{ "path": "packages/driver-mobilenext" },
+ { "path": "packages/inspector" },
{ "path": "packages/mobilewright" },
{ "path": "packages/test" }
]