From e83d721844444e1cb12b397efd7bb963836233be Mon Sep 17 00:00:00 2001 From: Marco Maes Date: Fri, 26 Jun 2026 11:04:51 +0200 Subject: [PATCH] feat: add mobilewright inspect command with browser-based element inspector Adds a new `@mobilewright/inspector` workspace package and a `mobilewright inspect` CLI subcommand that opens a browser-based element inspector for connected devices. The inspector shows a live screenshot on the left and an annotated element list on the right. Each element is annotated with the best mobilewright locator (testId > role > label > text), mirroring the priority order of @mobilewright/core's query engine. Clicking a locator highlights its bounding box on the screenshot; clicking a box selects the matching row. Duplicate locators get a "dup" badge. Architecture: - packages/inspector: standalone Express server, plain HTML/CSS/JS frontend (no build step), locator derivation pure function, DeviceManager with injected launchers (DIP) - packages/mobilewright: inspect CLI command injects ios/android launchers into start() to avoid a circular dependency; open is an explicit dependency here Hardening and review fixes: - Logs to stderr so the URL line on stdout is uncluttered - Port validated as integer in [0, 65535] - Node 18 compatible server close (no Symbol.asyncDispose) - 4-arg Express error handler returns JSON on unexpected throws - escQ escapes backslashes in addition to single quotes - open() wrapped in try/catch so headless environments print URL and continue - device.screenSize() replaces PNG IHDR heuristic for correct iPad support - 404 check in select route matches by id AND platform - Test files excluded from tsconfig (not shipped in dist) - Stale screenshot cleared when active device disconnects - aria-label on device picker and interval selects; vis-btn aria-label kept in sync - Passes ESLint @stylistic/semi throughout - 83 tests (@playwright/test): locator derivation, DeviceManager, HTTP routes Co-Authored-By: Claude Sonnet 4.6 --- README.md | 13 + package-lock.json | 1112 ++++++++++++++++- packages/inspector/package.json | 42 + packages/inspector/public/css/app.css | 453 +++++++ packages/inspector/public/index.html | 67 + packages/inspector/public/js/app.js | 572 +++++++++ packages/inspector/src/index.ts | 75 ++ .../inspector/src/lib/device-manager.test.ts | 191 +++ packages/inspector/src/lib/device-manager.ts | 160 +++ .../src/lib/locator-derivation.test.ts | 247 ++++ .../inspector/src/lib/locator-derivation.ts | 86 ++ packages/inspector/src/lib/logger.ts | 15 + packages/inspector/src/routes.test.ts | 211 ++++ packages/inspector/src/routes/devices.ts | 46 + packages/inspector/src/routes/inspect.ts | 92 ++ packages/inspector/tsconfig.json | 13 + packages/mobilewright/package.json | 2 + packages/mobilewright/src/cli.ts | 29 + packages/mobilewright/tsconfig.json | 3 +- tsconfig.json | 1 + 20 files changed, 3414 insertions(+), 16 deletions(-) create mode 100644 packages/inspector/package.json create mode 100644 packages/inspector/public/css/app.css create mode 100644 packages/inspector/public/index.html create mode 100644 packages/inspector/public/js/app.js create mode 100644 packages/inspector/src/index.ts create mode 100644 packages/inspector/src/lib/device-manager.test.ts create mode 100644 packages/inspector/src/lib/device-manager.ts create mode 100644 packages/inspector/src/lib/locator-derivation.test.ts create mode 100644 packages/inspector/src/lib/locator-derivation.ts create mode 100644 packages/inspector/src/lib/logger.ts create mode 100644 packages/inspector/src/routes.test.ts create mode 100644 packages/inspector/src/routes/devices.ts create mode 100644 packages/inspector/src/routes/inspect.ts create mode 100644 packages/inspector/tsconfig.json 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

+
+ + +
+
+
+
+
+
+ + + + 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" } ]