From ef4c546cae5c8b53d98ab89c620bddb3d9cea8d3 Mon Sep 17 00:00:00 2001 From: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:40:34 +0800 Subject: [PATCH 1/5] feat: add devtool-connector and devtool-mcp-server packages Migrate devtool packages from lynx-stack to this repository, updated to match the current internal version level: - @lynx-js/devtool-connector v0.9.3 (was v0.1.1 in lynx-stack) - @lynx-js/devtool-mcp-server v0.13.3 (was v0.5.1 in lynx-stack) - @lynx-js/skill-lynx-devtool updated to v0.13.3 New packages: - mcp-servers/devtool-connector: Transport layer for communicating with Lynx devices (Android/iOS/Desktop + background daemon) - mcp-servers/devtool-mcp-server: MCP server with 46 tools across 13 domains (App, CSS, DOM, Debugger, Device, HeapProfiler, Input, Lynx, Memory, Page, Performance, Runtime, UITree) Updated skill: - 16 commands (added global-switch, inspect, take-heap-snapshot, recorder-start/end/analysis, reactlynx) - Daemon transport support for persistent device connections CI: - Added E2E test job using EmbeddedLynx binary on Linux --- .changeset/config.json | 4 +- .github/workflows/test.yml | 38 + mcp-servers/devtool-connector/README.md | 152 +++ .../devtool-connector/e2e/index.test.ts | 370 +++++++ .../devtool-connector/e2e/webview-cdp.test.ts | 673 ++++++++++++ mcp-servers/devtool-connector/package.json | 97 ++ .../public/inspector-wrapper.html | 539 ++++++++++ mcp-servers/devtool-connector/rslib.config.ts | 30 + .../devtool-connector/src/client-id.ts | 26 + .../src/daemon/device-connection.ts | 175 ++++ .../devtool-connector/src/daemon/entry.ts | 68 ++ .../devtool-connector/src/daemon/index.ts | 5 + .../devtool-connector/src/daemon/manager.ts | 129 +++ .../devtool-connector/src/daemon/protocol.ts | 120 +++ .../devtool-connector/src/daemon/server.ts | 637 +++++++++++ .../src/daemon/static-server.ts | 124 +++ .../src/daemon/tarball-cache.ts | 128 +++ .../devtool-connector/src/daemon/version.ts | 7 + mcp-servers/devtool-connector/src/index.ts | 632 +++++++++++ .../devtool-connector/src/streams/cdp.ts | 56 + .../src/streams/customized.ts | 150 +++ .../devtool-connector/src/streams/index.ts | 7 + .../devtool-connector/src/streams/utils.ts | 58 + mcp-servers/devtool-connector/src/takeover.ts | 35 + .../src/transport/android.ts | 169 +++ .../devtool-connector/src/transport/base.ts | 61 ++ .../devtool-connector/src/transport/daemon.ts | 343 ++++++ .../src/transport/desktop.ts | 81 ++ .../devtool-connector/src/transport/index.ts | 13 + .../devtool-connector/src/transport/ios.ts | 80 ++ .../src/transport/peertalk.ts | 66 ++ .../src/transport/transport.ts | 53 + .../devtool-connector/src/transport/usbmux.ts | 183 ++++ .../src/transport/ws-stream.ts | 134 +++ mcp-servers/devtool-connector/src/types.ts | 204 ++++ .../devtool-connector/test/clientId.test.ts | 67 ++ .../test/connector-lifecycle.test.ts | 100 ++ .../test/daemon-connect-timeout.test.ts | 38 + .../test/daemon-device-connection.test.ts | 265 +++++ .../test/daemon-manager.test.ts | 40 + .../test/daemon-protocol.test.ts | 87 ++ .../test/daemon-server.test.ts | 763 ++++++++++++++ .../test/daemon-transport.test.ts | 278 +++++ .../devtool-connector/test/ios.test.ts | 165 +++ .../test/list-clients-fallback.test.ts | 52 + .../test/list-clients-setup.test.ts | 165 +++ .../test/open-app-daemon.test.ts | 65 ++ .../test/testWithClient.test.ts | 84 ++ .../devtool-connector/test/testWithClient.ts | 168 +++ .../test/transport-selection.test.ts | 150 +++ .../devtool-connector/test/usbmux.test.ts | 102 ++ mcp-servers/devtool-connector/tsconfig.json | 9 + mcp-servers/devtool-mcp-server/LICENSE | 202 ++++ mcp-servers/devtool-mcp-server/README.md | 100 ++ mcp-servers/devtool-mcp-server/package.json | 71 ++ .../devtool-mcp-server/rslib.config.ts | 39 + .../devtool-mcp-server/src/McpContext.ts | 22 + .../devtool-mcp-server/src/McpResponse.ts | 35 + .../devtool-mcp-server/src/connector.ts | 17 + mcp-servers/devtool-mcp-server/src/index.ts | 197 ++++ mcp-servers/devtool-mcp-server/src/main.ts | 39 + .../devtool-mcp-server/src/schema/index.ts | 93 ++ .../src/tools/App/GetGlobalSwitch.ts | 25 + .../src/tools/App/ListGlobalSwitch.ts | 36 + .../src/tools/App/SetGlobalSwitch.ts | 27 + .../src/tools/App/globalSwitch.ts | 27 + .../src/tools/CSS/GetBackgroundColors.ts | 29 + .../src/tools/CSS/GetComputedStyleForNode.ts | 30 + .../src/tools/CSS/GetInlineStylesForNode.ts | 29 + .../src/tools/CSS/GetMatchedStylesForNode.ts | 30 + .../src/tools/CSS/GetStyleSheetText.ts | 29 + .../src/tools/DOM/DescribeNode.ts | 38 + .../src/tools/DOM/GetAttributes.ts | 28 + .../src/tools/DOM/GetBoxModel.ts | 30 + .../src/tools/DOM/GetDocument.ts | 35 + .../src/tools/DOM/GetDocumentWithBoxModel.ts | 29 + .../src/tools/DOM/GetNodeForLocation.ts | 30 + .../src/tools/DOM/GetOriginalNodeIndex.ts | 28 + .../src/tools/DOM/GetSearchResults.ts | 32 + .../src/tools/DOM/InnerText.ts | 28 + .../src/tools/DOM/PerformSearch.ts | 30 + .../DOM/PushNodesByBackendIdsToFrontend.ts | 28 + .../src/tools/DOM/QuerySelector.ts | 32 + .../src/tools/DOM/QuerySelectorAll.ts | 32 + .../src/tools/DOM/RequestChildNodes.ts | 32 + .../src/tools/DOM/ScrollIntoViewIfNeeded.ts | 32 + .../src/tools/DOM/SetAttributesAsText.ts | 41 + .../src/tools/Debugger/GetScriptSource.ts | 52 + .../src/tools/Debugger/ListScripts.ts | 66 ++ .../src/tools/Device/ClosePage.ts | 22 + .../src/tools/Device/ListClients.ts | 21 + .../src/tools/Device/ListDevices.ts | 25 + .../src/tools/Device/ListSessions.ts | 24 + .../src/tools/Device/OpenPage.ts | 55 + .../tools/HeapProfiler/TakeHeapSnapshot.ts | 172 +++ .../tools/Input/EmulateTouchFromMouseEvent.ts | 47 + .../src/tools/Lynx/GetVersion.ts | 25 + .../src/tools/Memory/GetAllMemoryUsage.ts | 54 + .../src/tools/Page/GetResourceContent.ts | 45 + .../src/tools/Page/GetResourceTree.ts | 25 + .../src/tools/Page/Reload.ts | 35 + .../src/tools/Page/TakeScreenshot.ts | 84 ++ .../Performance/GetAllPerformanceEntries.ts | 37 + .../src/tools/Performance/GetAllTimingInfo.ts | 37 + .../src/tools/Runtime/Evaluate.ts | 65 ++ .../src/tools/Runtime/GetHeapUsage.ts | 40 + .../src/tools/Runtime/GetProperties.ts | 57 + .../src/tools/Runtime/ListConsole.ts | 153 +++ .../src/tools/UITree/GetLynxUITree.ts | 30 + .../src/tools/defineTool.ts | 52 + .../test/McpResponse.test.ts | 22 + .../devtool-mcp-server/test/tools/AGENTS.md | 78 ++ .../test/tools/App_globalSwitch.test.ts | 89 ++ .../test/tools/DOM_describeNode.test.ts | 152 +++ .../test/tools/DOM_getDocument.test.ts | 60 ++ .../tools/DOM_setAttributesAsText.test.ts | 51 + .../test/tools/Device_openPage.test.ts | 86 ++ .../HeapProfiler_takeHeapSnapshot.test.ts | 240 +++++ .../tools/Memory_getAllMemoryUsage.test.ts | 101 ++ .../tools/Page_Lynx_resourceVersion.test.ts | 131 +++ ...rformance_getAllPerformanceEntries.test.ts | 89 ++ .../Runtime_evaluate_getProperties.test.ts | 105 ++ .../test/tools/Runtime_getHeapUsage.test.ts | 90 ++ .../test/tools/UITree_getLynxUITree.test.ts | 88 ++ .../test/tools/proxyTestUtils.ts | 50 + .../test/utils/cdp-types.ts | 99 ++ .../devtool-mcp-server/test/utils/testTool.ts | 63 ++ mcp-servers/devtool-mcp-server/tsconfig.json | 45 + packages/skills/lynx-devtool/SKILL.md | 382 ++++++- .../examples/getting-console-logs.md | 7 +- .../main-thread-logs-and-properties.md | 23 + .../examples/programmatic-debugging.md | 72 ++ ...ogrammatic-touch-click-and-double-click.md | 232 ++++ .../examples/touch-using-dom-coordinates.md | 59 ++ .../examples/touch-using-lynx-geometry.md | 42 + .../touch-using-screenshot-coordinates.md | 28 + packages/skills/lynx-devtool/package.json | 9 +- .../lynx-devtool/references/global-switch.md | 112 ++ .../lynx-devtool/references/library-usage.md | 238 +++++ .../lynx-devtool/references/recorder.md | 97 ++ .../references/take-heap-snapshot.md | 52 + packages/skills/lynx-devtool/rslib.config.ts | 36 +- .../skills/lynx-devtool/src/commands/app.ts | 30 +- .../skills/lynx-devtool/src/commands/cdp.ts | 51 +- .../lynx-devtool/src/commands/get-console.ts | 252 +++-- .../lynx-devtool/src/commands/get-sources.ts | 97 +- .../src/commands/global-switch.ts | 103 ++ .../lynx-devtool/src/commands/inspect.ts | 30 + .../lynx-devtool/src/commands/list-clients.ts | 45 +- .../src/commands/list-sessions.ts | 30 +- .../skills/lynx-devtool/src/commands/open.ts | 57 +- .../src/commands/reactlynx/find.ts | 181 ++++ .../src/commands/reactlynx/format.ts | 109 ++ .../src/commands/reactlynx/index.ts | 20 + .../src/commands/reactlynx/inspect.ts | 191 ++++ .../src/commands/reactlynx/protocol.ts | 220 ++++ .../src/commands/reactlynx/transport.ts | 212 ++++ .../src/commands/reactlynx/tree.ts | 83 ++ .../src/commands/reactlynx/update.ts | 214 ++++ .../src/commands/recorder-analysis.ts | 104 ++ .../lynx-devtool/src/commands/recorder-end.ts | 242 +++++ .../src/commands/recorder-start.ts | 74 ++ .../src/commands/take-heap-snapshot.ts | 138 +++ .../src/commands/take-screenshot.ts | 96 +- .../skills/lynx-devtool/src/commands/utils.ts | 197 +++- packages/skills/lynx-devtool/src/connector.ts | 35 + packages/skills/lynx-devtool/src/devtool.ts | 104 +- packages/skills/lynx-devtool/src/index.ts | 37 +- pnpm-lock.yaml | 990 +++++++++++++++++- pnpm-workspace.yaml | 5 + 170 files changed, 17546 insertions(+), 479 deletions(-) create mode 100644 mcp-servers/devtool-connector/README.md create mode 100644 mcp-servers/devtool-connector/e2e/index.test.ts create mode 100644 mcp-servers/devtool-connector/e2e/webview-cdp.test.ts create mode 100644 mcp-servers/devtool-connector/package.json create mode 100644 mcp-servers/devtool-connector/public/inspector-wrapper.html create mode 100644 mcp-servers/devtool-connector/rslib.config.ts create mode 100644 mcp-servers/devtool-connector/src/client-id.ts create mode 100644 mcp-servers/devtool-connector/src/daemon/device-connection.ts create mode 100644 mcp-servers/devtool-connector/src/daemon/entry.ts create mode 100644 mcp-servers/devtool-connector/src/daemon/index.ts create mode 100644 mcp-servers/devtool-connector/src/daemon/manager.ts create mode 100644 mcp-servers/devtool-connector/src/daemon/protocol.ts create mode 100644 mcp-servers/devtool-connector/src/daemon/server.ts create mode 100644 mcp-servers/devtool-connector/src/daemon/static-server.ts create mode 100644 mcp-servers/devtool-connector/src/daemon/tarball-cache.ts create mode 100644 mcp-servers/devtool-connector/src/daemon/version.ts create mode 100644 mcp-servers/devtool-connector/src/index.ts create mode 100644 mcp-servers/devtool-connector/src/streams/cdp.ts create mode 100644 mcp-servers/devtool-connector/src/streams/customized.ts create mode 100644 mcp-servers/devtool-connector/src/streams/index.ts create mode 100644 mcp-servers/devtool-connector/src/streams/utils.ts create mode 100644 mcp-servers/devtool-connector/src/takeover.ts create mode 100644 mcp-servers/devtool-connector/src/transport/android.ts create mode 100644 mcp-servers/devtool-connector/src/transport/base.ts create mode 100644 mcp-servers/devtool-connector/src/transport/daemon.ts create mode 100644 mcp-servers/devtool-connector/src/transport/desktop.ts create mode 100644 mcp-servers/devtool-connector/src/transport/index.ts create mode 100644 mcp-servers/devtool-connector/src/transport/ios.ts create mode 100644 mcp-servers/devtool-connector/src/transport/peertalk.ts create mode 100644 mcp-servers/devtool-connector/src/transport/transport.ts create mode 100644 mcp-servers/devtool-connector/src/transport/usbmux.ts create mode 100644 mcp-servers/devtool-connector/src/transport/ws-stream.ts create mode 100644 mcp-servers/devtool-connector/src/types.ts create mode 100644 mcp-servers/devtool-connector/test/clientId.test.ts create mode 100644 mcp-servers/devtool-connector/test/connector-lifecycle.test.ts create mode 100644 mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts create mode 100644 mcp-servers/devtool-connector/test/daemon-device-connection.test.ts create mode 100644 mcp-servers/devtool-connector/test/daemon-manager.test.ts create mode 100644 mcp-servers/devtool-connector/test/daemon-protocol.test.ts create mode 100644 mcp-servers/devtool-connector/test/daemon-server.test.ts create mode 100644 mcp-servers/devtool-connector/test/daemon-transport.test.ts create mode 100644 mcp-servers/devtool-connector/test/ios.test.ts create mode 100644 mcp-servers/devtool-connector/test/list-clients-fallback.test.ts create mode 100644 mcp-servers/devtool-connector/test/list-clients-setup.test.ts create mode 100644 mcp-servers/devtool-connector/test/open-app-daemon.test.ts create mode 100644 mcp-servers/devtool-connector/test/testWithClient.test.ts create mode 100644 mcp-servers/devtool-connector/test/testWithClient.ts create mode 100644 mcp-servers/devtool-connector/test/transport-selection.test.ts create mode 100644 mcp-servers/devtool-connector/test/usbmux.test.ts create mode 100644 mcp-servers/devtool-connector/tsconfig.json create mode 100644 mcp-servers/devtool-mcp-server/LICENSE create mode 100644 mcp-servers/devtool-mcp-server/README.md create mode 100644 mcp-servers/devtool-mcp-server/package.json create mode 100644 mcp-servers/devtool-mcp-server/rslib.config.ts create mode 100644 mcp-servers/devtool-mcp-server/src/McpContext.ts create mode 100644 mcp-servers/devtool-mcp-server/src/McpResponse.ts create mode 100644 mcp-servers/devtool-mcp-server/src/connector.ts create mode 100644 mcp-servers/devtool-mcp-server/src/index.ts create mode 100644 mcp-servers/devtool-mcp-server/src/main.ts create mode 100644 mcp-servers/devtool-mcp-server/src/schema/index.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts create mode 100644 mcp-servers/devtool-mcp-server/src/tools/defineTool.ts create mode 100644 mcp-servers/devtool-mcp-server/test/McpResponse.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/AGENTS.md create mode 100644 mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts create mode 100644 mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts create mode 100644 mcp-servers/devtool-mcp-server/test/utils/cdp-types.ts create mode 100644 mcp-servers/devtool-mcp-server/test/utils/testTool.ts create mode 100644 mcp-servers/devtool-mcp-server/tsconfig.json create mode 100644 packages/skills/lynx-devtool/examples/main-thread-logs-and-properties.md create mode 100644 packages/skills/lynx-devtool/examples/programmatic-debugging.md create mode 100644 packages/skills/lynx-devtool/examples/programmatic-touch-click-and-double-click.md create mode 100644 packages/skills/lynx-devtool/examples/touch-using-dom-coordinates.md create mode 100644 packages/skills/lynx-devtool/examples/touch-using-lynx-geometry.md create mode 100644 packages/skills/lynx-devtool/examples/touch-using-screenshot-coordinates.md create mode 100644 packages/skills/lynx-devtool/references/global-switch.md create mode 100644 packages/skills/lynx-devtool/references/library-usage.md create mode 100644 packages/skills/lynx-devtool/references/recorder.md create mode 100644 packages/skills/lynx-devtool/references/take-heap-snapshot.md create mode 100644 packages/skills/lynx-devtool/src/commands/global-switch.ts create mode 100644 packages/skills/lynx-devtool/src/commands/inspect.ts create mode 100644 packages/skills/lynx-devtool/src/commands/reactlynx/find.ts create mode 100644 packages/skills/lynx-devtool/src/commands/reactlynx/format.ts create mode 100644 packages/skills/lynx-devtool/src/commands/reactlynx/index.ts create mode 100644 packages/skills/lynx-devtool/src/commands/reactlynx/inspect.ts create mode 100644 packages/skills/lynx-devtool/src/commands/reactlynx/protocol.ts create mode 100644 packages/skills/lynx-devtool/src/commands/reactlynx/transport.ts create mode 100644 packages/skills/lynx-devtool/src/commands/reactlynx/tree.ts create mode 100644 packages/skills/lynx-devtool/src/commands/reactlynx/update.ts create mode 100644 packages/skills/lynx-devtool/src/commands/recorder-analysis.ts create mode 100644 packages/skills/lynx-devtool/src/commands/recorder-end.ts create mode 100644 packages/skills/lynx-devtool/src/commands/recorder-start.ts create mode 100644 packages/skills/lynx-devtool/src/commands/take-heap-snapshot.ts create mode 100644 packages/skills/lynx-devtool/src/connector.ts diff --git a/.changeset/config.json b/.changeset/config.json index 441e4d0..6d13450 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,9 @@ "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [], + "fixed": [ + ["@lynx-js/devtool-connector", "@lynx-js/devtool-mcp-server", "@lynx-js/skill-lynx-devtool"] + ], "linked": [], "access": "public", "baseBranch": "main", diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bad3109..8bccf7a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,3 +87,41 @@ jobs: - name: Run Tests run: pnpm test + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Pnpm + run: npm i -g corepack@latest --force && corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: "pnpm" + + - name: Install Dependencies + run: pnpm install + + - name: Download EmbeddedLynx + run: | + curl -sL https://github.com/lynx-community/skills/releases/download/embedded-lynx-202606041609/embedded-lynx-linux-x86_64.tar.gz | tar -xzf - -C /tmp + chmod +x /tmp/embedded-lynx + echo "EMBEDDED_LYNX_BINARY=/tmp/embedded-lynx" >> "$GITHUB_ENV" + + - name: Run E2E Tests (devtool-connector) + working-directory: mcp-servers/devtool-connector + env: + LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS: EmbeddedLynx + run: node --test --test-concurrency=1 'e2e/**/*.test.ts' + + - name: Run E2E Tests (devtool-mcp-server) + working-directory: mcp-servers/devtool-mcp-server + env: + LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS: EmbeddedLynx + EMBEDDED_LYNX_BINARY: /tmp/embedded-lynx + run: node --test --test-concurrency=1 'e2e/**/*.test.ts' diff --git a/mcp-servers/devtool-connector/README.md b/mcp-servers/devtool-connector/README.md new file mode 100644 index 0000000..4869344 --- /dev/null +++ b/mcp-servers/devtool-connector/README.md @@ -0,0 +1,152 @@ +# @lynx-js/devtool-connector + +`@lynx-js/devtool-connector` provides low-level connectivity for Lynx DevTool. It unifies transport layers for Android / iOS / Desktop, and exposes stable request APIs (such as `CDP`, `App`, and `ListSession`). + +It can be used as a TypeScript library in higher-level services like `devtool-mcp-server`. + +## Requirements + +- Node.js >= 18.19 +- At least one available device channel: + - Android: ADB (default `127.0.0.1:5037`) + - iOS: usbmuxd + - Desktop: local `127.0.0.1` port + +## Installation + +```bash +pnpm add @lynx-js/devtool-connector +``` + +## Library usage + +### 1) Initialize Connector + +```ts +import { Connector } from "@lynx-js/devtool-connector"; +import { DaemonTransport } from "@lynx-js/devtool-connector/transport"; + +const transports = [ + new DaemonTransport(), +]; + +const connector = new Connector(transports); +``` + +Use `DaemonTransport` as the default transport. It automatically manages a local daemon process and reuses stable device connections, so it should be the primary path for client discovery and follow-up requests. + +Add other transports only when you need fallback behavior for environments where the daemon is unavailable or cannot discover a target client. When fallback transports are present, `Connector` tries daemon-backed clients first, then the remaining direct platform transports. + +Fallback example: + +```ts +import { Connector } from "@lynx-js/devtool-connector"; +import { + AndroidTransport, + DaemonTransport, + DesktopTransport, + iOSTransport, +} from "@lynx-js/devtool-connector/transport"; + +const connector = new Connector([ + new DaemonTransport(), + new AndroidTransport(), + new iOSTransport(), + new DesktopTransport(), +]); +``` + +### 2) List devices, clients, and sessions + +```ts +const devices = await connector.listDevices(); +const clients = await connector.listClients(); + +if (clients.length === 0) { + throw new Error("No available clients found"); +} + +const clientId = clients[0].id; +const sessions = await connector.sendListSessionMessage(clientId); +``` + +### 3) Send CDP / App requests + +```ts +const sessionId = sessions[0]?.session_id; +if (!sessionId) { + throw new Error("No session found"); +} + +const dom = await connector.sendCDPMessage( + clientId, + sessionId, + "DOM.getDocument", + { depth: -1 }, +); + +const mainThreadEval = await connector.sendCDPMessage( + clientId, + sessionId, + "Runtime.evaluate", + { expression: "2 + 2" }, + // isMainThread + true, +); + +await connector.sendAppMessage(clientId, "App.openPage", { + url: "https://lynxjs.org", +}); +``` + +Pass `true` as the optional `isMainThread` argument to target the main-thread VM. Main-thread CDP requests currently support only `Debugger.*`, `Runtime.*`, `HeapProfiler.*`, and `Profiler.*` methods. + +### 4) Streaming APIs (advanced) + +The connector also supports streaming send/receive: + +- `sendCDPStream(...)` +- `sendStream(...)` (custom pipeline) + +These APIs are useful for subscription-style logs, continuous requests, or protocol debugging. + +When you finish consuming the returned stream, make sure to close it (dispose the output stream) to release the underlying connection. + +Example: consume CDP events with `for await...of` + +```ts +import { ReadableStream } from "node:stream/web"; + +await using outputStream = await connector.sendCDPStream( + clientId, + sessionId, + ReadableStream.from([ + { method: "Runtime.enable" }, + ]), +); + +for await (const message of outputStream) { + // `sendCDPStream` yields CDP events, e.g. { method: "Runtime.consoleAPICalled", params: ... } + console.log(message.method, message.params); + break; +} +``` + +## Exported entry points + +- `@lynx-js/devtool-connector`: `Connector`, `ClientId`, and protocol transform streams +- `@lynx-js/devtool-connector/transport`: platform transport implementations and type definitions +- `@lynx-js/devtool-connector/test-with-client`: helper for integration tests with real clients + +## Debugging + +Use the `debug` namespace to inspect connection/protocol send-receive details while running code that imports this package: + +```bash +DEBUG=devtool-mcp-server:connector* node ./your-script.mjs +``` + +## Known limitations + +- USB-based iOS transport (`iOSTransport`) still does not implement `listAvailableApps` / `openApp`. +- `listClients()` has snapshot semantics: each call re-scans ports and re-validates clients via handshake. diff --git a/mcp-servers/devtool-connector/e2e/index.test.ts b/mcp-servers/devtool-connector/e2e/index.test.ts new file mode 100644 index 0000000..b0d89d5 --- /dev/null +++ b/mcp-servers/devtool-connector/e2e/index.test.ts @@ -0,0 +1,370 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { ReadableStream } from "node:stream/web"; +import type { TestContext } from "node:test"; +import { ClientId, type Connector, type GlobalKeys } from "../src/index.ts"; +import { getTestingSession, testWithClient } from "../test/testWithClient.ts"; + +const GLOBAL_SWITCH_READY_TIMEOUT_MS = 5_000; +const GLOBAL_SWITCH_READY_POLL_INTERVAL_MS = 250; + +async function waitForGlobalSwitch( + t: TestContext, + connector: Connector, + clientId: string, + key: GlobalKeys, + expected: boolean, + message: string, +): Promise { + const { setTimeout } = await import("node:timers/promises"); + const deadline = Date.now() + GLOBAL_SWITCH_READY_TIMEOUT_MS; + let lastValue: boolean | undefined; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + lastValue = await connector.getGlobalSwitch(clientId, key); + if (lastValue === expected) { + return; + } + } catch (error) { + lastError = error; + } + + await setTimeout(GLOBAL_SWITCH_READY_POLL_INTERVAL_MS); + } + + const lastErrorMessage = lastError instanceof Error ? lastError.message : String(lastError); + t.assert.fail( + `${message}; expected ${key}=${expected}, last value: ${lastValue ?? "unavailable"}, last error: ${ + lastError === undefined ? "none" : lastErrorMessage + }`, + ); +} + +testWithClient("Connector", async (t, connector, client, testingTarget) => { + const clientId = client.id; + + await t.test("sendMessage", async (t) => { + await t.test("ListSession", async (t: TestContext) => { + await getTestingSession(connector, clientId); + + const response = await connector.sendListSessionMessage(clientId); + + t.assert.ok(Array.isArray(response)); + t.assert.ok(response.length > 0); + }); + + await t.test("listClients", async (t) => { + const clients = await connector.listClients(); + t.assert.equal(Array.isArray(clients), true); + t.assert.equal(clients.every(client => ClientId.deserialize(client.id) !== null), true); + t.assert.equal(clients.some(({ id }) => id === clientId), true); + }); + + await t.test("listClients should enable devtool", async (t) => { + await connector.setGlobalSwitch(clientId, "enable_devtool", false); + await waitForGlobalSwitch( + t, + connector, + clientId, + "enable_devtool", + false, + "setGlobalSwitch should disable devtool before listClients setup", + ); + + await connector.listClients(); + + await waitForGlobalSwitch( + t, + connector, + clientId, + "enable_devtool", + true, + "listClients should enable devtool", + ); + }); + }); + + await t.test("sendAppMessage", { + skip: testingTarget.appPackageName === "com.lynx.explorer" + ? false + : "The host app may not expose `App.*` methods", + }, async (t) => { + await t.test("App.openPage and App.closePage round-trip", { + skip: testingTarget.appPackageName === "com.lynx.explorer" + ? "App keeps the Lynx session alive after App.closePage" + : false, + }, async (t) => { + const initialSessions = await connector.sendListSessionMessage(clientId); + + await connector.sendAppMessage(clientId, "App.openPage", { url: testingTarget.openUrl }); + + const { setTimeout } = await import("node:timers/promises"); + let sessions = await connector.sendListSessionMessage(clientId); + for (let i = 0; i < 10 && sessions.length <= initialSessions.length; i++) { + await setTimeout(500); + sessions = await connector.sendListSessionMessage(clientId); + } + + t.assert.ok( + sessions.length > initialSessions.length, + `App.openPage should create a new session (before: ${initialSessions.length}, after: ${sessions.length})`, + ); + + for (let i = 0; i < sessions.length; i++) { + await connector.sendAppMessage(clientId, "App.closePage", {}); + } + + await setTimeout(1000); + + const afterClose = await connector.sendListSessionMessage(clientId); + t.assert.equal(afterClose.length, 0, "App.closePage should remove all sessions"); + + await connector.sendAppMessage(clientId, "App.openPage", { url: testingTarget.openUrl }); + await setTimeout(1000); + }); + + // TODO(Android): need restart App + await t.test("App.setBOE on", { skip: true }, async (t: TestContext) => { + await connector.sendAppMessage( + clientId, + "App.setBOE", + { value: "prod", switch: true }, + ); + const result = await connector.sendAppMessage<{ switch: string; value: string }>( + clientId, + "App.getBOE", + ); + + t.assert.equal(result.value, "prod"); + t.assert.ok(/**Android */ result.switch === "true" || /** iOS */ result.switch === "1"); + }); + + // TODO(Android): need restart App + await t.test("App.setBOE off", { skip: true }, async (t: TestContext) => { + await connector.sendAppMessage( + clientId, + "App.setBOE", + { switch: false }, + ); + + const result = await connector.sendAppMessage<{ switch: string; value: string }>( + clientId, + "App.getBOE", + ); + t.assert.ok(/**Android */ result.switch === "false" || /** iOS */ result.switch === "0"); + }); + + await t.test("non exist method without params", async (t) => { + await t.assert.rejects(() => connector.sendAppMessage(clientId, "App.fooBar"), { + name: "Error", + message: "App request App.fooBar error: not implemented", + }); + }); + + await t.test("non exist method", async (t) => { + await t.assert.rejects(() => connector.sendAppMessage(clientId, "App.fooBar", {}), { + name: "Error", + message: "App request App.fooBar error: not implemented", + }); + }); + }); + + await t.test("sendCDPMessage", async (t: TestContext) => { + await t.test("DOM.getDocument", async (t) => { + const session = await getTestingSession(connector, clientId); + + const result = await connector.sendCDPMessage( + clientId, + session.session_id, + "DOM.getDocument", + ); + + t.assert.partialDeepStrictEqual(result, { root: {} }); + }); + + await t.test("Runtime.evaluate without sessionId: 'Main'", async () => { + const session = await getTestingSession(connector, clientId); + + const result = await connector.sendCDPMessage< + { result: { value: unknown; type: "undefined" | "number" | "string" } }, + { expression: string } + >( + clientId, + session.session_id, + "Runtime.evaluate", + { expression: "SystemInfo.runtimeType" }, + false, + ); + + t.assert.equal(result.result.type, "string"); + t.assert.equal(result.result.value, "quickjs"); + }); + await t.test("Runtime.evaluate with sessionId: 'Main'", async () => { + const session = await getTestingSession(connector, clientId); + + const result = await connector.sendCDPMessage< + { result: { description: string; value: unknown; type: "number" | "string" } }, + { expression: string } + >( + clientId, + session.session_id, + "Runtime.evaluate", + { expression: "SystemInfo.runtimeType" }, + true, + ); + + t.assert.equal(result.result.type, "undefined"); + t.assert.equal(result.result.value, undefined); + }); + + await t.test("DOM.getDocument with invalid sessionId", async (t) => { + await t.assert.rejects(() => connector.sendCDPMessage(clientId, -1, "DOM.getDocument"), { + name: "Error", + message: "CDP request error: Not implemented: DOM.getDocument", + }); + }); + + await t.test("non exist method without params", async (t: TestContext) => { + const session = await getTestingSession(connector, clientId); + + await t.assert.rejects(() => connector.sendCDPMessage(clientId, session.session_id, "DOM.nonExistMethod"), { + name: "Error", + message: "CDP request error: Not implemented: DOM.nonExistMethod", + }); + }); + + await t.test("non exist method", async (t: TestContext) => { + const session = await getTestingSession(connector, clientId); + + await t.assert.rejects( + () => connector.sendCDPMessage(clientId, session.session_id, "DOM.nonExistMethod", {}), + { + name: "Error", + message: "CDP request error: Not implemented: DOM.nonExistMethod", + }, + ); + }); + }); + + await t.test("getGlobalSwitch", async (t) => { + await t.test("enable_devtool", async (t) => { + const response = await connector.getGlobalSwitch(clientId, "enable_devtool"); + + t.assert.equal(typeof response, "boolean"); + }); + + await t.test("unknown key", async (t) => { + // Newer LynxExample returns false for unknown keys; older Android builds never reply. + try { + const response = await connector.getGlobalSwitch(clientId, "unknown_key_v0" as never); + t.assert.equal(response, false); + } catch (error) { + t.assert.ok(error instanceof Error); + t.assert.match(error.message, /No response found for clientId/); + } + }); + }); + + await t.test("setGlobalSwitch", async (t) => { + await t.test("enable_devtool", async (t) => { + await connector.setGlobalSwitch(clientId, "enable_devtool", true); + const devtoolEnabled = await connector.getGlobalSwitch(clientId, "enable_devtool"); + + t.assert.equal(devtoolEnabled, true); + }); + + await t.test("unknown key", async () => { + await connector.setGlobalSwitch(clientId, "unknown_key" as never, false); + }); + }); + + await t.test("sendStream", async (t) => { + await t.test("timeout", { skip: true }, async (t) => { + await t.assert.rejects(() => + connector.sendStream(clientId, new ReadableStream(), { + signal: AbortSignal.timeout(100), + }) + ); + }); + + await t.test("Page.takeScreenshot", async (t: TestContext) => { + const session = await getTestingSession(connector, clientId); + const sessionId = session.session_id; + + // eslint-disable-next-line n/no-unsupported-features/es-syntax + const { promise, resolve } = Promise.withResolvers<{ data: string; metadata: Record }>(); + + await using stream = await connector.sendCDPStream( + clientId, + sessionId, + new ReadableStream({ + async start(controller) { + controller.enqueue({ + method: "Page.startScreencast", + params: { + "format": "jpeg", + "quality": 80, + "mode": "lynxview", + }, + }); + + await promise; + + controller.enqueue({ + method: "Page.stopScreencast", + }); + controller.close(); + }, + }), + { signal: t.signal }, + ); + + for await (const { method, params } of stream) { + if (method === "Page.screencastFrame") { + resolve(params as never); + break; + } + } + + const { data, metadata } = await promise; + + t.assert.equal(typeof data, "string"); + t.assert.ok(data.length > 0, "Screenshot data should not be empty"); + // JPEG base64 starts with /9j/ + t.assert.ok(data.startsWith("/9j/"), "Screenshot data should be a JPEG image"); + t.assert.ok(typeof metadata["timestamp"] === "number"); + }); + + await t.test("Lynx.getScreenshot", async (t: TestContext) => { + const session = await getTestingSession(connector, clientId); + const sessionId = session.session_id; + + await using stream = await connector.sendCDPStream( + clientId, + sessionId, + // eslint-disable-next-line n/no-unsupported-features/node-builtins + ReadableStream.from([ + { method: "Lynx.getScreenshot" }, + ]), + { + signal: AbortSignal.any([t.signal, AbortSignal.timeout(30_000)]), + }, + ); + + t.plan(3); + for await (const { method, params } of stream) { + if (method === "Lynx.screenshotCaptured") { + const result = params as { data: string }; + t.assert.equal(typeof result.data, "string"); + t.assert.ok(result.data.length > 0, "Screenshot data should not be empty"); + t.assert.ok(result.data.startsWith("/9j/"), "Screenshot data should be a JPEG image"); + break; + } + } + }); + }); +}); diff --git a/mcp-servers/devtool-connector/e2e/webview-cdp.test.ts b/mcp-servers/devtool-connector/e2e/webview-cdp.test.ts new file mode 100644 index 0000000..a0b2513 --- /dev/null +++ b/mcp-servers/devtool-connector/e2e/webview-cdp.test.ts @@ -0,0 +1,673 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { readdir } from "node:fs/promises"; +import { ReadableStream } from "node:stream/web"; +import { test, type TestContext } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import type { Connector } from "../src/index.ts"; +import type { Session } from "../src/types.ts"; +import { type TestingTarget, testWithClient } from "../test/testWithClient.ts"; + +const DOUYIN_PACKAGE_NAMES = new Set([ + "com.example.app", + "com.example.app.lite", + "com.example.app.ep", +]); + +const WEBVIEW_UNSUPPORTED_CDP_METHODS = [ + "DOM.getDocumentWithBoxModel", + "DOM.getOriginalNodeIndex", + "DOM.innerText", + "Lynx.getRectToWindow", + "Lynx.getVersion", + "Lynx.getViewLocationOnScreen", + "Lynx.sendVMEvent", + "Memory.getAllMemoryUsage", + "Performance.getAllTimingInfo", + "Performance.getAllPerformanceEntries", + "WhiteBoard.enable", + "WhiteBoard.disable", + "WhiteBoard.setSharedData", + "WhiteBoard.getSharedData", + "WhiteBoard.removeSharedData", + "WhiteBoard.clear", + "UITree.enable", + "UITree.getLynxUITree", +] as const; + +const WEBVIEW_TESTED_CDP_METHODS = [ + "CSS.getBackgroundColors", + "CSS.getComputedStyleForNode", + "CSS.getInlineStylesForNode", + "CSS.getMatchedStylesForNode", + "DOM.describeNode", + "DOM.disable", + "DOM.discardSearchResults", + "DOM.enable", + "DOM.getAttributes", + "DOM.getBoxModel", + "DOM.getDocument", + "DOM.getNodeForLocation", + "DOM.getOuterHTML", + "DOM.getSearchResults", + "DOM.performSearch", + "DOM.querySelector", + "DOM.querySelectorAll", + "DOM.requestChildNodes", + "DOM.scrollIntoViewIfNeeded", + "DOM.setAttributesAsText", + "Debugger.getScriptSource", + "Input.emulateTouchFromMouseEvent", + "Overlay.hideHighlight", + "Overlay.highlightNode", + "Page.getResourceContent", + "Page.getResourceTree", + "Page.reload", + "Performance.disable", + "Performance.enable", + "Runtime.callFunctionOn", + "Runtime.compileScript", + "Runtime.disable", + "Runtime.discardConsoleEntries", + "Runtime.enable", + "Runtime.evaluate", + "Runtime.getHeapUsage", + "Runtime.getProperties", + "Runtime.globalLexicalScopeNames", + "Runtime.runScript", + "Runtime.setAsyncCallStackDepth", +] as const; + +const CDP_REFERENCE_DIR = new URL("../../../skills/lynx-devtool/references/cdp/", import.meta.url); +const TARGET_APP_PACKAGE_NAME = process.env["LYNX_DEVTOOL_MCP_TESTING_APP_PACKAGE"]?.trim() ?? ""; +const testWithDouyinWebViewClient = DOUYIN_PACKAGE_NAMES.has(TARGET_APP_PACKAGE_NAME) + ? testWithClient + : testWithClient.skip; + +type CdpRequest = { method: string; params?: unknown }; + +interface RemoteObject { + type: string; + value?: unknown; + objectId?: string; + description?: string; +} + +interface RuntimeResult { + result: RemoteObject; + exceptionDetails?: unknown; +} + +interface RuntimeCompileScriptResult { + scriptId?: string; + exceptionDetails?: unknown; +} + +interface DomNode { + nodeId: number; + backendNodeId?: number; + nodeType: number; + nodeName: string; + localName: string; + nodeValue: string; + attributes?: string[]; + childNodeCount?: number; + children?: DomNode[]; +} + +interface BoxModel { + content: number[]; + padding: number[]; + border: number[]; + margin: number[]; + width: number; + height: number; +} + +interface FrameResourceTree { + frame: { + id: string; + url: string; + }; + resources?: Array<{ url: string }>; + childFrames?: FrameResourceTree[]; +} + +interface ScriptParsedEvent { + scriptId: string; + url: string; +} + +function isDouyinTarget(target: TestingTarget): boolean { + return DOUYIN_PACKAGE_NAMES.has(target.appPackageName); +} + +function isHttpUrl(value: string): boolean { + return value.startsWith("http://") || value.startsWith("https://"); +} + +function urlMatchesTarget(url: string, target: TestingTarget): boolean { + return url === target.openUrl || url === target.pageUrl || url.includes(target.openUrl) + || url.includes(target.pageUrl); +} + +function isTargetWebViewSession(session: Session, target: TestingTarget): boolean { + if (session.type === "web") return urlMatchesTarget(session.url, target); + return isHttpUrl(session.url) && urlMatchesTarget(session.url, target) && !session.url.endsWith(".js"); +} + +function isWebViewLikeSession(session: Session): boolean { + return session.type === "web" || (isHttpUrl(session.url) && !session.url.endsWith(".js")); +} + +async function listDocumentedCdpMethods(dir = CDP_REFERENCE_DIR): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const methods = await Promise.all(entries.map(async (entry) => { + if (entry.isDirectory()) { + return listDocumentedCdpMethods(new URL(`${entry.name}/`, dir)); + } + if (!entry.isFile() || !entry.name.endsWith(".md")) { + return []; + } + + const method = entry.name.slice(0, -".md".length); + if (method === "index" || !method.includes(".")) { + return []; + } + return [method]; + })); + + return methods.flat().sort(); +} + +function sortedUnique(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +function streamFrom(items: T[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const item of items) { + controller.enqueue(item); + } + controller.close(); + }, + }); +} + +async function* readUntilIdle( + stream: ReadableStream, + opts: { idleMs: number; maxMs: number }, +): AsyncGenerator { + const reader = stream.getReader(); + const startTime = Date.now(); + let terminated = false; + + try { + while (Date.now() - startTime < opts.maxMs) { + const result = await Promise.race([ + reader.read(), + delay(opts.idleMs, "timeout" as const), + ]); + + if (result === "timeout") { + await reader.cancel(); + terminated = true; + return; + } + + const { done, value } = result; + if (done) { + terminated = true; + return; + } + + yield value; + } + + await reader.cancel(); + terminated = true; + } finally { + if (!terminated) { + await reader.cancel().catch(() => {}); + } + reader.releaseLock(); + } +} + +async function waitForWebViewSession( + connector: Connector, + clientId: string, + target: TestingTarget, + initialSessionIds: Set, +): Promise { + let fallbackSession: Session | undefined; + + for (let i = 0; i < 30; i++) { + await delay(500); + const sessions = await connector.sendListSessionMessage(clientId); + const newWebSession = sessions.find((session) => + !initialSessionIds.has(session.session_id) + && (isTargetWebViewSession(session, target) || isWebViewLikeSession(session)) + ); + if (newWebSession) return newWebSession; + + fallbackSession = sessions.find((session) => isTargetWebViewSession(session, target)) ?? fallbackSession; + } + + return fallbackSession; +} + +async function openDouyinWebViewSession( + t: TestContext, + connector: Connector, + clientId: string, + target: TestingTarget, +): Promise { + if (!isHttpUrl(target.openUrl)) { + throw new Error(`LYNX_DEVTOOL_MCP_TESTING_OPEN_URL must be an HTTP(S) WebView URL, got ${target.openUrl}`); + } + + await connector.sendAppMessage(clientId, "App.enableWebviewDebug", {}); + await connector.sendAppMessage(clientId, "App.closeSecSwitch", {}).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + t.diagnostic(`App.closeSecSwitch failed before WebView open: ${message}`); + }); + + const initialSessions = await connector.sendListSessionMessage(clientId); + const initialSessionIds = new Set(initialSessions.map((session) => session.session_id)); + + await connector.sendAppMessage(clientId, "App.openPage", { url: target.openUrl }); + + const session = await waitForWebViewSession(connector, clientId, target, initialSessionIds); + if (!session) { + const sessions = await connector.sendListSessionMessage(clientId); + throw new Error( + `Timed out waiting for a Douyin WebView session for ${target.openUrl}. Available sessions: ${ + sessions.map(({ session_id, type, url }) => `${session_id}:${type}:${url}`).join("; ") + }`, + ); + } + + await delay(1_000); + return session; +} + +function assertNoRuntimeException(t: TestContext, result: RuntimeResult | RuntimeCompileScriptResult, label: string) { + if (result.exceptionDetails) { + t.assert.fail(`${label} returned exceptionDetails: ${JSON.stringify(result.exceptionDetails)}`); + } +} + +function centerOfQuad(quad: number[]): { x: number; y: number } { + const xs = [quad[0], quad[2], quad[4], quad[6]]; + const ys = [quad[1], quad[3], quad[5], quad[7]]; + return { + x: Math.round((Math.min(...xs) + Math.max(...xs)) / 2), + y: Math.round((Math.min(...ys) + Math.max(...ys)) / 2), + }; +} + +function firstResource(tree: FrameResourceTree): { frameId: string; url: string } { + if (tree.frame.url) { + return { frameId: tree.frame.id, url: tree.frame.url }; + } + + const resource = tree.resources?.find((item) => item.url); + if (resource) { + return { frameId: tree.frame.id, url: resource.url }; + } + + for (const child of tree.childFrames ?? []) { + return firstResource(child); + } + + throw new Error("No resource URL found in Page.getResourceTree response"); +} + +async function collectScriptParsedEvents( + connector: Connector, + clientId: string, + sessionId: number, + signal: AbortSignal, +): Promise { + await using stream = await connector.sendCDPStream( + clientId, + sessionId, + streamFrom([ + { method: "Debugger.disable" }, + { method: "Debugger.enable" }, + ]), + { signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]) }, + ); + + const scripts: ScriptParsedEvent[] = []; + for await (const value of readUntilIdle(stream, { idleMs: 1_000, maxMs: 5_000 })) { + if ( + typeof value === "object" && value !== null && "method" in value && value.method === "Debugger.scriptParsed" + && "params" in value + ) { + const params = value.params as Partial; + if (typeof params.scriptId === "string") { + scripts.push({ + scriptId: params.scriptId, + url: typeof params.url === "string" ? params.url : "", + }); + } + } + } + + return scripts; +} + +test("Douyin WebView CDP test matrix matches documented methods minus unsupported WebView extensions", async (t) => { + const documentedMethods = await listDocumentedCdpMethods(); + const unsupportedMethods = new Set(WEBVIEW_UNSUPPORTED_CDP_METHODS); + const expectedMethods = documentedMethods.filter((method) => !unsupportedMethods.has(method)).sort(); + + t.assert.deepStrictEqual( + sortedUnique(WEBVIEW_TESTED_CDP_METHODS), + expectedMethods, + "Every documented CDP method should be tested for WebView unless it is explicitly unsupported", + ); + t.assert.equal( + WEBVIEW_UNSUPPORTED_CDP_METHODS.length, + 18, + "The WebView unsupported method allowlist should stay explicit", + ); +}); + +testWithDouyinWebViewClient("Douyin WebView CDP", async (suite, connector, client, target) => { + const clientId = client.id; + + await suite.test("supports documented CDP methods except explicit WebView gaps", { + skip: isDouyinTarget(target) + ? false + : `Douyin WebView CDP coverage only runs for Douyin targets, got ${target.appPackageName}`, + }, async (t: TestContext) => { + if (!isHttpUrl(target.openUrl)) { + t.skip(`LYNX_DEVTOOL_MCP_TESTING_OPEN_URL must be an HTTP(S) WebView URL, got ${target.openUrl}`); + return; + } + + const session = await openDouyinWebViewSession(t, connector, clientId, target); + const sessionId = session.session_id; + const cdp = , Params = Record>( + method: string, + params?: Params, + ) => connector.sendCDPMessage(clientId, sessionId, method, params); + + await t.test("Runtime methods", async (t) => { + await cdp("Runtime.enable", {}); + + const fixtureHtml = [ + `
`, + ``, + `
`, + ].join(""); + const evaluation = await cdp("Runtime.evaluate", { + expression: `(() => { + if (!document.body) { + document.documentElement.appendChild(document.createElement("body")); + } + document.title = "WebView CDP Fixture"; + document.body.innerHTML = ${JSON.stringify(fixtureHtml)}; + globalThis.__webviewCdpValue = { answer: 42, label: "webview" }; + return { + title: document.title, + hasFixture: Boolean(document.querySelector("#webview-cdp-fixture")), + }; + })()`, + awaitPromise: true, + returnByValue: true, + }); + assertNoRuntimeException(t, evaluation, "Runtime.evaluate"); + t.assert.deepStrictEqual(evaluation.result.value, { + title: "WebView CDP Fixture", + hasFixture: true, + }); + + const objectEvaluation = await cdp("Runtime.evaluate", { + expression: "globalThis.__webviewCdpValue", + objectGroup: "webview-cdp-test", + }); + assertNoRuntimeException(t, objectEvaluation, "Runtime.evaluate object"); + t.assert.ok(objectEvaluation.result.objectId, "Runtime.evaluate should return an objectId"); + + const properties = await cdp<{ result: Array<{ name: string; value?: RemoteObject }> }>( + "Runtime.getProperties", + { + objectId: objectEvaluation.result.objectId, + ownProperties: true, + }, + ); + t.assert.ok( + properties.result.some((property) => property.name === "answer" && property.value?.value === 42), + "Runtime.getProperties should expose object properties", + ); + + const callResult = await cdp("Runtime.callFunctionOn", { + objectId: objectEvaluation.result.objectId, + functionDeclaration: "function() { return `${this.label}:${this.answer}`; }", + returnByValue: true, + }); + assertNoRuntimeException(t, callResult, "Runtime.callFunctionOn"); + t.assert.equal(callResult.result.value, "webview:42"); + + const lexicalScopeNames = await cdp<{ names: string[] }>("Runtime.globalLexicalScopeNames", {}); + t.assert.ok(Array.isArray(lexicalScopeNames.names), "Runtime.globalLexicalScopeNames should return names"); + + const compiled = await cdp("Runtime.compileScript", { + expression: "globalThis.__webviewCdpCompiled = 41 + 1; globalThis.__webviewCdpCompiled;", + sourceURL: "webview-cdp-compiled.js", + persistScript: true, + }); + assertNoRuntimeException(t, compiled, "Runtime.compileScript"); + t.assert.ok(compiled.scriptId, "Runtime.compileScript should return a scriptId"); + + const runResult = await cdp("Runtime.runScript", { + scriptId: compiled.scriptId, + returnByValue: true, + }); + assertNoRuntimeException(t, runResult, "Runtime.runScript"); + t.assert.equal(runResult.result.value, 42); + + const heap = await cdp<{ usedSize: number; totalSize: number }>("Runtime.getHeapUsage", {}); + t.assert.equal(typeof heap.usedSize, "number"); + t.assert.equal(typeof heap.totalSize, "number"); + + await cdp("Runtime.setAsyncCallStackDepth", { maxDepth: 0 }); + await cdp("Runtime.discardConsoleEntries", {}); + }); + + let rootNodeId = 0; + let fixtureNodeId = 0; + let buttonNodeId = 0; + let boxCenter = { x: 0, y: 0 }; + + await t.test("DOM methods", async (t) => { + await cdp("DOM.enable", {}); + + const document = await cdp<{ root: DomNode }>("DOM.getDocument", { depth: -1, pierce: true }); + rootNodeId = document.root.nodeId; + t.assert.equal(document.root.nodeName, "#document"); + + const fixture = await cdp<{ nodeId: number }>("DOM.querySelector", { + nodeId: rootNodeId, + selector: "#webview-cdp-fixture", + }); + fixtureNodeId = fixture.nodeId; + t.assert.ok(fixtureNodeId > 0, "DOM.querySelector should find the fixture"); + + const button = await cdp<{ nodeId: number }>("DOM.querySelector", { + nodeId: rootNodeId, + selector: "#webview-cdp-button", + }); + buttonNodeId = button.nodeId; + t.assert.ok(buttonNodeId > 0, "DOM.querySelector should find the button"); + + const allFixtureNodes = await cdp<{ nodeIds: number[] }>("DOM.querySelectorAll", { + nodeId: rootNodeId, + selector: "[data-cdp]", + }); + t.assert.ok(allFixtureNodes.nodeIds.length >= 2, "DOM.querySelectorAll should find fixture nodes"); + + const description = await cdp<{ node: DomNode }>("DOM.describeNode", { + nodeId: fixtureNodeId, + depth: 1, + }); + t.assert.equal(description.node.nodeName, "MAIN"); + + const attributes = await cdp<{ attributes: string[] }>("DOM.getAttributes", { nodeId: fixtureNodeId }); + t.assert.ok(attributes.attributes.includes("data-cdp"), "DOM.getAttributes should include fixture attributes"); + + await cdp("DOM.setAttributesAsText", { + nodeId: fixtureNodeId, + text: "data-cdp-updated=\"true\"", + name: "data-cdp-updated", + }); + const updatedAttributes = await cdp<{ attributes: string[] }>("DOM.getAttributes", { nodeId: fixtureNodeId }); + t.assert.ok( + updatedAttributes.attributes.includes("data-cdp-updated"), + "DOM.setAttributesAsText should update attributes", + ); + + const outerHtml = await cdp<{ outerHTML: string }>("DOM.getOuterHTML", { nodeId: fixtureNodeId }); + t.assert.match(outerHtml.outerHTML, /webview-cdp-fixture/); + + await cdp("DOM.requestChildNodes", { nodeId: fixtureNodeId, depth: 1 }); + await cdp("DOM.scrollIntoViewIfNeeded", { nodeId: fixtureNodeId }); + + const box = await cdp<{ model: BoxModel }>("DOM.getBoxModel", { nodeId: fixtureNodeId }); + t.assert.ok(Array.isArray(box.model.content), "DOM.getBoxModel should return content quad"); + boxCenter = centerOfQuad(box.model.content); + + const nodeForLocation = await cdp<{ nodeId?: number; backendNodeId?: number }>("DOM.getNodeForLocation", { + x: boxCenter.x, + y: boxCenter.y, + }); + t.assert.ok( + typeof nodeForLocation.nodeId === "number" || typeof nodeForLocation.backendNodeId === "number", + "DOM.getNodeForLocation should identify a node", + ); + + const search = await cdp<{ searchId: string; resultCount: number }>("DOM.performSearch", { + query: "webview-cdp-fixture", + }); + t.assert.ok(search.searchId, "DOM.performSearch should return searchId"); + t.assert.ok(search.resultCount > 0, "DOM.performSearch should find the fixture"); + + const results = await cdp<{ nodeIds: number[] }>("DOM.getSearchResults", { + searchId: search.searchId, + fromIndex: 0, + toIndex: Math.min(search.resultCount, 1), + }); + t.assert.ok(Array.isArray(results.nodeIds), "DOM.getSearchResults should return nodeIds"); + + await cdp("DOM.discardSearchResults", { searchId: search.searchId }); + }); + + await t.test("CSS methods", async (t) => { + await cdp("CSS.enable", {}); + + const computed = await cdp<{ computedStyle: Array<{ name: string; value: string }> }>( + "CSS.getComputedStyleForNode", + { nodeId: fixtureNodeId }, + ); + t.assert.ok( + computed.computedStyle.some((item) => item.name === "background-color"), + "CSS.getComputedStyleForNode should include background-color", + ); + + const inline = await cdp<{ inlineStyle?: unknown; attributesStyle?: unknown }>( + "CSS.getInlineStylesForNode", + { nodeId: fixtureNodeId }, + ); + t.assert.ok( + typeof inline === "object" && inline !== null, + "CSS.getInlineStylesForNode should return a style object", + ); + + const matched = await cdp>("CSS.getMatchedStylesForNode", { nodeId: fixtureNodeId }); + t.assert.ok( + typeof matched === "object" && matched !== null, + "CSS.getMatchedStylesForNode should return matched style data", + ); + + const backgrounds = await cdp>("CSS.getBackgroundColors", { nodeId: fixtureNodeId }); + t.assert.ok( + Object.hasOwn(backgrounds, "backgroundColors") || Object.hasOwn(backgrounds, "computedFontSize") + || Object.keys(backgrounds).length === 0, + "CSS.getBackgroundColors should return a valid CDP result object", + ); + + await cdp("CSS.disable", {}).catch(() => {}); + }); + + await t.test("Overlay and Input methods", async () => { + await cdp("Overlay.enable", {}); + await cdp("Overlay.highlightNode", { + nodeId: fixtureNodeId, + highlightConfig: { + showInfo: true, + contentColor: { r: 10, g: 120, b: 200, a: 0.35 }, + borderColor: { r: 255, g: 255, b: 255, a: 0.8 }, + }, + }); + await cdp("Overlay.hideHighlight", {}); + + await cdp("Input.emulateTouchFromMouseEvent", { + type: "mouseMoved", + x: boxCenter.x, + y: boxCenter.y, + button: "none", + timestamp: Date.now() / 1000, + }); + }); + + await t.test("Page methods", async (t) => { + await cdp("Page.enable", {}); + + const resourceTree = await cdp<{ frameTree: FrameResourceTree }>("Page.getResourceTree", {}); + const resource = firstResource(resourceTree.frameTree); + + const content = await cdp<{ content: string; base64Encoded: boolean }>("Page.getResourceContent", { + frameId: resource.frameId, + url: resource.url, + }); + t.assert.equal(typeof content.content, "string"); + t.assert.equal(typeof content.base64Encoded, "boolean"); + + await cdp("Page.disable", {}).catch(() => {}); + }); + + await t.test("Performance methods", async (t) => { + const result = await cdp>("Performance.enable", {}); + t.assert.deepStrictEqual(result, {}, "Performance.enable should return an empty result"); + await cdp("Performance.disable", {}); + }); + + await t.test("Debugger methods", async (t) => { + const scripts = await collectScriptParsedEvents(connector, clientId, sessionId, t.signal); + t.assert.ok(scripts.length > 0, "Debugger.enable should emit scriptParsed events"); + + const script = scripts.find((item) => item.url.includes("webview-cdp-compiled.js")) + ?? scripts.find((item) => item.url) + ?? scripts[0]; + const source = await cdp<{ scriptSource: string }>("Debugger.getScriptSource", { + scriptId: script.scriptId, + }); + t.assert.equal(typeof source.scriptSource, "string"); + }); + + await t.test("Disable and reload methods", async () => { + await cdp("Runtime.disable", {}); + await cdp("DOM.disable", {}); + await cdp("Page.reload", { ignoreCache: true }); + }); + }); +}); diff --git a/mcp-servers/devtool-connector/package.json b/mcp-servers/devtool-connector/package.json new file mode 100644 index 0000000..70fb039 --- /dev/null +++ b/mcp-servers/devtool-connector/package.json @@ -0,0 +1,97 @@ +{ + "name": "@lynx-js/devtool-connector", + "version": "0.9.3", + "type": "module", + "license": "Apache-2.0", + "main": "./src/index.ts", + "types": "./src/index.ts", + "imports": { + "#daemon-entry": "./src/daemon/entry.ts" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + }, + "./daemon": { + "types": "./src/daemon/index.ts", + "import": "./src/daemon/index.ts", + "default": "./src/daemon/index.ts" + }, + "./transport": { + "types": "./src/transport/index.ts", + "import": "./src/transport/index.ts", + "default": "./src/transport/index.ts" + }, + "./streams": { + "types": "./src/streams/index.ts", + "import": "./src/streams/index.ts", + "default": "./src/streams/index.ts" + }, + "./test-with-client": { + "types": "./test/testWithClient.ts", + "import": "./test/testWithClient.ts", + "default": "./test/testWithClient.ts" + }, + "./package.json": "./package.json" + }, + "files": [ + "CHANGELOG.md", + "dist", + "public" + ], + "scripts": { + "build": "rslib build", + "test": "node --test --test-concurrency=1 'test/**/*.test.ts'", + "test:e2e": "node --test --test-concurrency=1 'e2e/**/*.test.ts'" + }, + "dependencies": { + "ws": "^8.21.0" + }, + "devDependencies": { + "@rslib/core": "catalog:rstack", + "@types/node": "^24.13.2", + "@types/ws": "^8.18.1", + "@yume-chan/adb": "catalog:adb", + "@yume-chan/adb-server-node-tcp": "catalog:adb", + "@yume-chan/stream-extra": "catalog:adb", + "obug": "^2.1.3", + "plist": "^5.0.0", + "rsbuild-plugin-publint": "^1.0.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.19" + }, + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "imports": { + "#daemon-entry": "./dist/daemon/entry.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./daemon": { + "types": "./dist/daemon/index.d.ts", + "import": "./dist/daemon/index.js", + "default": "./dist/daemon/index.js" + }, + "./transport": { + "types": "./dist/transport/index.d.ts", + "import": "./dist/transport/index.js", + "default": "./dist/transport/index.js" + }, + "./streams": { + "types": "./dist/streams/index.d.ts", + "import": "./dist/streams/index.js", + "default": "./dist/streams/index.js" + }, + "./package.json": "./package.json" + } + } +} diff --git a/mcp-servers/devtool-connector/public/inspector-wrapper.html b/mcp-servers/devtool-connector/public/inspector-wrapper.html new file mode 100644 index 0000000..e18a5cd --- /dev/null +++ b/mcp-servers/devtool-connector/public/inspector-wrapper.html @@ -0,0 +1,539 @@ + + + + + Lynx Inspector Wrapper + + + +
+ + + + + Disconnected +
+ + + + + diff --git a/mcp-servers/devtool-connector/rslib.config.ts b/mcp-servers/devtool-connector/rslib.config.ts new file mode 100644 index 0000000..4340987 --- /dev/null +++ b/mcp-servers/devtool-connector/rslib.config.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { defineConfig } from "@rslib/core"; +import { pluginPublint } from "rsbuild-plugin-publint"; + +export default defineConfig({ + plugins: [ + pluginPublint({ throwOn: "suggestion" }), + ], + source: { + entry: { + "daemon/entry": "./src/daemon/entry.ts", + "daemon/index": "./src/daemon/index.ts", + index: "./src/index.ts", + "transport/index": "./src/transport/index.ts", + "streams/index": "./src/streams/index.ts", + }, + }, + lib: [ + { + format: "esm", + syntax: "es2022", + dts: { + bundle: false, + }, + }, + ], +}); diff --git a/mcp-servers/devtool-connector/src/client-id.ts b/mcp-servers/devtool-connector/src/client-id.ts new file mode 100644 index 0000000..f01438a --- /dev/null +++ b/mcp-servers/devtool-connector/src/client-id.ts @@ -0,0 +1,26 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export class ClientId { + static serialize(deviceId: string, port: number): string { + return `${encodeURIComponent(deviceId)}:${port}`; + } + + static deserialize(clientId: string): { deviceId: string; port: number } | null { + try { + const lastColonIndex = clientId.lastIndexOf(":"); + if (lastColonIndex === -1) return null; + + const port = Number.parseInt(clientId.substring(lastColonIndex + 1), 10); + if (Number.isNaN(port)) return null; + + return { + deviceId: decodeURIComponent(clientId.substring(0, lastColonIndex)), + port, + }; + } catch { + return null; + } + } +} diff --git a/mcp-servers/devtool-connector/src/daemon/device-connection.ts b/mcp-servers/devtool-connector/src/daemon/device-connection.ts new file mode 100644 index 0000000..3140f8f --- /dev/null +++ b/mcp-servers/devtool-connector/src/daemon/device-connection.ts @@ -0,0 +1,175 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { createDebug } from "obug"; +import type { Connection, Transport, TransportConnectOptions } from "../transport/transport.ts"; +import { type AppInfo, isInitializeResponse, type Response } from "../types.ts"; + +const debug = createDebug("devtool-mcp-server:daemon:device-connection"); + +export interface DeviceConnectionSubscriber { + readonly id: number; + send(message: unknown): void; + close(): void; +} + +/** + * Maintains a persistent connection to a single device:port through a real + * transport. + * + * On first connect it performs the Initialize/Register handshake and caches + * the {@link AppInfo}. After that, messages from the device are broadcast to + * all subscribers, and messages from clients are forwarded to the device. + */ +export class DeviceConnection { + readonly key: string; + readonly deviceId: string; + readonly port: number; + + #conn: Connection | null = null; + // eslint-disable-next-line n/no-unsupported-features/node-builtins + #writer: WritableStreamDefaultWriter | null = null; + #subscribers = new Map(); + #transport: Transport; + #options: TransportConnectOptions; + #disposed = false; + #readLoopPromise: Promise | null = null; + + /** Populated after a successful Initialize/Register handshake. */ + appInfo: AppInfo | null = null; + + constructor(transport: Transport, options: TransportConnectOptions) { + this.#transport = transport; + this.#options = options; + this.deviceId = options.deviceId; + this.port = options.port; + this.key = `${options.deviceId}:${options.port}`; + } + + /** + * Opens the underlying transport connection. The caller should wrap this + * in a try/catch — a failure means the device:port is unreachable. + */ + async connect(): Promise { + debug("connecting to %s", this.key); + if (this.#disposed) { + throw new Error(`DeviceConnection ${this.key} was disposed before connect started`); + } + + const conn = await this.#transport.connect(this.#options); + if (this.#disposed) { + await conn[Symbol.asyncDispose](); + throw new Error(`DeviceConnection ${this.key} was disposed before connect completed`); + } + + this.#conn = conn; + this.#writer = this.#conn.writable.getWriter(); + this.#readLoopPromise = this.#readLoop(); + debug("connected to %s", this.key); + } + + addSubscriber(subscriber: DeviceConnectionSubscriber): void { + this.#subscribers.set(subscriber.id, subscriber); + debug("subscriber %d added to %s (total: %d)", subscriber.id, this.key, this.#subscribers.size); + } + + removeSubscriber(id: number): void { + this.#subscribers.delete(id); + debug("subscriber %d removed from %s (total: %d)", id, this.key, this.#subscribers.size); + } + + get subscriberCount(): number { + return this.#subscribers.size; + } + + get isDisposed(): boolean { + return this.#disposed; + } + + get isPersistent(): boolean { + return this.#transport.persistent === true; + } + + async send(message: unknown): Promise { + if (!this.#writer) { + throw new Error(`DeviceConnection ${this.key} is not connected`); + } + try { + await this.#writer.write(message); + } catch (err) { + debug("send to %s failed: %O", this.key, err); + throw err; + } + } + + async dispose(): Promise { + if (this.#disposed) return; + this.#disposed = true; + debug("disposing device connection %s", this.key); + + try { + this.#writer?.releaseLock(); + } catch { + // ignore + } + + try { + await this.#conn?.[Symbol.asyncDispose](); + } catch (err) { + debug("error disposing connection %s: %O", this.key, err); + } + + await this.#readLoopPromise; + this.#subscribers.clear(); + } + + async #readLoop(): Promise { + if (!this.#conn) return; + + try { + for await (const message of this.#conn.readable) { + if (this.appInfo === null) { + const response = message as Response; + if (isInitializeResponse(response)) { + this.appInfo = response.data.info; + debug("captured appInfo for %s: %O", this.key, this.appInfo); + continue; + } + } + + this.#broadcast(message); + } + } catch (err) { + if (!this.#disposed) { + debug("read loop error on %s: %O", this.key, err); + } + } + + if (!this.#disposed) { + debug("device connection %s closed by remote", this.key); + this.#disposed = true; + this.#closeAllSubscribers(); + } + } + + #closeAllSubscribers(): void { + for (const [, subscriber] of this.#subscribers) { + try { + subscriber.close(); + } catch (err) { + debug("failed to close subscriber %d: %O", subscriber.id, err); + } + } + } + + #broadcast(message: unknown): void { + for (const [, subscriber] of this.#subscribers) { + try { + subscriber.send(message); + } catch (err) { + debug("failed to send to subscriber %d: %O", subscriber.id, err); + } + } + } +} diff --git a/mcp-servers/devtool-connector/src/daemon/entry.ts b/mcp-servers/devtool-connector/src/daemon/entry.ts new file mode 100644 index 0000000..62b1344 --- /dev/null +++ b/mcp-servers/devtool-connector/src/daemon/entry.ts @@ -0,0 +1,68 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Daemon process entry point. + * + * Spawned by DaemonManager as a detached child process. + * Usage: node daemon/entry.ts --port 21783 + */ +/* eslint-disable n/no-unsupported-features/node-builtins, n/no-process-exit */ +import { parseArgs } from "node:util"; +import { AndroidTransport } from "../transport/android.ts"; +import { DesktopTransport } from "../transport/desktop.ts"; +import { iOSTransport } from "../transport/ios.ts"; +import { DEFAULT_DAEMON_PORT } from "./manager.ts"; +import { DevtoolDaemon } from "./server.ts"; + +function getAndroidTransportSpec(env: NodeJS.ProcessEnv): { host: string; port: number } { + const port = Number.parseInt(env["ADB_SERVER_PORT"] ?? "5037", 10); + + return { + host: env["ADB_SERVER_HOST"] ?? "127.0.0.1", + port: Number.isInteger(port) && port > 0 ? port : 5037, + }; +} + +const { values } = parseArgs({ + options: { + port: { type: "string", default: String(DEFAULT_DAEMON_PORT) }, + }, + strict: true, +}); + +const port = Number.parseInt(values.port ?? String(DEFAULT_DAEMON_PORT), 10); + +const daemon = new DevtoolDaemon( + [ + new AndroidTransport(getAndroidTransportSpec(process.env)), + new iOSTransport(), + new DesktopTransport(), + ], + { + onIdle: () => { + void daemon.close().then(() => { + process.exit(0); + }); + }, + onShutdown: () => { + process.exit(0); + }, + }, +); + +await daemon.start(port); + +// Handle graceful shutdown +process.on("SIGTERM", () => { + void daemon.close().then(() => { + process.exit(0); + }); +}); + +process.on("SIGINT", () => { + void daemon.close().then(() => { + process.exit(0); + }); +}); diff --git a/mcp-servers/devtool-connector/src/daemon/index.ts b/mcp-servers/devtool-connector/src/daemon/index.ts new file mode 100644 index 0000000..47bd291 --- /dev/null +++ b/mcp-servers/devtool-connector/src/daemon/index.ts @@ -0,0 +1,5 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export { DevtoolDaemon } from "./server.ts"; diff --git a/mcp-servers/devtool-connector/src/daemon/manager.ts b/mcp-servers/devtool-connector/src/daemon/manager.ts new file mode 100644 index 0000000..2120289 --- /dev/null +++ b/mcp-servers/devtool-connector/src/daemon/manager.ts @@ -0,0 +1,129 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { spawn } from "node:child_process"; +import { closeSync, openSync } from "node:fs"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { createDebug } from "obug"; +import { DAEMON_WS_PATH, DEFAULT_DAEMON_PORT } from "./protocol.ts"; + +export { DEFAULT_DAEMON_PORT } from "./protocol.ts"; + +const debug = createDebug("devtool-mcp-server:daemon:manager"); + +const DEBUG_ROUTER_DIR = path.join(os.homedir(), ".DebugRouterConnector"); +const PIDFILE = path.join(DEBUG_ROUTER_DIR, "daemon.pid"); +const LOG = path.join(DEBUG_ROUTER_DIR, "daemon.log"); +const ERR = path.join(DEBUG_ROUTER_DIR, "daemon.err"); + +export function resolveDaemonEntryPath(moduleUrl: string = import.meta.url): string { + return createRequire(moduleUrl).resolve("#daemon-entry"); +} + +/** + * Manages the daemon process lifecycle. + * + * - `ensureRunning()`: checks if the daemon is alive, spawns it if not + * - `spawn()`: forks a detached daemon process + * - `kill()`: sends SIGTERM to the daemon + */ +export class DaemonManager { + static async ensureRunning(port: number = DEFAULT_DAEMON_PORT): Promise { + const url = `ws://127.0.0.1:${port}${DAEMON_WS_PATH}`; + + // 1. Quick probe — if the daemon is already running, we're done + if (await this.#isAlive(port)) { + debug("daemon already running on port %d", port); + return url; + } + + // 2. Spawn a new daemon + debug("daemon not running, spawning..."); + await this.#spawn(port); + + // 3. Wait for it to become ready + await this.#waitReady(port, 5_000); + debug("daemon is ready on port %d", port); + + return url; + } + + static async kill(): Promise { + try { + const pidStr = await fs.readFile(PIDFILE, "utf-8"); + const pid = Number.parseInt(pidStr.trim(), 10); + if (!Number.isNaN(pid)) { + debug("killing daemon pid %d", pid); + process.kill(pid, "SIGTERM"); + } + } catch { + debug("no pidfile found or cannot read it"); + } + } + + static async #isAlive(port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }, () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + socket.setTimeout(1_000, () => { + socket.destroy(); + resolve(false); + }); + }); + } + + static async #spawn(port: number): Promise { + await fs.mkdir(DEBUG_ROUTER_DIR, { recursive: true }); + + const entryPath = resolveDaemonEntryPath(); + + const out = openSync(LOG, "w"); + const err = openSync(ERR, "w"); + + const child = spawn(process.execPath, [entryPath, "--port", String(port)], { + detached: true, + stdio: ["ignore", out, err], + env: { + ...process.env, + // Propagate debug namespace if set + DEBUG: process.env["DEBUG"] ?? "", + }, + }); + + closeSync(out); + closeSync(err); + + child.unref(); + + // Write pidfile + if (child.pid !== undefined) { + await fs.writeFile(PIDFILE, String(child.pid), "utf-8"); + debug("spawned daemon with pid %d", child.pid); + } + } + + static async #waitReady(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (await this.#isAlive(port)) { + return; + } + await sleep(200); + } + + throw new Error(`Daemon failed to start within ${timeoutMs}ms on port ${port}`); + } +} diff --git a/mcp-servers/devtool-connector/src/daemon/protocol.ts b/mcp-servers/devtool-connector/src/daemon/protocol.ts new file mode 100644 index 0000000..7cad183 --- /dev/null +++ b/mcp-servers/devtool-connector/src/daemon/protocol.ts @@ -0,0 +1,120 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { AppInfo } from "../types.ts"; + +// ===== Standard debug-router protocol (reused from HDT) ===== + +export interface InitializeEvent { + event: "Initialize"; + data: number; +} + +export interface RegisterEvent { + event: "Register"; + data: { id: number; type: "Driver" }; +} + +export interface ClientListEvent { + event: "ClientList"; + data: ClientListEntry[]; +} + +export interface ClientListEntry { + id: string; + info: AppInfo; + type: "runtime"; +} + +export interface ListClientsRequest { + event: "ListClients"; +} + +export interface PingEvent { + event: "Ping"; +} + +export interface PongEvent { + event: "Pong"; +} + +// ===== Extended control protocol (daemon-specific) ===== + +export interface ControlRequest { + event: "Control"; + data: { + id: number; + method: "listClients" | "listDevices" | "listAvailableApps" | "openApp" | "subscribe"; + params?: Record; + }; +} + +export interface ControlResponse { + event: "ControlResponse"; + data: { + id: number; + result?: unknown; + error?: string; + }; +} + +// ===== Union types ===== + +export type DaemonIncomingMessage = + | RegisterEvent + | ListClientsRequest + | PingEvent + | ControlRequest + | CustomizedMessage; + +export type DaemonOutgoingMessage = + | InitializeEvent + | ClientListEvent + | PongEvent + | ControlResponse + | CustomizedMessage; + +export interface CustomizedMessage { + event: "Customized"; + data: { + type: string; + data: { + client_id?: number; + session_id?: number; + message?: unknown; + [key: string]: unknown; + }; + sender?: number; + [key: string]: unknown; + }; + to?: number; +} + +export function isCustomizedMessage(msg: unknown): msg is CustomizedMessage { + return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "Customized"; +} + +export function isControlRequest(msg: unknown): msg is ControlRequest { + return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "Control"; +} + +export function isListClientsRequest(msg: unknown): msg is ListClientsRequest { + return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "ListClients"; +} + +export function isPingEvent(msg: unknown): msg is PingEvent { + return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "Ping"; +} + +export function isRegisterEvent(msg: unknown): msg is RegisterEvent { + return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "Register"; +} + +// ===== Constants ===== + +export const DEFAULT_DAEMON_PORT = 21783; +export const DAEMON_WS_PATH = "/devtool/connector"; +export const DAEMON_VERSION_PATH: string = `${DAEMON_WS_PATH}/version`; +export const DAEMON_SHUTDOWN_PATH: string = `${DAEMON_WS_PATH}/shutdown`; +export const DAEMON_INSPECTOR_PATH: string = `${DAEMON_WS_PATH}/inspector`; diff --git a/mcp-servers/devtool-connector/src/daemon/server.ts b/mcp-servers/devtool-connector/src/daemon/server.ts new file mode 100644 index 0000000..fd19c1e --- /dev/null +++ b/mcp-servers/devtool-connector/src/daemon/server.ts @@ -0,0 +1,637 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { setTimeout as sleep } from "node:timers/promises"; +import { createDebug } from "obug"; +import { type WebSocket, WebSocketServer } from "ws"; +import { ClientId } from "../client-id.ts"; +import type { Device, Transport } from "../transport/transport.ts"; +import { DeviceConnection, type DeviceConnectionSubscriber } from "./device-connection.ts"; +import { + type ClientListEntry, + type ControlRequest, + type CustomizedMessage, + DAEMON_SHUTDOWN_PATH, + DAEMON_VERSION_PATH, + DAEMON_WS_PATH, + isControlRequest, + isCustomizedMessage, + isListClientsRequest, + isPingEvent, + isRegisterEvent, +} from "./protocol.ts"; +import { StaticServer } from "./static-server.ts"; +import { CONNECTOR_VERSION } from "./version.ts"; + +const debug = createDebug("devtool-mcp-server:daemon:server"); + +const IDLE_TIMEOUT_MS = 300_000; +/** Grace period before disposing an unsubscribed device connection. */ +const DEVICE_CONN_GRACE_MS = 10_000; +const DEVICE_DISCOVERY_TIMEOUT_MS = 2_500; +const DEVICE_CONN_SETUP_TIMEOUT_MS = 5_000; +const DEVICE_CONN_DISPOSE_TIMEOUT_MS = 1_000; + +interface WsClientSession extends DeviceConnectionSubscriber { + readonly id: number; + /** Which device connections this client is subscribed to. */ + readonly subscriptions: Set; + send(message: unknown): void; + close(): void; +} + +/** + * The daemon process. Holds real transports, manages persistent device + * connections, and exposes a WebSocket server for DaemonTransport clients. + */ +export class DevtoolDaemon { + #httpServer: http.Server; + #wss: WebSocketServer | null = null; + #transports: Transport[]; + #deviceConnections = new Map(); + #pendingDeviceConnections = new Map>(); + #deviceConnectionCleanupTimers = new Map>(); + #wsClients = new Map(); + #nextClientId = 0; + #idleTimer: ReturnType | null = null; + #closed = false; + #onIdle: (() => void) | undefined; + #onShutdown: (() => void) | undefined; + #staticServer = new StaticServer(); + + constructor(transports: Transport[], options?: { onIdle?: () => void; onShutdown?: () => void }) { + this.#transports = transports; + this.#onIdle = options?.onIdle; + this.#onShutdown = options?.onShutdown; + this.#httpServer = http.createServer((req, res) => { + if (req.method === "GET" && this.#isVersionRequest(req.url)) { + this.#sendJson(res, 200, { version: CONNECTOR_VERSION }); + return; + } + + if (this.#staticServer.tryHandle(req, res)) { + return; + } + + if (req.method === "POST" && this.#isShutdownRequest(req.url)) { + this.#sendJson(res, 202, { ok: true }, () => { + void this.close() + .catch((err: unknown) => { + debug("failed to close daemon after shutdown request: %O", err); + }) + .finally(() => { + this.#onShutdown?.(); + }); + }); + return; + } + + res.writeHead(404); + res.end(); + }); + } + + async start(port: number): Promise { + const wss = new WebSocketServer({ noServer: true }); + this.#wss = wss; + + this.#httpServer.on("upgrade", (request, socket, head) => { + if (!request.url?.startsWith(DAEMON_WS_PATH)) { + socket.destroy(); + return; + } + wss.handleUpgrade(request, socket, head, (ws) => { + this.#handleConnection(ws); + }); + }); + + return new Promise((resolve, reject) => { + this.#httpServer.once("error", reject); + this.#httpServer.listen(port, "127.0.0.1", () => { + this.#httpServer.removeListener("error", reject); + this.#resetIdleTimer(); + const address = this.#httpServer.address() as AddressInfo; + debug("daemon listening on ws://127.0.0.1:%d%s", address.port, DAEMON_WS_PATH); + resolve(address.port); + }); + }); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#clearIdleTimer(); + + for (const [, client] of this.#wsClients) { + client.close(); + } + this.#wsClients.clear(); + + for (const [, conn] of this.#deviceConnections) { + await conn.dispose(); + } + this.#deviceConnections.clear(); + + for (const [, timer] of this.#deviceConnectionCleanupTimers) { + clearTimeout(timer); + } + this.#deviceConnectionCleanupTimers.clear(); + + for (const transport of this.#transports) { + await transport.close(); + } + + this.#wss?.close(); + return new Promise((resolve) => { + this.#httpServer.close(() => resolve()); + }); + } + + // --------------------------------------------------------------------------- + // WebSocket client lifecycle + // --------------------------------------------------------------------------- + + #isVersionRequest(url: string | undefined): boolean { + return new URL(url ?? "/", "http://127.0.0.1").pathname === DAEMON_VERSION_PATH; + } + + #isShutdownRequest(url: string | undefined): boolean { + return new URL(url ?? "/", "http://127.0.0.1").pathname === DAEMON_SHUTDOWN_PATH; + } + + #sendJson(res: http.ServerResponse, statusCode: number, data: unknown, callback?: () => void): void { + const body = JSON.stringify(data); + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(body), + "cache-control": "no-store", + }); + res.end(body, callback); + } + + #handleConnection(ws: WebSocket): void { + const clientId = ++this.#nextClientId; + debug("new ws client %d", clientId); + this.#clearIdleTimer(); + + const session: WsClientSession = { + id: clientId, + subscriptions: new Set(), + send(message: unknown) { + // ws.OPEN === 1 + if (ws.readyState === 1) { + ws.send(JSON.stringify(message)); + } + }, + close() { + ws.close(1001, "device disconnected"); + }, + }; + + // Standard debug-router handshake: send Initialize + session.send({ event: "Initialize", data: clientId }); + + ws.on("message", (raw: Buffer | string) => { + try { + const msg: unknown = JSON.parse(String(raw)); + this.#handleMessage(session, msg); + } catch (err) { + debug("failed to parse message from client %d: %O", clientId, err); + } + }); + + ws.on("close", () => { + debug("ws client %d disconnected", clientId); + this.#wsClients.delete(clientId); + + for (const key of session.subscriptions) { + const deviceConn = this.#deviceConnections.get(key); + if (!deviceConn) continue; + deviceConn.removeSubscriber(clientId); + if (deviceConn.subscriberCount === 0) { + this.#scheduleDeviceConnectionCleanup(key); + } + } + session.subscriptions.clear(); + this.#resetIdleTimer(); + }); + + ws.on("error", (err: Error) => { + debug("ws client %d error: %O", clientId, err); + }); + } + + // --------------------------------------------------------------------------- + // Message dispatch + // --------------------------------------------------------------------------- + + #handleMessage(session: WsClientSession, msg: unknown): void { + if (isRegisterEvent(msg)) { + this.#wsClients.set(session.id, session); + debug("client %d registered", session.id); + return; + } + if (isListClientsRequest(msg)) { + void this.#sendClientList(session); + return; + } + if (isPingEvent(msg)) { + session.send({ event: "Pong" }); + return; + } + if (isControlRequest(msg)) { + void this.#handleControlRequest(session, msg); + return; + } + if (isCustomizedMessage(msg)) { + void this.#handleCustomizedMessage(session, msg); + return; + } + debug("unknown message from client %d: %O", session.id, msg); + } + + // --------------------------------------------------------------------------- + // Customized message forwarding (Client → Device) + // --------------------------------------------------------------------------- + + async #handleCustomizedMessage(session: WsClientSession, msg: CustomizedMessage): Promise { + // DaemonTransport sets `to` = port. + // CustomizedClientIdTransformStream also sets client_id = port. + const targetPort = msg.to ?? msg.data?.data?.client_id; + if (typeof targetPort !== "number") { + debug("cannot determine target port from message: %O", msg); + return; + } + + // Route to the device connection this client is subscribed to + for (const key of session.subscriptions) { + const deviceConn = this.#deviceConnections.get(key); + if (deviceConn && deviceConn.port === targetPort) { + try { + // Multiple daemon WS clients share one app-side debug-router port. + // Keep the app's client id stable even when the upstream WS sender differs. + await deviceConn.send({ + ...msg, + data: { + ...msg.data, + sender: targetPort, + }, + }); + } catch (err) { + debug("failed to forward message to %s: %O", key, err); + } + return; + } + } + + debug("no matching device connection for client %d, port %d", session.id, targetPort); + } + + // --------------------------------------------------------------------------- + // Control RPC + // --------------------------------------------------------------------------- + + async #handleControlRequest(session: WsClientSession, req: ControlRequest): Promise { + const { id, method, params } = req.data; + + try { + let result: unknown; + + switch (method) { + case "listClients": { + result = await this.#discoverClients(); + break; + } + + case "listDevices": { + const devices: Device[] = []; + const allResults = await Promise.allSettled( + this.#transports.map(t => t.listDevices()), + ); + for (const r of allResults) { + if (r.status === "fulfilled") devices.push(...r.value); + } + result = devices; + break; + } + + case "listAvailableApps": { + const deviceId = (params as { deviceId?: string } | undefined)?.deviceId; + if (!deviceId) throw new Error("deviceId is required"); + const transport = await this.#findTransportWithDeviceId(deviceId); + result = await transport.listAvailableApps(deviceId); + break; + } + + case "openApp": { + const p = (params ?? {}) as { deviceId?: string; packageName?: string; withDataCleared?: boolean }; + if (!p.deviceId || !p.packageName) throw new Error("deviceId and packageName are required"); + const transport = await this.#findTransportWithDeviceId(p.deviceId); + await transport.openApp(p.deviceId, p.packageName, { withDataCleared: p.withDataCleared }); + result = null; + break; + } + + case "subscribe": { + const s = (params ?? {}) as { deviceId?: string; port?: number }; + if (!s.deviceId || s.port === undefined) throw new Error("deviceId and port are required"); + const transport = await this.#findTransportWithDeviceId(s.deviceId); + const conn = await this.#getOrCreateDeviceConnection(transport, s.deviceId, s.port); + conn.addSubscriber(session); + session.subscriptions.add(conn.key); + result = null; + break; + } + + default: + throw new Error(`Unknown control method: ${method}`); + } + + session.send({ event: "ControlResponse", data: { id, result } }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + session.send({ event: "ControlResponse", data: { id, error: message } }); + } + } + + // --------------------------------------------------------------------------- + // Client list discovery + // --------------------------------------------------------------------------- + + async #sendClientList(session: WsClientSession): Promise { + try { + const clients = await this.#discoverClients(); + session.send({ event: "ClientList", data: clients }); + } catch (err) { + debug("failed to send client list: %O", err); + session.send({ event: "ClientList", data: [] }); + } + } + + async #discoverClients(): Promise { + const entries: ClientListEntry[] = []; + + // 0. Collect clients from transports with listClients() capability. + const clientListTransports = this.#transports.filter( + (t): t is Transport & { listClients(): Promise<{ id: string; info: Record }[]> } => + typeof t.listClients === "function", + ); + const clientListResults = await Promise.allSettled( + clientListTransports.map(t => t.listClients()), + ); + for (const r of clientListResults) { + if (r.status === "fulfilled") { + for (const { id, info } of r.value) { + entries.push({ id, info, type: "runtime" }); + } + } + } + + // 1. Report already-connected device connections that completed handshake + for (const [, conn] of this.#deviceConnections) { + if (conn.appInfo && !conn.isDisposed) { + const id = ClientId.serialize(conn.deviceId, conn.port); + if (entries.some((e) => e.id === id)) continue; + entries.push({ + id, + info: conn.appInfo, + type: "runtime", + }); + } + } + + // 2. Probe unconnected ports on all discovered devices + const allDevices: { transport: Transport; devices: Device[] }[] = []; + const transportResults = await Promise.allSettled( + this.#transports + .filter(transport => typeof transport.listClients !== "function") + .map(async (transport) => ({ + transport, + devices: await transport.listDevices(), + })), + ); + for (const r of transportResults) { + if (r.status === "fulfilled") allDevices.push(r.value); + } + + const MIN_PORT = 8901; + const PORTS = Array.from({ length: 10 }, (_, i) => MIN_PORT + i); + const existingKeys = new Set(this.#deviceConnections.keys()); + + const probeResults = await Promise.allSettled( + allDevices.flatMap(({ transport, devices }) => + devices.flatMap((device) => + PORTS + .filter((port) => !existingKeys.has(`${device.id}:${port}`)) + .map(async (port) => { + const conn = await this.#getOrCreateDeviceConnection(transport, device.id, port); + // Match direct transport discovery timeout so cold-start daemon scans + // do not give up before the device finishes the Initialize/Register handshake. + const deadline = Date.now() + DEVICE_DISCOVERY_TIMEOUT_MS; + while (!conn.appInfo && !conn.isDisposed && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return conn; + }) + ) + ), + ); + + for (const r of probeResults) { + if (r.status === "fulfilled") { + const conn = r.value; + if (conn.appInfo && !conn.isDisposed) { + const clientId = ClientId.serialize(conn.deviceId, conn.port); + if (!entries.some((e) => e.id === clientId)) { + entries.push({ id: clientId, info: conn.appInfo, type: "runtime" }); + } + } + } + } + + return entries; + } + + // --------------------------------------------------------------------------- + // Device connection pool + // --------------------------------------------------------------------------- + + async #getOrCreateDeviceConnection( + transport: Transport, + deviceId: string, + port: number, + ): Promise { + const key = `${deviceId}:${port}`; + const existing = this.#deviceConnections.get(key); + if (existing && !existing.isDisposed) { + this.#clearDeviceConnectionCleanup(key); + return existing; + } + + const pending = this.#pendingDeviceConnections.get(key); + if (pending) { + return await pending; + } + + const connectionPromise = (async () => { + const setupAbortController = new AbortController(); + // The signal is passed into transports, so keep the setup deadline clearable. + // AbortSignal.timeout() would abort later even after this pooled connection succeeds. + const setupTimeout = setTimeout(() => { + setupAbortController.abort(createDeviceConnectionSetupTimeoutError(key)); + }, DEVICE_CONN_SETUP_TIMEOUT_MS); + const conn = new DeviceConnection(transport, { + deviceId, + port, + signal: setupAbortController.signal, + }); + const connectPromise = conn.connect(); + + try { + await withAbortSignal(connectPromise, setupAbortController.signal); + // Trigger the Initialize handshake so the device sends back Register + await withAbortSignal( + conn.send({ event: "Initialize", data: port }), + setupAbortController.signal, + ); + this.#deviceConnections.set(key, conn); + return conn; + } catch (err) { + debug("failed to connect to %s: %O", key, err); + this.#deviceConnections.delete(key); + await this.#disposeDeviceConnectionBestEffort(key, conn); + throw err; + } finally { + clearTimeout(setupTimeout); + this.#pendingDeviceConnections.delete(key); + } + })(); + + this.#pendingDeviceConnections.set(key, connectionPromise); + return await connectionPromise; + } + + async #disposeDeviceConnectionBestEffort(key: string, conn: DeviceConnection): Promise { + const timeoutAbortController = new AbortController(); + const disposePromise = conn.dispose().catch((err: unknown) => { + debug("failed to dispose device connection %s after setup failure: %O", key, err); + }); + const timeoutPromise = sleep(DEVICE_CONN_DISPOSE_TIMEOUT_MS, undefined, { + signal: timeoutAbortController.signal, + }).then(() => { + throw new Error(`Timed out disposing failed device connection ${key}`); + }); + + try { + await Promise.race([ + disposePromise, + timeoutPromise, + ]); + } catch (err) { + debug("best-effort dispose for %s did not complete: %O", key, err); + } finally { + timeoutAbortController.abort(); + } + } + + #scheduleDeviceConnectionCleanup(key: string): void { + const conn = this.#deviceConnections.get(key); + if (conn?.isPersistent) { + // Drop any cleanup timer left over from a previous (non-persistent) + // connection on this key, otherwise it could later dispose this + // persistent connection — contradicting "persistent is never cleaned up". + this.#clearDeviceConnectionCleanup(key); + debug("skipping cleanup for persistent connection %s", key); + return; + } + debug("scheduling cleanup for %s in %dms", key, DEVICE_CONN_GRACE_MS); + this.#clearDeviceConnectionCleanup(key); + + const timer = setTimeout(() => { + this.#deviceConnectionCleanupTimers.delete(key); + const conn = this.#deviceConnections.get(key); + if (conn && conn.subscriberCount === 0) { + debug("disposing idle device connection %s", key); + this.#deviceConnections.delete(key); + void conn.dispose(); + } + }, DEVICE_CONN_GRACE_MS); + + this.#deviceConnectionCleanupTimers.set(key, timer); + } + + #clearDeviceConnectionCleanup(key: string): void { + const timer = this.#deviceConnectionCleanupTimers.get(key); + if (!timer) return; + + clearTimeout(timer); + this.#deviceConnectionCleanupTimers.delete(key); + } + + async #findTransportWithDeviceId(deviceId: string): Promise { + for (const transport of this.#transports) { + try { + const devices = await transport.listDevices(); + if (devices.some(({ id }) => id === deviceId)) return transport; + } catch { + // skip + } + } + throw new Error(`Device with id: ${deviceId} not found`); + } + + // --------------------------------------------------------------------------- + // Idle auto-shutdown + // --------------------------------------------------------------------------- + + #resetIdleTimer(): void { + this.#clearIdleTimer(); + if (this.#wsClients.size === 0 && !this.#closed) { + debug("no clients connected, starting idle timer (%dms)", IDLE_TIMEOUT_MS); + this.#idleTimer = setTimeout(() => { + if (this.#wsClients.size === 0) { + debug("idle timeout reached, shutting down daemon"); + this.#onIdle?.(); + } + }, IDLE_TIMEOUT_MS); + } + } + + #clearIdleTimer(): void { + if (this.#idleTimer) { + clearTimeout(this.#idleTimer); + this.#idleTimer = null; + } + } +} + +function createDeviceConnectionSetupTimeoutError(key: string): Error { + return new Error( + `Timed out setting up device connection ${key} after ${DEVICE_CONN_SETUP_TIMEOUT_MS}ms`, + ); +} + +async function withAbortSignal(promise: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + + return await new Promise((resolve, reject) => { + const abortHandler = () => { + reject(signal.reason ?? new Error("The operation was aborted")); + }; + + signal.addEventListener("abort", abortHandler, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", abortHandler); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", abortHandler); + reject(error); + }, + ); + }); +} diff --git a/mcp-servers/devtool-connector/src/daemon/static-server.ts b/mcp-servers/devtool-connector/src/daemon/static-server.ts new file mode 100644 index 0000000..c17dc54 --- /dev/null +++ b/mcp-servers/devtool-connector/src/daemon/static-server.ts @@ -0,0 +1,124 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import fs from "node:fs"; +import type http from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import zlib from "node:zlib"; +import { createDebug } from "obug"; +import { DAEMON_INSPECTOR_PATH } from "./protocol.ts"; +import { TarballCache } from "./tarball-cache.ts"; + +const debug = createDebug("devtool-mcp-server:daemon:static-server"); + +const DEVTOOL_FRONTEND_TARBALL_URL = "https://github.com/lynx-family/lynx-devtool/releases/download/devtools-frontend-lynx-7/devtool.frontend.lynx_1.0.1779085629.tar.gz"; +const DEVTOOL_FRONTEND_PATH_PREFIX = "/devtool-frontend/"; + +const MIME_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".ttf": "font/ttf", +}; + +export class StaticServer { + #frontendCache: TarballCache | null = null; + + tryHandle(req: http.IncomingMessage, res: http.ServerResponse): boolean { + if (req.method !== "GET") return false; + const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname; + + if (pathname === DAEMON_INSPECTOR_PATH) { + this.#serveInspectorWrapper(res); + return true; + } + + if (pathname.startsWith(DEVTOOL_FRONTEND_PATH_PREFIX)) { + void this.#serveFrontendRes(req, res, pathname); + return true; + } + + return false; + } + + #serveInspectorWrapper(res: http.ServerResponse): void { + const base = path.dirname(fileURLToPath(import.meta.url)); + const primary = path.resolve(base, "../../public"); + const secondary = path.resolve(base, "../public"); + const candidates = [primary, secondary]; + const filePath = candidates + .map((d) => path.join(d, "inspector-wrapper.html")) + .find((f) => fs.existsSync(f)) ?? path.join(primary, "inspector-wrapper.html"); + fs.readFile(filePath, "utf-8", (err, content) => { + if (err) { + res.writeHead(404); + res.end("Not found"); + return; + } + res.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(content), + "cache-control": "no-store", + }); + res.end(content); + }); + } + + async #serveFrontendRes(req: http.IncomingMessage, res: http.ServerResponse, pathname: string): Promise { + try { + if (!this.#frontendCache) { + this.#frontendCache = new TarballCache(); + this.#frontendCache.start(DEVTOOL_FRONTEND_TARBALL_URL); + } + const relativePath = pathname.slice(DEVTOOL_FRONTEND_PATH_PREFIX.length); + if (relativePath.includes("..")) { + res.writeHead(404); + res.end("Not found"); + return; + } + const ext = path.extname(relativePath).toLowerCase(); + if (ext === ".map") { + res.writeHead(404); + res.end("Not found"); + return; + } + const entry = this.#frontendCache.get(relativePath) ?? await this.#frontendCache.waitFor(relativePath); + if (!entry) { + res.writeHead(404); + res.end("Not found"); + return; + } + const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; + const acceptGzip = req.headers["accept-encoding"]?.includes("gzip"); + if (acceptGzip) { + res.writeHead(200, { + "content-type": contentType, + "content-encoding": "gzip", + "content-length": entry.gzipped.length, + "cache-control": "public, max-age=31536000, immutable", + }); + res.end(entry.gzipped); + } else { + const raw = zlib.gunzipSync(entry.gzipped); + res.writeHead(200, { + "content-type": contentType, + "content-length": raw.length, + "cache-control": "public, max-age=31536000, immutable", + }); + res.end(raw); + } + } catch (err) { + debug("failed to serve frontend file: %O", err); + res.writeHead(502); + res.end("Failed to load resource"); + } + } +} diff --git a/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts b/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts new file mode 100644 index 0000000..b405735 --- /dev/null +++ b/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts @@ -0,0 +1,128 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import path from "node:path"; +import zlib from "node:zlib"; +import { createDebug } from "obug"; + +const debug = createDebug("devtool-mcp-server:daemon:tarball-cache"); + +const TAR_FILTER_PREFIX = ""; + +export interface TarballEntry { + gzipped: Buffer; + rawSize: number; +} + +/** + * Streaming tarball loader. Downloads, gunzips, and parses tar entries on + * the fly. Only retains files matching the filter prefix, stored gzip-compressed. + * Files become available for serving as soon as each entry is fully read. + */ +export class TarballCache { + #files = new Map(); + #pending = new Map void; reject: (err: Error) => void }>>(); + #done = false; + #error: Error | null = null; + #loading: Promise | null = null; + + get(filePath: string): TarballEntry | undefined { + return this.#files.get(filePath); + } + + get isDone(): boolean { + return this.#done; + } + + waitFor(filePath: string): Promise { + const existing = this.#files.get(filePath); + if (existing) return Promise.resolve(existing); + if (this.#error) return Promise.reject(this.#error); + if (this.#done) return Promise.resolve(null); + return new Promise((resolve, reject) => { + let waiters = this.#pending.get(filePath); + if (!waiters) { + waiters = []; + this.#pending.set(filePath, waiters); + } + waiters.push({ resolve, reject }); + }); + } + + start(url: string): void { + if (this.#loading) return; + this.#loading = this.#load(url); + } + + async #load(url: string): Promise { + try { + // eslint-disable-next-line n/no-unsupported-features/node-builtins -- Node 18+ exposes fetch; keep undici out of the bundle. + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch tarball: ${response.status}`); + if (!response.body) throw new Error("No response body"); + + const gunzip = zlib.createGunzip(); + const { Readable } = await import("node:stream"); + Readable.fromWeb(response.body as never).pipe(gunzip); + + let buf: Buffer = Buffer.alloc(0); + for await (const chunk of gunzip) { + buf = Buffer.concat([buf, chunk as Buffer]); + buf = this.#consumeTar(buf); + } + this.#consumeTar(buf); + } catch (err) { + this.#error = err instanceof Error ? err : new Error(String(err)); + debug("tarball stream error: %O", this.#error); + } finally { + this.#done = true; + for (const [, waiters] of this.#pending) { + for (const { resolve, reject } of waiters) { + if (this.#error) reject(this.#error); + else resolve(null); + } + } + this.#pending.clear(); + debug("tarball cache done: %d files", this.#files.size); + } + } + + #consumeTar(buf: Buffer): Buffer { + while (buf.length >= 512) { + const header = buf.subarray(0, 512); + if (header.every((b) => b === 0)) { + buf = buf.subarray(512); + continue; + } + + const rawName = header.subarray(0, 100).toString("utf-8").replace(/\0.*$/, ""); + const prefix = header.subarray(345, 500).toString("utf-8").replace(/\0.*$/, ""); + const name = prefix ? `${prefix}/${rawName}` : rawName; + const sizeStr = header.subarray(124, 136).toString("utf-8").replace(/\0.*$/, "").trim(); + const size = parseInt(sizeStr, 8) || 0; + const typeFlag = header[156]; + const paddedSize = Math.ceil(size / 512) * 512; + + if (buf.length < 512 + paddedSize) break; + + if ((typeFlag === 48 || typeFlag === 0) && name.startsWith(TAR_FILTER_PREFIX)) { + const fileData = buf.subarray(512, 512 + size); + const ext = path.extname(name).toLowerCase(); + if (ext !== ".map") { + const gzipped = zlib.gzipSync(fileData, { level: zlib.constants.Z_BEST_SPEED }); + const entry: TarballEntry = { gzipped, rawSize: size }; + this.#files.set(name, entry); + const waiters = this.#pending.get(name); + if (waiters) { + this.#pending.delete(name); + for (const { resolve } of waiters) resolve(entry); + } + } + } + + buf = buf.subarray(512 + paddedSize); + } + return buf; + } +} diff --git a/mcp-servers/devtool-connector/src/daemon/version.ts b/mcp-servers/devtool-connector/src/daemon/version.ts new file mode 100644 index 0000000..ef4ec49 --- /dev/null +++ b/mcp-servers/devtool-connector/src/daemon/version.ts @@ -0,0 +1,7 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import packageJson from "../../package.json" with { type: "json" }; + +export const CONNECTOR_VERSION: string = packageJson.version; diff --git a/mcp-servers/devtool-connector/src/index.ts b/mcp-servers/devtool-connector/src/index.ts new file mode 100644 index 0000000..1fc9059 --- /dev/null +++ b/mcp-servers/devtool-connector/src/index.ts @@ -0,0 +1,632 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/* eslint-disable n/no-unpublished-import */ +import { randomInt } from "node:crypto"; +import { ReadableStream, TransformStream } from "node:stream/web"; +import { createDebug } from "obug"; +import { CDPOutputTransformStream, CDPRequestTransformStream, CDPResponseTransformStream } from "./streams/cdp.ts"; +import { + AppResponseTransformStream, + CustomizedClientIdTransformStream, + CustomizedRequestTransformStream, + CustomizedResponseTransformStream, + GlobalSwitchRequestTransformStream, +} from "./streams/customized.ts"; +import { FilterTransformStream, InspectStream, SessionGuardTransformStream } from "./streams/utils.ts"; + +export { CDPOutputTransformStream, CDPRequestTransformStream, CDPResponseTransformStream } from "./streams/cdp.ts"; + +import { ClientId } from "./client-id.ts"; +import { DaemonTransport } from "./transport/daemon.ts"; +import type { App, Client, Device, OpenAppOptions, Transport, TransportConnectOptions } from "./transport/transport.ts"; +import { + type AppInfo, + type CDPRequestMessage, + type GetGlobalSwitchResponse, + type GlobalKeys, + type HeadlessPrepareRequest, + type HeadlessPrepareResponse, + type HeadlessPrepareState, + type InitializeRequest, + type InitializeResponse, + isGetGlobalSwitchResponse, + isHeadlessPrepareResponse, + isInitializeResponse, + isListSessionResponse, + isSetGlobalSwitchResponse, + type ListSessionRequest, + type ListSessionResponse, + type Session, +} from "./types.ts"; + +const debug = createDebug("devtool-mcp-server:connector"); + +interface OutputStream extends AsyncDisposable, ReadableStream { + inputClosed: Promise; +} + +export { ClientId }; + +type Pipeline = { + input: TransformStream[]; + output: TransformStream[]; +}; + +type ClientListTransport = Transport & { + listClients(): Promise; +}; + +function hasClientList(transport: Transport): transport is ClientListTransport { + return typeof transport.listClients === "function"; +} + +export class Connector { + #transports: Transport[]; + #daemonTransports: DaemonTransport[]; + + constructor(transports: Transport[]) { + this.#transports = transports; + this.#daemonTransports = transports.filter((t): t is DaemonTransport => t instanceof DaemonTransport); + } + + async listClients(): Promise { + // 0. Try to get clients from daemon transport, which supports multiplexing. + const daemonClientResults = await Promise.allSettled( + this.#daemonTransports.map(async (transport) => { + const clients = await transport.listClients(); + await Promise.allSettled( + clients.flatMap(({ id }) => this.#setupClient(transport, id)), + ); + return clients; + }), + ); + const fulfilledDaemonClientResults = daemonClientResults + .filter((r) => r.status === "fulfilled"); + const daemonClients = fulfilledDaemonClientResults.flatMap((r) => r.value); + + if (fulfilledDaemonClientResults.length > 0) { + debug("Using clients from daemon transport: %o", daemonClients); + return daemonClients; + } + + // 1. Try direct connection for other transports. + const transportDevices = await Promise.allSettled( + this.#transports + .filter(t => !(t instanceof DaemonTransport)) + .map(async (transport) => ({ + transport, + devices: await transport.listDevices(), + })), + ); + + for (const result of transportDevices) { + if (result.status === "rejected") { + debug("listClients: listDevices failed on one transport: %O", result.reason); + } + } + + const results = await Promise.allSettled( + transportDevices + .filter(r => r.status === "fulfilled") + .map(r => r.value) + .flatMap(({ transport, devices }) => devices.flatMap(({ id }) => this.#listClientsForDevice(transport, id))), + ); + + return results + .filter((r) => r.status === "fulfilled") + .flatMap((r) => r.value); + } + + async listDevices(): Promise { + const results = await Promise.allSettled( + this.#transports.map(t => t.listDevices()), + ); + + return results + .filter(result => result.status === "fulfilled") + .flatMap(({ value }) => value); + } + + async listAvailableApps(deviceId: string): Promise { + const transport = await this.#findTransportWithDeviceId(deviceId); + + return await transport.listAvailableApps(deviceId); + } + + async openApp(deviceId: string, packageName: string, options?: OpenAppOptions): Promise { + const transport = await this.#findTransportWithDeviceId(deviceId); + + await transport.openApp(deviceId, packageName, options); + + const signal = AbortSignal.any([ + options?.signal, + AbortSignal.timeout(60_000), + ].filter(i => i !== undefined)); + + const { setTimeout } = await import("node:timers/promises"); + while (!signal.aborted) { + try { + const clients = hasClientList(transport) + ? (await transport.listClients()) + .filter(({ id }) => ClientId.deserialize(id)?.deviceId === deviceId) + : await this.#listClientsForDevice(transport, deviceId); + + if ( + clients.some(({ info }) => + /** Android */ info.AppProcessName === packageName + /** iOS */ || info.bundleId === packageName + || info.bundleName === packageName + ) + ) { + break; + } + } catch (err) { + // ignore error + debug(`openApp ${deviceId} ${packageName} client not found %o`, err); + } + await setTimeout(1_000); + } + } + + async sendMessage( + clientId: string, + message: T, + pipeline: Pipeline = { input: [], output: [] }, + ): Promise { + return this.#sendMessage(clientId, message, pipeline); + } + + /** + * Send a message to the device without waiting for a response. + * + * Unlike {@link sendMessage}, this method does not wait for the output + * stream to produce a value. It connects, writes the message, and then + * disposes of the connection. This is useful for fire-and-forget messages + * such as `xdb_proxy_config` where the device does not send a reply. + */ + async sendMessageNoReply( + clientId: string, + message: T, + ): Promise { + const { deviceId, port } = this.#resolveClientId(clientId); + const transport = await this.#findTransportWithDeviceId(deviceId); + const signal = AbortSignal.timeout(5_000); + + const conn = await transport.connect({ deviceId, port, signal }); + + try { + // Write the message through the input pipeline (CustomizedClientIdTransformStream etc.) + const inputStream = [ + new CustomizedClientIdTransformStream(port), + new InspectStream((msg: unknown) => + debug(`sendMessageNoReply ${deviceId}:${port} send %o`, JSON.stringify(msg)) + ), + ].reduce( + (stream, transform) => stream.pipeThrough(transform), + // eslint-disable-next-line n/no-unsupported-features/node-builtins + ReadableStream.from([message]), + ); + + // Wait for the message to be fully written before closing. + await inputStream.pipeTo(conn.writable, { preventClose: true }); + } finally { + await conn[Symbol.asyncDispose](); + } + } + + async sendAppMessage( + clientId: string, + method: string, + params?: Params, + ): Promise { + const id = randomInt(10_000, 50_000); + + return await this.#sendMessage, Output>(clientId, { + method, + params: /** App message requires params to be an object */ { ...params }, + }, { + input: [ + new CustomizedRequestTransformStream({ + type: "App", + sessionId: -1, + messageBuilder: (message) => ({ id, ...message }), + }), + ], + output: [ + new CustomizedResponseTransformStream("App", id), + new AppResponseTransformStream(method), + ], + }); + } + + async sendCDPMessage( + clientId: string, + sessionId: number, + method: string, + params?: Params, + isMainThread = false, + ): Promise { + const id = randomInt(10_000, 50_000); + + const SUPPORTED_DOMAIN = ["Debugger", "Runtime", "HeapProfiler", "Profiler"]; + + if (isMainThread && !SUPPORTED_DOMAIN.some(domain => method.startsWith(domain + "."))) { + throw new Error( + `Method ${method} is not supported for main thread. Supported domains: ${SUPPORTED_DOMAIN.join(", ")}`, + ); + } + + return await this.#sendMessage, Output>(clientId, { + method, + params, + sessionId: isMainThread ? "Main" : undefined, + }, { + input: [ + new CDPRequestTransformStream(sessionId, id), + ], + output: [ + new CDPResponseTransformStream(id), + new CDPOutputTransformStream(), + ], + }); + } + + async sendListSessionMessage( + clientId: string, + ): Promise { + return await this.#sendListSessionMessage(clientId); + } + + /** + * Probe the headless runtime's readiness. Returns immediately with the + * current state and, when not ready, kicks off the binary download in the + * background. Callers (e.g. the `open` command) poll this until `ready` so + * the download is driven by a caller-controlled timeout rather than a single + * request that could be cut off mid-download. + */ + async prepareHeadless(clientId: string): Promise { + const { data: { data: state } } = await this.#sendMessage( + clientId, + { + event: "Customized", + data: { + type: "HeadlessPrepare", + data: {}, + }, + }, + { + input: [], + output: [ + new FilterTransformStream(isHeadlessPrepareResponse), + ], + }, + ); + + return state; + } + + /** + * Block until the headless runtime is ready, polling {@link prepareHeadless} + * so the (potentially long) first-use binary download is not cut off by a + * single request timeout. A transient `error` is tolerated until the overall + * deadline because the transport retries the download on the next poll. + * + * Intended to be called from the CLI/tool layer before opening a page on the + * headless client; the core request path is intentionally left untouched. + */ + async waitForHeadlessReady( + clientId: string, + options: { timeoutMs?: number; pollIntervalMs?: number } = {}, + ): Promise { + const timeoutMs = options.timeoutMs ?? 5 * 60_000; + const pollIntervalMs = options.pollIntervalMs ?? 1_000; + const { setTimeout: delay } = await import("node:timers/promises"); + const deadline = Date.now() + timeoutMs; + let lastError: string | undefined; + for (;;) { + const state = await this.prepareHeadless(clientId); + if (state.status === "ready") return; + if (state.status === "error") { + lastError = state.message ?? "unknown error"; + } + if (Date.now() >= deadline) { + throw new Error( + lastError !== undefined + ? `Failed to prepare headless runtime: ${lastError}` + : `Timed out preparing headless runtime after ${timeoutMs}ms`, + ); + } + await delay(pollIntervalMs); + } + } + + async #sendListSessionMessage( + clientId: string, + ): Promise { + const { data: { data: sessions } } = await this.#sendMessage( + clientId, + { + event: "Customized", + data: { + type: "ListSession", + data: {}, + }, + }, + { + input: [], + output: [ + new FilterTransformStream(isListSessionResponse), + ], + }, + ); + + return sessions.map(session => ({ + ...session, + type: session.type === "" ? "lynx" : session.type, + })); + } + + async getGlobalSwitch( + clientId: string, + key: GlobalKeys, + ): Promise { + const { + data: { data: { message } }, + } = await this.#sendMessage<{ key: GlobalKeys }, GetGlobalSwitchResponse>(clientId, { key }, { + input: [ + new GlobalSwitchRequestTransformStream("GetGlobalSwitch"), + ], + output: [ + new FilterTransformStream(isGetGlobalSwitchResponse), + ], + }); + + if (typeof message === "object") { + return message?.global_value === "true" || message?.global_value === true; + } else { + return message === "true" || message === true; + } + } + + async setGlobalSwitch( + clientId: string, + key: GlobalKeys, + value: boolean, + ): Promise { + await this.#sendMessage(clientId, { key, value }, { + input: [ + new GlobalSwitchRequestTransformStream("SetGlobalSwitch"), + ], + output: [ + new FilterTransformStream(isSetGlobalSwitchResponse), + ], + }); + } + + async sendStream( + clientId: string, + inputStream: ReadableStream, + { signal, pipeline }: { signal?: AbortSignal | undefined; pipeline?: Pipeline | undefined } = {}, + ): Promise> { + const { deviceId, port } = this.#resolveClientId(clientId); + const transport = await this.#findTransportWithDeviceId(deviceId); + + return await this.#connect( + transport, + { deviceId, port, signal }, + inputStream, + pipeline ?? { input: [], output: [] }, + ); + } + + async sendCDPStream( + clientId: string, + sessionId: number, + inputStream: ReadableStream, + { signal }: { signal?: AbortSignal } = {}, + ): Promise> { + return await this.sendStream(clientId, inputStream, { + signal, + pipeline: { + input: [ + new CDPRequestTransformStream(sessionId), + ], + output: [ + new SessionGuardTransformStream(sessionId), + new CDPResponseTransformStream(), + ], + }, + }); + } + + #resolveClientId(clientId: string): TransportConnectOptions { + const parsed = ClientId.deserialize(clientId); + if (!parsed) { + throw new Error(`Invalid clientId: ${clientId}`); + } + return parsed; + } + + async #findTransportWithDeviceId(deviceId: string): Promise { + // Keep the same preference as listClients(): if a daemon transport can see + // this device, stick to it for follow-up requests. Otherwise a faster direct + // transport can win the race here and bypass the stable daemon path that was + // used during discovery, which is exactly what made list-sessions flaky. + const daemonTransport = await this.#findTransportWithDeviceIdInPool(this.#daemonTransports, deviceId); + if (daemonTransport) { + return daemonTransport; + } + + const transport = await this.#findTransportWithDeviceIdInPool( + this.#transports.filter(t => !(t instanceof DaemonTransport)), + deviceId, + ); + if (transport) { + return transport; + } + + throw new Error(`Device with id: ${deviceId} not found`); + } + + async #findTransportWithDeviceIdInPool(transports: Transport[], deviceId: string): Promise { + return await Promise.any( + transports.map(async (transport) => { + const devices = await transport.listDevices(); + if (devices.some(({ id }) => id === deviceId)) return transport; + throw new Error("Not found in this transport"); + }), + ).catch(() => null); + } + + async #connect( + transport: Transport, + options: TransportConnectOptions, + inputStream: ReadableStream, + pipeline: Pipeline, + ): Promise> { + const { deviceId, port } = options; + + const conn = await transport.connect(options); + + const inputAbortController = new AbortController(); + + const inputClosed = [ + ...pipeline.input, + new CustomizedClientIdTransformStream(port), + new InspectStream((msg) => debug(`connect ${deviceId}:${port} input stream send %o`, JSON.stringify(msg))), + ].reduce((stream, transform) => stream.pipeThrough(transform), inputStream) + .pipeTo(conn.writable, { preventClose: true, signal: inputAbortController.signal }) + .catch((err) => { + if (err?.name !== "AbortError") { + debug(`connect ${deviceId}:${port} input stream err %O`, err); + } + }); + + const outputStream = [ + new InspectStream((msg) => debug(`connect ${deviceId}:${port} output stream receive %O`, msg)), + ...pipeline.output, + ].reduce( + (stream, transform) => stream.pipeThrough(transform, { preventCancel: true }), + conn.readable, + ); + + return Object.assign(outputStream, { + inputClosed, + async [Symbol.asyncDispose]() { + debug(`connect ${deviceId}:${port} close connection`); + inputAbortController.abort(); + await inputClosed.catch(() => {}); + return conn[Symbol.asyncDispose](); + }, + }); + } + + async #sendMessage( + clientId: string, + input: I, + pipeline: Pipeline = { input: [], output: [] }, + ): Promise { + const { deviceId, port } = this.#resolveClientId(clientId); + const transport = await this.#findTransportWithDeviceId(deviceId); + + const signal = AbortSignal.timeout(10_000); + + return this.#sendMessageWithTransport( + transport, + { deviceId, port, signal }, + input, + pipeline, + ); + } + + async #sendMessageWithTransport( + transport: Transport, + options: TransportConnectOptions, + input: I, + pipeline: Pipeline, + ): Promise { + await using outputStream = await this.#connect( + transport, + options, + // We have polyfill for this + // eslint-disable-next-line n/no-unsupported-features/node-builtins + ReadableStream.from([input]), + pipeline, + ); + for await (const response of outputStream) { + await outputStream.inputClosed; + return response; + } + + await outputStream.inputClosed; + + const clientId = ClientId.serialize(options.deviceId, options.port); + throw new Error(`No response found for clientId: ${clientId}`); + } + + async #listClientsForDevice( + transport: Transport, + deviceId: string, + ): Promise<{ id: string; info: AppInfo; port: number }[]> { + const MIN_PORT = 8901; + const PORTS = Array.from({ length: 10 }, (_, i) => MIN_PORT + i); + const signal = AbortSignal.timeout(5_000); + const results = await Promise.allSettled(PORTS.map(async (port: number) => { + const { data: { info } } = await this.#sendMessageWithTransport( + transport, + { deviceId, port, signal }, + { event: "Initialize", data: port }, + { + input: [], + output: [ + new FilterTransformStream(isInitializeResponse), + ], + }, + ); + + const clientId = ClientId.serialize(deviceId, port); + await this.#setupClient(transport, clientId); + + return { id: clientId, info, port }; + })); + + return results + .filter(result => result.status === "fulfilled") + .map(result => result.value); + } + + async #setupClient(transport: Transport, clientId: string): Promise { + const { deviceId, port } = this.#resolveClientId(clientId); + for ( + const input of [ + { key: "enable_devtool", value: true }, + // `enable_quickjs_debug` is required for `Runtime.*` and `HeapProfiler.*` to work, + // so we enable it by default. It won't have effect if the devtool doesn't support quickjs debug. + // And it will not turn off `enable_v8` if it's already on, so it won't break v8 debug. + { key: "enable_quickjs_debug", value: true }, + ] as const + ) { + try { + await this.#sendMessageWithTransport<{ key: GlobalKeys; value: boolean }, never>( + transport, + { deviceId, port, signal: AbortSignal.timeout(3_000) }, + input, + { + input: [ + new GlobalSwitchRequestTransformStream("SetGlobalSwitch"), + ], + output: [ + new FilterTransformStream(isSetGlobalSwitchResponse), + ], + }, + ); + } catch (err) { + debug(`setupClient ${deviceId}:${port} ${input.key} failed %O`, err); + } + } + } +} + +export * from "./types.ts"; diff --git a/mcp-servers/devtool-connector/src/streams/cdp.ts b/mcp-servers/devtool-connector/src/streams/cdp.ts new file mode 100644 index 0000000..e4f305c --- /dev/null +++ b/mcp-servers/devtool-connector/src/streams/cdp.ts @@ -0,0 +1,56 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { randomInt } from "node:crypto"; +import type { CDPRequestMessage, CDPResponseMessage } from "../types.ts"; +import { + CustomizedRequestTransformStream, + CustomizedResponseTransformStream, + ResponseParserTransformStream, +} from "./customized.ts"; + +export class CDPRequestTransformStream extends CustomizedRequestTransformStream< + CDPRequestMessage +> { + constructor(sessionId: number, fixedId?: number) { + super({ + type: "CDP", + sessionId, + messageBuilder: (chunk) => { + const id = fixedId ?? randomInt(10_000, 50_000); + return { id, ...chunk }; + }, + }); + } +} + +export class CDPResponseTransformStream + extends CustomizedResponseTransformStream<"CDP", Output> +{ + constructor(id?: number) { + super("CDP", id); + } +} + +export class CDPOutputTransformStream extends ResponseParserTransformStream { + constructor() { + super({ + checkError: (message) => { + if ("error" in message) { + return new Error( + `CDP request error: ${message.error.message}`, + { cause: message }, + ); + } + return null; + }, + parseResult: (message) => { + if ("result" in message) { + return message.result as Output; + } + throw new Error("No result in CDP response message", { cause: message }); + }, + }); + } +} diff --git a/mcp-servers/devtool-connector/src/streams/customized.ts b/mcp-servers/devtool-connector/src/streams/customized.ts new file mode 100644 index 0000000..210dc97 --- /dev/null +++ b/mcp-servers/devtool-connector/src/streams/customized.ts @@ -0,0 +1,150 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { TransformStream } from "node:stream/web"; +import { + type AppResponseMessage, + type CustomizedResponseMap, + type CustomizedResponseMessageMap, + isCustomizedResponseWithType, + type Response, +} from "../types.ts"; + +export class CustomizedClientIdTransformStream extends TransformStream { + constructor(clientId: number) { + super({ + transform(chunk, controller) { + if (chunk.event === "Customized") { + controller.enqueue({ + ...chunk, + data: { + ...chunk?.data, + data: { + ...chunk?.data?.data, + client_id: clientId, + }, + sender: clientId, + }, + }); + } else { + controller.enqueue(chunk); + } + }, + }); + } +} + +export class CustomizedRequestTransformStream extends TransformStream { + constructor(options: { + type: string; + sessionId?: number | ((chunk: T) => number); + messageBuilder: (chunk: T) => unknown; + }) { + const { type, sessionId = -1, messageBuilder } = options; + super({ + transform(chunk, controller) { + const sid = typeof sessionId === "function" ? sessionId(chunk) : sessionId; + controller.enqueue({ + event: "Customized", + data: { + type, + data: { + session_id: sid, + message: messageBuilder(chunk), + }, + }, + }); + }, + }); + } +} + +export class CustomizedResponseTransformStream< + T extends keyof CustomizedResponseMap, + Output = CustomizedResponseMessageMap[T], +> extends TransformStream { + constructor(type: T, id?: number) { + super({ + transform(response, controller) { + if (!isCustomizedResponseWithType(response, type)) { + return; + } + + try { + const message = JSON.parse(response.data.data.message) as Output & { id?: number }; + if (id === undefined || message?.id === id) { + controller.enqueue(message); + } + } catch (err) { + controller.error( + new Error(`Failed to parse response for type ${type}`, { + cause: err, + }), + ); + } + }, + }); + } +} + +/** + * Common response parser that handles JSON parsing and error checking in the payload. + */ +export class ResponseParserTransformStream extends TransformStream { + constructor(options: { + parseResult: (input: Input) => Output; + checkError: (input: Input) => Error | null; + }) { + const { parseResult, checkError } = options; + super({ + transform(chunk, controller) { + const error = checkError(chunk); + if (error) { + controller.error(error); + return; + } + + try { + controller.enqueue(parseResult(chunk)); + } catch (err) { + controller.error(err); + } + }, + }); + } +} + +export class AppResponseTransformStream extends ResponseParserTransformStream { + constructor(method: string) { + super({ + checkError: (message) => { + try { + const result = JSON.parse(message.result) as { code: number | string; message: string }; + if (/** Android */ result.code !== 0 && /** iOS */ result.code !== "0") { + return new Error(`App request ${method} error: ${result.message}`, { cause: message }); + } + return null; + } catch (err) { + return new Error("Failed to parse App response message", { cause: err }); + } + }, + parseResult: (message) => { + return JSON.parse(message.result) as Output; + }, + }); + } +} + +export class GlobalSwitchRequestTransformStream extends CustomizedRequestTransformStream<{ + key: string; + value?: boolean; +}> { + constructor(type: "SetGlobalSwitch" | "GetGlobalSwitch") { + super({ + type, + sessionId: -1, + messageBuilder: ({ key, value }) => ({ global_key: key, global_value: value }), + }); + } +} diff --git a/mcp-servers/devtool-connector/src/streams/index.ts b/mcp-servers/devtool-connector/src/streams/index.ts new file mode 100644 index 0000000..39c47b4 --- /dev/null +++ b/mcp-servers/devtool-connector/src/streams/index.ts @@ -0,0 +1,7 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export * from "./cdp.ts"; +export * from "./customized.ts"; +export * from "./utils.ts"; diff --git a/mcp-servers/devtool-connector/src/streams/utils.ts b/mcp-servers/devtool-connector/src/streams/utils.ts new file mode 100644 index 0000000..32d5e7c --- /dev/null +++ b/mcp-servers/devtool-connector/src/streams/utils.ts @@ -0,0 +1,58 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { TransformStream } from "node:stream/web"; +import type { Response, Session } from "../types.ts"; +import { isListSessionResponse } from "../types.ts"; + +export class FilterTransformStream extends TransformStream { + constructor(filter: (chunk: T) => chunk is P) { + super({ + transform(chunk, controller) { + if (filter(chunk)) { + controller.enqueue(chunk); + } + }, + }); + } +} + +export class InspectStream extends TransformStream { + constructor(callback: (message: T) => void) { + super({ + transform(chunk, controller) { + callback(chunk); + controller.enqueue(chunk); + }, + }); + } +} + +/** + * Monitors the stream for `SessionList` updates and terminates it when the + * watched session disappears from the list. This allows CDP stream consumers + * (e.g. `get-console --watch`) to exit cleanly when a page is closed, even + * though the underlying transport connection remains open (shared with other + * sessions on the same device:port). + */ +export class SessionGuardTransformStream extends TransformStream { + constructor(sessionId: number) { + super({ + transform(chunk, controller) { + if (isListSessionResponse(chunk)) { + const sessions = chunk.data.data as Session[]; + if (!Array.isArray(sessions)) { + controller.enqueue(chunk); + return; + } + if (!sessions.some(s => s?.session_id === sessionId)) { + controller.terminate(); + return; + } + } + controller.enqueue(chunk); + }, + }); + } +} diff --git a/mcp-servers/devtool-connector/src/takeover.ts b/mcp-servers/devtool-connector/src/takeover.ts new file mode 100644 index 0000000..e29ba9e --- /dev/null +++ b/mcp-servers/devtool-connector/src/takeover.ts @@ -0,0 +1,35 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createDebug } from "obug"; + +const debug = createDebug("devtool-mcp-server:takeover"); + +const DEBUG_ROUTER_DIR = path.join(os.homedir(), ".DebugRouterConnector"); +const DEBUG_ROUTER_LOCK_DIR = path.join(DEBUG_ROUTER_DIR, "lockfile"); +const DEBUG_ROUTER_LATEST_FILE = path.join(DEBUG_ROUTER_DIR, "LatestDriverProcess"); + +export async function takeoverDebugRouterLock(): Promise { + try { + await fs.mkdir(DEBUG_ROUTER_DIR, { recursive: true }); + + await fs.rm(DEBUG_ROUTER_LOCK_DIR, { recursive: true, force: true }); + + await fs.mkdir(DEBUG_ROUTER_LOCK_DIR, { recursive: true }); + + await fs.writeFile(DEBUG_ROUTER_LATEST_FILE, `${process.pid}`, "utf-8"); + debug(`wrote PID=${process.pid}`); + } catch (err) { + debug("skipped due to filesystem error %O", err); + } finally { + try { + await fs.rm(DEBUG_ROUTER_LOCK_DIR, { recursive: true, force: true }); + } catch (_cleanupError) { + debug("failed to remove lock directory %O", _cleanupError); + } + } +} diff --git a/mcp-servers/devtool-connector/src/transport/android.ts b/mcp-servers/devtool-connector/src/transport/android.ts new file mode 100644 index 0000000..6a8d76b --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/android.ts @@ -0,0 +1,169 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { Adb, AdbServerClient } from "@yume-chan/adb"; +import { AdbServerNodeTcpConnector } from "@yume-chan/adb-server-node-tcp"; +import type { SocketConnectOpts } from "node:net"; +import { createDebug } from "obug"; +import { connectWithPeertalk } from "./base.ts"; +import type { App, Connection, Device, OpenAppOptions, Transport, TransportConnectOptions } from "./transport.ts"; + +const debug = createDebug("devtool-mcp-server:connector:android"); + +const KNOWNS_APPS: Array = [ + { packageName: "com.lynx.uiapp", name: "Lynx Example" }, + { packageName: "com.lynx.explorer", name: "Lynx Explorer" }, +]; + +export class AndroidTransport implements Transport { + protected readonly client: AdbServerClient; + + constructor(spec: SocketConnectOpts = { port: 5037 }) { + this.client = new AdbServerClient(new AdbServerNodeTcpConnector(spec)); + } + + async connect( + options: TransportConnectOptions, + ): Promise> { + return connectWithPeertalk(opts => this.#connectRaw(opts), options); + } + + async #createAdb(deviceId: string): Promise { + const adb = await this.client.createAdb({ serial: deviceId }); + return Object.assign(adb, { + async [Symbol.asyncDispose]() { + await adb.close(); + }, + }); + } + + async close(): Promise { + // noop + debug("Android transport closed"); + return; + } + + async #connectRaw( + { deviceId, port, signal }: TransportConnectOptions, + ): Promise { + const adb = await this.client.createAdb({ serial: deviceId }); + + debug(`connect: create connection to deviceId: ${deviceId}, port: ${port}`); + + signal?.throwIfAborted(); + + const service = `tcp:${port}`; + + let socket: Awaited>; + try { + socket = await adb.createSocket(service); + } catch (err) { + await adb.close(); + debug(`connect: create socket to ${service} failed with err: %o`, err); + throw err; + } + + if (signal?.aborted) { + await socket.close(); + await adb.close(); + signal.throwIfAborted(); + } + + const abortHandler = () => { + void Promise.resolve(socket.close()).catch((err: unknown) => { + debug(`connect: socket ${service} close on abort err: %o`, err); + }); + }; + signal?.addEventListener("abort", abortHandler, { once: true }); + + void Promise.resolve(socket.closed).catch((err: unknown) => { + debug(`connect: socket ${service} closed with err: %o`, err); + }); + + return { + readable: socket.readable as never, + writable: socket.writable as never, + async [Symbol.asyncDispose]() { + signal?.removeEventListener("abort", abortHandler); + debug(`connect: close connection to deviceId: ${deviceId}, port: ${port}`); + try { + await socket.close(); + } finally { + await adb.close(); + } + }, + }; + } + + async listDevices(): Promise { + const devices = await this.client.getDevices(); + + debug("listDevices: devices %o", devices); + + return devices.map(({ serial }) => ({ + os: "Android", + id: serial, + })); + } + + async listAvailableApps(deviceId: string): Promise { + await using adb = await this.#createAdb(deviceId); + const output = await adb.subprocess.noneProtocol.spawnWaitText([ + // adb shell pm list packages + "pm", + "list", + "packages", + "-3", // third-party apps only + ]); + const packages = new Set( + output + .split("\n") + .map((line) => line.replace("package:", "").trim()) + .filter(i => i !== ""), + ); + debug(`listAvailableApps all packages: %o`, packages); + + return KNOWNS_APPS.filter((app) => packages.has(app.packageName)); + } + + async openApp( + deviceId: string, + packageName: string, + { withDataCleared }: OpenAppOptions = {}, + ): Promise { + const apps = await this.listAvailableApps(deviceId); + await using adb = await this.#createAdb(deviceId); + + if (!apps.some((app) => app.packageName === packageName)) { + throw new Error(`package ${packageName} not found`); + } + + if (withDataCleared) { + const output = await adb.subprocess.noneProtocol.spawnWaitText([ + // adb shell pm clear + "pm", + "clear", + packageName, + ]); + debug(`openApp clear data output ${output}`); + } + + const output = await adb.subprocess.noneProtocol.spawnWaitText([ + // adb shell monkey -p -c android.intent.category.LAUNCHER 1 + "monkey", + "-p", + packageName, + "-c", + "android.intent.category.LAUNCHER", + "1", + ]); + debug(`openApp LAUNCHER output ${output}`); + if (output.includes("No activities found")) { + throw new Error(`No launchable activity found for package ${packageName}.`); + } + if (output.includes("monkey aborted")) { + throw new Error(`Failed to open app ${packageName}.`); + } + } +} diff --git a/mcp-servers/devtool-connector/src/transport/base.ts b/mcp-servers/devtool-connector/src/transport/base.ts new file mode 100644 index 0000000..334af47 --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/base.ts @@ -0,0 +1,61 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { TransformStream } from "node:stream/web"; +import { takeoverDebugRouterLock } from "../takeover.ts"; +import { MessageToPeertalkTransformStream, PeertalkToMessageTransformStream } from "./peertalk.ts"; +import type { Connection, TransportConnectOptions } from "./transport.ts"; + +export interface MessageCodecFactory { + createEncodeTransformStream(): TransformStream; + createDecodeTransformStream(): TransformStream; +} + +export const peertalkCodecFactory: MessageCodecFactory = { + createEncodeTransformStream(): TransformStream { + return new MessageToPeertalkTransformStream(); + }, + + createDecodeTransformStream(): TransformStream { + return new PeertalkToMessageTransformStream() as TransformStream; + }, +}; + +export async function createMessageConnection( + connectRaw: (options: TransportConnectOptions) => Promise, + codecFactory: MessageCodecFactory, + options: TransportConnectOptions, +): Promise> { + const conn = await connectRaw(options); + const encoder = codecFactory.createEncodeTransformStream(); + + const pipeAbortController = new AbortController(); + + void encoder.readable.pipeTo(conn.writable, { preventClose: true, signal: pipeAbortController.signal }).catch( + (err) => { + if (err?.name !== "AbortError") { + void conn[Symbol.asyncDispose](); + } + }, + ); + + const readable = conn.readable.pipeThrough(codecFactory.createDecodeTransformStream()); + + return { + readable, + writable: encoder.writable, + async [Symbol.asyncDispose]() { + pipeAbortController.abort(); + await conn[Symbol.asyncDispose](); + }, + }; +} + +export async function connectWithPeertalk( + connectRaw: (options: TransportConnectOptions) => Promise, + options: TransportConnectOptions, +): Promise> { + await takeoverDebugRouterLock(); + return createMessageConnection(connectRaw, peertalkCodecFactory, options); +} diff --git a/mcp-servers/devtool-connector/src/transport/daemon.ts b/mcp-servers/devtool-connector/src/transport/daemon.ts new file mode 100644 index 0000000..661a6ec --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/daemon.ts @@ -0,0 +1,343 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { randomInt } from "node:crypto"; +import { TransformStream, WritableStream } from "node:stream/web"; +import type { ReadableStream } from "node:stream/web"; +import { createDebug } from "obug"; +import { DaemonManager, DEFAULT_DAEMON_PORT } from "../daemon/manager.ts"; +import type { ClientListEntry, ControlResponse } from "../daemon/protocol.ts"; +import type { + App, + Client, + Connection, + Device, + OpenAppOptions, + Transport, + TransportConnectOptions, +} from "./transport.ts"; + +const debug = createDebug("devtool-mcp-server:daemon:transport"); + +/** + * A Transport implementation that communicates with devices through the + * daemon process over WebSocket. + * + * The daemon holds the real transports (Android, iOS, etc.) and maintains + * persistent connections to device ports. DaemonTransport simply relays + * messages through the daemon's WebSocket API using the same + * Initialize/Register/Customized protocol as HDT. + */ +export class DaemonTransport implements Transport { + #port: number; + + constructor(port: number = DEFAULT_DAEMON_PORT) { + this.#port = port; + } + + async close(): Promise { + // DaemonTransport is stateless — nothing to close. + // The daemon itself manages its own lifecycle. + } + + async listDevices(): Promise { + const result = await this.#controlRequest("listDevices"); + return result; + } + + async listAvailableApps(deviceId: string): Promise { + const result = await this.#controlRequest("listAvailableApps", { deviceId }); + return result; + } + + async openApp(deviceId: string, packageName: string, options?: OpenAppOptions): Promise { + await this.#controlRequest("openApp", { deviceId, packageName, withDataCleared: options?.withDataCleared }); + } + + async listClients(): Promise { + const entries = await this.#controlRequest("listClients"); + debug("received ClientList with %d entries", entries.length); + return entries.map(({ id, info }) => ({ id, info })); + } + + async connect( + options: TransportConnectOptions, + ): Promise> { + const { deviceId, port, signal } = options; + debug("connect to %s:%d via daemon", deviceId, port); + + await DaemonManager.ensureRunning(this.#port); + const conn = await this.#createWebSocketConnection(signal); + + try { + // Subscribe this WS session to the target device:port on the daemon + await this.#controlRequestOnConn(conn, "subscribe", { deviceId, port }); + } catch (error) { + await conn[Symbol.asyncDispose](); + throw new Error( + `Failed to subscribe to ${deviceId}:${port}`, + { cause: error }, + ); + } + + const writable = new WritableStream({ + async write(chunk) { + const message = routeDaemonMessage(chunk, conn.assignedId, port); + await writeMessage(conn.writable, stringifyMessage(message), signal); + }, + async abort() { + await conn[Symbol.asyncDispose](); + }, + }); + + // Set up output pipeline: WS → JSON parse → TOutput + const outputReadable = conn.readable + .pipeThrough(new JSONStringToObjectStream()) as ReadableStream; + + return { + readable: outputReadable, + writable, + async [Symbol.asyncDispose]() { + await conn[Symbol.asyncDispose](); + }, + }; + } + + // ---- Private helpers ---- + + async #controlRequest( + method: string, + params?: Record, + ): Promise { + await DaemonManager.ensureRunning(this.#port); + const conn = await this.#createWebSocketConnection(); + try { + return await this.#controlRequestOnConn(conn, method, params); + } finally { + await conn[Symbol.asyncDispose](); + } + } + + /** + * Send a Control request on an already-open WS connection and wait for + * the matching ControlResponse. + */ + async #controlRequestOnConn( + conn: { readable: ReadableStream; writable: WritableStream }, + method: string, + params?: Record, + ): Promise { + const id = randomInt(10_000, 50_000); + + const signal = AbortSignal.timeout(10_000); + + await this.#writeMessage( + conn.writable, + JSON.stringify({ + event: "Control", + data: { id, method, params }, + }), + signal, + ); + + for await (const value of this.#readMessages(conn.readable, signal)) { + const msg = value as { event: string; data: unknown }; + + if (msg.event === "ControlResponse") { + const resp = msg.data as ControlResponse["data"]; + if (resp.id === id) { + if (resp.error) { + throw new Error(resp.error); + } + return resp.result as T; + } + } + } + + throw new Error(`No response for control request: ${method}`); + } + + async #createWebSocketConnection(signal?: AbortSignal): Promise<{ + assignedId: number; + readable: ReadableStream; + writable: WritableStream; + [Symbol.asyncDispose](): Promise; + }> { + const url = await DaemonManager.ensureRunning(this.#port); + + signal?.throwIfAborted(); + + const { wsStreams } = await import("./ws-stream.ts"); + const wss = wsStreams.create(url); + + const abortHandler = () => { + wss.close(); + }; + signal?.addEventListener("abort", abortHandler, { once: true }); + + wss.closed.catch(() => { + debug("WebSocket to daemon closed"); + }); + + try { + const { readable, writable } = await this.#withAbortSignal(wss.opened, signal); + + // Read Initialize message + const reader = readable.getReader(); + const { value, done } = await reader.read(); + reader.releaseLock(); + + if (done) { + throw new Error("WebSocket closed before initialization."); + } + + const initMsg = JSON.parse(value as string) as { event: "Initialize"; data: number }; + if (initMsg.event !== "Initialize") { + throw new Error(`Expected Initialize, got ${initMsg.event}`); + } + + const assignedId = initMsg.data; + + // Send Register + await this.#writeMessage( + writable, + JSON.stringify({ + event: "Register", + data: { id: assignedId, type: "Driver" }, + }), + signal, + ); + + return { + assignedId, + readable, + writable, + async [Symbol.asyncDispose]() { + signal?.removeEventListener("abort", abortHandler); + wss.close(); + await wss.closed.catch(() => {}); + }, + }; + } catch (err) { + signal?.removeEventListener("abort", abortHandler); + try { + wss.close(); + } catch { /* ignore */ } + try { + await wss.closed; + } catch { /* ignore */ } + throw err; + } + } + + async *#readMessages(readable: ReadableStream, signal: AbortSignal): AsyncGenerator { + const reader = readable.getReader(); + const abortHandler = () => { + void reader.cancel(signal.reason); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + + try { + while (!signal.aborted) { + const { value, done } = await reader.read(); + if (done) break; + try { + yield JSON.parse(value as string); + } catch { + // skip unparseable messages + } + } + } finally { + signal.removeEventListener("abort", abortHandler); + reader.releaseLock(); + } + } + + async #writeMessage(writable: WritableStream, chunk: string, signal?: AbortSignal) { + await writeMessage(writable, chunk, signal); + } + + async #withAbortSignal(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + + return new Promise((resolve, reject) => { + const abortHandler = () => { + reject(signal.reason); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", abortHandler); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", abortHandler); + reject(error); + }, + ); + }); + } +} + +function routeDaemonMessage(chunk: T, sender: number, port: number): unknown { + if (isRecord(chunk) && chunk["event"] === "Customized") { + const data = isRecord(chunk["data"]) ? chunk["data"] : {}; + return { + ...chunk, + data: { + ...data, + sender, + }, + to: port, + }; + } + + return chunk; +} + +function stringifyMessage(message: unknown): string { + try { + return JSON.stringify(message); + } catch (err) { + throw new Error(`Failed to stringify object: ${err instanceof Error ? err.message : String(err)}`, { + cause: err, + }); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +async function writeMessage(writable: WritableStream, chunk: string, signal?: AbortSignal) { + signal?.throwIfAborted(); + const writer = writable.getWriter(); + const abortHandler = () => { + void writer.abort(signal?.reason); + }; + + signal?.addEventListener("abort", abortHandler, { once: true }); + + try { + await writer.write(chunk); + } finally { + signal?.removeEventListener("abort", abortHandler); + writer.releaseLock(); + } +} + +class JSONStringToObjectStream extends TransformStream { + constructor() { + super({ + transform(chunk, controller) { + try { + controller.enqueue(JSON.parse(chunk)); + } catch (err) { + controller.error(new Error(`Failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`)); + } + }, + }); + } +} diff --git a/mcp-servers/devtool-connector/src/transport/desktop.ts b/mcp-servers/devtool-connector/src/transport/desktop.ts new file mode 100644 index 0000000..2248e9a --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/desktop.ts @@ -0,0 +1,81 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import net from "node:net"; +import { Duplex } from "node:stream"; +import { createDebug } from "obug"; +import { connectWithPeertalk } from "./base.ts"; +import type { App, Connection, Device, Transport, TransportConnectOptions } from "./transport.ts"; + +const debug = createDebug("devtool-mcp-server:connector:desktop"); + +export class DesktopTransport implements Transport { + async connect( + options: TransportConnectOptions, + ): Promise> { + return connectWithPeertalk(opts => this.#connectRaw(opts), options); + } + + async close(): Promise { + debug("Desktop transport closed"); + } + + async listDevices(): Promise { + return [{ id: "localhost", os: "Desktop" }]; + } + + async listAvailableApps(deviceId: string): Promise { + void deviceId; + return []; + } + + async openApp(deviceId: string, packageName: string): Promise { + void deviceId; + void packageName; + throw new Error("openApp is not supported on DesktopTransport"); + } + + async #connectRaw({ + deviceId, + port, + signal, + }: TransportConnectOptions): Promise { + if (deviceId !== "localhost") { + throw new Error( + `DesktopTransport only supports 'localhost' deviceId, got: ${deviceId}`, + ); + } + + debug(`connect: connecting to 127.0.0.1:${port}`); + + const socket = net.createConnection({ host: "127.0.0.1", port, signal }); + + try { + if (!socket.connecting) { + // already connected or failed immediately + } else { + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + } + + debug(`connect: connected to 127.0.0.1:${port}`); + + const { readable, writable } = Duplex.toWeb(socket); + return { + readable, + writable, + async [Symbol.asyncDispose]() { + debug(`connect: closing connection to 127.0.0.1:${port}`); + socket.destroy(); + }, + }; + } catch (err) { + debug(`connect: error connecting to 127.0.0.1:${port} %O`, err); + socket.destroy(); + throw err; + } + } +} diff --git a/mcp-servers/devtool-connector/src/transport/index.ts b/mcp-servers/devtool-connector/src/transport/index.ts new file mode 100644 index 0000000..b24fe56 --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/index.ts @@ -0,0 +1,13 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export * from "./android.ts"; +export * from "./base.ts"; +export * from "./daemon.ts"; +export * from "./desktop.ts"; +export * from "./ios.ts"; +export * from "./peertalk.ts"; +export * from "./transport.ts"; +export * from "./usbmux.ts"; +export * from "./ws-stream.ts"; diff --git a/mcp-servers/devtool-connector/src/transport/ios.ts b/mcp-servers/devtool-connector/src/transport/ios.ts new file mode 100644 index 0000000..7033fe2 --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/ios.ts @@ -0,0 +1,80 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { NetConnectOpts } from "node:net"; +import { createDebug } from "obug"; +import { connectWithPeertalk } from "./base.ts"; +import type { App, Connection, Device, OpenAppOptions, Transport, TransportConnectOptions } from "./transport.ts"; +import { Usbmux } from "./usbmux.ts"; + +const debug = createDebug("devtool-mcp-server:connector:ios"); + +export class iOSTransport implements Transport { + #client: Usbmux; + + constructor(options?: NetConnectOpts) { + this.#client = new Usbmux(options); + } + + async connect( + options: TransportConnectOptions, + ): Promise> { + return connectWithPeertalk(opts => this.#connectRaw(opts), options); + } + + async close(): Promise { + debug("iOS transport closed"); + } + + async #connectRaw( + { deviceId, port, signal }: TransportConnectOptions, + ): Promise { + debug(`connect: create connection to deviceId: ${deviceId}, port: ${port}`); + + const id = await this.#resolveUsbmuxDeviceId(deviceId, signal); + const conn = await this.#client.connect(id, port, signal); + + return { + readable: conn.readable, + writable: conn.writable, + async [Symbol.asyncDispose]() { + debug(`connect: close connection to deviceId: ${deviceId}, port: ${port}`); + conn.dispose(); + }, + }; + } + + async #resolveUsbmuxDeviceId(deviceId: string, signal?: AbortSignal): Promise { + const numericDeviceId = Number(deviceId); + if (Number.isInteger(numericDeviceId)) { + return numericDeviceId; + } + + const devices = await this.#client.listDevices(signal); + const device = devices.find(({ Properties }) => Properties.SerialNumber === deviceId); + if (!device) { + throw new Error(`iOS device with id: ${deviceId} not found`); + } + + return device.DeviceID; + } + + async listDevices(): Promise { + const devices = await this.#client.listDevices(AbortSignal.timeout(1_000)); + debug("listDevices: devices %o", devices); + return devices.map(({ Properties }) => ({ + os: "iOS", + id: Properties.SerialNumber, + })); + } + + async listAvailableApps(): Promise { + throw new Error("Not implemented"); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async openApp(_: string, __?: string, ___?: OpenAppOptions): Promise { + throw new Error("Not implemented"); + } +} diff --git a/mcp-servers/devtool-connector/src/transport/peertalk.ts b/mcp-servers/devtool-connector/src/transport/peertalk.ts new file mode 100644 index 0000000..9dde0a7 --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/peertalk.ts @@ -0,0 +1,66 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { TransformStream } from "node:stream/web"; +import type { Response } from "../types.ts"; + +export class PeertalkToMessageTransformStream extends TransformStream { + constructor() { + let buffer = new Uint8Array(0); + const decoder = new TextDecoder(); + + super({ + transform: (chunk, c) => { + const n = new Uint8Array(buffer.length + chunk.length); + n.set(buffer); + n.set(chunk, buffer.length); + buffer = n; + + while (buffer.length >= 20) { + const v = new DataView(buffer.buffer, buffer.byteOffset); + const len = v.getUint32(16); + + if (buffer.length < 20 + len) break; + + try { + c.enqueue(JSON.parse(decoder.decode(buffer.subarray(20, 20 + len)))); + } catch (e) { + c.error(e); + } + + buffer = buffer.subarray(20 + len); + } + }, + }); + } +} + +export class MessageToPeertalkTransformStream extends TransformStream< + TMessage, + Uint8Array +> { + constructor() { + const encoder = new TextEncoder(); + + super({ + transform(chunk, controller) { + const body = encoder.encode(JSON.stringify(chunk)); + const len = body.length; + + const data = new Uint8Array(20 + len); + const view = new DataView(data.buffer); + + view.setUint32(0, 1); + view.setUint32(4, 101); + view.setUint32(8, 0); + view.setUint32(12, len + 4); + view.setUint32(16, len); + + data.set(body, 20); + + controller.enqueue(data); + }, + }); + } +} diff --git a/mcp-servers/devtool-connector/src/transport/transport.ts b/mcp-servers/devtool-connector/src/transport/transport.ts new file mode 100644 index 0000000..20365e0 --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/transport.ts @@ -0,0 +1,53 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { ReadableStream, WritableStream } from "node:stream/web"; +import type { AppInfo } from "../types.ts"; + +export interface TransportConnectOptions { + deviceId: string; + port: number; + signal?: AbortSignal | undefined; +} + +export interface OpenAppOptions { + signal?: AbortSignal; + withDataCleared?: boolean | undefined; +} + +export interface Transport { + close(): Promise; + + listDevices(): Promise; + + listClients?(): Promise; + + listAvailableApps(deviceId: string): Promise; + + openApp(deviceId: string, packageName: string, options?: OpenAppOptions): Promise; + + connect(options: TransportConnectOptions): Promise>; + + readonly persistent?: boolean; +} + +export interface Device { + id: string; + os: "iOS" | "Android" | "Desktop"; +} + +export interface Client { + id: string; + info: AppInfo; +} + +export interface App { + packageName: string; + name: string; +} + +export interface Connection extends AsyncDisposable { + readable: ReadableStream; + writable: WritableStream; +} diff --git a/mcp-servers/devtool-connector/src/transport/usbmux.ts b/mcp-servers/devtool-connector/src/transport/usbmux.ts new file mode 100644 index 0000000..c209540 --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/usbmux.ts @@ -0,0 +1,183 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { on, once } from "node:events"; +import * as net from "node:net"; +import { Duplex } from "node:stream"; +import { ReadableStream, WritableStream } from "node:stream/web"; +import { build, parse, type PlistValue } from "plist"; + +const HEADER_SIZE = 16; +const USBMUXD_VERSION = 1; +const USBMUXD_PACKET_TYPE_PLIST = 8; +const TAG = 1; + +export interface UsbmuxdDeviceProperties { + ConnectionSpeed: number; + ConnectionType: string; + DeviceID: number; + LocationID: number; + ProductID: number; + SerialNumber: string; + USBSerialNumber: string; +} + +export interface UsbmuxdDeviceRecord { + DeviceID: number; + MessageType: string; + Properties: UsbmuxdDeviceProperties; +} + +export type UsbmuxdResponse = UsbmuxdListDevicesResponse | UsbmuxdResultResponse; + +export interface UsbmuxdListDevicesResponse { + DeviceList: UsbmuxdDeviceRecord[]; +} + +export interface UsbmuxdResultResponse { + MessageType: "Result"; + Number: number; +} + +export interface UsbmuxdConnectRequest { + MessageType: "Connect"; + ClientVersionString: string; + ProgName: string; + DeviceID: number; + PortNumber: number; +} + +export interface UsbmuxdListDevicesRequest { + MessageType: "ListDevices"; + ClientVersionString: string; + ProgName: string; +} + +export type UsbmuxdRequest = UsbmuxdConnectRequest | UsbmuxdListDevicesRequest; + +export interface UsbmuxConnection { + readable: ReadableStream; + writable: WritableStream; + dispose: () => void; +} + +export class Usbmux { + private connectOptions: net.NetConnectOpts; + + constructor(connectOptions?: net.NetConnectOpts | string) { + if (typeof connectOptions === "string") { + this.connectOptions = { path: connectOptions }; + } else if (connectOptions) { + this.connectOptions = connectOptions; + } else { + this.connectOptions = { path: "/var/run/usbmuxd" }; + } + } + + public async listDevices(signal?: AbortSignal): Promise { + const { socket, response } = await this.#sendAndReceive< + UsbmuxdListDevicesRequest, + UsbmuxdListDevicesResponse + >({ + MessageType: "ListDevices", + ClientVersionString: "usbmux-driver", + ProgName: "usbmux-driver", + }, signal); + + socket.destroy(); + + return response.DeviceList; + } + + public async connect( + deviceId: number, + port: number, + signal?: AbortSignal, + ): Promise { + // Port must be in network byte order (big-endian) + const networkPort = ((port >> 8) & 0xFF) | ((port << 8) & 0xFF00); + + const { socket, response, tail } = await this.#sendAndReceive< + UsbmuxdConnectRequest, + UsbmuxdResultResponse + >({ + MessageType: "Connect", + ClientVersionString: "usbmux-driver", + ProgName: "usbmux-driver", + DeviceID: Number(deviceId), + PortNumber: networkPort, + }, signal); + + if (response.MessageType === "Result" && response.Number === 0) { + if (tail.length > 0) { + socket.unshift(tail); + } + + const { readable, writable } = Duplex.toWeb(socket); + return { + readable, + writable, + dispose: () => socket.destroy(), + }; + } + + socket.destroy(); + throw new Error(`Invalid response for Connect: ${JSON.stringify(response)}`); + } + + async #sendAndReceive( + payload: T, + signal?: AbortSignal, + ): Promise<{ socket: net.Socket; response: R; tail: Buffer }> { + const socket = net.createConnection(this.connectOptions); + + if (signal) { + const abortHandler = () => socket.destroy(); + signal.addEventListener("abort", abortHandler, { once: true }); + socket.once("close", () => signal.removeEventListener("abort", abortHandler)); + } + + try { + await once(socket, "connect", { signal }); + + socket.write(encodeRequest()); + + let buffer = Buffer.alloc(0); + + // We still use Node.js streams for the handshake part because `Duplex.toWeb` + // consumes the stream, making it hard to "peek" or "unshift" without extra overhead. + // Once handshake is done, we convert to Web Streams in `connect`. + for await (const [chunk] of on(socket, "data", { signal })) { + buffer = Buffer.concat([buffer, chunk]); + if (buffer.length < HEADER_SIZE) continue; + + const length = buffer.readUInt32LE(0); + if (buffer.length < length) continue; + + const responseBuffer = buffer.subarray(HEADER_SIZE, length); + const tail = buffer.subarray(length); + + const response = parse(responseBuffer.toString("utf8")) as R; + return { socket, response, tail }; + } + + throw new Error("Connection closed before response received"); + } catch (error) { + socket.destroy(); + throw error; + } + + function encodeRequest(): Buffer { + const xml = build(payload as PlistValue); + const body = Buffer.from(xml, "utf8"); + const length = HEADER_SIZE + body.length; + const header = Buffer.alloc(HEADER_SIZE); + header.writeUInt32LE(length, 0); + header.writeUInt32LE(USBMUXD_VERSION, 4); + header.writeUInt32LE(USBMUXD_PACKET_TYPE_PLIST, 8); + header.writeUInt32LE(TAG, 12); + return Buffer.concat([header, body]); + } + } +} diff --git a/mcp-servers/devtool-connector/src/transport/ws-stream.ts b/mcp-servers/devtool-connector/src/transport/ws-stream.ts new file mode 100644 index 0000000..70cb0be --- /dev/null +++ b/mcp-servers/devtool-connector/src/transport/ws-stream.ts @@ -0,0 +1,134 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { ReadableStream, WritableStream } from "node:stream/web"; +import { WebSocket } from "ws"; + +/** + * A lightweight wrapper around the `ws` WebSocket that exposes a + * `WebSocketStream`-compatible interface (opened / closed / close). + * + * This allows `DaemonTransport` to work on Node 18+ + * without depending on `undici`'s `WebSocketStream` (which requires + * Node 22+). + */ +export class WsWebSocketStream { + #ws: WebSocket; + + opened: Promise<{ readable: ReadableStream; writable: WritableStream }>; + closed: Promise; + + #resolveClosed!: () => void; + #rejectClosed!: (err: unknown) => void; + + constructor(url: string) { + this.#ws = new WebSocket(url); + + this.closed = new Promise((resolve, reject) => { + this.#resolveClosed = resolve; + this.#rejectClosed = reject; + }); + + this.opened = new Promise((resolve, reject) => { + const ws = this.#ws; + + const onError = (err: Error) => { + cleanup(); + reject(err); + this.#rejectClosed(err); + }; + + const onClose = () => { + cleanup(); + reject(new Error("WebSocket closed before opening.")); + this.#resolveClosed(); + }; + + const cleanup = () => { + ws.removeListener("error", onError); + ws.removeListener("close", onClose); + }; + + ws.once("open", () => { + cleanup(); + + // --- readable --- + const readable = new ReadableStream({ + start(controller) { + ws.on("message", (data) => { + controller.enqueue(typeof data === "string" ? data : data.toString()); + }); + + ws.on("close", () => { + try { + controller.close(); + } catch { + // already closed + } + }); + + ws.on("error", (err) => { + try { + controller.error(err); + } catch { + // already errored / closed + } + }); + }, + cancel() { + ws.close(); + }, + }); + + // --- writable --- + const writable = new WritableStream({ + write(chunk) { + return new Promise((res, rej) => { + ws.send(chunk, (err) => { + if (err) rej(err); + else res(); + }); + }); + }, + close() { + ws.close(); + }, + abort() { + ws.close(); + }, + }); + + resolve({ readable, writable }); + + // Wire up closed promise + ws.on("close", () => { + this.#resolveClosed(); + }); + + ws.on("error", (err) => { + this.#rejectClosed(err); + }); + }); + + ws.once("error", onError); + ws.once("close", onClose); + }); + } + + close(): void { + this.#ws.close(); + } +} + +/** + * Factory namespace for creating WebSocketStream instances. + * + * Transport classes use `wsStreams.create(url)` instead of `new WsWebSocketStream(url)` + * to allow tests to swap the implementation without relying on ESM module mocking. + */ +export const wsStreams = { + create(url: string): WsWebSocketStream { + return new WsWebSocketStream(url); + }, +}; diff --git a/mcp-servers/devtool-connector/src/types.ts b/mcp-servers/devtool-connector/src/types.ts new file mode 100644 index 0000000..7bc694f --- /dev/null +++ b/mcp-servers/devtool-connector/src/types.ts @@ -0,0 +1,204 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +type Event = { + event: E; + data: D; +}; + +type CustomizedEvent = Event<"Customized", { + type: TType; + data: TData; +}>; + +export type AppInfo = { + App: string; + AppVersion: string; + /** Android only */ + AppProcessName?: string; + /** iOS only */ + bundleId?: string; + bundleName?: string; + debugRouterId: string; + debugRouterVersion: string; + deviceModel: string; + did?: string; + network: string; + osVersion: string; + sdkVersion: string; + osType?: string; +}; +export type InitializeRequest = Event<"Initialize", number>; +export type InitializeResponse = Event<"Register", { + id: number; + info: AppInfo; +}>; + +export type AppResponse = CustomizedEvent<"App", { + /** JSON string. See {@link AppResponseMessage} for parsed result. */ + message: string; +}>; +export type AppResponseMessage = { + id: number; + /** JSON string */ + result: string; +}; +export type CDPRequestMessage = { method: string; params?: T | undefined }; +export type CDPRequest = CustomizedEvent<"CDP", { + client_id: number; + session_id: number; + message: CDPRequestMessage & { id: number }; +}>; +export type CDPResponse = CustomizedEvent<"CDP", { + /** JSON string. See {@link CDPResponseMessage} for parsed result. */ + message: string; +}>; +export type CDPResponseMessage = { id: number } & ({ result: unknown } | { error: { code: number; message: string } }); + +export type Session = { + session_id: number; + type: "" | "lynx" | "web"; + url: string; + /** + * Headless (embedded-lynx) runtime metadata, present only for sessions + * served by the headless client. Each session is backed by a dedicated + * renderer child process; `pid`/`logFile` let callers inspect or stop it + * (e.g. `kill `). + */ + headless?: { + pid?: number; + logFile: string; + }; +}; +export type ListSessionRequest = CustomizedEvent<"ListSession", Record>; +export type ListSessionResponse = CustomizedEvent<"SessionList", Session[]>; + +/** + * Readiness probe for the headless (embedded-lynx) runtime. The headless + * transport downloads its binary lazily on first use; this lets the caller + * poll for readiness and drive the (potentially long) download with its own + * timeout instead of a single request that could be cut off. + */ +export type HeadlessPrepareState = { + status: "ready" | "preparing" | "error"; + message?: string; +}; +export type HeadlessPrepareRequest = CustomizedEvent<"HeadlessPrepare", Record>; +export type HeadlessPrepareResponse = CustomizedEvent<"HeadlessPrepare", HeadlessPrepareState>; + +// See: https://github.com/lynx-family/lynx/blob/f36190e701964032d92e70e9515538497460ea31/platform/android/lynx_android/src/main/java/com/lynx/devtoolwrapper/DevToolSettings.java#L31-L44 +export type GlobalKeys = + | "enable_devtool" + | "enable_logbox" + | "enable_debug_mode" + | "enable_dom_tree" + | "enable_quickjs_debug" + | "enable_quickjs_cache" + | "enable_v8" + | "enable_cdp_domain_dom" + | "enable_cdp_domain_css" + | "enable_cdp_domain_page" + | "enable_long_press_menu" + | "enable_highlight_touch" + | "enable_preview_screen_shot" + | "enable_pixel_copy" + | "enable_fsp_screenshot"; +export type GetGlobalSwitchRequest = CustomizedEvent<"GetGlobalSwitch", { + client_id: number; + session_id: number; + message: { global_key: GlobalKeys }; +}>; +export type GetGlobalSwitchResponse = CustomizedEvent<"GetGlobalSwitch", { + client_id: number; + session_id: number; + message: string | boolean | { global_value: string | boolean }; +}>; +export type SetGlobalSwitchRequest = CustomizedEvent<"SetGlobalSwitch", { + client_id: number; + session_id: number; + message: { global_key: GlobalKeys; global_value: boolean }; +}>; +export type SetGlobalSwitchResponse = CustomizedEvent<"SetGlobalSwitch", { + client_id: number; + session_id: number; + /** JSON string */ + message: string; +}>; + +export type XdbJsbRequest = CustomizedEvent<"xdb_jsb", { + client_id: number; + session_id: number; + message: { type: string }; +}>; + +export type XdbJsbResponse = CustomizedEvent<"xdb_jsb", { + client_id: number; + session_id: number; + /** JSON string */ + message: string; +}>; + +export type XdbGlobalPropsRequest = CustomizedEvent<"xdb_globalprops", { + client_id: number; + session_id: number; + message: { type: string; timestamp: string }; +}>; + +export type XdbGlobalPropsResponse = CustomizedEvent<"xdb_globalprops", { + client_id: number; + session_id: number; + /** JSON string */ + message: string; +}>; + +export type CustomizedResponseMap = { + App: AppResponse; + CDP: CDPResponse; + xdb_jsb: XdbJsbResponse; + xdb_globalprops: XdbGlobalPropsResponse; +}; +export type CustomizedResponseMessageMap = { + App: AppResponseMessage; + CDP: CDPResponseMessage; + xdb_jsb: unknown; + xdb_globalprops: unknown; +}; + +export type Response = + | InitializeResponse + | ListSessionResponse + | AppResponse + | CDPResponse + | GetGlobalSwitchResponse + | SetGlobalSwitchResponse + | XdbJsbResponse + | XdbGlobalPropsResponse + | HeadlessPrepareResponse; + +export function isInitializeResponse(response: Response): response is InitializeResponse { + return response.event === "Register"; +} + +export function isHeadlessPrepareResponse(response: Response): response is HeadlessPrepareResponse { + return response.event === "Customized" && response.data.type === "HeadlessPrepare"; +} + +export function isListSessionResponse(response: Response): response is ListSessionResponse { + return response.event === "Customized" && response.data.type === "SessionList"; +} + +export function isGetGlobalSwitchResponse(response: Response): response is GetGlobalSwitchResponse { + return response.event === "Customized" && response.data.type === "GetGlobalSwitch"; +} + +export function isSetGlobalSwitchResponse(response: Response): response is SetGlobalSwitchResponse { + return response.event === "Customized" && response.data.type === "SetGlobalSwitch"; +} + +export function isCustomizedResponseWithType( + response: Response, + type: T, +): response is CustomizedResponseMap[T] { + return response.event === "Customized" && response.data.type === type; +} diff --git a/mcp-servers/devtool-connector/test/clientId.test.ts b/mcp-servers/devtool-connector/test/clientId.test.ts new file mode 100644 index 0000000..c4ec590 --- /dev/null +++ b/mcp-servers/devtool-connector/test/clientId.test.ts @@ -0,0 +1,67 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { describe, test } from "node:test"; +import type { TestContext } from "node:test"; +import { ClientId } from "../src/index.ts"; + +describe("ClientId", () => { + describe("serialize", () => { + test("returns deviceId:port format", (t: TestContext) => { + const serialized = ClientId.serialize("device-001", 9000); + + t.assert.equal(serialized, "device-001:9000"); + }); + + test("URL encodes deviceId", (t: TestContext) => { + const serialized = ClientId.serialize("设备 001/测试", 9100); + + t.assert.equal(serialized, "%E8%AE%BE%E5%A4%87%20001%2F%E6%B5%8B%E8%AF%95:9100"); + }); + + test("encodes colon inside deviceId", (t: TestContext) => { + const serialized = ClientId.serialize("foo:bar", 9200); + + t.assert.equal(serialized, "foo%3Abar:9200"); + }); + }); + + describe("deserialize", () => { + test("parses previously serialized value", (t: TestContext) => { + const result = ClientId.deserialize("device-001:9000"); + + t.assert.deepStrictEqual(result, { deviceId: "device-001", port: 9000 }); + }); + + test("uses the last colon when multiple are present", (t: TestContext) => { + const result = ClientId.deserialize("foo:bar:1234"); + + t.assert.deepStrictEqual(result, { deviceId: "foo:bar", port: 1234 }); + }); + + test("returns null when no colon exists", (t: TestContext) => { + const result = ClientId.deserialize("foobar"); + + t.assert.equal(result, null); + }); + + test("returns null when port cannot be parsed", (t: TestContext) => { + const result = ClientId.deserialize("foo:port"); + + t.assert.equal(result, null); + }); + + test("returns null when decodeURIComponent throws", (t: TestContext) => { + const result = ClientId.deserialize("%E0%A4%:1234"); + + t.assert.equal(result, null); + }); + + test("decodes colon inside deviceId", (t: TestContext) => { + const result = ClientId.deserialize("foo%3Abar:9300"); + + t.assert.deepStrictEqual(result, { deviceId: "foo:bar", port: 9300 }); + }); + }); +}); diff --git a/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts b/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts new file mode 100644 index 0000000..b20d685 --- /dev/null +++ b/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts @@ -0,0 +1,100 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert/strict"; +import { ReadableStream, WritableStream } from "node:stream/web"; +import { test } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; +import { ClientId, Connector } from "../src/index.ts"; +import type { App, Client, Connection, Device, Transport } from "../src/transport/transport.ts"; + +test("Connector.sendMessage waits for input write before disposing connection", async () => { + const writeStarted = deferred(); + const writeFinished = deferred(); + const responseQueued = deferred(); + + let writeSettled = false; + let disposedBeforeWriteSettled = false; + + class DelayedWriteTransport implements Transport { + close(): Promise { + return Promise.resolve(); + } + + listDevices(): Promise { + return Promise.resolve([{ id: "device-1", os: "Android" }]); + } + + listAvailableApps(): Promise { + return Promise.resolve([]); + } + + openApp(): Promise { + return Promise.resolve(); + } + + async connect(): Promise> { + const readable = new ReadableStream({ + async start(controller) { + await writeStarted.promise; + controller.enqueue("response" as TOutput); + responseQueued.resolve(); + }, + }); + + const writable = new WritableStream({ + async write() { + writeStarted.resolve(); + await writeFinished.promise; + writeSettled = true; + }, + }); + + return { + readable, + writable, + async [Symbol.asyncDispose]() { + disposedBeforeWriteSettled ||= !writeSettled; + }, + }; + } + + listClients(): Promise { + return Promise.resolve([]); + } + } + + const connector = new Connector([new DelayedWriteTransport()]); + const resultPromise = connector.sendMessage( + ClientId.serialize("device-1", 8901), + "request", + ); + + await responseQueued.promise; + + let settledBeforeWriteFinished = false; + resultPromise.then(() => { + settledBeforeWriteFinished = true; + }, () => { + settledBeforeWriteFinished = true; + }); + await sleep(10); + + assert.equal(settledBeforeWriteFinished, false); + + writeFinished.resolve(); + assert.equal(await resultPromise, "response"); + assert.equal(disposedBeforeWriteSettled, false); +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} diff --git a/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts b/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts new file mode 100644 index 0000000..bea7398 --- /dev/null +++ b/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts @@ -0,0 +1,38 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { describe, test } from "node:test"; +import { DevtoolDaemon } from "../src/daemon/server.ts"; +import { DaemonTransport } from "../src/transport/daemon.ts"; +import type { Connection, Transport, TransportConnectOptions } from "../src/transport/transport.ts"; + +describe("DaemonTransport connection setup timeout", () => { + test("listClients returns when a daemon device port connect never settles", async (t) => { + const hangingTransport: Transport = { + async close() {}, + async listDevices() { + return [{ id: "test-device", os: "Android" as const }]; + }, + async listAvailableApps() { + return []; + }, + async openApp() {}, + async connect(options: TransportConnectOptions): Promise> { + if (options.port === 8901) { + return await new Promise>(() => {}); + } + + throw new Error(`Connection refused on port ${options.port}`); + }, + }; + const daemon = new DevtoolDaemon([hangingTransport]); + const daemonPort = await daemon.start(0); + t.after(() => daemon.close()); + + const transport = new DaemonTransport(daemonPort); + const clients = await transport.listClients(); + + t.assert.deepEqual(clients, []); + }); +}); diff --git a/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts b/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts new file mode 100644 index 0000000..d9a95b6 --- /dev/null +++ b/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts @@ -0,0 +1,265 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/* eslint-disable n/no-unsupported-features/node-builtins */ +import assert from "node:assert/strict"; +import { ReadableStream, TransformStream, WritableStream } from "node:stream/web"; +import { describe, test } from "node:test"; +import { DeviceConnection } from "../src/daemon/device-connection.ts"; +import type { Connection, Transport } from "../src/transport/transport.ts"; + +/** + * Creates a fake transport where `connect()` returns an in-memory + * readable/writable pair. The caller can push messages into the device + * side via the returned `deviceWriter` and read what was sent to the + * device via `deviceMessages`. + */ +function createFakeTransport(): { + transport: Transport; + deviceWriter: WritableStreamDefaultWriter; + deviceMessages: unknown[]; + closeConnection: () => void; +} { + const deviceMessages: unknown[] = []; + + // Device → Connector direction + const { readable: deviceReadable, writable: deviceSideWritable } = new TransformStream(); + const deviceWriter = deviceSideWritable.getWriter(); + + // Connector → Device direction + const closeConnection = (): void => { + void deviceWriter.close().catch(() => {}); + }; + const connectorWritable = new WritableStream({ + write(chunk) { + deviceMessages.push(chunk); + }, + }); + + const transport: Transport = { + async close() {}, + async listDevices() { + return [{ id: "fake-device", os: "Android" }]; + }, + async listAvailableApps() { + return []; + }, + async openApp() {}, + async connect(): Promise> { + return { + readable: deviceReadable as ReadableStream, + writable: connectorWritable as WritableStream, + async [Symbol.asyncDispose]() { + closeConnection(); + }, + }; + }, + }; + + return { transport, deviceWriter, deviceMessages, closeConnection }; +} + +describe("DeviceConnection", () => { + test("connect() establishes the connection and exposes key/deviceId/port", async (t) => { + const { transport, closeConnection } = createFakeTransport(); + t.after(() => closeConnection()); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + await conn.connect(); + t.after(() => conn.dispose()); + + t.assert.equal(conn.key, "fake-device:8901"); + t.assert.equal(conn.deviceId, "fake-device"); + t.assert.equal(conn.port, 8901); + }); + + test("send() forwards messages to the underlying transport", async (t) => { + const { transport, deviceMessages, closeConnection } = createFakeTransport(); + t.after(() => closeConnection()); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + await conn.connect(); + t.after(() => conn.dispose()); + + await conn.send({ event: "Test", data: "hello" }); + await conn.send({ event: "Test", data: "world" }); + + t.assert.equal(deviceMessages.length, 2); + assert.deepStrictEqual(deviceMessages[0], { event: "Test", data: "hello" }); + assert.deepStrictEqual(deviceMessages[1], { event: "Test", data: "world" }); + }); + + test("broadcasts device messages to all subscribers", async (t) => { + const { transport, deviceWriter, closeConnection } = createFakeTransport(); + t.after(() => closeConnection()); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + await conn.connect(); + t.after(() => conn.dispose()); + + const receivedA: unknown[] = []; + const receivedB: unknown[] = []; + + conn.addSubscriber({ id: 1, send: (msg) => receivedA.push(msg), close() {} }); + conn.addSubscriber({ id: 2, send: (msg) => receivedB.push(msg), close() {} }); + + t.assert.equal(conn.subscriberCount, 2); + + // Push a message from the device side + await deviceWriter.write({ event: "Customized", data: { type: "CDP" } }); + + // Give the read loop a tick to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + t.assert.equal(receivedA.length, 1); + t.assert.equal(receivedB.length, 1); + assert.deepStrictEqual(receivedA[0], { event: "Customized", data: { type: "CDP" } }); + assert.deepStrictEqual(receivedB[0], { event: "Customized", data: { type: "CDP" } }); + }); + + test("removeSubscriber stops broadcasting to that subscriber", async (t) => { + const { transport, deviceWriter, closeConnection } = createFakeTransport(); + t.after(() => closeConnection()); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + await conn.connect(); + t.after(() => conn.dispose()); + + const receivedA: unknown[] = []; + const receivedB: unknown[] = []; + + conn.addSubscriber({ id: 1, send: (msg) => receivedA.push(msg), close() {} }); + conn.addSubscriber({ id: 2, send: (msg) => receivedB.push(msg), close() {} }); + + // Remove subscriber A + conn.removeSubscriber(1); + t.assert.equal(conn.subscriberCount, 1); + + await deviceWriter.write({ event: "Test" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + t.assert.equal(receivedA.length, 0); + t.assert.equal(receivedB.length, 1); + }); + + test("captures appInfo from Register response and does not broadcast it", async (t) => { + const { transport, deviceWriter, closeConnection } = createFakeTransport(); + t.after(() => closeConnection()); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + await conn.connect(); + t.after(() => conn.dispose()); + + const received: unknown[] = []; + conn.addSubscriber({ id: 1, send: (msg) => received.push(msg), close() {} }); + + t.assert.equal(conn.appInfo, null); + + // Simulate the device responding to Initialize with Register + await deviceWriter.write({ + event: "Register", + data: { + id: 8901, + info: { + App: "TestApp", + AppVersion: "1.0", + debugRouterId: "1", + debugRouterVersion: "2.0", + deviceModel: "Pixel", + network: "USB", + osVersion: "14", + sdkVersion: "3.0", + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // appInfo should be captured + assert.ok(conn.appInfo !== null); + assert.equal(conn.appInfo?.App, "TestApp"); + + // Register message should NOT be broadcast to subscribers + t.assert.equal(received.length, 0); + + // Subsequent messages SHOULD be broadcast + await deviceWriter.write({ event: "Customized", data: { type: "CDP" } }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + t.assert.equal(received.length, 1); + }); + + test("dispose() cleans up and clears subscribers", async (t) => { + const { transport, closeConnection } = createFakeTransport(); + t.after(() => closeConnection()); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + await conn.connect(); + + conn.addSubscriber({ id: 1, send: () => {}, close() {} }); + t.assert.equal(conn.subscriberCount, 1); + t.assert.equal(conn.isDisposed, false); + + await conn.dispose(); + + t.assert.equal(conn.isDisposed, true); + t.assert.equal(conn.subscriberCount, 0); + }); + + test("send() throws if not connected", async (t) => { + const { transport } = createFakeTransport(); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + // Don't call connect() + + await t.assert.rejects( + () => conn.send({ event: "Test" }), + /not connected/, + ); + }); + + test("dispose() is idempotent", async (t) => { + const { transport, closeConnection } = createFakeTransport(); + t.after(() => closeConnection()); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + await conn.connect(); + + await conn.dispose(); + await conn.dispose(); // Should not throw + + t.assert.equal(conn.isDisposed, true); + }); + + test("closes all subscribers when device disconnects (remote close)", async (t) => { + const { transport, closeConnection } = createFakeTransport(); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + await conn.connect(); + t.after(() => conn.dispose()); + + const closed: number[] = []; + conn.addSubscriber({ id: 1, send: () => {}, close: () => closed.push(1) }); + conn.addSubscriber({ id: 2, send: () => {}, close: () => closed.push(2) }); + + closeConnection(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.deepStrictEqual(closed.sort(), [1, 2]); + }); + + test("does not close subscribers when dispose() is called explicitly", async (t) => { + const { transport, closeConnection } = createFakeTransport(); + t.after(() => closeConnection()); + + const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + await conn.connect(); + + const closed: number[] = []; + conn.addSubscriber({ id: 1, send: () => {}, close: () => closed.push(1) }); + + await conn.dispose(); + + t.assert.equal(closed.length, 0); + }); +}); diff --git a/mcp-servers/devtool-connector/test/daemon-manager.test.ts b/mcp-servers/devtool-connector/test/daemon-manager.test.ts new file mode 100644 index 0000000..94a44a3 --- /dev/null +++ b/mcp-servers/devtool-connector/test/daemon-manager.test.ts @@ -0,0 +1,40 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { resolveDaemonEntryPath } from "../src/daemon/manager.ts"; + +test("resolveDaemonEntryPath resolves the source daemon entry from this package", () => { + assert.equal(resolveDaemonEntryPath(), path.resolve("src/daemon/entry.ts")); +}); + +test("resolveDaemonEntryPath respects package imports in a built package", async (t) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "daemon-manager-")); + t.after(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + await fs.mkdir(path.join(tempDir, "dist", "daemon"), { recursive: true }); + await fs.mkdir(path.join(tempDir, "src", "daemon"), { recursive: true }); + await fs.writeFile( + path.join(tempDir, "package.json"), + JSON.stringify({ + type: "module", + imports: { "#daemon-entry": "./dist/daemon/entry.js" }, + }), + ); + await fs.writeFile(path.join(tempDir, "dist", "daemon", "entry.js"), "export {};\n"); + await fs.writeFile(path.join(tempDir, "src", "daemon", "manager.js"), "export {};\n"); + const expectedEntryPath = await fs.realpath(path.join(tempDir, "dist", "daemon", "entry.js")); + + assert.equal( + resolveDaemonEntryPath(pathToFileURL(path.join(tempDir, "src", "daemon", "manager.js")).href), + expectedEntryPath, + ); +}); diff --git a/mcp-servers/devtool-connector/test/daemon-protocol.test.ts b/mcp-servers/devtool-connector/test/daemon-protocol.test.ts new file mode 100644 index 0000000..5a6ca4d --- /dev/null +++ b/mcp-servers/devtool-connector/test/daemon-protocol.test.ts @@ -0,0 +1,87 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { describe, test } from "node:test"; +import type { TestContext } from "node:test"; +import { + isControlRequest, + isCustomizedMessage, + isListClientsRequest, + isPingEvent, + isRegisterEvent, +} from "../src/daemon/protocol.ts"; + +describe("daemon protocol type guards", () => { + describe("isRegisterEvent", () => { + test("returns true for a valid Register message", (t: TestContext) => { + t.assert.ok(isRegisterEvent({ event: "Register", data: { id: 1, type: "Driver" } })); + }); + + test("returns false for Initialize", (t: TestContext) => { + t.assert.ok(!isRegisterEvent({ event: "Initialize", data: 1 })); + }); + + test("returns false for null", (t: TestContext) => { + t.assert.ok(!isRegisterEvent(null)); + }); + + test("returns false for a string", (t: TestContext) => { + t.assert.ok(!isRegisterEvent("Register")); + }); + }); + + describe("isCustomizedMessage", () => { + test("returns true for a Customized message", (t: TestContext) => { + t.assert.ok(isCustomizedMessage({ + event: "Customized", + data: { type: "CDP", data: { client_id: 1 } }, + })); + }); + + test("returns false for a non-Customized message", (t: TestContext) => { + t.assert.ok(!isCustomizedMessage({ event: "Register", data: {} })); + }); + + test("returns false for undefined", (t: TestContext) => { + t.assert.ok(!isCustomizedMessage(undefined)); + }); + }); + + describe("isControlRequest", () => { + test("returns true for a Control message", (t: TestContext) => { + t.assert.ok(isControlRequest({ + event: "Control", + data: { id: 1, method: "listDevices" }, + })); + }); + + test("returns false for Customized", (t: TestContext) => { + t.assert.ok(!isControlRequest({ event: "Customized", data: {} })); + }); + }); + + describe("isListClientsRequest", () => { + test("returns true for ListClients", (t: TestContext) => { + t.assert.ok(isListClientsRequest({ event: "ListClients" })); + }); + + test("returns false for Ping", (t: TestContext) => { + t.assert.ok(!isListClientsRequest({ event: "Ping" })); + }); + }); + + describe("isPingEvent", () => { + test("returns true for Ping", (t: TestContext) => { + t.assert.ok(isPingEvent({ event: "Ping" })); + }); + + test("returns false for Pong", (t: TestContext) => { + t.assert.ok(!isPingEvent({ event: "Pong" })); + }); + + test("returns false for an empty object", (t: TestContext) => { + t.assert.ok(!isPingEvent({})); + }); + }); +}); diff --git a/mcp-servers/devtool-connector/test/daemon-server.test.ts b/mcp-servers/devtool-connector/test/daemon-server.test.ts new file mode 100644 index 0000000..d568806 --- /dev/null +++ b/mcp-servers/devtool-connector/test/daemon-server.test.ts @@ -0,0 +1,763 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert/strict"; +import http from "node:http"; +import { createRequire } from "node:module"; +import { TransformStream } from "node:stream/web"; +import { describe, test } from "node:test"; +import type { TestContext } from "node:test"; +import { WebSocket } from "ws"; +import { DevtoolDaemon } from "../src/daemon/server.ts"; +import type { Connection, Transport, TransportConnectOptions } from "../src/transport/transport.ts"; + +const packageJson = createRequire(import.meta.url)("../package.json") as { version: string }; + +// --------------------------------------------------------------------------- +// Fake transport +// --------------------------------------------------------------------------- + +/** + * A fake transport that simulates a device running on a specific set of ports. + * When `connect()` is called, it: + * 1. Returns an in-memory readable/writable pair + * 2. Waits for an Initialize message + * 3. Responds with a Register message (completing the handshake) + * 4. Echoes all subsequent messages back to the caller + */ +function createFakeTransport(opts: { + deviceId: string; + activePorts: number[]; + silentPorts?: number[]; + registerDelayMs?: number; + registerDelayMsByPort?: Record; + connectDelayMs?: number; + onMessage?: (message: unknown, options: TransportConnectOptions) => void; +}): Transport { + const { + deviceId, + activePorts, + silentPorts = [], + registerDelayMs = 0, + registerDelayMsByPort = {}, + connectDelayMs = 0, + onMessage, + } = opts; + + return { + async close() {}, + async listDevices() { + return [{ id: deviceId, os: "Android" as const }]; + }, + async listAvailableApps() { + return [{ packageName: "com.test.app", name: "Test App" }]; + }, + async openApp() {}, + async connect( + options: TransportConnectOptions, + ): Promise> { + if (connectDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, connectDelayMs)); + } + + if (!activePorts.includes(options.port)) { + throw new Error(`Connection refused on port ${options.port}`); + } + + // In-memory pipe: what the connector writes → device reads → device writes → connector reads + const { readable: toDevice, writable: toDeviceWritable } = new TransformStream(); + const { readable: fromDevice, writable: fromDeviceWritable } = new TransformStream(); + const fromDeviceWriter = fromDeviceWritable.getWriter(); + + // Process incoming messages from the connector + void (async () => { + try { + for await (const msg of toDevice) { + onMessage?.(msg, options); + const parsed = msg as Record; + if (parsed["event"] === "Initialize") { + if (silentPorts.includes(options.port)) { + continue; + } + + const delayMs = registerDelayMsByPort[options.port] ?? registerDelayMs; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + // Respond with Register + await fromDeviceWriter.write({ + event: "Register", + data: { + id: options.port, + info: { + App: "FakeApp", + AppVersion: "1.0", + AppProcessName: "com.test.app", + debugRouterId: "1", + debugRouterVersion: "1.0", + deviceModel: "FakeDevice", + network: "USB", + osVersion: "14", + sdkVersion: "1.0", + }, + }, + } as TOutput); + } else { + // Echo back any other message + await fromDeviceWriter.write(msg as unknown as TOutput); + } + } + } catch { + // stream closed + } finally { + try { + await fromDeviceWriter.close(); + } catch { /* ignore */ } + } + })(); + + return { + readable: fromDevice, + writable: toDeviceWritable, + async [Symbol.asyncDispose]() { + try { + await fromDeviceWriter.close(); + } catch { /* ignore */ } + }, + }; + }, + }; +} + +function createCountingFakeTransport(opts: { + deviceId: string; + activePorts: number[]; + registerDelayMs?: number; + connectDelayMs?: number; +}): { + transport: Transport; + getConnectCount: (port?: number) => number; +} { + const connectCounts = new Map(); + const baseTransport = createFakeTransport(opts); + + return { + transport: { + ...baseTransport, + async connect(options: TransportConnectOptions): Promise> { + connectCounts.set(options.port, (connectCounts.get(options.port) ?? 0) + 1); + return baseTransport.connect(options); + }, + }, + getConnectCount: (port?: number) => + port === undefined + ? Array.from(connectCounts.values()).reduce((sum, count) => sum + count, 0) + : (connectCounts.get(port) ?? 0), + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TEST_PORT = 29783; // Avoid collisions with real daemons + +/** + * Creates a WS connection that buffers all incoming messages from the start, + * avoiding race conditions between `open` and early server messages. + */ +function connectWs(port: number): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/devtool/connector`); + const inbox: unknown[] = []; + // Start collecting messages immediately, before "open" fires + ws.on("message", (raw) => { + inbox.push(JSON.parse(String(raw))); + }); + ws.on("open", () => resolve(Object.assign(ws, { inbox }))); + ws.on("error", reject); + }); +} + +async function readMessage(ws: WebSocket & { inbox: unknown[] }): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (ws.inbox.length > 0) { + return ws.inbox.shift()!; + } + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error("timeout waiting for WS message"); +} + +async function readMessageWithin( + ws: WebSocket & { inbox: unknown[] }, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (ws.inbox.length > 0) { + return ws.inbox.shift()!; + } + await new Promise((r) => setTimeout(r, 10)); + } + return "timeout"; +} + +async function assertNoMessage(ws: WebSocket & { inbox: unknown[] }, timeoutMs = 100): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (ws.inbox.length > 0) { + assert.fail(`expected no WS message, got ${JSON.stringify(ws.inbox.shift())}`); + } + await new Promise((r) => setTimeout(r, 10)); + } +} + +function sendAndRead(ws: WebSocket & { inbox: unknown[] }, msg: unknown): Promise { + ws.send(JSON.stringify(msg)); + return readMessage(ws); +} + +async function requestClientList( + ws: WebSocket & { inbox: unknown[] }, + id: number, +): Promise> { + ws.send(JSON.stringify({ + event: "Control", + data: { id, method: "listClients" }, + })); + + const response = await readMessage(ws) as { + event: string; + data: { + id: number; + result?: Array<{ id: string; info: { App: string } }>; + error?: string; + }; + }; + assert.equal(response.event, "ControlResponse"); + assert.equal(response.data.id, id); + assert.equal(response.data.error, undefined); + return response.data.result ?? []; +} + +function requestJson(port: number, path: string, method: string = "GET"): Promise<{ + body: T; + headers: http.IncomingHttpHeaders; + statusCode: number; +}> { + return new Promise((resolve, reject) => { + const request = http.request({ host: "127.0.0.1", method, path, port }, (response) => { + let rawBody = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + rawBody += chunk; + }); + response.on("end", () => { + try { + resolve({ + body: JSON.parse(rawBody) as T, + headers: response.headers, + statusCode: response.statusCode ?? 0, + }); + } catch (err) { + reject(err); + } + }); + }); + + request.on("error", reject); + request.end(); + }); +} + +async function subscribeToPort( + ws: WebSocket & { inbox: unknown[] }, + params: { id: number; deviceId: string; port: number }, +): Promise { + ws.send(JSON.stringify({ + event: "Control", + data: { id: params.id, method: "subscribe", params: { deviceId: params.deviceId, port: params.port } }, + })); + + const response = await readMessage(ws) as { event: string; data: { id: number; error?: string } }; + assert.equal(response.event, "ControlResponse"); + assert.equal(response.data.id, params.id); + assert.equal(response.data.error, undefined); +} + +/** + * Performs the Initialize/Register handshake and returns the assigned client ID. + */ +async function performHandshake(ws: WebSocket & { inbox: unknown[] }): Promise { + const init = await readMessage(ws) as { event: string; data: number }; + assert.equal(init.event, "Initialize"); + const id = init.data; + ws.send(JSON.stringify({ event: "Register", data: { id, type: "Driver" } })); + return id; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("DevtoolDaemon", () => { + test("starts and stops cleanly", async (t: TestContext) => { + const daemon = new DevtoolDaemon([]); + await daemon.start(TEST_PORT); + t.after(() => daemon.close()); + // If we get here without throwing, the server started successfully + }); + + test("serves connector version over HTTP", async (t: TestContext) => { + const daemon = new DevtoolDaemon([]); + await daemon.start(TEST_PORT + 14); + t.after(() => daemon.close()); + + const response = await requestJson<{ version?: string }>(TEST_PORT + 14, "/devtool/connector/version"); + const contentType = response.headers["content-type"]; + + t.assert.equal(response.statusCode, 200); + t.assert.match(Array.isArray(contentType) ? contentType.join(",") : contentType ?? "", /application\/json/); + assert.deepStrictEqual(response.body, { version: packageJson.version }); + }); + + test("accepts HTTP shutdown requests", async (t: TestContext) => { + let resolveShutdown: (() => void) | undefined; + const shutdown = new Promise((resolve) => { + resolveShutdown = resolve; + }); + const daemon = new DevtoolDaemon([], { onShutdown: () => resolveShutdown?.() }); + await daemon.start(TEST_PORT + 15); + t.after(() => daemon.close()); + + const response = await requestJson<{ ok?: boolean }>(TEST_PORT + 15, "/devtool/connector/shutdown", "POST"); + const contentType = response.headers["content-type"]; + + t.assert.equal(response.statusCode, 202); + t.assert.match(Array.isArray(contentType) ? contentType.join(",") : contentType ?? "", /application\/json/); + assert.deepStrictEqual(response.body, { ok: true }); + await shutdown; + await assert.rejects(() => requestJson(TEST_PORT + 15, "/devtool/connector/shutdown", "POST")); + }); + + test("performs Initialize/Register handshake with connecting client", async (t: TestContext) => { + const daemon = new DevtoolDaemon([]); + await daemon.start(TEST_PORT + 1); + t.after(() => daemon.close()); + + const ws = await connectWs(TEST_PORT + 1); + t.after(() => ws.close()); + + // Should receive Initialize + const init = await readMessage(ws) as { event: string; data: number }; + t.assert.equal(init.event, "Initialize"); + t.assert.equal(typeof init.data, "number"); + + // Send Register + ws.send(JSON.stringify({ event: "Register", data: { id: init.data, type: "Driver" } })); + + await assertNoMessage(ws); + }); + + test("responds to Ping with Pong", async (t: TestContext) => { + const daemon = new DevtoolDaemon([]); + await daemon.start(TEST_PORT + 2); + t.after(() => daemon.close()); + + const ws = await connectWs(TEST_PORT + 2); + t.after(() => ws.close()); + + await performHandshake(ws); + + const pong = await sendAndRead(ws, { event: "Ping" }); + assert.deepStrictEqual(pong, { event: "Pong" }); + }); + + test("handles Control listDevices request", async (t: TestContext) => { + const fakeTransport = createFakeTransport({ deviceId: "emulator-5554", activePorts: [8901] }); + const daemon = new DevtoolDaemon([fakeTransport]); + await daemon.start(TEST_PORT + 3); + t.after(() => daemon.close()); + + const ws = await connectWs(TEST_PORT + 3); + t.after(() => ws.close()); + + await performHandshake(ws); + + ws.send(JSON.stringify({ + event: "Control", + data: { id: 42, method: "listDevices" }, + })); + + const resp = await readMessage(ws) as { event: string; data: { id: number; result: unknown } }; + t.assert.equal(resp.event, "ControlResponse"); + t.assert.equal(resp.data.id, 42); + assert.deepStrictEqual(resp.data.result, [{ id: "emulator-5554", os: "Android" }]); + }); + + test("handles Control listAvailableApps request", async (t: TestContext) => { + const fakeTransport = createFakeTransport({ deviceId: "emulator-5554", activePorts: [8901] }); + const daemon = new DevtoolDaemon([fakeTransport]); + await daemon.start(TEST_PORT + 4); + t.after(() => daemon.close()); + + const ws = await connectWs(TEST_PORT + 4); + t.after(() => ws.close()); + + await performHandshake(ws); + + ws.send(JSON.stringify({ + event: "Control", + data: { id: 99, method: "listAvailableApps", params: { deviceId: "emulator-5554" } }, + })); + + const resp = await readMessage(ws) as { event: string; data: { id: number; result: unknown } }; + t.assert.equal(resp.event, "ControlResponse"); + t.assert.equal(resp.data.id, 99); + assert.deepStrictEqual(resp.data.result, [{ packageName: "com.test.app", name: "Test App" }]); + }); + + test("Control request returns error for unknown device", async (t: TestContext) => { + const daemon = new DevtoolDaemon([]); + await daemon.start(TEST_PORT + 5); + t.after(() => daemon.close()); + + const ws = await connectWs(TEST_PORT + 5); + t.after(() => ws.close()); + + await performHandshake(ws); + + ws.send(JSON.stringify({ + event: "Control", + data: { id: 77, method: "listAvailableApps", params: { deviceId: "nonexistent" } }, + })); + + const resp = await readMessage(ws) as { event: string; data: { id: number; error?: string } }; + t.assert.equal(resp.event, "ControlResponse"); + t.assert.equal(resp.data.id, 77); + t.assert.ok(typeof resp.data.error === "string"); + t.assert.ok(resp.data.error.includes("not found")); + }); + + test("subscribe + Customized message forwarding round-trip", async (t: TestContext) => { + const fakeTransport = createFakeTransport({ deviceId: "emulator-5554", activePorts: [8901] }); + const daemon = new DevtoolDaemon([fakeTransport]); + await daemon.start(TEST_PORT + 6); + t.after(() => daemon.close()); + + const ws = await connectWs(TEST_PORT + 6); + t.after(() => ws.close()); + + await performHandshake(ws); + + // Subscribe to device:port + ws.send(JSON.stringify({ + event: "Control", + data: { id: 1, method: "subscribe", params: { deviceId: "emulator-5554", port: 8901 } }, + })); + + const subResp = await readMessage(ws) as { event: string; data: { id: number; error?: string } }; + t.assert.equal(subResp.event, "ControlResponse"); + t.assert.equal(subResp.data.id, 1); + t.assert.equal(subResp.data.error, undefined); + + // Send a Customized message — the fake transport echoes it back + ws.send(JSON.stringify({ + event: "Customized", + data: { + type: "CDP", + data: { client_id: 8901, session_id: 1, message: { id: 100, method: "DOM.getDocument" } }, + sender: 1, + }, + to: 8901, + })); + + const echo = await readMessage(ws) as { event: string; data: { type: string } }; + t.assert.equal(echo.event, "Customized"); + t.assert.equal(echo.data.type, "CDP"); + }); + + test("rejects WebSocket connections to wrong path", async (t: TestContext) => { + const daemon = new DevtoolDaemon([]); + await daemon.start(TEST_PORT + 7); + t.after(() => daemon.close()); + + await t.assert.rejects( + () => + new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT + 7}/wrong/path`); + ws.on("open", () => { + ws.close(); + resolve(undefined); + }); + ws.on("error", reject); + setTimeout(() => reject(new Error("timeout")), 2_000); + }), + ); + }); + + test("multiple clients both receive broadcasts from same device", async (t: TestContext) => { + const fakeTransport = createFakeTransport({ deviceId: "emulator-5554", activePorts: [8901] }); + const daemon = new DevtoolDaemon([fakeTransport]); + await daemon.start(TEST_PORT + 8); + t.after(() => daemon.close()); + + // Connect client A + const wsA = await connectWs(TEST_PORT + 8); + t.after(() => wsA.close()); + await performHandshake(wsA); + + // Subscribe A + wsA.send(JSON.stringify({ + event: "Control", + data: { id: 1, method: "subscribe", params: { deviceId: "emulator-5554", port: 8901 } }, + })); + await readMessage(wsA); // consume ControlResponse + + // Connect client B + const wsB = await connectWs(TEST_PORT + 8); + t.after(() => wsB.close()); + await performHandshake(wsB); + + // Subscribe B to same device:port + wsB.send(JSON.stringify({ + event: "Control", + data: { id: 2, method: "subscribe", params: { deviceId: "emulator-5554", port: 8901 } }, + })); + await readMessage(wsB); // consume ControlResponse + + // Client A sends a message — device echoes it — both A and B should get it + wsA.send(JSON.stringify({ + event: "Customized", + data: { + type: "CDP", + data: { client_id: 8901, session_id: 1, message: { id: 200, method: "test" } }, + sender: 1, + }, + to: 8901, + })); + + const echoA = await readMessage(wsA) as { event: string }; + const echoB = await readMessage(wsB) as { event: string }; + + t.assert.equal(echoA.event, "Customized"); + t.assert.equal(echoB.event, "Customized"); + }); + + test("forwards subscribed client messages with one stable app-side sender", async (t: TestContext) => { + const forwardedMessages: unknown[] = []; + const fakeTransport = createFakeTransport({ + deviceId: "emulator-5554", + activePorts: [8901], + onMessage: message => forwardedMessages.push(message), + }); + const daemon = new DevtoolDaemon([fakeTransport]); + await daemon.start(TEST_PORT + 13); + t.after(() => daemon.close()); + + const wsA = await connectWs(TEST_PORT + 13); + t.after(() => wsA.close()); + await performHandshake(wsA); + await subscribeToPort(wsA, { id: 1, deviceId: "emulator-5554", port: 8901 }); + + const wsB = await connectWs(TEST_PORT + 13); + t.after(() => wsB.close()); + await performHandshake(wsB); + await subscribeToPort(wsB, { id: 2, deviceId: "emulator-5554", port: 8901 }); + + wsA.send(JSON.stringify({ + event: "Customized", + data: { + type: "CDP", + data: { client_id: 8901, session_id: 1, message: { id: 201, method: "DOM.getDocument" } }, + sender: 101, + }, + to: 8901, + })); + wsB.send(JSON.stringify({ + event: "Customized", + data: { + type: "CDP", + data: { client_id: 8901, session_id: 1, message: { id: 202, method: "Runtime.evaluate" } }, + sender: 102, + }, + to: 8901, + })); + + const deadline = Date.now() + 1_000; + let forwardedCustomized: Array<{ data: { sender?: number } }> = []; + while (Date.now() < deadline) { + forwardedCustomized = forwardedMessages.filter((message): message is { data: { sender?: number } } => + typeof message === "object" && message !== null + && (message as { event?: string }).event === "Customized" + ); + if (forwardedCustomized.length >= 2) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + t.assert.equal(forwardedCustomized.length, 2); + t.assert.deepEqual( + forwardedCustomized.map(message => message.data.sender), + [8901, 8901], + ); + }); + + test("Control listClients waits for delayed device Register", async (t: TestContext) => { + const fakeTransport = createFakeTransport({ + deviceId: "emulator-5554", + activePorts: [8901], + registerDelayMs: 800, + }); + const daemon = new DevtoolDaemon([fakeTransport]); + await daemon.start(TEST_PORT + 9); + t.after(() => daemon.close()); + + const ws = await connectWs(TEST_PORT + 9); + t.after(() => ws.close()); + + await performHandshake(ws); + + const clientList = await requestClientList(ws, 42); + + t.assert.equal(clientList.length, 1); + t.assert.equal(clientList[0]?.id, "emulator-5554:8901"); + t.assert.equal(clientList[0]?.info.App, "FakeApp"); + }); + + test("caps discovery wait while returning all responsive clients", async (t: TestContext) => { + const fakeTransport = createFakeTransport({ + deviceId: "emulator-5554", + activePorts: [8901, 8902, 8903], + silentPorts: [8901], + registerDelayMsByPort: { + 8903: 800, + }, + }); + const daemon = new DevtoolDaemon([fakeTransport]); + await daemon.start(TEST_PORT + 12); + t.after(() => daemon.close()); + + const ws = await connectWs(TEST_PORT + 12); + t.after(() => ws.close()); + + await performHandshake(ws); + + const clientList = await requestClientList(ws, 43); + + t.assert.deepEqual( + clientList.map((client) => client.id).sort(), + ["emulator-5554:8902", "emulator-5554:8903"], + ); + }); + + test("Control listClients skips device ports whose connect never settles", async (t: TestContext) => { + const hangingTransport: Transport = { + async close() {}, + async listDevices() { + return [{ id: "emulator-5554", os: "Android" as const }]; + }, + async listAvailableApps() { + return []; + }, + async openApp() {}, + async connect(options: TransportConnectOptions): Promise> { + if (options.port === 8901) { + return await new Promise>(() => {}); + } + throw new Error(`Connection refused on port ${options.port}`); + }, + }; + const daemon = new DevtoolDaemon([hangingTransport]); + await daemon.start(TEST_PORT + 16); + t.after(() => daemon.close()); + + const ws = await connectWs(TEST_PORT + 16); + t.after(() => ws.close()); + await performHandshake(ws); + + ws.send(JSON.stringify({ + event: "Control", + data: { id: 45, method: "listClients" }, + })); + + const response = await readMessageWithin(ws, 6_000) as + | "timeout" + | { event: string; data: { id: number; result?: unknown[]; error?: string } }; + + t.assert.notEqual(response, "timeout"); + t.assert.equal(response.event, "ControlResponse"); + t.assert.equal(response.data.id, 45); + t.assert.equal(response.data.error, undefined); + t.assert.deepEqual(response.data.result, []); + }); + + test("reusing a device connection resets the idle cleanup grace period", async (t: TestContext) => { + const { transport, getConnectCount } = createCountingFakeTransport({ + deviceId: "emulator-5554", + activePorts: [8901], + }); + const daemon = new DevtoolDaemon([transport]); + await daemon.start(TEST_PORT + 10); + t.after(() => daemon.close()); + + const wsA = await connectWs(TEST_PORT + 10); + await performHandshake(wsA); + await subscribeToPort(wsA, { id: 1, deviceId: "emulator-5554", port: 8901 }); + wsA.close(); + + await new Promise((resolve) => setTimeout(resolve, 9_000)); + + const wsB = await connectWs(TEST_PORT + 10); + await performHandshake(wsB); + await subscribeToPort(wsB, { id: 2, deviceId: "emulator-5554", port: 8901 }); + wsB.close(); + + await new Promise((resolve) => setTimeout(resolve, 1_500)); + + const wsC = await connectWs(TEST_PORT + 10); + t.after(() => wsC.close()); + await performHandshake(wsC); + const clientList = await requestClientList(wsC, 44); + + t.assert.equal(clientList.length, 1); + t.assert.equal(getConnectCount(8901), 1); + }); + + test("concurrent ClientList discovery reuses one in-flight connection per port", async (t: TestContext) => { + const { transport, getConnectCount } = createCountingFakeTransport({ + deviceId: "emulator-5554", + activePorts: [8901], + connectDelayMs: 100, + registerDelayMs: 100, + }); + const daemon = new DevtoolDaemon([transport]); + await daemon.start(TEST_PORT + 11); + t.after(() => daemon.close()); + + const sockets = await Promise.all([ + connectWs(TEST_PORT + 11), + connectWs(TEST_PORT + 11), + connectWs(TEST_PORT + 11), + ]); + for (const socket of sockets) { + t.after(() => socket.close()); + } + + await Promise.all(sockets.map(socket => performHandshake(socket))); + + const clientLists = await Promise.all( + sockets.map((socket, index) => requestClientList(socket, index + 1)), + ); + + for (const clientList of clientLists) { + t.assert.equal(clientList.length, 1); + } + + t.assert.equal(getConnectCount(), 10); + t.assert.equal(getConnectCount(8901), 1); + }); +}); diff --git a/mcp-servers/devtool-connector/test/daemon-transport.test.ts b/mcp-servers/devtool-connector/test/daemon-transport.test.ts new file mode 100644 index 0000000..b43d25e --- /dev/null +++ b/mcp-servers/devtool-connector/test/daemon-transport.test.ts @@ -0,0 +1,278 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert/strict"; +import { ReadableStream, WritableStream } from "node:stream/web"; +import { test } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; +import { WebSocketServer } from "ws"; +import { DaemonManager } from "../src/daemon/manager.ts"; +import { DaemonTransport } from "../src/transport/daemon.ts"; +import { wsStreams } from "../src/transport/ws-stream.ts"; + +test("DaemonTransport listClients sends explicit Control listClients request", async (t) => { + const server = new WebSocketServer({ port: 0, path: "/devtool/connector" }); + t.after(() => { + for (const client of server.clients) { + client.terminate(); + } + server.close(); + }); + + server.on("connection", (ws) => { + ws.send(JSON.stringify({ event: "Initialize", data: 1 })); + ws.on("message", (raw) => { + const msg = JSON.parse(String(raw)) as { + event?: string; + data?: { id?: number; method?: string }; + }; + if (msg.event !== "Control" || msg.data?.method !== "listClients") { + return; + } + + setTimeout(() => { + ws.send(JSON.stringify({ + event: "ControlResponse", + data: { + id: msg.data.id, + result: [{ id: "device:8901", info: { App: "FakeApp" } }], + }, + })); + }, 1_200); + }); + }); + + const address = server.address(); + assert(address && typeof address !== "string"); + + const transport = new DaemonTransport(address.port); + const clients = await transport.listClients(); + + assert.equal(clients.length, 1); + assert.equal(clients[0]?.id, "device:8901"); +}); + +test("DaemonTransport closes daemon connection when subscribe fails", async (t) => { + const server = new WebSocketServer({ port: 0, path: "/devtool/connector" }); + t.after(() => { + for (const client of server.clients) { + client.terminate(); + } + server.close(); + }); + + let resolveClosed: (() => void) | undefined; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + + server.on("connection", (ws) => { + ws.send(JSON.stringify({ event: "Initialize", data: 1 })); + ws.on("close", () => { + resolveClosed?.(); + }); + ws.on("message", (raw) => { + const msg = JSON.parse(String(raw)) as { + event?: string; + data?: { id?: number; method?: string }; + }; + + if (msg.event !== "Control" || msg.data?.method !== "subscribe") { + return; + } + + ws.send(JSON.stringify({ + event: "ControlResponse", + data: { id: msg.data.id, error: "device unavailable" }, + })); + }); + }); + + const address = server.address(); + assert(address && typeof address !== "string"); + + const transport = new DaemonTransport(address.port); + await assert.rejects(() => transport.connect({ deviceId: "device", port: 8901 })); + + await Promise.race([ + closed, + sleep(500).then(() => { + throw new Error("Timed out waiting for daemon connection to close"); + }), + ]); +}); + +test("DaemonTransport aborts stalled daemon Register writes", async (t) => { + t.mock.method(DaemonManager, "ensureRunning", async () => "ws://daemon.test/devtool/connector"); + + const originalCreate = wsStreams.create; + t.after(() => { + wsStreams.create = originalCreate; + }); + + let abortCalled = false; + + class HangingWebSocketStream { + #resolveClosed: (() => void) | undefined; + + opened = Promise.resolve({ + readable: new ReadableStream({ + start(controller) { + controller.enqueue(JSON.stringify({ event: "Initialize", data: 1 })); + controller.close(); + }, + }), + writable: { + getWriter() { + let rejectWrite: ((reason?: unknown) => void) | undefined; + + return { + write() { + return new Promise((_, reject) => { + rejectWrite = reject; + }); + }, + abort(reason?: unknown) { + abortCalled = true; + rejectWrite?.(reason); + return Promise.resolve(); + }, + releaseLock() {}, + }; + }, + }, + }); + + closed = new Promise((resolve) => { + this.#resolveClosed = resolve; + }); + + close() { + this.#resolveClosed?.(); + } + } + + wsStreams.create = (/* url */) => new HangingWebSocketStream() as never; + + const transport = new DaemonTransport(21783); + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(new DOMException("register write timed out", "TimeoutError")); + }, 20); + t.after(() => { + clearTimeout(timeout); + }); + + await assert.rejects( + () => + Promise.race([ + transport.connect({ deviceId: "device", port: 8901, signal: controller.signal }), + sleep(500).then(() => { + throw new Error("Timed out waiting for stalled Register write to abort"); + }), + ]), + (error: unknown) => + error instanceof DOMException + && error.name === "TimeoutError" + && error.message === "register write timed out", + ); + assert.equal(abortCalled, true); +}); + +test("DaemonTransport writable waits for the websocket write to finish", async (t) => { + t.mock.method(DaemonManager, "ensureRunning", async () => "ws://daemon.test/devtool/connector"); + + const originalCreate = wsStreams.create; + t.after(() => { + wsStreams.create = originalCreate; + }); + + const websocketWrite = deferred(); + const customizedWriteStarted = deferred(); + const websocketClosed = deferred(); + let daemonWriteFinished = false; + let enqueueReadable: ((chunk: string) => void) | undefined; + + const readable = new ReadableStream({ + start(controller) { + enqueueReadable = chunk => controller.enqueue(chunk); + controller.enqueue(JSON.stringify({ event: "Initialize", data: 123 })); + }, + }); + + const writable = new WritableStream({ + write(chunk) { + const message = JSON.parse(chunk) as { event?: string; data?: { id?: number; method?: string } }; + if (message.event === "Control" && message.data?.method === "subscribe") { + queueMicrotask(() => { + enqueueReadable?.(JSON.stringify({ + event: "ControlResponse", + data: { id: message.data?.id, result: null }, + })); + }); + return Promise.resolve(); + } + + if (message.event === "Customized") { + customizedWriteStarted.resolve(); + return websocketWrite.promise; + } + + return Promise.resolve(); + }, + }); + + wsStreams.create = () => + ({ + opened: Promise.resolve({ readable, writable }), + closed: websocketClosed.promise, + close() { + websocketWrite.resolve(); + websocketClosed.resolve(); + }, + }) as never; + + const transport = new DaemonTransport(21783); + await using conn = await transport.connect({ + deviceId: "device", + port: 8901, + signal: AbortSignal.timeout(1_000), + }); + + const writer = conn.writable.getWriter(); + try { + const writePromise = writer.write({ + event: "Customized", + data: { + type: "ListSession", + data: {}, + }, + }).then(() => { + daemonWriteFinished = true; + }); + + await customizedWriteStarted.promise; + await sleep(20); + + assert.equal(daemonWriteFinished, false); + + websocketWrite.resolve(); + await writePromise; + + assert.equal(daemonWriteFinished, true); + } finally { + writer.releaseLock(); + } +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} diff --git a/mcp-servers/devtool-connector/test/ios.test.ts b/mcp-servers/devtool-connector/test/ios.test.ts new file mode 100644 index 0000000..5314e7e --- /dev/null +++ b/mcp-servers/devtool-connector/test/ios.test.ts @@ -0,0 +1,165 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert/strict"; +import { once } from "node:events"; +import net from "node:net"; +import { test } from "node:test"; +import { build, parse, type PlistValue } from "plist"; +import { iOSTransport } from "../src/transport/ios.ts"; + +const HEADER_SIZE = 16; +const USBMUXD_VERSION = 1; +const USBMUXD_PACKET_TYPE_PLIST = 8; +const TAG = 1; + +function encodePacket(payload: PlistValue): Buffer { + const body = Buffer.from(build(payload), "utf8"); + const header = Buffer.alloc(HEADER_SIZE); + header.writeUInt32LE(HEADER_SIZE + body.length, 0); + header.writeUInt32LE(USBMUXD_VERSION, 4); + header.writeUInt32LE(USBMUXD_PACKET_TYPE_PLIST, 8); + header.writeUInt32LE(TAG, 12); + return Buffer.concat([header, body]); +} + +function decodePacket(buffer: Buffer): PlistValue { + const length = buffer.readUInt32LE(0); + assert.ok(buffer.length >= length); + return parse(buffer.subarray(HEADER_SIZE, length).toString("utf8")); +} + +function toNetworkByteOrderPort(port: number): number { + return ((port >> 8) & 0xFF) | ((port << 8) & 0xFF00); +} + +test("iOSTransport listDevices uses usbmux serial number as device id", async () => { + let resolveRequest!: (value: PlistValue) => void; + let rejectRequest!: (reason?: unknown) => void; + const request = new Promise((resolve, reject) => { + resolveRequest = resolve; + rejectRequest = reject; + }); + + const server = net.createServer((socket) => { + socket.once("data", (chunk: Buffer) => { + try { + resolveRequest(decodePacket(chunk)); + socket.write(encodePacket({ + DeviceList: [ + { + DeviceID: 42, + MessageType: "Attached", + Properties: { + ConnectionSpeed: 480000000, + ConnectionType: "USB", + DeviceID: 42, + LocationID: 123, + ProductID: 456, + SerialNumber: "00008130-0008545E3608001C", + USBSerialNumber: "usb-serial", + }, + }, + ], + })); + } catch (error) { + rejectRequest(error); + } + }); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + try { + const address = server.address(); + assert.ok(address && typeof address === "object"); + + const transport = new iOSTransport({ host: "127.0.0.1", port: address.port }); + const devices = await transport.listDevices(); + + assert.deepStrictEqual(await request, { + MessageType: "ListDevices", + ClientVersionString: "usbmux-driver", + ProgName: "usbmux-driver", + }); + assert.deepStrictEqual(devices, [ + { id: "00008130-0008545E3608001C", os: "iOS" }, + ]); + } finally { + server.close(); + await once(server, "close"); + } +}); + +test("iOSTransport connect resolves usbmux serial number to device id", async () => { + const requests: PlistValue[] = []; + + const server = net.createServer((socket) => { + socket.once("data", (chunk: Buffer) => { + const request = decodePacket(chunk); + requests.push(request); + + if (typeof request === "object" && request !== null && request["MessageType"] === "ListDevices") { + socket.write(encodePacket({ + DeviceList: [ + { + DeviceID: 42, + MessageType: "Attached", + Properties: { + ConnectionSpeed: 480000000, + ConnectionType: "USB", + DeviceID: 42, + LocationID: 123, + ProductID: 456, + SerialNumber: "00008130-0008545E3608001C", + USBSerialNumber: "usb-serial", + }, + }, + ], + })); + return; + } + + socket.write(encodePacket({ + MessageType: "Result", + Number: 0, + })); + }); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + try { + const address = server.address(); + assert.ok(address && typeof address === "object"); + + const transport = new iOSTransport({ host: "127.0.0.1", port: address.port }); + await using conn = await transport.connect({ + deviceId: "00008130-0008545E3608001C", + port: 8901, + signal: AbortSignal.timeout(1_000), + }); + void conn; + + assert.deepStrictEqual(requests, [ + { + MessageType: "ListDevices", + ClientVersionString: "usbmux-driver", + ProgName: "usbmux-driver", + }, + { + MessageType: "Connect", + ClientVersionString: "usbmux-driver", + ProgName: "usbmux-driver", + DeviceID: 42, + PortNumber: toNetworkByteOrderPort(8901), + }, + ]); + } finally { + server.close(); + await once(server, "close"); + } +}); diff --git a/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts b/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts new file mode 100644 index 0000000..4a20584 --- /dev/null +++ b/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts @@ -0,0 +1,52 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { describe, test } from "node:test"; +import { Connector } from "../src/index.ts"; +import { DaemonTransport } from "../src/transport/daemon.ts"; +import type { Client, Connection, Transport } from "../src/transport/transport.ts"; + +class EmptyDaemonTransport extends DaemonTransport { + listClientsCalls = 0; + + override async listClients(): Promise { + this.listClientsCalls += 1; + return []; + } +} + +class DirectFallbackProbeTransport implements Transport { + listDevicesCalls = 0; + + async close(): Promise {} + + async listDevices() { + this.listDevicesCalls += 1; + return []; + } + + async listAvailableApps() { + return []; + } + + async openApp(): Promise {} + + async connect(): Promise> { + throw new Error("Direct fallback probe should not connect"); + } +} + +describe("Connector listClients fallback", () => { + test("uses a fulfilled daemon result even when it is empty", async (t) => { + const daemonTransport = new EmptyDaemonTransport(); + const directTransport = new DirectFallbackProbeTransport(); + const connector = new Connector([daemonTransport, directTransport]); + + const clients = await connector.listClients(); + + t.assert.deepStrictEqual(clients, []); + t.assert.equal(daemonTransport.listClientsCalls, 1); + t.assert.equal(directTransport.listDevicesCalls, 0); + }); +}); diff --git a/mcp-servers/devtool-connector/test/list-clients-setup.test.ts b/mcp-servers/devtool-connector/test/list-clients-setup.test.ts new file mode 100644 index 0000000..9c39d6c --- /dev/null +++ b/mcp-servers/devtool-connector/test/list-clients-setup.test.ts @@ -0,0 +1,165 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { ReadableStream, WritableStream } from "node:stream/web"; +import { describe, test } from "node:test"; +import type { TestContext } from "node:test"; +import { ClientId, Connector } from "../src/index.ts"; +import type { Connection, Transport, TransportConnectOptions } from "../src/transport/transport.ts"; + +class SetupAwareTransport implements Transport { + readonly #deviceId = "device under:test"; + readonly #ports = [8901, 8902]; + readonly setupRequests: { key: string; port: number }[] = []; + + async close(): Promise {} + + async listDevices() { + return [{ id: this.#deviceId, os: "Android" as const }]; + } + + async listAvailableApps() { + return []; + } + + async openApp(): Promise {} + + async connect(options: TransportConnectOptions): Promise> { + if (options.deviceId !== this.#deviceId) { + throw new Error(`Unexpected deviceId: ${options.deviceId}`); + } + + let enqueueResponse: ((value: unknown) => void) | undefined; + let closeReadable: (() => void) | undefined; + + const readable = new ReadableStream({ + start(controller) { + enqueueResponse = (value) => controller.enqueue(value); + closeReadable = () => controller.close(); + }, + }); + + const writable = new WritableStream({ + write: (chunk) => { + if (this.#isExpectedInitialize(chunk, options.port)) { + enqueueResponse?.(this.#createRegisterResponse(options.port)); + closeReadable?.(); + return; + } + + const setupKey = this.#getSetGlobalSwitchKey(chunk); + if (setupKey) { + this.setupRequests.push({ key: setupKey, port: options.port }); + enqueueResponse?.({ + event: "Customized", + data: { + type: "SetGlobalSwitch", + data: { + client_id: options.port, + session_id: -1, + message: "ok", + }, + }, + }); + closeReadable?.(); + return; + } + + closeReadable?.(); + }, + close: () => { + closeReadable?.(); + }, + }); + + return { + readable, + writable, + async [Symbol.asyncDispose]() {}, + }; + } + + #createRegisterResponse(port: number) { + return { + event: "Register", + data: { + id: port, + info: { + App: `app-${port}`, + AppVersion: "1.0.0", + AppProcessName: `app-${port}`, + debugRouterId: `router-${port}`, + debugRouterVersion: "1.0.0", + deviceModel: "fake-device", + network: "wifi", + osVersion: "1", + sdkVersion: "1", + }, + }, + }; + } + + #isExpectedInitialize(message: unknown, port: number): boolean { + return this.#ports.includes(port) + && typeof message === "object" + && message !== null + && "event" in message + && message.event === "Initialize" + && "data" in message + && message.data === port; + } + + #getSetGlobalSwitchKey(message: unknown): string | null { + if ( + typeof message !== "object" + || message === null + || !("event" in message) + || message.event !== "Customized" + || !("data" in message) + || typeof message.data !== "object" + || message.data === null + || !("type" in message.data) + || message.data.type !== "SetGlobalSwitch" + || !("data" in message.data) + || typeof message.data.data !== "object" + || message.data.data === null + || !("message" in message.data.data) + || typeof message.data.data.message !== "object" + || message.data.data.message === null + || !("global_key" in message.data.data.message) + || typeof message.data.data.message.global_key !== "string" + ) { + return null; + } + + return message.data.data.message.global_key; + } +} + +describe("Connector listClients setup", () => { + test("sets up every discovered client each time clients are listed", async (t: TestContext) => { + const transport = new SetupAwareTransport(); + const connector = new Connector([transport]); + + const clients = await connector.listClients(); + + t.assert.deepStrictEqual( + clients.map(({ id }) => id).sort(), + [ + ClientId.serialize("device under:test", 8901), + ClientId.serialize("device under:test", 8902), + ], + ); + t.assert.deepStrictEqual(transport.setupRequests, [ + { key: "enable_devtool", port: 8901 }, + { key: "enable_devtool", port: 8902 }, + { key: "enable_quickjs_debug", port: 8901 }, + { key: "enable_quickjs_debug", port: 8902 }, + ]); + + await connector.listClients(); + + t.assert.equal(transport.setupRequests.length, 8); + }); +}); diff --git a/mcp-servers/devtool-connector/test/open-app-daemon.test.ts b/mcp-servers/devtool-connector/test/open-app-daemon.test.ts new file mode 100644 index 0000000..6f7fd17 --- /dev/null +++ b/mcp-servers/devtool-connector/test/open-app-daemon.test.ts @@ -0,0 +1,65 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Connector } from "../src/index.ts"; +import type { App, Client, Connection, Device, Transport } from "../src/transport/transport.ts"; + +class ClientListTransport implements Transport { + #openedPackageName: string | null = null; + listClientsCalls = 0; + + async close(): Promise { + } + + async listDevices(): Promise { + return [{ id: "device", os: "Android" }]; + } + + async listAvailableApps(): Promise { + return []; + } + + async openApp(_: string, packageName: string): Promise { + this.#openedPackageName = packageName; + } + + async listClients(): Promise { + this.listClientsCalls++; + if (!this.#openedPackageName) { + return []; + } + + return [{ + id: "device:8902", + info: { + App: "LynxPlayground", + AppProcessName: this.#openedPackageName, + AppVersion: "1.0.0", + debugRouterId: "debug-router-id", + debugRouterVersion: "0.0.20", + deviceModel: "device", + network: "USB", + osVersion: "10", + sdkVersion: "0.0.1", + }, + }]; + } + + async connect(): Promise> { + throw new Error("openApp should use transport.listClients instead of probing ports"); + } +} + +test("Connector.openApp waits for clients through transport.listClients when available", async () => { + const transport = new ClientListTransport(); + const connector = new Connector([transport]); + + await connector.openApp("device", "com.lynx.uiapp", { + signal: AbortSignal.timeout(50), + }); + + assert.equal(transport.listClientsCalls, 1); +}); diff --git a/mcp-servers/devtool-connector/test/testWithClient.test.ts b/mcp-servers/devtool-connector/test/testWithClient.test.ts new file mode 100644 index 0000000..185446c --- /dev/null +++ b/mcp-servers/devtool-connector/test/testWithClient.test.ts @@ -0,0 +1,84 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { test } from "node:test"; +import type { Client } from "../src/transport/transport.ts"; +import { getTestingSession, selectTestingClient, TEST_PAGE_URL, type TestingTarget } from "./testWithClient.ts"; + +test("selectTestingClient picks the target package", (t) => { + const clients: Client[] = [ + { + id: "device:8901", + info: { + App: "TikTok-M", + AppProcessName: "com.zhiliaoapp.musically", + }, + }, + { + id: "device:8902", + info: { + App: "LynxPlayground", + AppProcessName: "com.lynx.uiapp", + }, + }, + ]; + const target: TestingTarget = { + appPackageName: "com.lynx.uiapp", + pageUrl: TEST_PAGE_URL, + openUrl: TEST_PAGE_URL, + }; + + const client = selectTestingClient(clients, target); + + t.assert.equal(client?.id, "device:8902"); +}); + +test("selectTestingClient returns undefined when no match", (t) => { + const clients: Client[] = [ + { + id: "device:8901", + info: { + App: "TikTok-M", + AppProcessName: "com.zhiliaoapp.musically", + }, + }, + ]; + const target: TestingTarget = { + appPackageName: "com.lynx.uiapp", + pageUrl: TEST_PAGE_URL, + openUrl: TEST_PAGE_URL, + }; + + const client = selectTestingClient(clients, target); + + t.assert.equal(client, undefined); +}); + +test("getTestingSession returns the latest session", async (t) => { + const connector = { + async sendListSessionMessage() { + return [ + { session_id: 1, url: "https://example.com/a" }, + { session_id: 2, url: TEST_PAGE_URL }, + ]; + }, + }; + + const session = await getTestingSession(connector, "device:8901"); + + t.assert.equal(session.session_id, 2); +}); + +test("getTestingSession throws when no sessions exist", async (t) => { + const connector = { + async sendListSessionMessage() { + return []; + }, + }; + + await t.assert.rejects( + () => getTestingSession(connector, "device:8901"), + { message: /No sessions found/ }, + ); +}); diff --git a/mcp-servers/devtool-connector/test/testWithClient.ts b/mcp-servers/devtool-connector/test/testWithClient.ts new file mode 100644 index 0000000..57d0030 --- /dev/null +++ b/mcp-servers/devtool-connector/test/testWithClient.ts @@ -0,0 +1,168 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { describe, test, type TestContext } from "node:test"; +import { Connector } from "../src/index.ts"; +import { AndroidTransport } from "../src/transport/android.ts"; +import { DaemonTransport } from "../src/transport/daemon.ts"; +import { DesktopTransport } from "../src/transport/desktop.ts"; +import { iOSTransport } from "../src/transport/ios.ts"; +import type { Client, Transport } from "../src/transport/transport.ts"; + +export const TEST_APP_PACKAGE_NAME = "com.lynx.uiapp"; +export const TEST_PAGE_URL = + "https://example.com/template.js"; +const TEST_APP_PACKAGE_ENV = "LYNX_DEVTOOL_MCP_TESTING_APP_PACKAGE"; +const TEST_PAGE_URL_ENV = "LYNX_DEVTOOL_MCP_TESTING_PAGE_URL"; +const TEST_OPEN_URL_ENV = "LYNX_DEVTOOL_MCP_TESTING_OPEN_URL"; + +const transportsFromEnv = process.env["LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS"] + ? process.env["LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS"].split(",") + : null; + +export interface TestingTarget { + appPackageName: string; + pageUrl: string; + openUrl: string; +} + +function readEnv(env: NodeJS.ProcessEnv, name: string): string | undefined { + const value = env[name]?.trim(); + return value ? value : undefined; +} + +function resolveTestingTarget(env: NodeJS.ProcessEnv = process.env): TestingTarget { + const pageUrl = readEnv(env, TEST_PAGE_URL_ENV) ?? TEST_PAGE_URL; + + return { + appPackageName: readEnv(env, TEST_APP_PACKAGE_ENV) ?? TEST_APP_PACKAGE_NAME, + pageUrl, + openUrl: readEnv(env, TEST_OPEN_URL_ENV) ?? pageUrl, + }; +} + +function isClientForTarget(client: Client, target: TestingTarget): boolean { + return client.info.AppProcessName === target.appPackageName + || client.info.bundleId === target.appPackageName + || client.info.bundleName === target.appPackageName + || client.info.App === target.appPackageName; +} + +export function selectTestingClient( + clients: Client[], + target: TestingTarget = resolveTestingTarget(), +): Client | undefined { + return clients.find(client => isClientForTarget(client, target)); +} + +function formatClient(client: Client): string { + const info = client.info; + return [ + `id=${client.id}`, + `App=${info.App}`, + info.AppProcessName ? `AppProcessName=${info.AppProcessName}` : undefined, + info.bundleId ? `bundleId=${info.bundleId}` : undefined, + info.bundleName ? `bundleName=${info.bundleName}` : undefined, + info.osType ? `osType=${info.osType}` : undefined, + info.deviceModel ? `deviceModel=${info.deviceModel}` : undefined, + ].filter(Boolean).join(", "); +} + +export function formatNoTestingClientMessage( + name: string, + clients: Client[], + target: TestingTarget, +): string { + if (clients.length === 0) { + return `No ${name} clients found for target package ${target.appPackageName}`; + } + + return `No ${name} clients matched target package ${target.appPackageName}. Available clients: ${ + clients.map(formatClient).join("; ") + }`; +} + +export type TestingSession = { + session_id: number; + type?: string; + url?: string; +}; + +export async function getTestingSession( + connector: { sendListSessionMessage(clientId: string): Promise }, + clientId: string, +): Promise { + const sessions = await connector.sendListSessionMessage(clientId); + const session = sessions[sessions.length - 1]; + if (!session) { + throw new Error( + `No sessions found for client ${clientId}. Ensure a page is opened before running tests (e.g. node skills/lynx-devtool/scripts/index.mjs open )`, + ); + } + return session; +} + +const Transports: { name: string; createTransports: () => Promise | Transport[] }[] = [ + { name: "iOS", createTransports: () => [new iOSTransport()] }, + { name: "Android", createTransports: () => [new AndroidTransport()] }, + { name: "Daemon", createTransports: () => [new DaemonTransport()] }, + { + name: "EmbeddedLynx", + createTransports: () => [new DesktopTransport()], + }, +] + .filter(i => !transportsFromEnv || transportsFromEnv.includes(i.name)); + +if (transportsFromEnv && Transports.length === 0) { + throw new Error( + `No transports matched LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS=${process.env["LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS"]}`, + ); +} + +function createRunner(testFn: (name: string, fn: (t: TestContext) => Promise) => Promise) { + return ( + testName: string, + callback: ( + t: TestContext, + connector: Connector, + client: Client, + target: TestingTarget, + ) => Promise, + ): Promise => { + return describe(testName, () => { + Transports.forEach(({ name, createTransports }) => { + testFn(`${testName} - ${name}`, async (t: TestContext) => { + const target = resolveTestingTarget(); + const transports = await createTransports(); + if (transports.length === 0) { + throw new Error(`No ${name} transports available`); + } + const connector = new Connector(transports); + + t.after(async () => { + await Promise.all(transports.map((transport) => transport.close())); + }); + + const clients = await connector.listClients(); + const client = selectTestingClient(clients, target); + + if (!client) { + t.assert.fail(formatNoTestingClientMessage(name, clients, target)); + } + + await callback(t, connector, client, target); + }); + }); + }); + }; +} + +export const testWithClient = Object.assign( + createRunner(test), + { + only: createRunner(test.only), + todo: createRunner(test.todo), + skip: createRunner(test.skip), + }, +); diff --git a/mcp-servers/devtool-connector/test/transport-selection.test.ts b/mcp-servers/devtool-connector/test/transport-selection.test.ts new file mode 100644 index 0000000..d36028d --- /dev/null +++ b/mcp-servers/devtool-connector/test/transport-selection.test.ts @@ -0,0 +1,150 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { ReadableStream, WritableStream } from "node:stream/web"; +import { describe, test } from "node:test"; +import type { TestContext } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; +import { ClientId, Connector } from "../src/index.ts"; +import { DaemonTransport } from "../src/transport/daemon.ts"; +import type { Connection, Transport } from "../src/transport/transport.ts"; + +class SlowDaemonSessionTransport extends DaemonTransport { + readonly #deviceId = "device under:test"; + connectCalls = 0; + + async close(): Promise {} + + async listDevices() { + await sleep(10); + return [{ id: this.#deviceId, os: "Android" as const }]; + } + + async listAvailableApps() { + return []; + } + + async openApp(): Promise {} + + async connect(): Promise> { + this.connectCalls += 1; + + let closeReadable: (() => void) | undefined; + let enqueueResponse: ((value: unknown) => void) | undefined; + + const readable = new ReadableStream({ + start(controller) { + closeReadable = () => controller.close(); + enqueueResponse = (value) => controller.enqueue(value); + }, + }); + + const writable = new WritableStream({ + write: (chunk) => { + if (!this.#isListSessionRequest(chunk)) { + closeReadable?.(); + return; + } + + enqueueResponse?.({ + event: "Customized", + data: { + type: "SessionList", + data: [ + { + session_id: 101, + type: "lynx", + url: "https://example.test/session/101", + }, + ], + }, + }); + closeReadable?.(); + }, + close: () => { + closeReadable?.(); + }, + }); + + return { + readable, + writable, + async [Symbol.asyncDispose]() {}, + }; + } + + #isListSessionRequest(message: unknown): boolean { + return typeof message === "object" + && message !== null + && "event" in message + && message.event === "Customized" + && "data" in message + && typeof message.data === "object" + && message.data !== null + && "type" in message.data + && message.data.type === "ListSession"; + } +} + +class FastDirectNoResponseTransport implements Transport { + readonly #deviceId = "device under:test"; + connectCalls = 0; + + async close(): Promise {} + + async listDevices() { + return [{ id: this.#deviceId, os: "Android" as const }]; + } + + async listAvailableApps() { + return []; + } + + async openApp(): Promise {} + + async connect(): Promise> { + this.connectCalls += 1; + + let closeReadable: (() => void) | undefined; + + const readable = new ReadableStream({ + start(controller) { + closeReadable = () => controller.close(); + }, + }); + + const writable = new WritableStream({ + write: () => { + closeReadable?.(); + }, + close: () => { + closeReadable?.(); + }, + }); + + return { + readable, + writable, + async [Symbol.asyncDispose]() {}, + }; + } +} + +describe("Connector transport selection", () => { + test("prefers daemon transports over other transports for the same device", async (t: TestContext) => { + const daemonTransport = new SlowDaemonSessionTransport(); + const directTransport = new FastDirectNoResponseTransport(); + const connector = new Connector([directTransport, daemonTransport]); + + const sessions = await connector.sendListSessionMessage(ClientId.serialize("device under:test", 8901)); + + t.assert.deepStrictEqual(sessions, [{ + session_id: 101, + type: "lynx", + url: "https://example.test/session/101", + }]); + t.assert.equal(daemonTransport.connectCalls, 1); + t.assert.equal(directTransport.connectCalls, 0); + }); +}); diff --git a/mcp-servers/devtool-connector/test/usbmux.test.ts b/mcp-servers/devtool-connector/test/usbmux.test.ts new file mode 100644 index 0000000..148fdc9 --- /dev/null +++ b/mcp-servers/devtool-connector/test/usbmux.test.ts @@ -0,0 +1,102 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert/strict"; +import { once } from "node:events"; +import net from "node:net"; +import { test } from "node:test"; +import { build, parse, type PlistValue } from "plist"; +import { Usbmux } from "../src/transport/usbmux.ts"; + +const HEADER_SIZE = 16; +const USBMUXD_VERSION = 1; +const USBMUXD_PACKET_TYPE_PLIST = 8; +const TAG = 1; + +function encodePacket(payload: PlistValue): Buffer { + const body = Buffer.from(build(payload), "utf8"); + const header = Buffer.alloc(HEADER_SIZE); + header.writeUInt32LE(HEADER_SIZE + body.length, 0); + header.writeUInt32LE(USBMUXD_VERSION, 4); + header.writeUInt32LE(USBMUXD_PACKET_TYPE_PLIST, 8); + header.writeUInt32LE(TAG, 12); + return Buffer.concat([header, body]); +} + +function decodePacket(buffer: Buffer): PlistValue { + const length = buffer.readUInt32LE(0); + assert.ok(buffer.length >= length); + return parse(buffer.subarray(HEADER_SIZE, length).toString("utf8")); +} + +test("listDevices exchanges plist packets with usbmuxd", async () => { + let resolveRequest!: (value: PlistValue) => void; + let rejectRequest!: (reason?: unknown) => void; + const request = new Promise((resolve, reject) => { + resolveRequest = resolve; + rejectRequest = reject; + }); + + const server = net.createServer((socket) => { + socket.once("data", (chunk: Buffer) => { + try { + resolveRequest(decodePacket(chunk)); + socket.write(encodePacket({ + DeviceList: [ + { + DeviceID: 42, + MessageType: "Attached", + Properties: { + ConnectionSpeed: 480000000, + ConnectionType: "USB", + DeviceID: 42, + LocationID: 123, + ProductID: 456, + SerialNumber: "device-serial", + USBSerialNumber: "usb-serial", + }, + }, + ], + })); + } catch (error) { + rejectRequest(error); + } + }); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + try { + const address = server.address(); + assert.ok(address && typeof address === "object"); + + const usbmux = new Usbmux({ host: "127.0.0.1", port: address.port }); + const devices = await usbmux.listDevices(AbortSignal.timeout(1000)); + + assert.deepStrictEqual(await request, { + MessageType: "ListDevices", + ClientVersionString: "usbmux-driver", + ProgName: "usbmux-driver", + }); + assert.deepStrictEqual(devices, [ + { + DeviceID: 42, + MessageType: "Attached", + Properties: { + ConnectionSpeed: 480000000, + ConnectionType: "USB", + DeviceID: 42, + LocationID: 123, + ProductID: 456, + SerialNumber: "device-serial", + USBSerialNumber: "usb-serial", + }, + }, + ]); + } finally { + server.close(); + await once(server, "close"); + } +}); diff --git a/mcp-servers/devtool-connector/tsconfig.json b/mcp-servers/devtool-connector/tsconfig.json new file mode 100644 index 0000000..25e00f4 --- /dev/null +++ b/mcp-servers/devtool-connector/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "noEmit": true, + "types": ["ws"], + }, + "include": ["src"], +} diff --git a/mcp-servers/devtool-mcp-server/LICENSE b/mcp-servers/devtool-mcp-server/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/mcp-servers/devtool-mcp-server/README.md b/mcp-servers/devtool-mcp-server/README.md new file mode 100644 index 0000000..90d954b --- /dev/null +++ b/mcp-servers/devtool-mcp-server/README.md @@ -0,0 +1,100 @@ +# @lynx-js/devtool-mcp-server + +> The Lynx DevTool for coding agents. + +`@lynx-js/devtool-mcp-server` lets your coding agent (such as Gemini, Claude, Cursor or Copilot) +control and inspect a live Lynx Engine. It acts as a Model-Context-Protocol +(MCP) server, giving your AI coding assistant access to the full power of +Lynx DevTools for reliable automation. + +## Requirements + +- [Node.js](https://nodejs.org/) v18.19 or a newer [latest maintenance LTS](https://github.com/nodejs/Release#release-schedule) version. +- A device or simulator opened with [Lynx Engine](https://lynxjs.org/) opened and connected. + +## Getting started + +Add the following config to your MCP client: + +```json +{ + "mcpServers": { + "lynx-devtool": { + "command": "npx", + "args": [ + "-y", + "@lynx-js/devtool-mcp-server@latest" + ] + } + } +} +``` + +
+ Claude Code + Use the Claude Code CLI to add the Lynx DevTool MCP server (guide): + +```bash +claude mcp add lynx-devtool npx @lynx-js/devtool-mcp-server@latest +``` + +
+ +
+ Codex + Follow the configure MCP guide + using the standard config from above. You can also install the Lynx DevTool MCP server using the Codex CLI: + +```bash +codex mcp add lynx-devtool -- npx @lynx-js/devtool-mcp-server@latest +``` + +
+ +
+ Copilot / VS Code + Follow the MCP install guide, + with the standard config from above. You can also install the Lynx DevTool MCP server using the VS Code CLI: + +```bash +code --add-mcp '{"name":"lynx-devtool","command":"npx","args":["@lynx-js/devtool-mcp-server@latest"]}' +``` + +
+ +
+ Cursor + +**Click the button to install:** + +[Install in Cursor](https://cursor.com/en/install-mcp?name=lynx-devtool&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIi0tcmVnaXN0cnkiLCJodHRwczovL2JucG0uYnl0ZWQub3JnIiwiQGJ5dGVkLWx5bngvZGV2dG9vbC1tY3Atc2VydmVyQGxhdGVzdCJdfQ==%3D) + +**Or install manually:** + +Go to `Cursor Settings` -> `MCP` -> `New MCP Server`. Use the config provided above. + +
+ +
+ Gemini CLI +Install the Lynx DevTool MCP server using the Gemini CLI. + +**Project wide:** + +```bash +gemini mcp add lynx-devtool npx @lynx-js/devtool-mcp-server@latest +``` + +**Globally:** + +```bash +gemini mcp add -s user lynx-devtool npx @lynx-js/devtool-mcp-server@latest +``` + +Alternatively, follow the MCP guide and use the standard config from above. + +
+ +## Credits + +This project is inspired by [chrome-devtool-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp). Both the implementation and documentation have been adapted and referenced from the original MCP server. diff --git a/mcp-servers/devtool-mcp-server/package.json b/mcp-servers/devtool-mcp-server/package.json new file mode 100644 index 0000000..7c3d142 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/package.json @@ -0,0 +1,71 @@ +{ + "name": "@lynx-js/devtool-mcp-server", + "version": "0.13.3", + "description": "A mcp server that lets coding agents to control, operate and preview Lynx pages.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + }, + "./connector": { + "types": "./src/connector.ts", + "import": "./src/connector.ts", + "default": "./src/connector.ts" + }, + "./package.json": "./package.json" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "bin": "./src/main.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rslib build", + "test": "node --test --test-concurrency=1 'test/**/*.test.ts'", + "test:e2e": "node --test --test-concurrency=1 'e2e/**/*.test.ts'" + }, + "dependencies": { + "@lynx-js/devtool-connector": "workspace:*" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "@rslib/core": "catalog:rstack", + "core-js": "^3.49.0", + "obug": "^2.1.3", + "rsbuild-plugin-publint": "^1.0.0", + "zod": "^3.25.76" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "*" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + }, + "engines": { + "node": ">=18.19" + }, + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": "./dist/main.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./connector": { + "types": "./dist/connector.d.ts", + "import": "./dist/connector.js", + "default": "./dist/connector.js" + }, + "./package.json": "./package.json" + } + } +} diff --git a/mcp-servers/devtool-mcp-server/rslib.config.ts b/mcp-servers/devtool-mcp-server/rslib.config.ts new file mode 100644 index 0000000..ce77f12 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/rslib.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from "@rslib/core"; +import { pluginPublint } from "rsbuild-plugin-publint"; + +export default defineConfig({ + plugins: [ + pluginPublint({ throwOn: "suggestion" }), + ], + lib: [ + { + format: "esm", + syntax: "es2022", + source: { + entry: { + connector: "./src/connector.ts", + index: "./src/index.ts", + }, + }, + dts: { + bundle: { + bundledPackages: [], + }, + }, + }, + { + format: "esm", + syntax: "es2022", + dts: false, + source: { + entry: { + main: "./src/main.ts", + }, + }, + autoExternal: { + // Bundle @modelcontextprotocol/sdk in CLI. + peerDependencies: false, + }, + }, + ], +}); diff --git a/mcp-servers/devtool-mcp-server/src/McpContext.ts b/mcp-servers/devtool-mcp-server/src/McpContext.ts new file mode 100644 index 0000000..f5a21ea --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/McpContext.ts @@ -0,0 +1,22 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { Connector } from "@lynx-js/devtool-connector"; +import type { Context } from "./tools/defineTool.ts"; + +export class McpContext implements Context { + #connector: Connector; + + constructor(connector: Connector) { + this.#connector = connector; + } + + static async withConnector(connector: Connector): Promise { + return new McpContext(connector); + } + + connector(): Connector { + return this.#connector; + } +} diff --git a/mcp-servers/devtool-mcp-server/src/McpResponse.ts b/mcp-servers/devtool-mcp-server/src/McpResponse.ts new file mode 100644 index 0000000..6547ddf --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/McpResponse.ts @@ -0,0 +1,35 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { ImageContent, TextContent } from "@modelcontextprotocol/sdk/types.js"; +import type { McpContext } from "./McpContext.ts"; +import type { ImageContentData, Response } from "./tools/defineTool.ts"; + +export class McpResponse implements Response { + #additionalLines: string[] = []; + appendLines(...lines: string[]): void { + this.#additionalLines.push(...lines); + } + + #images: ImageContentData[] = []; + attachImage(value: ImageContentData): void { + this.#images.push(value); + } + + async handle( + _toolName: string, + _context: McpContext, + ): Promise> { + return [ + { + type: "text", + text: this.#additionalLines.join("\n"), + }, + ...this.#images.map(img => ({ + type: "image" as const, + ...img, + })), + ]; + } +} diff --git a/mcp-servers/devtool-mcp-server/src/connector.ts b/mcp-servers/devtool-mcp-server/src/connector.ts new file mode 100644 index 0000000..4e84d54 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/connector.ts @@ -0,0 +1,17 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export { DevtoolDaemon } from "@lynx-js/devtool-connector/daemon"; +export { + CustomizedRequestTransformStream, + CustomizedResponseTransformStream, + FilterTransformStream, +} from "@lynx-js/devtool-connector/streams"; +export { + AndroidTransport, + DaemonTransport, + DesktopTransport, + iOSTransport, +} from "@lynx-js/devtool-connector/transport"; +export type { Transport } from "@lynx-js/devtool-connector/transport"; diff --git a/mcp-servers/devtool-mcp-server/src/index.ts b/mcp-servers/devtool-mcp-server/src/index.ts new file mode 100644 index 0000000..9228814 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/index.ts @@ -0,0 +1,197 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import "core-js/modules/es.promise.with-resolvers.js"; +import { Connector } from "@lynx-js/devtool-connector"; +import { + AndroidTransport, + DaemonTransport, + DesktopTransport, + iOSTransport, +} from "@lynx-js/devtool-connector/transport"; +import type { Transport } from "@lynx-js/devtool-connector/transport"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpContext } from "./McpContext.ts"; +import { McpResponse } from "./McpResponse.ts"; +import { GetGlobalSwitch } from "./tools/App/GetGlobalSwitch.ts"; +import { ListGlobalSwitch } from "./tools/App/ListGlobalSwitch.ts"; +import { SetGlobalSwitch } from "./tools/App/SetGlobalSwitch.ts"; +import { GetBackgroundColors } from "./tools/CSS/GetBackgroundColors.ts"; +import { GetComputedStyleForNode } from "./tools/CSS/GetComputedStyleForNode.ts"; +import { GetInlineStylesForNode } from "./tools/CSS/GetInlineStylesForNode.ts"; +import { GetMatchedStylesForNode } from "./tools/CSS/GetMatchedStylesForNode.ts"; +import { GetStyleSheetText } from "./tools/CSS/GetStyleSheetText.ts"; +import { GetScriptSource } from "./tools/Debugger/GetScriptSource.ts"; +import { ListScripts } from "./tools/Debugger/ListScripts.ts"; +import type { ToolDefinition } from "./tools/defineTool.ts"; +import { ClosePage } from "./tools/Device/ClosePage.ts"; +import { ListClients } from "./tools/Device/ListClients.ts"; +import { ListDevices } from "./tools/Device/ListDevices.ts"; +import { ListSessions } from "./tools/Device/ListSessions.ts"; +import { OpenPage } from "./tools/Device/OpenPage.ts"; +import { DescribeNode } from "./tools/DOM/DescribeNode.ts"; +import { GetAttributes } from "./tools/DOM/GetAttributes.ts"; +import { GetBoxModel } from "./tools/DOM/GetBoxModel.ts"; +import { GetDocument } from "./tools/DOM/GetDocument.ts"; +import { GetDocumentWithBoxModel } from "./tools/DOM/GetDocumentWithBoxModel.ts"; +import { GetNodeForLocation } from "./tools/DOM/GetNodeForLocation.ts"; +import { GetOriginalNodeIndex } from "./tools/DOM/GetOriginalNodeIndex.ts"; +import { GetSearchResults } from "./tools/DOM/GetSearchResults.ts"; +import { InnerText } from "./tools/DOM/InnerText.ts"; +import { PerformSearch } from "./tools/DOM/PerformSearch.ts"; +import { PushNodesByBackendIdsToFrontend } from "./tools/DOM/PushNodesByBackendIdsToFrontend.ts"; +import { QuerySelector } from "./tools/DOM/QuerySelector.ts"; +import { QuerySelectorAll } from "./tools/DOM/QuerySelectorAll.ts"; +import { RequestChildNodes } from "./tools/DOM/RequestChildNodes.ts"; +import { ScrollIntoViewIfNeeded } from "./tools/DOM/ScrollIntoViewIfNeeded.ts"; +import { SetAttributesAsText } from "./tools/DOM/SetAttributesAsText.ts"; +import { TakeHeapSnapshot } from "./tools/HeapProfiler/TakeHeapSnapshot.ts"; +import { EmulateTouchFromMouseEvent } from "./tools/Input/EmulateTouchFromMouseEvent.ts"; +import { GetVersion } from "./tools/Lynx/GetVersion.ts"; +import { GetAllMemoryUsage } from "./tools/Memory/GetAllMemoryUsage.ts"; +import { GetResourceContent } from "./tools/Page/GetResourceContent.ts"; +import { GetResourceTree } from "./tools/Page/GetResourceTree.ts"; +import { Reload } from "./tools/Page/Reload.ts"; +import { TakeScreenshot } from "./tools/Page/TakeScreenshot.ts"; +import { GetAllPerformanceEntries } from "./tools/Performance/GetAllPerformanceEntries.ts"; +import { GetAllTimingInfo } from "./tools/Performance/GetAllTimingInfo.ts"; +import { Evaluate } from "./tools/Runtime/Evaluate.ts"; +import { GetHeapUsage } from "./tools/Runtime/GetHeapUsage.ts"; +import { GetProperties } from "./tools/Runtime/GetProperties.ts"; +import { ListConsole } from "./tools/Runtime/ListConsole.ts"; +import { GetLynxUITree } from "./tools/UITree/GetLynxUITree.ts"; + +const TOOLS = [ + // App + ListGlobalSwitch, + GetGlobalSwitch, + SetGlobalSwitch, + + // CSS + GetBackgroundColors, + GetComputedStyleForNode, + GetInlineStylesForNode, + GetMatchedStylesForNode, + GetStyleSheetText, + + // Debugger + GetScriptSource, + ListScripts, + + // Device + ClosePage, + ListClients, + ListDevices, + ListSessions, + OpenPage, + + // DOM + DescribeNode, + GetAttributes, + GetBoxModel, + GetDocument, + GetDocumentWithBoxModel, + GetNodeForLocation, + GetOriginalNodeIndex, + GetSearchResults, + InnerText, + PerformSearch, + PushNodesByBackendIdsToFrontend, + QuerySelector, + QuerySelectorAll, + RequestChildNodes, + ScrollIntoViewIfNeeded, + SetAttributesAsText, + + // Page + GetResourceTree, + GetResourceContent, + Reload, + TakeScreenshot, + + // Lynx + GetVersion, + + // Input + EmulateTouchFromMouseEvent, + + // Memory + GetAllMemoryUsage, + + // Runtime + Evaluate, + GetProperties, + ListConsole, + GetHeapUsage, + + // Performance + GetAllTimingInfo, + GetAllPerformanceEntries, + + // HeapProfiler + TakeHeapSnapshot, + + // UITree + GetLynxUITree, +] as unknown as ToolDefinition[]; + +export function registerTool(mcpServer: McpServer, tool: ToolDefinition, transports: Transport[]): void { + if (!tool.schema) { + throw new Error("Tool schema is required"); + } + mcpServer.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.schema, + annotations: tool.annotations, + }, + async (params, extra): Promise => { + const response = new McpResponse(); + const connector = new Connector(transports); + const context = await McpContext.withConnector(connector); + try { + await tool.handler({ params, extra }, response, context); + const content = await response.handle(tool.name, context); + + return { content }; + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + + return { + isError: true, + content: [ + { type: "text", text: errorText }, + ], + }; + } + }, + ); +} + +export function setupServer(mcpServer: McpServer, transports?: Transport[]): void { + transports ??= createDefaultTransports(); + for (const tool of TOOLS) { + registerTool(mcpServer, tool, transports); + } + + return; +} + +function createDefaultTransports(): Transport[] { + return [ + new DaemonTransport(), + new iOSTransport(), + new AndroidTransport({ + host: "127.0.0.1", + port: 5037, + }), + new DesktopTransport(), + ]; +} + +export type { Transport } from "./connector.ts"; +export * as Schema from "./schema/index.ts"; +export { defineTool, type ToolDefinition } from "./tools/defineTool.ts"; diff --git a/mcp-servers/devtool-mcp-server/src/main.ts b/mcp-servers/devtool-mcp-server/src/main.ts new file mode 100644 index 0000000..7cb382e --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/main.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import pkg from "../package.json" with { type: "json" }; + +async function main() { + const { setupServer } = await import("./index.ts"); + const mcpServer = new McpServer({ + name: "Lynx DevTool", + version: pkg.version, + }, { + instructions: `The Lynx DevTool MCP Server provides tools to interact with Lynx-based applications. + +Glossary: + 1. Device: The DevTool MCP Server can connect to multiple devices (simulators or real devices). + 2. Client: A device can have multiple clients (Lynx applications) opened. + 3. Session: Each client may open multiple sessions (e.g., Lynx pages with same or different URLs). + +Tool selection guidance: + 1. All tools have name '_'. is the domain of the tool, e.g., 'CSS', 'DOM', 'Debugger', 'Runtime', etc. Most of the tools would have the same functionality with Chrome DevTools Protocol. See documentation at: https://chromedevtools.github.io/devtools-protocol/tot//#method-. + +Tool usage guidance: + 1. Most of the tools would require a 'clientId' and a 'sessionId' parameter to identify the target client and session. You can get the list of connected devices, clients and sessions using the 'Device.listDevices', 'Device.listClients' and 'Device.listSessions' tools. +`, + }); + + setupServer(mcpServer); + + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); +} + +await main(); diff --git a/mcp-servers/devtool-mcp-server/src/schema/index.ts b/mcp-servers/devtool-mcp-server/src/schema/index.ts new file mode 100644 index 0000000..df2e635 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/schema/index.ts @@ -0,0 +1,93 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; + +export const clientId = z + .string() + .describe( + "The clientId to list sessions. Use `Device_listClients` to get the ID. If somehow no clients are found, tool `Device_openApp` (sometimes unavailable) may help.", + ); + +export const deviceId = z.string() + .describe( + "The deviceId. Use the `Device_listDevices` (if available, otherwise use `Device_acquireDevice`) to get ID for a devices.", + ); + +// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#type-NodeId +export const nodeId = z.number() + .describe("Identifier of the node. Unique DOM node identifier."); + +// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#type-BackendNodeId +export const backendNodeId = z.number() + .describe("Backend node identifier."); + +export const sessionId = z + .number() + .describe("The sessionId to list sessions. Use `Device_listSessions` to get the ID."); + +export const selector = z.string() + .describe("CSS selector string"); + +export const query = z.string() + .describe("Search query string"); + +export const searchId = z.union([z.number(), z.string()]) + .describe("Search identifier returned by `DOM.performSearch`. Pass it through unchanged."); + +export const fromIndex = z.number() + .describe("Start index for search results"); + +export const toIndex = z.number() + .describe("End index for search results"); + +export const x = z.number() + .describe("X coordinate"); + +export const y = z.number() + .describe("Y coordinate"); + +export const depth = z.number() + .optional() + .describe("Depth of child nodes to retrieve"); + +export const pierce = z.boolean() + .optional() + .describe("Whether to pierce through shadow DOM"); + +export const backendNodeIds = z.array(z.number()) + .describe("Array of backend node IDs"); + +export const includeUserAgentShadowDOM = z.boolean() + .optional() + .describe("Whether to include user agent shadow DOM in search"); + +// https://chromedevtools.github.io/devtools-protocol/tot/CSS/#type-StyleSheetId +export const styleSheetId = z.string() + .describe("Style sheet identifier"); + +// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#type-Rect +export const rect = z.object({ + x, + y, + width: z.number().describe("Width of the rectangle"), + height: z.number().describe("Height of the rectangle"), +}); + +export const scriptId = z.string() + .describe("Identifier of the script. Use `Debugger_listScripts` to get the script IDs of a session."); + +// Lynx DevTool native status codes (from devtool_status.cc) +export const screenshotMode = z.enum(["lynxview", "fullscreen"]) + .describe( + "Mode for screencast. `lynxview` captures only the viewable area of the page, while `fullscreen` captures the entire page.", + ); + +export const thread = z + .enum(["main", "background"]) + .optional() + .describe( + "Lynx has two Runtime/VM each on a separate thread. Some operations may need to specify the thread. Defaults to 'background'. 'main' for the main thread, 'background' for the background thread.", + ) + .default("background"); diff --git a/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts b/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts new file mode 100644 index 0000000..48a17e8 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts @@ -0,0 +1,25 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; +import { globalSwitchKeySchema } from "./globalSwitch.ts"; + +export const GetGlobalSwitch = /*#__PURE__*/ defineTool({ + name: "App_getGlobalSwitch", + description: "Get global switch state for one key.", + schema: { + clientId, + key: globalSwitchKeySchema, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + const value = await connector.getGlobalSwitch(params.clientId, params.key); + + response.appendLines(JSON.stringify({ key: params.key, value })); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts b/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts new file mode 100644 index 0000000..405e097 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts @@ -0,0 +1,36 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; +import { GLOBAL_SWITCH_KEYS } from "./globalSwitch.ts"; + +export const ListGlobalSwitch = /*#__PURE__*/ defineTool({ + name: "App_listGlobalSwitch", + description: "List all global switch states by querying each supported key.", + schema: { + clientId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + const switches: Array<{ key: string; value?: boolean; error?: string }> = []; + + for (const key of GLOBAL_SWITCH_KEYS) { + try { + const value = await connector.getGlobalSwitch(params.clientId, key); + switches.push({ key, value }); + } catch (error) { + switches.push({ + key, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + response.appendLines(JSON.stringify({ switches })); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts b/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts new file mode 100644 index 0000000..0cf08ad --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts @@ -0,0 +1,27 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; +import { clientId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; +import { globalSwitchKeySchema } from "./globalSwitch.ts"; + +export const SetGlobalSwitch = /*#__PURE__*/ defineTool({ + name: "App_setGlobalSwitch", + description: "Set global switch state for one key.", + schema: { + clientId, + key: globalSwitchKeySchema, + switch: z.boolean().describe("Switch value (true/false)."), + }, + annotations: { + readOnlyHint: false, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + await connector.setGlobalSwitch(params.clientId, params.key, params.switch); + + response.appendLines(JSON.stringify({ key: params.key, value: params.switch })); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts b/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts new file mode 100644 index 0000000..e8a4efd --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts @@ -0,0 +1,27 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; + +export const GLOBAL_SWITCH_KEYS = [ + "enable_devtool", + "enable_logbox", + "enable_debug_mode", + "enable_dom_tree", + "enable_quickjs_debug", + "enable_quickjs_cache", + "enable_v8", + "enable_cdp_domain_dom", + "enable_cdp_domain_css", + "enable_cdp_domain_page", + "enable_long_press_menu", + "enable_highlight_touch", + "enable_preview_screen_shot", + "enable_pixel_copy", + "enable_fsp_screenshot", +] as const; + +export const globalSwitchKeySchema = z + .enum(GLOBAL_SWITCH_KEYS) + .describe("Global switch key. Use `App_listGlobalSwitch` to inspect all keys."); diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts new file mode 100644 index 0000000..d8d612e --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts @@ -0,0 +1,29 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetBackgroundColors = /*#__PURE__*/ defineTool({ + name: "CSS_getBackgroundColors", + description: "Returns background color information for the node.", + schema: { + clientId, + sessionId, + nodeId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + const connector = context.connector(); + + // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getBackgroundColors + const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getBackgroundColors", { + nodeId, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts new file mode 100644 index 0000000..dd73332 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetComputedStyleForNode = /*#__PURE__*/ defineTool({ + name: "CSS_getComputedStyleForNode", + description: "Returns the computed style for a DOM node identified by nodeId.", + schema: { + clientId, + sessionId, + + nodeId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + const connector = context.connector(); + + // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getComputedStyleForNode + const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getComputedStyleForNode", { + nodeId, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts new file mode 100644 index 0000000..2bc03fc --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts @@ -0,0 +1,29 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetInlineStylesForNode = /*#__PURE__*/ defineTool({ + name: "CSS_getInlineStylesForNode", + description: "Returns inline style and style attribute for the node.", + schema: { + clientId, + sessionId, + nodeId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + const connector = context.connector(); + + // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getInlineStylesForNode + const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getInlineStylesForNode", { + nodeId, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts new file mode 100644 index 0000000..01e2811 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetMatchedStylesForNode = /*#__PURE__*/ defineTool({ + name: "CSS_getMatchedStylesForNode", + description: + "Returns CSS rules matching the specified node. The matchedCSSRules in the result are ordered by priority from high to low. When selectors have the same specificity, rules that appear later (further down) have higher priority.", + schema: { + clientId, + sessionId, + nodeId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + const connector = context.connector(); + + // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getMatchedStylesForNode + const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getMatchedStylesForNode", { + nodeId, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts new file mode 100644 index 0000000..408be31 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts @@ -0,0 +1,29 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, sessionId, styleSheetId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetStyleSheetText = /*#__PURE__*/ defineTool({ + name: "CSS_getStyleSheetText", + description: "Returns the text content of the stylesheet.", + schema: { + clientId, + sessionId, + styleSheetId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, styleSheetId } }, response, context) { + const connector = context.connector(); + + // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getStyleSheetText + const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getStyleSheetText", { + styleSheetId, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts new file mode 100644 index 0000000..441b42f --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts @@ -0,0 +1,38 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { backendNodeId, clientId, depth, nodeId, pierce, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const DescribeNode = /*#__PURE__*/ defineTool({ + name: "DOM_describeNode", + description: "Describe a DOM node, optionally including descendants.", + schema: { + clientId, + sessionId, + nodeId: nodeId.optional(), + backendNodeId: backendNodeId.optional(), + depth, + pierce, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId, backendNodeId, depth, pierce } }, response, context) { + const connector = context.connector(); + + await connector.sendCDPMessage(clientId, sessionId, "DOM.enable", { + useCompression: false, + }); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.describeNode", { + nodeId, + backendNodeId, + depth, + pierce, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts new file mode 100644 index 0000000..cfc51fa --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts @@ -0,0 +1,28 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetAttributes = /*#__PURE__*/ defineTool({ + name: "DOM_getAttributes", + description: "Get all attributes of the specified node.", + schema: { + clientId, + sessionId, + nodeId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getAttributes", { + nodeId, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts new file mode 100644 index 0000000..2bae6ca --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetBoxModel = /*#__PURE__*/ defineTool({ + name: "DOM_getBoxModel", + description: "Get the box model of an element.", + schema: { + clientId, + sessionId, + + nodeId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + const connector = context.connector(); + + // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getBoxModel + const box = await connector.sendCDPMessage(clientId, sessionId, "DOM.getBoxModel", { + nodeId, + }); + + response.appendLines(JSON.stringify(box)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts new file mode 100644 index 0000000..f1bb9bb --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts @@ -0,0 +1,35 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, depth, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetDocument = /*#__PURE__*/ defineTool({ + name: "DOM_getDocument", + description: "Get the document tree of the page.", + schema: { + clientId, + sessionId, + depth, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, depth } }, response, context) { + const connector = context.connector(); + + await connector.sendCDPMessage(clientId, sessionId, "DOM.enable", { + useCompression: false, + }); + + const tree = await connector.sendCDPMessage( + clientId, + sessionId, + "DOM.getDocument", + depth === undefined ? undefined : { depth }, + ); + + response.appendLines(JSON.stringify(tree)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts new file mode 100644 index 0000000..85e90cd --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts @@ -0,0 +1,29 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetDocumentWithBoxModel = /*#__PURE__*/ defineTool({ + name: "DOM_getDocumentWithBoxModel", + description: "Get the document tree of the Lynx page with box model information.", + schema: { + clientId, + sessionId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId } }, response, context) { + const connector = context.connector(); + + await connector.sendCDPMessage(clientId, sessionId, "DOM.enable", { + useCompression: false, + }); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getDocumentWithBoxModel"); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts new file mode 100644 index 0000000..d16d2d6 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, sessionId, x, y } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetNodeForLocation = /*#__PURE__*/ defineTool({ + name: "DOM_getNodeForLocation", + description: "Get the node at the specified coordinates.", + schema: { + clientId, + sessionId, + x, + y, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, x, y } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getNodeForLocation", { + x, + y, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts new file mode 100644 index 0000000..5141f1b --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts @@ -0,0 +1,28 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetOriginalNodeIndex = /*#__PURE__*/ defineTool({ + name: "DOM_getOriginalNodeIndex", + description: "Get the original index of the node in its parent.", + schema: { + clientId, + sessionId, + nodeId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getOriginalNodeIndex", { + nodeId, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts new file mode 100644 index 0000000..819e83b --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts @@ -0,0 +1,32 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, fromIndex, searchId, sessionId, toIndex } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetSearchResults = /*#__PURE__*/ defineTool({ + name: "DOM_getSearchResults", + description: "Get search results for the specified range.", + schema: { + clientId, + sessionId, + searchId, + fromIndex, + toIndex, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, searchId, fromIndex, toIndex } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getSearchResults", { + searchId, + fromIndex, + toIndex, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts new file mode 100644 index 0000000..6faee26 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts @@ -0,0 +1,28 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const InnerText = /*#__PURE__*/ defineTool({ + name: "DOM_innerText", + description: "Get the visible text content of the node.", + schema: { + clientId, + sessionId, + nodeId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.innerText", { + nodeId, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts new file mode 100644 index 0000000..928d4fd --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, includeUserAgentShadowDOM, query, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const PerformSearch = /*#__PURE__*/ defineTool({ + name: "DOM_performSearch", + description: "Search for nodes in the DOM tree.", + schema: { + clientId, + sessionId, + query, + includeUserAgentShadowDOM, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, query, includeUserAgentShadowDOM } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.performSearch", { + query, + includeUserAgentShadowDOM, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts new file mode 100644 index 0000000..1c178d9 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts @@ -0,0 +1,28 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { backendNodeIds, clientId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const PushNodesByBackendIdsToFrontend = /*#__PURE__*/ defineTool({ + name: "DOM_pushNodesByBackendIdsToFrontend", + description: "Push backend node IDs to the frontend for inspection.", + schema: { + clientId, + sessionId, + backendNodeIds, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, backendNodeIds } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.pushNodesByBackendIdsToFrontend", { + backendNodeIds, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts new file mode 100644 index 0000000..4c043d5 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts @@ -0,0 +1,32 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, selector, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const QuerySelector = /*#__PURE__*/ defineTool({ + name: "DOM_querySelector", + description: "Find the first element matching the CSS selector.", + schema: { + clientId, + sessionId, + nodeId: nodeId.optional().describe( + "Identifier of the node. Unique DOM node identifier. Defaults to root node if not specified.", + ), + selector, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId, selector } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.querySelector", { + nodeId, + selector, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts new file mode 100644 index 0000000..2924cbf --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts @@ -0,0 +1,32 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, selector, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const QuerySelectorAll = /*#__PURE__*/ defineTool({ + name: "DOM_querySelectorAll", + description: "Find all elements matching the CSS selector.", + schema: { + clientId, + sessionId, + nodeId: nodeId.optional().describe( + "Identifier of the node. Unique DOM node identifier. Defaults to root node if not specified.", + ), + selector, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId, selector } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.querySelectorAll", { + nodeId, + selector, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts new file mode 100644 index 0000000..bdce24f --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts @@ -0,0 +1,32 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, depth, nodeId, pierce, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const RequestChildNodes = /*#__PURE__*/ defineTool({ + name: "DOM_requestChildNodes", + description: "Request child nodes for a given parent node.", + schema: { + clientId, + sessionId, + nodeId, + depth, + pierce, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId, nodeId, depth, pierce } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.requestChildNodes", { + nodeId, + depth, + pierce, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts new file mode 100644 index 0000000..75a2fb6 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts @@ -0,0 +1,32 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, nodeId, rect, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const ScrollIntoViewIfNeeded = /*#__PURE__*/ defineTool({ + name: "DOM_scrollIntoViewIfNeeded", + description: "Scrolls the specified rect of the given node into view if not already visible.", + schema: { + clientId, + sessionId, + nodeId, + rect: rect.describe( + "The rect to be scrolled into view, relative to the node's border box, in CSS pixels. When omitted, center of the node will be used.", + ).optional(), + }, + annotations: { + readOnlyHint: false, + }, + async handler({ params: { clientId, sessionId, nodeId, rect } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.scrollIntoViewIfNeeded", { + nodeId, + rect, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts b/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts new file mode 100644 index 0000000..643451f --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts @@ -0,0 +1,41 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const SetAttributesAsText = /*#__PURE__*/ defineTool({ + name: "DOM_setAttributesAsText", + description: "Set node attributes from a text representation.", + schema: { + clientId, + sessionId, + nodeId, + text: z.string().describe("Attribute text, for example `style='color: pink;'`."), + name: z.string().optional().describe("Optional attribute name to replace."), + }, + annotations: { + readOnlyHint: false, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + const cdpParams = Object.fromEntries( + [ + ["nodeId", params.nodeId], + ["text", params.text], + ["name", params.name], + ].filter(([, value]) => value !== undefined), + ); + + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "DOM.setAttributesAsText", + cdpParams, + ); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts b/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts new file mode 100644 index 0000000..4d54e12 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts @@ -0,0 +1,52 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import * as z from "zod"; +import { clientId, scriptId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetScriptSource = /*#__PURE__*/ defineTool({ + name: "Debugger_getScriptSource", + description: + "Get the source code of a script identified by the given script identifier. Use `saveToTmp` if the response is too large.", + schema: { + clientId, + sessionId, + + scriptId, + saveToTmp: z.boolean() + .optional() + .describe("Whether to save the script source to a temporary file if it is large.") + .default(false), + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + + const { scriptSource } = await connector.sendCDPMessage<{ scriptSource: string }, { scriptId: string }>( + params.clientId, + params.sessionId, + "Debugger.getScriptSource", + { scriptId: params.scriptId }, + ); + + if (params.saveToTmp) { + const tmp = await fs.mkdtemp(path.join(tmpdir(), "lynx-devtool-mcp-")); + await fs.writeFile( + path.join(tmp, `${params.scriptId}.js`), + scriptSource, + ); + response.appendLines(`Script saved to ${tmp}/${params.scriptId}.js`); + } else { + response.appendLines("```javascript"); + response.appendLines(scriptSource); + response.appendLines("```"); + } + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts b/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts new file mode 100644 index 0000000..b97f9bf --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts @@ -0,0 +1,66 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { ReadableStream } from "node:stream/web"; +import { setTimeout } from "node:timers/promises"; +import { clientId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const ListScripts = /*#__PURE__*/ defineTool({ + name: "Debugger_listScripts", + description: + "List all parsed scripts. If no scripts found, it means that the page is opened before the DevTool connected. Use `Page_reload` to reload the page and get the scripts again.", + schema: { + clientId, + sessionId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params, extra }, response, context) { + const connector = context.connector(); + + await using stream = await connector.sendCDPStream( + params.clientId, + params.sessionId, + ReadableStream.from([{ + method: "Debugger.enable", + }]), + { signal: extra.signal }, + ); + + const scripts: { scriptId: string; url: string }[] = []; + + const reader = stream.getReader(); + const IDLE_TIMEOUT = 200; + const MAX_TOTAL_TIME = 2000; + const startTime = Date.now(); + + try { + while (Date.now() - startTime < MAX_TOTAL_TIME) { + const result = await Promise.race([ + reader.read(), + setTimeout(IDLE_TIMEOUT, "timeout" as const), + ]); + if (result === "timeout") { + await reader.cancel(); + break; + } + + const { done, value } = result; + if (done) break; + + if (value.method === "Debugger.scriptParsed") { + scripts.push(value.params as never); + } + } + } finally { + reader.releaseLock(); + } + + response.appendLines( + ...scripts.map(({ scriptId, url }) => `- scriptId: ${scriptId}, url: ${url}`), + ); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts b/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts new file mode 100644 index 0000000..9d8af8f --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts @@ -0,0 +1,22 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const ClosePage = /*#__PURE__*/ defineTool({ + name: "Device_closePage", + description: "Close the current page", + schema: { + clientId, + }, + annotations: { + readOnlyHint: false, + }, + async handler({ params }, _, context) { + const connector = context.connector(); + + await connector.sendAppMessage(params.clientId, "App.closePage"); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts b/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts new file mode 100644 index 0000000..fd18dfa --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts @@ -0,0 +1,21 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { defineTool } from "../defineTool.ts"; + +export const ListClients = /*#__PURE__*/ defineTool({ + name: "Device_listClients", + description: "List all connected clients. This tool may timeout if no clients are connected or just started.", + schema: {}, + annotations: { + readOnlyHint: true, + }, + async handler(_, response, context) { + const connector = context.connector(); + + const clients = await connector.listClients(); + + response.appendLines(JSON.stringify(clients, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts b/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts new file mode 100644 index 0000000..aad410d --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts @@ -0,0 +1,25 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { defineTool } from "../defineTool.ts"; + +export const ListDevices = /*#__PURE__*/ defineTool({ + name: "Device_listDevices", + description: "List all connected devices.", + schema: {}, + annotations: { + readOnlyHint: true, + }, + async handler(_, response, context) { + const connector = context.connector(); + + const devices = await connector.listDevices(); + + response.appendLines(JSON.stringify( + devices.map(({ id }) => id), + null, + 2, + )); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts b/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts new file mode 100644 index 0000000..8c2ada8 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts @@ -0,0 +1,24 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const ListSessions = /*#__PURE__*/ defineTool({ + name: "Device_listSessions", + description: "List all opened sessions", + schema: { + clientId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + + const sessions = await connector.sendListSessionMessage(params.clientId); + + response.appendLines(JSON.stringify(sessions)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts b/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts new file mode 100644 index 0000000..6f21d64 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts @@ -0,0 +1,55 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { isListSessionResponse } from "@lynx-js/devtool-connector"; +import { FilterTransformStream } from "@lynx-js/devtool-connector/streams"; +import * as z from "zod"; +import { clientId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const OpenPage = /*#__PURE__*/ defineTool({ + name: "Device_openPage", + description: "Open a page", + schema: { + url: z.string().describe("The URL of the page"), + clientId, + }, + annotations: { + readOnlyHint: false, + }, + async handler({ params }, _, context) { + const connector = context.connector(); + + // The built-in headless runtime downloads its binary lazily on first use. + // Wait for it here (tool layer) so the open request below is not cut off + // while the binary is still downloading. + if (params.clientId.startsWith("headless:")) { + await connector.waitForHeadlessReady(params.clientId); + } + + try { + await connector.sendAppMessage(params.clientId, "App.openPage", { + url: params.url, + }); + } catch { + await connector.sendMessage(params.clientId, { + event: "Customized", + data: { + type: "OpenCard", + data: { + type: "url", + url: params.url, + }, + sender: -1, + }, + from: -1, + }, { + input: [], + output: [ + new FilterTransformStream(isListSessionResponse), + ], + }); + } + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts b/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts new file mode 100644 index 0000000..f04964d --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts @@ -0,0 +1,172 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { type CDPResponseMessage, CDPResponseTransformStream } from "@lynx-js/devtool-connector"; +import { randomInt } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { ReadableStream } from "node:stream/web"; +import { setTimeout } from "node:timers/promises"; +import { clientId, sessionId, thread } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ + name: "HeapProfiler_takeHeapSnapshot", + description: "Take a heap snapshot and save it to a .heapsnapshot file.", + schema: { + clientId, + sessionId, + thread, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params, extra }, response, context) { + const connector = context.connector(); + const expectedSessionId = params.thread === "main" ? "Main" : undefined; + const extraParams = expectedSessionId ? { sessionId: expectedSessionId } : {}; + + const timeoutSignal = AbortSignal.timeout(60_000); // 60s timeout for heap snapshot + const signal = extra.signal + ? AbortSignal.any([extra.signal, timeoutSignal]) + : timeoutSignal; + + const requestId = randomInt(10_000, 50_000); + + await using stream = await connector.sendStream( + params.clientId, + ReadableStream.from([{ + event: "Customized", + data: { + type: "CDP", + data: { + session_id: params.sessionId, + message: { + id: requestId - 1, + method: "HeapProfiler.enable", + params: {}, + ...extraParams, + }, + }, + }, + }, { + event: "Customized", + data: { + type: "CDP", + data: { + session_id: params.sessionId, + message: { + id: requestId, + method: "HeapProfiler.takeHeapSnapshot", + params: { + reportProgress: true, + treatGlobalObjectsAsRoots: true, + captureNumericValue: false, + }, + ...extraParams, + }, + }, + }, + }]), + { + signal, + pipeline: { + input: [], + output: [ + new CDPResponseTransformStream(), + ], + }, + }, + ); + + let didReceiveSnapshotResponse = false; + const tmpFile = path.join( + tmpdir(), + `heap-${params.thread === "main" ? "main" : "background"}-${Date.now()}.heapsnapshot`, + ); + + const reader = stream.getReader(); + const IDLE_TIMEOUT = 15000; + const MAX_TOTAL_TIME = 60000; + const startTime = Date.now(); + let didWriteSnapshotChunk = false; + let shouldKeepSnapshotFile = false; + + try { + async function* snapshotChunks() { + while (Date.now() - startTime < MAX_TOTAL_TIME) { + const result = await Promise.race([ + reader.read(), + setTimeout(IDLE_TIMEOUT, "timeout" as const), + ]); + + if (result === "timeout") { + await reader.cancel(); + break; + } + + const { done, value } = result; + if (done) break; + + const { method, params: eventParams, id, sessionId } = value as CDPResponseMessage & { + method?: string; + params?: { + chunk?: string; + finished?: boolean; + }; + sessionId?: string; + }; + + if (method === "HeapProfiler.addHeapSnapshotChunk") { + if (sessionId !== expectedSessionId) { + continue; + } + + const chunk = eventParams?.chunk; + if (!chunk) { + continue; + } + + didWriteSnapshotChunk = true; + yield chunk; + if (didReceiveSnapshotResponse) { + break; + } + } else if (method === "HeapProfiler.reportHeapSnapshotProgress") { + if (sessionId !== expectedSessionId) { + continue; + } + } else if (id === requestId) { + didReceiveSnapshotResponse = true; + if (didWriteSnapshotChunk) { + break; + } + } + } + } + + await pipeline( + snapshotChunks(), + createWriteStream(tmpFile, { encoding: "utf8" }), + { signal }, + ); + + if (!didWriteSnapshotChunk) { + throw new Error("Failed to capture heap snapshot, no chunks received or timed out."); + } + + shouldKeepSnapshotFile = true; + } finally { + reader.releaseLock(); + if (!shouldKeepSnapshotFile) { + await fs.unlink(tmpFile).catch(() => {}); + } + } + + response.appendLines(`Heap snapshot saved to ${tmpFile}`); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts b/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts new file mode 100644 index 0000000..a67ccb9 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts @@ -0,0 +1,47 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; +import { clientId, sessionId, x, y } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +const type = z.enum(["mousePressed", "mouseReleased", "mouseMoved"]).describe("Type of mouse event"); +const timestamp = z.number().describe("Timestamp of the mouse event"); +const button = z.enum(["left", "middle", "right"]).describe("Mouse button"); +const deltaX = z.number().optional().describe("Horizontal scroll delta (optional)"); +const deltaY = z.number().optional().describe("Vertical scroll delta (optional)"); + +export const EmulateTouchFromMouseEvent = defineTool({ + name: "Input_emulateTouchFromMouseEvent", + description: "Emulate touch from mouse event - converts mouse events to touch events for testing touch interactions", + annotations: { + readOnlyHint: false, + }, + schema: { + clientId, + sessionId, + type, + x, + y, + timestamp, + button, + deltaX, + deltaY, + }, + async handler({ params: { clientId, sessionId, type, x, y, timestamp, button, deltaX, deltaY } }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(clientId, sessionId, "Input.emulateTouchFromMouseEvent", { + type, + x, + y, + timestamp, + button, + deltaX, + deltaY, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts b/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts new file mode 100644 index 0000000..7cebeed --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts @@ -0,0 +1,25 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetVersion = /*#__PURE__*/ defineTool({ + name: "Lynx_getVersion", + description: "Return the Lynx engine version for the selected session.", + schema: { + clientId, + sessionId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(params.clientId, params.sessionId, "Lynx.getVersion", {}); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts b/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts new file mode 100644 index 0000000..c107d7c --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts @@ -0,0 +1,54 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; +import { clientId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +const GLOBAL_CDP_SESSION_ID = -1; +const MAX_MEMORY_USAGE_TIMEOUT_MS = 300_000; + +const globalSessionId = z + .number() + .int() + .optional() + .describe( + "CDP session ID. Defaults to -1 for the global DevTool handler. Override only for platform-specific routing.", + ); + +const timeoutMs = z + .number() + .int() + .min(0) + .max(MAX_MEMORY_USAGE_TIMEOUT_MS) + .optional() + .describe( + `Optional query timeout in milliseconds. Must be between 0 and ${MAX_MEMORY_USAGE_TIMEOUT_MS}.`, + ); + +export const GetAllMemoryUsage = /*#__PURE__*/ defineTool({ + name: "Memory_getAllMemoryUsage", + description: "Get global Lynx-attributed memory usage across live registered Lynx instances.", + schema: { + clientId, + sessionId: globalSessionId, + timeoutMs, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + const requestParams = params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }; + + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId ?? GLOBAL_CDP_SESSION_ID, + "Memory.getAllMemoryUsage", + requestParams, + ); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts b/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts new file mode 100644 index 0000000..7a68006 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts @@ -0,0 +1,45 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; +import { clientId, nodeId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetResourceContent = /*#__PURE__*/ defineTool({ + name: "Page_getResourceContent", + description: "Return the content of a page resource by URL or Lynx node id.", + schema: { + clientId, + sessionId, + url: z.string().optional().describe("Resource URL returned by Page_getResourceTree."), + frameId: z.string().optional().describe("Frame id returned by Page_getResourceTree, when present."), + nodeId: nodeId.optional().describe("Lynx node id for engines that resolve resource content by node."), + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + if (!params.url && params.nodeId === undefined) { + throw new Error("Either url or nodeId is required."); + } + + const connector = context.connector(); + const cdpParams = Object.fromEntries( + [ + ["url", params.url], + ["frameId", params.frameId], + ["nodeId", params.nodeId], + ].filter(([, value]) => value !== undefined), + ); + + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Page.getResourceContent", + cdpParams, + ); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts b/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts new file mode 100644 index 0000000..76de0d1 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts @@ -0,0 +1,25 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetResourceTree = /*#__PURE__*/ defineTool({ + name: "Page_getResourceTree", + description: "Return the page resource tree for the selected session.", + schema: { + clientId, + sessionId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(params.clientId, params.sessionId, "Page.getResourceTree", {}); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts b/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts new file mode 100644 index 0000000..7f0a833 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts @@ -0,0 +1,35 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; +import { clientId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const Reload = /*#__PURE__*/ defineTool({ + name: "Page_reload", + description: "Reload the current page.", + schema: { + clientId, + sessionId, + url: z.string() + .describe("The URL to reload, if different from the current page. Optional.") + .optional(), + ignoreCache: z.boolean() + .describe("Whether to ignore the cache when reloading the page. Defaults to `true`") + .default(true), + }, + annotations: { + readOnlyHint: false, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + + const result = await connector.sendCDPMessage(params.clientId, params.sessionId, "Page.reload", { + ignoreCache: params.ignoreCache, + url: params.url, + }); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts b/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts new file mode 100644 index 0000000..078cd86 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts @@ -0,0 +1,84 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ReadableStream } from "node:stream/web"; +import { setTimeout } from "node:timers/promises"; +import { clientId, screenshotMode, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const TakeScreenshot = /*#__PURE__*/ defineTool({ + name: "Page_takeScreenshot", + description: "Take a screenshot of the current page.", + schema: { + clientId, + sessionId, + screenshotMode: screenshotMode.optional(), + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params, extra }, response, context) { + const connector = context.connector(); + + const timeoutSignal = AbortSignal.timeout(10_000); + const signal = extra.signal + ? AbortSignal.any([extra.signal, timeoutSignal]) + : timeoutSignal; + + const { promise, resolve } = Promise.withResolvers(); + + await using stream = await connector.sendCDPStream( + params.clientId, + params.sessionId, + new ReadableStream({ + async start(controller) { + controller.enqueue({ + method: "Page.startScreencast", + params: { + "format": "jpeg", + "quality": 80, + "mode": params.screenshotMode ?? "lynxview", + }, + }); + await Promise.race([ + promise, + setTimeout(10_000, undefined, { signal }).catch(() => {}), + ]); + controller.enqueue({ + method: "Page.stopScreencast", + }); + controller.close(); + }, + }), + { signal }, + ); + + for await (const { method, params: eventParams } of stream) { + if (method === "Page.screencastFrame") { + const { data } = eventParams as { data: string }; + if (data) { + resolve(); + response.attachImage({ + data, + mimeType: "image/jpeg", + }); + + const tmp = await fs.mkdtemp(path.join(tmpdir(), "lynx-devtool-mcp-")); + const fileName = `screenshot-Lynx_getScreenshot.jpeg`; + await fs.writeFile( + path.join(tmp, fileName), + Buffer.from(data, "base64"), + ); + response.appendLines(`Screenshot saved to ${tmp}/${fileName}`); + return; + } + } + } + + throw new Error("Failed to capture screenshot, no Page.screencastFrame event received within 10 seconds."); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts b/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts new file mode 100644 index 0000000..b611a03 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts @@ -0,0 +1,37 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetAllPerformanceEntries = /*#__PURE__*/ defineTool({ + name: "Performance_getAllPerformanceEntries", + description: "Get all cached PerformanceEntry objects from the current page.", + schema: { + clientId, + sessionId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + + await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Performance.enable", + {}, + ); + + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Performance.getAllPerformanceEntries", + {}, + ); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts b/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts new file mode 100644 index 0000000..e01ac2e --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts @@ -0,0 +1,37 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetAllTimingInfo = /*#__PURE__*/ defineTool({ + name: "Performance_getAllTimingInfo", + description: "Get all metric time durations(FR3 / PPE / FCP ..etc)", + schema: { + clientId, + sessionId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + + await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Performance.enable", + {}, + ); + + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Performance.getAllTimingInfo", + {}, + ); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts b/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts new file mode 100644 index 0000000..71a5f8e --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts @@ -0,0 +1,65 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; +import { clientId, sessionId, thread } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const Evaluate = /*#__PURE__*/ defineTool({ + name: "Runtime_evaluate", + description: "Evaluate a JavaScript expression in the selected Lynx VM.", + schema: { + clientId, + sessionId, + thread, + expression: z.string().describe("JavaScript expression to evaluate."), + silent: z.boolean().optional().describe("Do not report or pause on exceptions during evaluation."), + contextId: z.number().int().optional().describe("Execution context id to evaluate in."), + throwOnSideEffect: z.boolean().optional().describe("Throw if side effects cannot be ruled out."), + generatePreview: z.boolean().optional().describe("Whether to generate a preview for the result."), + objectGroup: z.string().optional().describe("Symbolic group name for released remote objects."), + returnByValue: z.boolean().optional().describe("Return the result by value when supported by the engine."), + awaitPromise: z.boolean().optional().describe("Await the resulting promise when supported by the engine."), + includeCommandLineAPI: z.boolean().optional().describe("Expose command line API during evaluation when supported."), + }, + annotations: { + readOnlyHint: false, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + const isMainThread = params.thread === "main"; + + await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Runtime.enable", + {}, + isMainThread, + ); + + const evaluateParams = Object.fromEntries( + [ + ["expression", params.expression], + ["silent", params.silent], + ["contextId", params.contextId], + ["throwOnSideEffect", params.throwOnSideEffect], + ["generatePreview", params.generatePreview], + ["objectGroup", params.objectGroup], + ["returnByValue", params.returnByValue], + ["awaitPromise", params.awaitPromise], + ["includeCommandLineAPI", params.includeCommandLineAPI], + ].filter(([, value]) => value !== undefined), + ); + + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Runtime.evaluate", + evaluateParams, + isMainThread, + ); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts b/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts new file mode 100644 index 0000000..50d9f82 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts @@ -0,0 +1,40 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, sessionId, thread } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetHeapUsage = /*#__PURE__*/ defineTool({ + name: "Runtime_getHeapUsage", + description: "Returns the JavaScript heap usage for the given session.", + schema: { + clientId, + sessionId, + thread, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + + await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Runtime.enable", + {}, + params.thread === "main", + ); + + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Runtime.getHeapUsage", + {}, + params.thread === "main", + ); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts b/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts new file mode 100644 index 0000000..e96f1c9 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts @@ -0,0 +1,57 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as z from "zod"; +import { clientId, sessionId, thread } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetProperties = /*#__PURE__*/ defineTool({ + name: "Runtime_getProperties", + description: "Return properties for a Runtime remote object.", + schema: { + clientId, + sessionId, + thread, + objectId: z.string().describe("Remote object id returned by Runtime.evaluate or console output."), + ownProperties: z.boolean().optional().describe("Return only properties owned by the object."), + accessorPropertiesOnly: z.boolean().optional().describe("Return accessor properties only when supported."), + generatePreview: z.boolean().optional().describe("Whether to generate previews for property values."), + nonIndexedPropertiesOnly: z.boolean().optional().describe("Return non-indexed properties only when supported."), + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + const isMainThread = params.thread === "main"; + + await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Runtime.enable", + {}, + isMainThread, + ); + + const getPropertiesParams = Object.fromEntries( + [ + ["objectId", params.objectId], + ["ownProperties", params.ownProperties], + ["accessorPropertiesOnly", params.accessorPropertiesOnly], + ["generatePreview", params.generatePreview], + ["nonIndexedPropertiesOnly", params.nonIndexedPropertiesOnly], + ].filter(([, value]) => value !== undefined), + ); + + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + "Runtime.getProperties", + getPropertiesParams, + isMainThread, + ); + + response.appendLines(JSON.stringify(result, null, 2)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts b/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts new file mode 100644 index 0000000..ca497fe --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts @@ -0,0 +1,153 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { ReadableStream } from "node:stream/web"; +import { setTimeout } from "node:timers/promises"; +import * as z from "zod"; +import { clientId, sessionId, thread } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +interface ConsoleCallFrame { + url: string; + lineNumber: number; + columnNumber: number; +} + +interface ConsoleStackTrace { + callFrames: ConsoleCallFrame[]; +} + +interface ConsoleArg { + value?: unknown; + className?: string; + description?: string; + objectId?: string; +} + +interface ConsoleMessage { + type: string; + args: ConsoleArg[]; + stackTrace?: ConsoleStackTrace; + consoleTag?: string; +} + +export const ListConsole = /*#__PURE__*/ defineTool({ + name: "Runtime_listConsole", + description: "List all console messages.", + schema: { + clientId, + sessionId, + + offset: z.number().optional().describe("The number of console messages to skip before returning results."), + limit: z.number().min(1).max(100).optional().describe("The maximum number of console messages to return."), + includeStackTraces: z.boolean().optional().describe( + "By default, only error messages would contain stack traces. Set this to true to include stack traces for all messages in the output.", + ), + level: z.array(z.enum(["log", "info", "warning", "error"])).optional().describe( + "The log level to filter messages. Defaults to ['info', 'log', 'warning', 'error']", + ), + thread: z.array(thread).optional().describe("VM thread to target: background or main. Defaults to both."), + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params }, response, context) { + const connector = context.connector(); + + const { + offset = 0, + limit, + includeStackTraces = false, + level = ["info", "log", "warning", "error"], + thread: threads = ["background", "main"], + } = params; + + await using stream = await connector.sendCDPStream( + params.clientId, + params.sessionId, + ReadableStream.from([ + { + method: "Page.enable", + }, + ...threads.map((t: "background" | "main") => ({ + method: "Runtime.enable", + sessionId: t === "main" ? "Main" : undefined, + })), + ]), + ); + + const messages: ConsoleMessage[] = []; + let skipped = 0; + + const reader = stream.getReader(); + const IDLE_TIMEOUT = 500; + const MAX_TOTAL_TIME = 5000; + const startTime = Date.now(); + + try { + while (Date.now() - startTime < MAX_TOTAL_TIME) { + const result = await Promise.race([ + reader.read(), + setTimeout(IDLE_TIMEOUT, "timeout" as const), + ]); + if (result === "timeout") { + await reader.cancel(); + break; + } + + const { done, value } = result; + if (done) break; + + if (value.method === "Runtime.consoleAPICalled") { + const message = value.params as ConsoleMessage; + if (!level.includes(message.type as "log" | "info" | "warning" | "error")) { + continue; + } + + if (skipped < offset) { + skipped++; + continue; + } + + if (!includeStackTraces && message.type !== "error") { + delete message.stackTrace; + } + + messages.push(message); + + if (limit && messages.length >= limit) { + await reader.cancel(); + break; + } + } + } + } finally { + reader.releaseLock(); + } + + response.appendLines( + ...messages + .map(({ type, args, stackTrace, consoleTag }) => + `- [${type}/${consoleTag === "Lepus" ? "main-thread" : "background"}]: ${ + args + .map(arg => { + if (arg.objectId) { + return `<${arg.description || arg.className || "Object"} (objectId:${arg.objectId})>`; + } + return String(arg.value); + }) + .join(" ") + }${ + stackTrace + ? `\n${ + stackTrace.callFrames + .map(({ url, lineNumber, columnNumber }) => ` at ${url}:${lineNumber}:${columnNumber}`) + .join("\n") + }` + : "" + }` + ), + ); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts b/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts new file mode 100644 index 0000000..5e225ae --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { clientId, sessionId } from "../../schema/index.ts"; +import { defineTool } from "../defineTool.ts"; + +export const GetLynxUITree = /*#__PURE__*/ defineTool({ + name: "UITree_getLynxUITree", + description: + "Get the rendered Lynx UI tree with native UI metadata. The metadata fields tagName, nodeIndex, props, and label require Lynx 4.0 or newer.", + schema: { + clientId, + sessionId, + }, + annotations: { + readOnlyHint: true, + }, + async handler({ params: { clientId, sessionId } }, response, context) { + const connector = context.connector(); + + await connector.sendCDPMessage(clientId, sessionId, "UITree.enable", { + useCompression: false, + }); + + const result = await connector.sendCDPMessage(clientId, sessionId, "UITree.getLynxUITree"); + + response.appendLines(JSON.stringify(result)); + }, +}); diff --git a/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts b/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts new file mode 100644 index 0000000..6c92ae9 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts @@ -0,0 +1,52 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { Connector } from "@lynx-js/devtool-connector"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js"; +import type * as z from "zod"; + +export interface Request { + params: z.infer>; + extra: RequestHandlerExtra; +} + +export interface Response { + appendLines(...lines: string[]): void; + + attachImage(value: ImageContentData): void; +} + +export interface Context { + connector(): Connector; +} + +export interface ImageContentData { + data: string; + mimeType: string; +} + +export interface ToolDefinition { + name: string; + description: string; + annotations: { + title?: string; + /** + * If true, the tool does not modify its environment. + */ + readOnlyHint: boolean; + }; + schema: Schema; + handler: ( + request: Request, + response: Response, + context: Context, + ) => Promise; +} + +export function defineTool( + definition: ToolDefinition, +): ToolDefinition { + return definition; +} diff --git a/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts b/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts new file mode 100644 index 0000000..5fa3bef --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts @@ -0,0 +1,22 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert/strict"; +import test from "node:test"; +import type { McpContext } from "../src/McpContext.ts"; +import { McpResponse } from "../src/McpResponse.ts"; + +test("McpResponse emits appended text without a generated title", async () => { + const response = new McpResponse(); + response.appendLines(JSON.stringify({ ok: true })); + + const content = await response.handle("Example_tool", {} as McpContext); + + assert.deepEqual(content, [ + { + type: "text", + text: "{\"ok\":true}", + }, + ]); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/AGENTS.md b/mcp-servers/devtool-mcp-server/test/tools/AGENTS.md new file mode 100644 index 0000000..26eb3eb --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/AGENTS.md @@ -0,0 +1,78 @@ +# MCP Tools 测试指南 (Testing Guidelines) + +本文档旨在指导 AI Agent 和开发者如何为 `devtool-mcp-server` 中的工具编写测试用例。 + +## 1. 测试架构概览 + +为了提高测试效率并减少连接开销,所有的工具测试都合并在 `e2e/tools.test.ts` 中,共享同一个 `testWithClient` 会话。 + +我们提供了 `createToolContext` 辅助函数。它基于 `node:test` 框架,并在已有的连接中处理以下设置: + +- **环境交互**: 提供 `openPage` 辅助函数。 +- **上下文模拟**: 构造 `McpContext` 和 `McpResponse`。 +- **参数注入**: 自动填充 `clientId` 和 `sessionId`。 +- **结果解析**: 自动解析工具返回的 JSON 字符串。 + +## 2. 文件结构 + +- **源码**: `src/tools//.ts` +- **测试**: `e2e/tools.test.ts` (所有工具测试均在此文件中以 `t.test` 子测试形式存在) + +## 3. 编写测试 + +在 `e2e/tools.test.ts` 的 `testWithClient` 块内添加一个新的 `t.test`。 + +### 基本模板 + +```typescript +import type { TestContext } from "node:test"; +import { MyTool } from "../src/tools/MyDomain/MyTool.ts"; +import { createToolContext } from "./utils/testTool.ts"; + +// 在 testWithClient 块内部: +await suite.test("MyDomain.myTool", async (t: TestContext) => { + // 1. 创建工具上下文 + const { call, openPage } = createToolContext(MyTool, connector, clientId); + + // 2. (可选) 准备环境,例如打开特定页面 + // await openPage("https://www.example.com"); + + // 3. 调用工具 + // call 函数会自动处理 clientId 和 sessionId (如果 schema 中有定义) + const result = await call({ + // 在此传入工具特有的参数 + myParam: "value", + }); + + // 4. 断言结果 + // 如果工具返回 JSON 字符串,result 会是被自动 JSON.parse 后的对象 + t.assert.ok(result); + t.assert.equal(result.someField, "expectedValue"); +}); +``` + +### `call` 函数特性 + +`call` 是测试的核心帮助函数,具有以下“魔法”: + +1. **自动注入 `clientId`**: 如果工具的 Schema 定义了 `clientId` 字段且调用时未提供,会自动注入当前测试连接的 Client ID。 +2. **自动注入 `sessionId`**: 如果工具的 Schema 定义了 `sessionId` 字段且调用时未提供,会自动调用 `ListSessions` 获取第一个会话的 ID 并注入。 +3. **智能返回**: + - 如果工具通过 `response.appendLines` 返回了 JSON 字符串,`call` 会返回解析后的 **JavaScript 对象**。 + - 如果返回普通文本,则返回字符串。 + - 如果返回包含图像等多模态内容,则返回完整的 `Content` 数组。 + +## 4. 运行测试 + +在 `devtool-mcp-server` 目录下: + +```bash +# 运行工具测试 +node --test e2e/tools.test.ts +``` + +## 5. 最佳实践 + +- **尽量少 Mock**: 我们的测试环境是真实的(连接到真实设备或模拟器),尽量测试真实的 CDP 交互。 +- **关注 Schema**: 确保传入 `call` 的参数符合工具 Schema 的定义(除了自动注入的 ID)。 +- **验证结构**: 对于返回大段 DOM 树或复杂对象的工具,通常验证根节点或关键字段的存在性即可,不必断言整个对象。 diff --git a/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts b/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts new file mode 100644 index 0000000..20dc515 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts @@ -0,0 +1,89 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { GetGlobalSwitch } from "../../src/tools/App/GetGlobalSwitch.ts"; +import { ListGlobalSwitch } from "../../src/tools/App/ListGlobalSwitch.ts"; +import { SetGlobalSwitch } from "../../src/tools/App/SetGlobalSwitch.ts"; +import { createToolContext } from "../utils/testTool.ts"; + +const createMockConnector = (overrides: Record = {}) => ({ + getGlobalSwitch: async () => false, + setGlobalSwitch: async () => {}, + sendListSessionMessage: async () => [{ session_id: "mock_session" }], + ...overrides, +}); + +describe("App global switch tools", () => { + const testClientId = "test-client-id"; + + test("App_getGlobalSwitch should read one key", async () => { + let called: { clientId: string; key: string } | null = null; + const mockConnector = createMockConnector({ + getGlobalSwitch: async (clientId: string, key: string) => { + called = { clientId, key }; + return true; + }, + }); + + const { call } = createToolContext(GetGlobalSwitch, mockConnector as never, testClientId); + const result = await call<{ key: string; value: boolean }>({ key: "enable_devtool" }); + + assert.deepStrictEqual(called, { clientId: testClientId, key: "enable_devtool" }); + assert.deepStrictEqual(result, { key: "enable_devtool", value: true }); + }); + + test("App_setGlobalSwitch should write one key", async () => { + let called: { clientId: string; key: string; value: boolean } | null = null; + const mockConnector = createMockConnector({ + setGlobalSwitch: async (clientId: string, key: string, value: boolean) => { + called = { clientId, key, value }; + }, + }); + + const { call } = createToolContext(SetGlobalSwitch, mockConnector as never, testClientId); + const result = await call<{ key: string; value: boolean }>({ key: "enable_devtool", switch: false }); + + assert.deepStrictEqual(called, { clientId: testClientId, key: "enable_devtool", value: false }); + assert.deepStrictEqual(result, { key: "enable_devtool", value: false }); + }); + + test("App_listGlobalSwitch should return all key states", async () => { + const mockConnector = createMockConnector({ + getGlobalSwitch: async (_clientId: string, key: string) => key !== "enable_logbox", + }); + + const { call } = createToolContext(ListGlobalSwitch, mockConnector as never, testClientId); + const result = await call<{ switches: Array<{ key: string; value?: boolean; error?: string }> }>({}); + + assert.equal(result.switches.length, 15); + assert.deepStrictEqual( + result.switches.find(item => item.key === "enable_devtool"), + { key: "enable_devtool", value: true }, + ); + assert.deepStrictEqual( + result.switches.find(item => item.key === "enable_logbox"), + { key: "enable_logbox", value: false }, + ); + }); + + test("App_listGlobalSwitch should keep per-key failure", async () => { + const mockConnector = createMockConnector({ + getGlobalSwitch: async (_clientId: string, key: string) => { + if (key === "enable_v8") { + throw new Error("transport timeout"); + } + + return true; + }, + }); + + const { call } = createToolContext(ListGlobalSwitch, mockConnector as never, testClientId); + const result = await call<{ switches: Array<{ key: string; value?: boolean; error?: string }> }>({}); + const failed = result.switches.find(item => item.key === "enable_v8"); + + assert.equal(typeof failed?.error, "string"); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts b/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts new file mode 100644 index 0000000..5250431 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts @@ -0,0 +1,152 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { createToolContext } from "../utils/testTool.ts"; + +type SentCDPMessage = { + clientId: string; + sessionId: number; + method: string; + params?: Record; +}; + +async function loadDescribeNodeTool() { + const module = await import("../../src/tools/DOM/DescribeNode.ts").catch(() => undefined); + + assert.ok(module && "DescribeNode" in module, "Expected DOM DescribeNode tool module to exist"); + + return module.DescribeNode; +} + +describe("DOM.describeNode", () => { + test("disables compression before describing a node by nodeId", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params?: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + + if (method === "DOM.enable") { + return {}; + } + + assert.strictEqual(method, "DOM.describeNode"); + return { node: { nodeId: 7, nodeName: "view" }, compress: false }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + const DescribeNode = await loadDescribeNodeTool(); + + const { call } = createToolContext(DescribeNode, connector as never, "test-client-id:9999"); + const result = await call<{ node: { nodeId: number; nodeName: string }; compress: boolean }>({ + nodeId: 7, + }); + + assert.deepStrictEqual(sentMessages, [ + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "DOM.enable", + params: { useCompression: false }, + }, + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "DOM.describeNode", + params: { nodeId: 7, backendNodeId: undefined, depth: undefined, pierce: undefined }, + }, + ]); + assert.deepStrictEqual(result, { node: { nodeId: 7, nodeName: "view" }, compress: false }); + }); + + test("passes backendNodeId, depth, and pierce through to CDP", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params?: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + + if (method === "DOM.enable") { + return {}; + } + + assert.strictEqual(method, "DOM.describeNode"); + return { node: { backendNodeId: 9, childNodeCount: 1 }, compress: false }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + const DescribeNode = await loadDescribeNodeTool(); + + const { call } = createToolContext(DescribeNode, connector as never, "test-client-id:9999"); + const result = await call<{ node: { backendNodeId: number; childNodeCount: number }; compress: boolean }>({ + backendNodeId: 9, + depth: -1, + pierce: true, + }); + + assert.deepStrictEqual(sentMessages.at(-1), { + clientId: "test-client-id:9999", + sessionId: 1, + method: "DOM.describeNode", + params: { nodeId: undefined, backendNodeId: 9, depth: -1, pierce: true }, + }); + assert.deepStrictEqual(result, { node: { backendNodeId: 9, childNodeCount: 1 }, compress: false }); + }); + + test("preserves Lynx depth semantics in returned nodes", async () => { + const connector = { + sendCDPMessage: async ( + _clientId: string, + _sessionId: number, + method: string, + params?: Record, + ) => { + if (method === "DOM.enable") { + return {}; + } + + assert.strictEqual(method, "DOM.describeNode"); + + if (params?.depth === 0) { + return { node: { nodeId: 3, childNodeCount: 1 }, compress: false }; + } + + return { + node: { + nodeId: 3, + childNodeCount: 1, + children: [{ nodeId: 4, childNodeCount: 1 }], + }, + compress: false, + }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + const DescribeNode = await loadDescribeNodeTool(); + + const { call } = createToolContext(DescribeNode, connector as never, "test-client-id:9999"); + const depthZero = await call<{ node: { nodeId: number; childNodeCount: number; children?: unknown[] } }>({ + nodeId: 3, + depth: 0, + }); + const depthOne = await call<{ node: { nodeId: number; children?: Array<{ children?: unknown[] }> } }>({ + nodeId: 3, + depth: 1, + }); + + assert.deepStrictEqual(depthZero.node, { nodeId: 3, childNodeCount: 1 }); + assert.equal(depthOne.node.children?.length, 1); + assert.equal(depthOne.node.children?.[0]?.children, undefined); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts b/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts new file mode 100644 index 0000000..6b99114 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts @@ -0,0 +1,60 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { createToolContext } from "../utils/testTool.ts"; + +type SentCDPMessage = { + clientId: string; + sessionId: number; + method: string; + params?: Record; +}; + +describe("DOM.getDocument", () => { + test("disables compression and passes depth through to CDP", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params?: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + + if (method === "DOM.enable") { + return {}; + } + + assert.strictEqual(method, "DOM.getDocument"); + return { root: { nodeId: 1, nodeName: "#document", childNodeCount: 1 } }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + const { GetDocument } = await import("../../src/tools/DOM/GetDocument.ts"); + + const { call } = createToolContext(GetDocument, connector as never, "test-client-id:9999"); + const result = await call<{ root: { nodeId: number; nodeName: string; childNodeCount: number } }>({ + depth: -1, + }); + + assert.deepStrictEqual(sentMessages, [ + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "DOM.enable", + params: { useCompression: false }, + }, + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "DOM.getDocument", + params: { depth: -1 }, + }, + ]); + assert.deepStrictEqual(result, { root: { nodeId: 1, nodeName: "#document", childNodeCount: 1 } }); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts b/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts new file mode 100644 index 0000000..f53a2ca --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts @@ -0,0 +1,51 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { SetAttributesAsText } from "../../src/tools/DOM/SetAttributesAsText.ts"; +import { createToolContext } from "../utils/testTool.ts"; + +type SentCDPMessage = { + clientId: string; + sessionId: number; + method: string; + params: Record; +}; + +describe("DOM.setAttributesAsText", () => { + test("passes nodeId, text, and optional name through to CDP", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + assert.strictEqual(method, "DOM.setAttributesAsText"); + return {}; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(SetAttributesAsText, connector as never, "test-client-id:9999"); + const result = await call>({ + nodeId: 13, + text: "style='color: pink;'", + name: "style", + }); + + assert.deepStrictEqual(sentMessages, [ + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "DOM.setAttributesAsText", + params: { nodeId: 13, text: "style='color: pink;'", name: "style" }, + }, + ]); + assert.deepStrictEqual(result, {}); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts b/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts new file mode 100644 index 0000000..c287c1c --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts @@ -0,0 +1,86 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { ReadableStream, type TransformStream } from "node:stream/web"; +import { describe, test } from "node:test"; +import { OpenPage } from "../../src/tools/Device/OpenPage.ts"; +import { createToolContext } from "../utils/testTool.ts"; + +// Mock connector +const createMockConnector = (overrides = {}) => ({ + sendAppMessage: async () => {}, + sendMessage: async () => {}, + sendListSessionMessage: async () => [{ session_id: "mock_session" }], + ...overrides, +}); + +describe("Device.openPage", () => { + const testClientId = "test-client-id"; + + test("should send App.openPage message on success", async () => { + let sentMethod = ""; + let sentParams = null; + + const mockConnector = createMockConnector({ + sendAppMessage: async (cid: string, method: string, params: any) => { + if (cid === testClientId) { + sentMethod = method; + sentParams = params; + } + }, + }); + + const { call } = createToolContext(OpenPage, mockConnector as any, testClientId); + + await call({ + url: "https://example.com", + }); + + assert.strictEqual(sentMethod, "App.openPage"); + assert.deepStrictEqual(sentParams, { url: "https://example.com" }); + }); + + test("should fallback to Customized event if App.openPage fails", async () => { + let sentMessage: any = null; + let sentPipeline: any = null; + + const mockConnector = createMockConnector({ + sendAppMessage: async () => { + throw new Error("Failed"); + }, + sendMessage: async (cid: string, message: any, pipeline: any) => { + sentMessage = message; + sentPipeline = pipeline; + }, + }); + + const { call } = createToolContext(OpenPage, mockConnector as any, testClientId); + + await call({ + url: "https://example.com", + }); + + assert.ok(sentMessage); + assert.strictEqual(sentMessage.event, "Customized"); + assert.strictEqual(sentMessage.data.type, "OpenCard"); + assert.strictEqual(sentMessage.data.data.type, "url"); + assert.strictEqual(sentMessage.data.data.url, "https://example.com"); + + assert.strictEqual(sentMessage.data.sender, -1, "Sender should be -1"); + assert.strictEqual(sentMessage.from, -1, "From should be -1"); + + const sessionList = { + event: "Customized", + data: { type: "SessionList", data: [{ session_id: "opened_session" }] }, + }; + const filtered = await Array.fromAsync( + ReadableStream.from([ + { event: "Customized", data: { type: "OpenCard", data: {} } }, + sessionList, + ]).pipeThrough(sentPipeline.output[0] as TransformStream), + ); + assert.deepStrictEqual(filtered, [sessionList]); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts b/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts new file mode 100644 index 0000000..1c3cf16 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts @@ -0,0 +1,240 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { ClientId } from "@lynx-js/devtool-connector"; +import assert from "node:assert"; +import fs from "node:fs/promises"; +import { ReadableStream } from "node:stream/web"; +import { describe, test } from "node:test"; +import { TakeHeapSnapshot } from "../../src/tools/HeapProfiler/TakeHeapSnapshot.ts"; +import { createToolContext } from "../utils/testTool.ts"; + +type ParsedCdpMessage = { + method?: string; + params?: { + chunk?: string; + finished?: boolean; + }; + id?: number; + result?: Record; + sessionId?: string; +}; + +function createOutputStream(messages: ParsedCdpMessage[]) { + const stream = ReadableStream.from(messages); + return Object.assign(stream, { + async [Symbol.asyncDispose]() { + try { + await stream.cancel(); + } catch { + // ReadableStream may already be closed in tests. + } + }, + }); +} + +async function collectMessages(stream: ReadableStream): Promise { + const messages: unknown[] = []; + + for await (const message of stream) { + messages.push(message); + } + + return messages; +} + +const testClientId = ClientId.serialize("test-device", 8901); + +const createMockConnector = (buildMessages: (requestId: number) => ParsedCdpMessage[]) => ({ + sendCDPMessage: async () => ({}), + sendStream: async (_clientId: string, inputStream: ReadableStream) => { + const requests = await collectMessages(inputStream) as Array<{ + data: { + data: { + message: { + method: string; + id: number; + }; + }; + }; + }>; + + const request = requests.find((message) => message.data.data.message.method === "HeapProfiler.takeHeapSnapshot"); + + assert.ok(request, "Expected HeapProfiler.takeHeapSnapshot request in stream"); + + return createOutputStream(buildMessages(request.data.data.message.id)); + }, + sendListSessionMessage: async () => [{ session_id: 1 }], +}); + +describe("HeapProfiler.takeHeapSnapshot", () => { + test("preserves chunk order when writing a background snapshot", async () => { + const firstChunk = "{\"snapshot\":{\"meta\":{},\"node_count\":1,\"edge_count\":0,\"trace_function_count\":0},"; + const secondChunk = "\"nodes\":[],\"edges\":[],\"strings\":[]}"; + + const connector = createMockConnector((requestId) => [ + { + method: "HeapProfiler.reportHeapSnapshotProgress", + params: { finished: true }, + }, + { + method: "HeapProfiler.addHeapSnapshotChunk", + params: { chunk: firstChunk }, + }, + { + method: "HeapProfiler.addHeapSnapshotChunk", + params: { chunk: secondChunk }, + }, + { id: requestId, result: {} }, + ]); + + const { call } = createToolContext(TakeHeapSnapshot, connector as never, testClientId); + const result = await call({ thread: "background" }); + const filePath = result.replace("Heap snapshot saved to ", ""); + + try { + const content = await fs.readFile(filePath, "utf8"); + assert.deepStrictEqual(JSON.parse(content), { + snapshot: { + meta: {}, + node_count: 1, + edge_count: 0, + trace_function_count: 0, + }, + nodes: [], + edges: [], + strings: [], + }); + } finally { + await fs.unlink(filePath).catch(() => {}); + } + }); + + test("streams snapshot chunks to disk without joining them in memory", async () => { + const firstChunk = "{\"snapshot\":{\"streamed\":true},"; + const secondChunk = "\"strings\":[\"large-snapshot\"]}"; + + const connector = createMockConnector((requestId) => [ + { + method: "HeapProfiler.addHeapSnapshotChunk", + params: { chunk: firstChunk }, + }, + { + method: "HeapProfiler.addHeapSnapshotChunk", + params: { chunk: secondChunk }, + }, + { id: requestId, result: {} }, + ]); + + const { call } = createToolContext(TakeHeapSnapshot, connector as never, testClientId); + const originalJoin = Array.prototype.join; + let result: string; + + try { + Array.prototype.join = function patchedJoin(separator?: string) { + if (Array.isArray(this) && this.some((value) => value === firstChunk || value === secondChunk)) { + throw new Error("Array.prototype.join should not be used to assemble heap snapshots"); + } + + return originalJoin.call(this, separator); + } as typeof Array.prototype.join; + + result = await call({ thread: "background" }); + } finally { + Array.prototype.join = originalJoin; + } + + const filePath = result.replace("Heap snapshot saved to ", ""); + + try { + const content = await fs.readFile(filePath, "utf8"); + assert.deepStrictEqual(JSON.parse(content), { + snapshot: { streamed: true }, + strings: ["large-snapshot"], + }); + } finally { + await fs.unlink(filePath).catch(() => {}); + } + }); + + test("ignores chunks from another VM while capturing main-thread snapshot", async () => { + const backgroundSnapshot = JSON.stringify({ snapshot: { source: "background" } }); + const mainSnapshot = JSON.stringify({ snapshot: { source: "main" } }); + + const connector = createMockConnector((requestId) => [ + { + method: "HeapProfiler.addHeapSnapshotChunk", + params: { chunk: backgroundSnapshot }, + }, + { + method: "HeapProfiler.reportHeapSnapshotProgress", + params: { finished: true }, + sessionId: "Main", + }, + { + method: "HeapProfiler.addHeapSnapshotChunk", + params: { chunk: mainSnapshot }, + sessionId: "Main", + }, + { + id: requestId, + result: {}, + }, + ]); + + const { call } = createToolContext(TakeHeapSnapshot, connector as never, testClientId); + const result = await call({ thread: "main" }); + const filePath = result.replace("Heap snapshot saved to ", ""); + + try { + const content = await fs.readFile(filePath, "utf8"); + assert.deepStrictEqual(JSON.parse(content), JSON.parse(mainSnapshot)); + } finally { + await fs.unlink(filePath).catch(() => {}); + } + }); + + test("waits for the matching snapshot response instead of stopping on unrelated ids", async () => { + const firstChunk = "{\"snapshot\":{\"phase\":\"first\"},"; + const secondChunk = "\"strings\":[\"renderPage\"]}"; + + const connector = createMockConnector((requestId) => [ + { + method: "HeapProfiler.reportHeapSnapshotProgress", + params: { finished: true }, + }, + { + method: "HeapProfiler.addHeapSnapshotChunk", + params: { chunk: firstChunk }, + }, + { + id: requestId + 1, + result: {}, + }, + { + method: "HeapProfiler.addHeapSnapshotChunk", + params: { chunk: secondChunk }, + }, + { + id: requestId, + result: {}, + }, + ]); + + const { call } = createToolContext(TakeHeapSnapshot, connector as never, testClientId); + const result = await call({ thread: "background" }); + const filePath = result.replace("Heap snapshot saved to ", ""); + + try { + const content = await fs.readFile(filePath, "utf8"); + assert.deepStrictEqual(JSON.parse(content), { + snapshot: { phase: "first" }, + strings: ["renderPage"], + }); + } finally { + await fs.unlink(filePath).catch(() => {}); + } + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts b/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts new file mode 100644 index 0000000..e4710ab --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts @@ -0,0 +1,101 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { GetAllMemoryUsage } from "../../src/tools/Memory/GetAllMemoryUsage.ts"; +import { createToolContext } from "../utils/testTool.ts"; + +type SentCDPMessage = { + clientId: string; + sessionId: number; + method: string; + params: Record; +}; + +describe("Memory.getAllMemoryUsage", () => { + test("uses the global DevTool session by default", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + + assert.strictEqual(method, "Memory.getAllMemoryUsage"); + return { + collectionStatus: "completed", + totalBytes: 1024, + instances: [], + }; + }, + sendListSessionMessage: async () => { + throw new Error("Memory.getAllMemoryUsage should not require a LynxView session"); + }, + }; + + const { call } = createToolContext(GetAllMemoryUsage, connector as never, "test-client-id:9999"); + const result = await call<{ collectionStatus: string; totalBytes: number; instances: unknown[] }>({ + timeoutMs: 50_000, + }); + + assert.deepStrictEqual(sentMessages, [ + { + clientId: "test-client-id:9999", + sessionId: -1, + method: "Memory.getAllMemoryUsage", + params: { timeoutMs: 50_000 }, + }, + ]); + assert.deepStrictEqual(result, { + collectionStatus: "completed", + totalBytes: 1024, + instances: [], + }); + }); + + test("supports explicit session override", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + + assert.strictEqual(method, "Memory.getAllMemoryUsage"); + return { + collectionStatus: "timeout", + totalBytes: 2048, + instances: [], + }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(GetAllMemoryUsage, connector as never, "test-client-id:9999"); + const result = await call<{ collectionStatus: string; totalBytes: number; instances: unknown[] }>({ + sessionId: 7, + }); + + assert.deepStrictEqual(sentMessages, [ + { + clientId: "test-client-id:9999", + sessionId: 7, + method: "Memory.getAllMemoryUsage", + params: {}, + }, + ]); + assert.deepStrictEqual(result, { + collectionStatus: "timeout", + totalBytes: 2048, + instances: [], + }); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts b/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts new file mode 100644 index 0000000..eae964d --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts @@ -0,0 +1,131 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { GetVersion } from "../../src/tools/Lynx/GetVersion.ts"; +import { GetResourceContent } from "../../src/tools/Page/GetResourceContent.ts"; +import { GetResourceTree } from "../../src/tools/Page/GetResourceTree.ts"; +import { createToolContext } from "../utils/testTool.ts"; + +type SentCDPMessage = { + clientId: string; + sessionId: number; + method: string; + params?: Record; +}; + +describe("Page resource tools", () => { + test("gets the page resource tree", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params?: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + assert.strictEqual(method, "Page.getResourceTree"); + return { frameTree: { frame: { id: "frame-1", url: "lynx://page" }, resources: [] } }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(GetResourceTree, connector as never, "test-client-id:9999"); + const result = await call<{ frameTree: { frame: { id: string } } }>({}); + + assert.deepStrictEqual(sentMessages, [ + { clientId: "test-client-id:9999", sessionId: 1, method: "Page.getResourceTree", params: {} }, + ]); + assert.equal(result.frameTree.frame.id, "frame-1"); + }); + + test("gets resource content by url and optional frameId", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params?: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + assert.strictEqual(method, "Page.getResourceContent"); + return { content: "", base64Encoded: false }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(GetResourceContent, connector as never, "test-client-id:9999"); + const result = await call<{ content: string; base64Encoded: boolean }>({ + url: "lynx://page/template.js", + frameId: "frame-1", + }); + + assert.deepStrictEqual(sentMessages, [ + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "Page.getResourceContent", + params: { url: "lynx://page/template.js", frameId: "frame-1" }, + }, + ]); + assert.deepStrictEqual(result, { content: "", base64Encoded: false }); + }); + + test("passes nodeId through for Lynx engines that resolve resource content by node", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params?: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + assert.strictEqual(method, "Page.getResourceContent"); + return { content: "", base64Encoded: false }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(GetResourceContent, connector as never, "test-client-id:9999"); + await call({ nodeId: 10 }); + + assert.deepStrictEqual(sentMessages.at(-1), { + clientId: "test-client-id:9999", + sessionId: 1, + method: "Page.getResourceContent", + params: { nodeId: 10 }, + }); + }); +}); + +describe("Lynx.getVersion", () => { + test("gets the Lynx engine version", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params?: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + assert.strictEqual(method, "Lynx.getVersion"); + return "3.5.0"; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(GetVersion, connector as never, "test-client-id:9999"); + const result = await call({}); + + assert.deepStrictEqual(sentMessages, [ + { clientId: "test-client-id:9999", sessionId: 1, method: "Lynx.getVersion", params: {} }, + ]); + assert.equal(result, "3.5.0"); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts b/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts new file mode 100644 index 0000000..1b4de48 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts @@ -0,0 +1,89 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { createToolContext } from "../utils/testTool.ts"; + +type SentCDPMessage = { + clientId: string; + sessionId: number; + method: string; + params: Record; +}; + +async function loadGetAllPerformanceEntriesTool() { + const module = await import("../../src/tools/Performance/GetAllPerformanceEntries.ts").catch(() => undefined); + + assert.ok( + module && "GetAllPerformanceEntries" in module, + "Expected Performance GetAllPerformanceEntries tool module to exist", + ); + + return module.GetAllPerformanceEntries; +} + +describe("Performance.getAllPerformanceEntries", () => { + test("enables Performance before reading all entries", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + + if (method === "Performance.enable") { + return {}; + } + + assert.strictEqual(method, "Performance.getAllPerformanceEntries"); + return { + entries: [ + { + entryType: "metric", + name: "testMetric", + startTime: 1.5, + instanceId: 100, + }, + ], + }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + const GetAllPerformanceEntries = await loadGetAllPerformanceEntriesTool(); + + const { call } = createToolContext(GetAllPerformanceEntries, connector as never, "test-client-id:9999"); + const result = await call<{ + entries: Array<{ entryType: string; name: string; startTime: number; instanceId: number }>; + }>({}); + + assert.deepStrictEqual(sentMessages, [ + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "Performance.enable", + params: {}, + }, + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "Performance.getAllPerformanceEntries", + params: {}, + }, + ]); + assert.deepStrictEqual(result, { + entries: [ + { + entryType: "metric", + name: "testMetric", + startTime: 1.5, + instanceId: 100, + }, + ], + }); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts b/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts new file mode 100644 index 0000000..bf473b0 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts @@ -0,0 +1,105 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { Evaluate } from "../../src/tools/Runtime/Evaluate.ts"; +import { GetProperties } from "../../src/tools/Runtime/GetProperties.ts"; +import { createToolContext } from "../utils/testTool.ts"; + +type SentCDPMessage = { + clientId: string; + sessionId: number; + method: string; + params: Record; + isMainThread: boolean; +}; + +describe("Runtime.evaluate", () => { + test("enables runtime and evaluates on the background VM by default", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params: Record, + isMainThread = false, + ) => { + sentMessages.push({ clientId, sessionId, method, params, isMainThread }); + + if (method === "Runtime.enable") { + return {}; + } + + assert.strictEqual(method, "Runtime.evaluate"); + return { result: { type: "number", value: 4 } }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(Evaluate, connector as never, "test-client-id:9999"); + const result = await call<{ result: { type: string; value: number } }>({ + expression: "2 + 2", + generatePreview: true, + objectGroup: "mcp", + }); + + assert.deepStrictEqual(sentMessages, [ + { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.enable", params: {}, isMainThread: false }, + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "Runtime.evaluate", + params: { expression: "2 + 2", generatePreview: true, objectGroup: "mcp" }, + isMainThread: false, + }, + ]); + assert.deepStrictEqual(result, { result: { type: "number", value: 4 } }); + }); +}); + +describe("Runtime.getProperties", () => { + test("enables runtime and queries object properties on the requested VM thread", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params: Record, + isMainThread = false, + ) => { + sentMessages.push({ clientId, sessionId, method, params, isMainThread }); + + if (method === "Runtime.enable") { + return {}; + } + + assert.strictEqual(method, "Runtime.getProperties"); + return { result: [{ name: "answer", value: { type: "number", value: 42 } }] }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(GetProperties, connector as never, "test-client-id:9999"); + const result = await call<{ result: Array<{ name: string }> }>({ + objectId: "remote-object-id", + ownProperties: true, + thread: "main", + }); + + assert.deepStrictEqual(sentMessages, [ + { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.enable", params: {}, isMainThread: true }, + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "Runtime.getProperties", + params: { objectId: "remote-object-id", ownProperties: true }, + isMainThread: true, + }, + ]); + assert.deepStrictEqual(result.result.map(({ name }) => name), ["answer"]); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts b/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts new file mode 100644 index 0000000..796c855 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts @@ -0,0 +1,90 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { GetHeapUsage } from "../../src/tools/Runtime/GetHeapUsage.ts"; +import { createToolContext } from "../utils/testTool.ts"; + +type SentCDPMessage = { + clientId: string; + sessionId: number; + method: string; + params: Record; + isMainThread: boolean; +}; + +describe("Runtime.getHeapUsage", () => { + test("uses the background VM by default", async () => { + const sentMessages: SentCDPMessage[] = []; + + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params: Record, + isMainThread = false, + ) => { + sentMessages.push({ clientId, sessionId, method, params, isMainThread }); + + if (method === "Runtime.enable") { + return {}; + } + + assert.strictEqual(method, "Runtime.getHeapUsage"); + return { usedSize: 1, totalSize: 2 }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(GetHeapUsage, connector as never, "test-client-id:9999"); + const result = await call<{ usedSize: number; totalSize: number }>({ thread: "background" }); + + assert.deepStrictEqual(sentMessages, [ + { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.enable", params: {}, isMainThread: false }, + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "Runtime.getHeapUsage", + params: {}, + isMainThread: false, + }, + ]); + assert.deepStrictEqual(result, { usedSize: 1, totalSize: 2 }); + }); + + test("passes main-thread requests through sendCDPMessage", async () => { + const sentMessages: SentCDPMessage[] = []; + + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params: Record, + isMainThread = false, + ) => { + sentMessages.push({ clientId, sessionId, method, params, isMainThread }); + + if (method === "Runtime.enable") { + return {}; + } + + assert.strictEqual(method, "Runtime.getHeapUsage"); + return { usedSize: 3, totalSize: 5 }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(GetHeapUsage, connector as never, "test-client-id:9999"); + const result = await call<{ usedSize: number; totalSize: number }>({ thread: "main" }); + + assert.deepStrictEqual(sentMessages, [ + { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.enable", params: {}, isMainThread: true }, + { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.getHeapUsage", params: {}, isMainThread: true }, + ]); + assert.deepStrictEqual(result, { usedSize: 3, totalSize: 5 }); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts b/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts new file mode 100644 index 0000000..c6d740d --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts @@ -0,0 +1,88 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { GetLynxUITree } from "../../src/tools/UITree/GetLynxUITree.ts"; +import { createToolContext } from "../utils/testTool.ts"; + +type SentCDPMessage = { + clientId: string; + sessionId: number; + method: string; + params?: Record; +}; + +describe("UITree.getLynxUITree", () => { + test("enables UITree without compression before reading native UI metadata", async () => { + const sentMessages: SentCDPMessage[] = []; + const connector = { + sendCDPMessage: async ( + clientId: string, + sessionId: number, + method: string, + params?: Record, + ) => { + sentMessages.push({ clientId, sessionId, method, params }); + + if (method === "UITree.enable") { + return {}; + } + + assert.strictEqual(method, "UITree.getLynxUITree"); + return { + root: { + name: "com.lynx.tasm.behavior.ui.LynxUI", + id: 3, + tagName: "view", + nodeIndex: 2, + props: { id: "card" }, + label: "Card", + frame: [0, 0, 100, 40], + children: [], + }, + compress: false, + }; + }, + sendListSessionMessage: async () => [{ session_id: 1 }], + }; + + const { call } = createToolContext(GetLynxUITree, connector as never, "test-client-id:9999"); + const result = await call<{ + root: { + tagName: string; + nodeIndex: number; + props: Record; + label: string; + }; + compress: boolean; + }>({}); + + assert.deepStrictEqual(sentMessages, [ + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "UITree.enable", + params: { useCompression: false }, + }, + { + clientId: "test-client-id:9999", + sessionId: 1, + method: "UITree.getLynxUITree", + params: undefined, + }, + ]); + assert.deepStrictEqual(result.root, { + name: "com.lynx.tasm.behavior.ui.LynxUI", + id: 3, + tagName: "view", + nodeIndex: 2, + props: { id: "card" }, + label: "Card", + frame: [0, 0, 100, 40], + children: [], + }); + assert.strictEqual(result.compress, false); + }); +}); diff --git a/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts b/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts new file mode 100644 index 0000000..7850ec0 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts @@ -0,0 +1,50 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import assert from "node:assert"; + +export const testClientId = "test-client-id"; + +export const createMockConnector = (overrides: Record = {}) => ({ + sendMessage: async () => {}, + sendListSessionMessage: async () => [{ session_id: "mock_session" }], + ...overrides, +}); + +export const readRequestMessage = (message: unknown): Record => { + const envelope = message as { + event?: unknown; + data?: { + type?: unknown; + data?: { + session_id?: unknown; + message?: unknown; + }; + }; + }; + + assert.equal(envelope.event, "Customized"); + assert.equal(envelope.data?.type, "xdb_msg"); + assert.equal(envelope.data?.data?.session_id, -1); + const rawMessage = envelope.data?.data?.message; + assert.equal(typeof rawMessage, "string"); + + return JSON.parse(rawMessage as string) as Record; +}; + +export const createRespondingConnector = ( + data: unknown, + onRequest?: (request: Record) => void, +) => + createMockConnector({ + sendMessage: async (_clientId: string, message: unknown) => { + const request = readRequestMessage(message); + onRequest?.(request); + return { + type: "xdb_msg_resp", + __id: request["__id"], + data, + }; + }, + }); diff --git a/mcp-servers/devtool-mcp-server/test/utils/cdp-types.ts b/mcp-servers/devtool-mcp-server/test/utils/cdp-types.ts new file mode 100644 index 0000000..911c0e4 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/utils/cdp-types.ts @@ -0,0 +1,99 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export interface Node { + nodeId: number; + backendNodeId: number; + nodeType: number; + nodeName: string; + localName: string; + nodeValue: string; + attributes?: string[]; + childNodeCount?: number; + children?: Node[]; + box_model?: BoxModel; +} + +export interface BoxModel { + content: number[]; + padding: number[]; + border: number[]; + margin: number[]; + width: number; + height: number; +} + +export interface GetDocumentResponse { + root: Node; +} + +export interface DescribeNodeResponse { + node?: Node; + compress?: boolean; +} + +export interface GetAttributesResponse { + attributes: string[]; +} + +export interface GetBoxModelResponse { + model: BoxModel; +} + +export interface QuerySelectorResponse { + nodeId: number; +} + +export interface QuerySelectorAllResponse { + nodeIds: number[]; +} + +export interface PerformSearchResponse { + searchId: number | string; + resultCount: number; +} + +export interface GetSearchResultsResponse { + nodeIds: number[]; +} + +export interface PushNodesByBackendIdsToFrontendResponse { + nodeIds: number[]; +} + +export interface InnerTextResponse { + nodeId: number; + rawTextValues: string[]; +} + +export interface GetOriginalNodeIndexResponse { + nodeIndex: number; +} + +export interface UITreeNode { + name?: string; + id?: number; + tagName?: string; + nodeIndex?: number; + props?: Record; + label?: string; + frame?: number[]; + children?: UITreeNode[]; +} + +export interface GetLynxUITreeResponse { + root: UITreeNode; + compress?: boolean; +} + +export interface PerformanceEntry { + entryType?: string; + name?: string; + instanceId?: number; + [key: string]: unknown; +} + +export interface GetAllPerformanceEntriesResponse { + entries: PerformanceEntry[]; +} diff --git a/mcp-servers/devtool-mcp-server/test/utils/testTool.ts b/mcp-servers/devtool-mcp-server/test/utils/testTool.ts new file mode 100644 index 0000000..3cd9185 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/test/utils/testTool.ts @@ -0,0 +1,63 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { type Connector } from "@lynx-js/devtool-connector"; +import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; +import type { z } from "zod"; +import { McpContext } from "../../src/McpContext.ts"; +import { McpResponse } from "../../src/McpResponse.ts"; +import type { ToolDefinition } from "../../src/tools/defineTool.ts"; + +export function createToolContext( + tool: ToolDefinition, + connector: Connector, + clientId: string, +) { + const call = async (params: Partial>> = {}): Promise => { + const ctx = new McpContext(connector); + const resp = new McpResponse(); + + // Auto-fill parameters + const fullParams = { ...params } as any; + + if (tool.schema["clientId"] && !fullParams.clientId) { + fullParams.clientId = clientId; + } + + if (tool.schema["sessionId"] && !fullParams.sessionId) { + try { + // Auto-fetch session + const sessions = await connector.sendListSessionMessage(clientId); + const session = sessions[sessions.length - 1]; + if (session) { + fullParams.sessionId = session.session_id; + } + } catch (e) { + // Ignore error if session listing fails, maybe tool doesn't strictly need it or will fail gracefully + } + } + + await tool.handler( + { params: fullParams, extra: {} as any }, + resp, + ctx, + ); + + // Extract result + const contents = await resp.handle(tool.name, ctx); + + const texts = contents.filter((c): c is TextContent => c.type === "text").map((c) => c.text); + if (texts.length === 1 && texts[0]) { + try { + return JSON.parse(texts[0]) as T; + } catch { + return texts[0] as unknown as T; + } + } + + return contents as unknown as T; + }; + + return { call }; +} diff --git a/mcp-servers/devtool-mcp-server/tsconfig.json b/mcp-servers/devtool-mcp-server/tsconfig.json new file mode 100644 index 0000000..bbe2314 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/tsconfig.json @@ -0,0 +1,45 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "moduleResolution": "node16", + "target": "esnext", + "types": [], + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": false, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Style Options + "noImplicitReturns": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + + // Recommended Options + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + // "isolatedDeclarations": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + + "allowImportingTsExtensions": true, + "emitDeclarationOnly": true, + + "rootDir": "./src", + + "noEmit": true, + }, + "include": ["src"], +} diff --git a/packages/skills/lynx-devtool/SKILL.md b/packages/skills/lynx-devtool/SKILL.md index 253f960..533fda7 100644 --- a/packages/skills/lynx-devtool/SKILL.md +++ b/packages/skills/lynx-devtool/SKILL.md @@ -1,6 +1,6 @@ --- name: lynx-devtool -description: Interact with Lynx DevTool to inspect and debug Lynx applications. Use this skill to list connected clients and sessions, send Chrome DevTools Protocol (CDP) commands, send App commands, and open URLs in Lynx. This is useful for debugging UI issues, inspecting runtime state, or automating interactions with Lynx apps. +description: Use when working with Lynx DevTool or debugging a Lynx app, page, or device, especially when the task mentions clients or sessions, CDP or App commands, DOM/CSS inspection, runtime or console logs, screenshots, heap snapshots, Page.reload or App.openPage, global switches, or inspecting a ReactLynx component tree (`reactlynx tree`), searching components (`reactlynx find`), inspecting props/state/hooks (`reactlynx component`), or mutating props/state/context (`reactlynx update-prop` / `update-state` / `update-context`) on Android, iOS, or Desktop. --- # DevTool Skill @@ -11,6 +11,8 @@ This skill allows you to interact with Lynx applications running on connected de The CLI is located at `/scripts/index.mjs` relative to this skill's directory. You can run it using `node`. +The programmatic API is located at `/scripts/connector.mjs`. This entry re-exports everything from `@lynx-js/devtool-connector`, `@lynx-js/devtool-connector/transport`, and `@lynx-js/devtool-connector/streams`, and also provides `createDefaultTransports()` and `createDefaultConnector()` helpers that match the CLI defaults. + In the skill directory, use: ```bash @@ -19,36 +21,374 @@ node /scripts/index.mjs **Note:** All command outputs are multi-line JSON. You can use `jq` or Node.js to process the data. +### Use as a Library + +If you want to drive Lynx DevTool directly from JavaScript instead of shelling out to the CLI, import from `scripts/connector.mjs`. + +```js +import { + Connector, + createDefaultConnector, +} from "/scripts/connector.mjs"; + +const connector = createDefaultConnector(); +const clients = await connector.listClients(); + +console.log(clients); +``` + +For fuller programmatic workflows, see [Library Usage Reference](references/library-usage.md) and [Programmatic Debugging Example](examples/programmatic-debugging.md). + +You can also construct the connector manually if you need custom transports: + +```js +import { + AndroidTransport, + Connector, + DesktopTransport, + iOSTransport, +} from "/scripts/connector.mjs"; + +const connector = new Connector([ + new AndroidTransport({ host: "127.0.0.1", port: 5037 }), + new DesktopTransport(), + new iOSTransport(), +]); +``` + ### Global Options - `-h, --help`: Display help for command. **Note:** Each subcommand supports the `--help` flag (e.g. `node /scripts/index.mjs cdp --help`). Use this to view the full list of available arguments and their descriptions. -## Command List +### Client Targeting + +Commands that accept `-c, --client ` also accept `--client-name `. +Use `--client-name` to resolve a client from `list-clients` by package/app identifier +(`AppProcessName`, `bundleId`, `bundleName`, or `App`), for example: + +```bash +node /scripts/index.mjs cdp --client-name com.example.app -m DOM.getDocument +node /scripts/index.mjs list-sessions --client-name com.lynx.uiapp +``` + +If multiple clients match the name, use `list-clients` and pass the exact client ID with `--client`. + +### Commands + +#### 1. List Clients + +List all available Lynx clients (apps with DevTool enabled). + +```bash +node /scripts/index.mjs list-clients +``` + +#### 2. List Sessions + +List all active debugging sessions. A session corresponds to a specific Lynx view or context. + +```bash +node /scripts/index.mjs list-sessions +# Optional: Filter by client ID +node /scripts/index.mjs list-sessions --client +``` + +#### 3. Send CDP Command + +Send a Chrome DevTools Protocol (CDP) command to a specific session. + +> Note that Lynx only supports a part of the standard CDP command. +> LynxView note: when the target session is a LynxView, you **MUST** read [Supported CDP Methods](references/cdp/index.md) before sending a CDP command. +> WebView note: when the target session is a WebView (for example `type: "web"` or an HTTP/HTTPS URL), use the standard Chrome DevTools Protocol documentation for CDP method names, parameters, and enable prerequisites. The local `references/cdp` pages focus on LynxView support and Lynx-specific extensions, which may return `method not found` on WebView targets. + +```bash +node /scripts/index.mjs cdp -m [options] [params] +``` + +- `-m, --method `: The CDP method name (e.g., `DOM.getDocument`, `Runtime.evaluate`). +- `-c, --client `: (Optional) The Client ID. If omitted, uses the first available client. +- `--client-name `: (Optional) Package/app name resolved from `list-clients`. +- `-s, --session `: (Optional) The Session ID. If omitted, uses the latest session (with the largest session ID). +- `--thread `: (Optional) Target VM thread, `background` or `main`. Defaults to `background`. +- `[params]`: (Optional) JSON string of parameters for the command. + +When `--thread main` is used, only `Debugger.*`, `Runtime.*`, `HeapProfiler.*`, and `Profiler.*` methods are supported. + +Example: + +```bash +# Get the document root +node /scripts/index.mjs cdp -m DOM.getDocument + +# Evaluate JavaScript +node /scripts/index.mjs cdp -m Runtime.evaluate '{"expression": "2 + 2"}' + +# Evaluate JavaScript on the main-thread VM +node /scripts/index.mjs cdp --thread main -m Runtime.evaluate '{"expression": "2 + 2"}' +``` + +#### 4. Send App Command + +Send an App-level command. + +```bash +node /scripts/index.mjs app -m [options] [params] +``` + +- `-m, --method `: The App method name (e.g., `App.openPage`). +- `-c, --client `: (Optional) Client ID. +- `--client-name `: (Optional) Package/app name resolved from `list-clients`. +- `[params]`: (Optional) JSON string of parameters. + +> You **MUST** read [Supported App Methods](references/app/index.md) before sending an App command. + +#### 5. Open URL + +Open a specific URL in the Lynx app. + +```bash +node /scripts/index.mjs open [options] +``` + +- ``: The URL to open. +- `-c, --client `: (Optional) Client ID. + +Example: + +```bash +node /scripts/index.mjs open "lynx://example/page" +``` + +#### 6. Inspect + +Output the inspector URL for a client/session. + +```bash +node /scripts/index.mjs inspect [options] +``` + +- `-c, --client `: (Optional) Client ID. +- `-s, --session `: (Optional) Session ID. +- `--port `: (Optional) Daemon port. Defaults to `21783`. + +#### 7. Get Console + +Capture console logs from the device. + +```bash +node /scripts/index.mjs get-console [options] +``` + +- `-c, --client `: (Optional) Client ID. +- `-s, --session `: (Optional) Session ID. +- `--offset `: Skip N messages. +- `--limit `: Limit number of messages. +- `--include-stack-traces`: Include stack traces for non-error messages. +- `--level `: Filter log levels (e.g., `error,warning`). +- `--thread `: Target VM thread(s): `background` or `main`. If omitted, both threads are collected by default. + +#### 8. Get Sources + +List all parsed scripts. This is useful for finding script IDs to use with other commands (e.g., `Debugger.getScriptSource`). The command automatically fetches all currently loaded scripts. + +```bash +node /scripts/index.mjs get-sources [options] +``` + +- `-c, --client `: (Optional) Client ID. +- `-s, --session `: (Optional) Session ID. + +#### 9. Take Screenshot + +Take a screenshot of the current page. + +```bash +node /scripts/index.mjs take-screenshot [options] +``` + +- `-c, --client `: (Optional) Client ID. +- `-s, --session `: (Optional) Session ID. +- `--fullscreen`: (Optional) Capture the screenshot in `fullscreen` mode. Defaults to `lynxview` mode if not provided. +- `-o, --output `: (Optional) Output file path. + +#### 10. Take Heap Snapshot + +Capture a QuickJS heap snapshot from the current Lynx session and save it as a `.heapsnapshot` file. + +```bash +node /scripts/index.mjs take-heap-snapshot [options] +``` + +- `-c, --client `: (Optional) Client ID. +- `-s, --session `: (Optional) Session ID. +- `--thread `: (Optional) Target VM thread, `background` or `main`. Defaults to `background`. +- `-o, --output `: (Optional) Output file path. Defaults to the OS temp directory. + +#### 11. Global Switch + +Manage DevTool global switches. + +```bash +# List all supported keys and their current values +node /scripts/index.mjs global-switch list [options] + +# Get one key +node /scripts/index.mjs global-switch get --key [options] + +# Set one key +node /scripts/index.mjs global-switch set --key --status [options] +``` + +- `-c, --client `: (Optional) Client ID. + +`global-switch list` options: + +- `--fail-fast`: Abort on first key-read failure. + +`global-switch get` options: + +- `--key `: Global switch key. (Required) + +`global-switch set` options: + +- `--key `: Global switch key. (Required) +- `--status `: Target switch status. (Required) + +For the full key list and examples, see [Global Switch Reference](references/global-switch.md). + +#### 12. Query Global Memory Usage + +Query Lynx global memory usage through the global `Memory.*` CDP domain. Use the generic `cdp` command and send the request to the global DevTool handler with session ID `-1`. + +```bash +# Get global Lynx memory usage across live instances +node /scripts/index.mjs cdp -s -1 -m Memory.getAllMemoryUsage +node /scripts/index.mjs cdp -s -1 -m Memory.getAllMemoryUsage '{"timeoutMs":50000}' +``` + +- `-c, --client `: (Optional) Client ID. +- `-s, --session `: CDP session ID. Use `-1` for the global DevTool handler unless you have a platform-specific reason to override it. +- `params.timeoutMs` (Optional): Non-negative timeout in milliseconds. Maximum value is `300000`. + +When the DevTool MCP server is available, prefer the `Memory_getAllMemoryUsage` MCP tool for the same raw payload instead of shelling out to the CLI. + +#### 13. Recording + +Record Lynx page interactions via TestBench (CDP-based). Captures all actions (template loads, touch events, JS module calls, data updates) and produces a JSON replay file. + +```bash +# Start recording (BEFORE opening the target page) +node /scripts/index.mjs recorder start [options] + +# Stop recording and save the replay file +node /scripts/index.mjs recorder end [options] +``` + +- `-c, --client `: (Optional) Client ID for `start` and `end`. +- `-o, --output `: (Optional) Output file or directory path for `end`. Defaults to `~/.lynx-devtool/files/lynxrecorder/recording--.json`. + +Workflow: + +1. Run `recorder start`. If it enables `enable_debug_mode`, restart the app and run `recorder start` again. +2. User opens and interacts with the Lynx page. +3. Run `recorder end --output ` to stop and save. +4. Report the absolute file path to the user. + +**Important:** For a replayable file, open or reload the target page after `recorder start` so the recording includes `loadTemplate`. + +See [Recording Reference](references/recorder.md) for more details. + +#### 14. ReactLynx Component Tree + +Print the component tree of a running ReactLynx page, decoded from `@lynx-js/preact-devtools`. The CLI opens a `Lynx.onVMEvent` stream, sends a Preact DevTools `init`+`refresh` handshake, and renders the resulting `operation_v2` payloads as an ASCII tree. + +```bash +node /scripts/index.mjs reactlynx tree [options] +``` + +- `-c, --client `: (Optional) Client ID. +- `-s, --session `: (Optional) Session ID. +- `--depth `: (Optional) Maximum tree depth to print. Default: unbounded. +- `--show-shells`: Include the synthetic `Fragment` / `Root` / `Anonymous` wrappers ReactLynx inserts. They are hidden by default. +- `--json`: Emit `{ labels, roots, nodes }` instead of ASCII; use this when a script will consume the tree. + +Output uses `@cN [type] Name` references (the convention from `agent-react-devtools`). Labels are pre-order DFS over visible roots and reset on every invocation, so they are stable within a single command but **not** across runs: + +``` +@c1 [fn] App +├─ @c2 [fn] Header +│ └─ @c3 [fn] Logo +└─ @c4 [fn] Body +``` + +Requirements: -- [Send CDP Command](examples/cdp.md): Send a supported CDP method to a selected session. -- [Send App Command](examples/app.md): Send an App-level method to the Lynx app. -- [Open URL](examples/open.md): Open a target URL in the Lynx app. -- [Get Sources](examples/get-sources.md): List parsed scripts for later debugger operations. -- [Take Screenshot](examples/take-screenshot.md): Capture the current page as a screenshot. +- The page must be a **dev build** running `@lynx-js/preact-devtools` (production bundles strip `setupReactLynx()`). Successful initialization logs `[PREACT DEVTOOLS] Devtools initialized successfully` to the device console. +- `@lynx-js/preact-devtools` must include the `document.body` and `preactDevtoolsCtx.Node` fixes (PR #2 + PR #5 against `lynx-family/preact-devtools`). Without them, the `refresh` channel will return zero `operation_v2` frames and the CLI will print the "stale preact-devtools" diagnostic. -## Example List +When the tree comes back empty, the CLI exits with code `1` and writes one of three targeted diagnostics on stderr: -- [Inspecting the DOM](examples/inspecting-the-dom.md): Find a session, fetch the document tree, and inspect a specific node. -- [Evaluating JavaScript](examples/evaluating-javascript.md): Run a small JavaScript expression in the current Lynx session. -- [Redirect with Development URL](examples/redirect-with-development-url.md): Reload a page with a local dev-server URL during development. -- [Getting Console Logs](examples/getting-console-logs.md): Filter console output to focus on errors and warnings. +- **`saw 0 frames`**: nothing replied on the `PreactDevtools` channel. The App is most likely missing `@lynx-js/preact-devtools`, is a production build, has not finished `setupReactLynx()`, or you picked the wrong `--session`. +- **`saw N frames but no operation_v2`**: the hook is loaded but its `refresh` handler is buggy. Upgrade `@lynx-js/preact-devtools` to a build that contains PRs #2 and #5. +- **`tree is empty`**: every node was unmounted between commits -- rare, rerun with `DEBUG` (below) to see the raw envelopes. -## Troubleshooting +For deep debugging, set `DEBUG=devtool-mcp-server:reactlynx` to log every PreactDevtools frame (type + payload size) on stderr while leaving stdout (the tree / JSON) clean: -For connector and transport debug logging, see [Troubleshooting Reference](references/troubleshooting/index.md). +```bash +DEBUG='devtool-mcp-server:reactlynx' node /scripts/index.mjs reactlynx tree +``` + +#### 15. ReactLynx Component Inspect + +Inspect a single ReactLynx component (props / state / hooks / context / signals) by sending the Preact DevTools `inspect` envelope and reading back `inspect-result`. + +```bash +node /scripts/index.mjs reactlynx component [options] +``` + +- ``: either a label `@cN` produced by `reactlynx tree` / `reactlynx find`, or a numeric vnode id. + - With `@cN`, the CLI does an extra init+refresh+tree round-trip first to resolve the label. Pass `--show-shells` if (and only if) the label was generated with shells visible. + - With a numeric id (e.g. `3856353762`), the snapshot is skipped -- one round-trip total. +- `-c, --client `, `-s, --session `: (Optional) standard targeting flags. +- `--show-shells`: When resolving `@cN`, count synthetic Fragment / Root / Anonymous wrappers the same way `reactlynx tree --show-shells` does. +- `--json`: Print the raw `InspectData` payload as JSON. Default output is a compact ASCII summary. -## References +Example output: + +```text +@c5 (id=3856353783) [fn] TUXIntroViewListCell key=1. HMR + source: src/TUXIntroViewListCell.tsx:42:3 + props: + { + "title": "1. HMR", + "icon": { "type": "vnode", "name": "TUXIcon" } + } +``` + +#### 16. ReactLynx Component Find + +Find every component whose name matches a substring or regex. Output is ordered identically to `reactlynx tree` (pre-order DFS) so the `@cN` labels round-trip with the other subcommands. + +```bash +node /scripts/index.mjs reactlynx find [options] +``` + +- ``: substring (default, case-insensitive) or JavaScript regex with `--regex`. +- `-c, --client `, `-s, --session `: (Optional) standard targeting flags. +- `--regex`: Treat `` as a JavaScript regular expression (e.g. `--regex '^Toast(List)?$'`). +- `--show-shells`: Include synthetic Fragment / Root / Anonymous wrappers. +- `--limit `: Maximum number of matches to print. Default `50`. +- `--json`: Emit `[{ label, id, name, type, key, ancestors: [{label, name}] }, ...]` for scripted post-processing. + +Example output: + +```text +@c8 [fn] TUXCenterToastActivator + in @c1 TUXApp > @c2 Provider > @c3 App +@c10 [fn] TUXTopToastActivator + in @c1 TUXApp > @c2 Provider > @c3 App +``` -- [Supported CDP Methods](references/cdp/index.md): Detailed documentation of all supported CDP methods, their inputs, and outputs. -- [Supported App Methods](references/app/index.md): Detailed documentation of all supported App methods, their inputs, and outputs. -- [Get Console Reference](references/get-console.md): Detailed documentation of the `get-console` command. -- [Get Sources Reference](references/get-sources.md): Detailed documentation of the `get-sources` command. -- [Take Screenshot Reference](references/take-screenshot.md): Detailed documentation of the `take-screenshot` command. -- [Troubleshooting Reference](references/troubleshooting/index.md): Debug namespaces, transport-level logs, and examples of healthy connector output. +`reactlynx find` is the recommended way to discover labels for follow-up `reactlynx component @cN` calls when the tree is too large to scan visually. diff --git a/packages/skills/lynx-devtool/examples/getting-console-logs.md b/packages/skills/lynx-devtool/examples/getting-console-logs.md index 601ef4c..4afa68a 100644 --- a/packages/skills/lynx-devtool/examples/getting-console-logs.md +++ b/packages/skills/lynx-devtool/examples/getting-console-logs.md @@ -3,6 +3,9 @@ ```bash # Get only errors and warnings node /scripts/index.mjs get-console --level error,warning + +# Get main-thread console logs only +node /scripts/index.mjs get-console --thread main ``` ## Exploring Object Properties @@ -20,8 +23,8 @@ node /scripts/index.mjs get-console -s **Example Output:** ``` -- [log]: -- [log]: +- [log/background]: +- [log/main-thread]: ``` ### Step 2: Retrieve Object Properties diff --git a/packages/skills/lynx-devtool/examples/main-thread-logs-and-properties.md b/packages/skills/lynx-devtool/examples/main-thread-logs-and-properties.md new file mode 100644 index 0000000..9b84dbc --- /dev/null +++ b/packages/skills/lynx-devtool/examples/main-thread-logs-and-properties.md @@ -0,0 +1,23 @@ +# Main-Thread Logs and Object Properties + +Use `get-console --thread main` to capture main-thread console output, then inspect any logged runtime object with `Runtime.getProperties` on the same thread. + +## 1. Capture main-thread logs + +```bash +node /scripts/index.mjs get-console --thread main +``` + +Example output: + +```text +- [log/main-thread]: +``` + +## 2. Inspect the logged object + +```bash +node /scripts/index.mjs cdp --thread main -m Runtime.getProperties '{"objectId":"13561514624","ownProperties":true}' +``` + +This returns the object's enumerable properties from the main-thread VM. diff --git a/packages/skills/lynx-devtool/examples/programmatic-debugging.md b/packages/skills/lynx-devtool/examples/programmatic-debugging.md new file mode 100644 index 0000000..0a3e55a --- /dev/null +++ b/packages/skills/lynx-devtool/examples/programmatic-debugging.md @@ -0,0 +1,72 @@ +# Programmatic Debugging + +This example shows a realistic JavaScript workflow using `/scripts/connector.mjs` instead of the CLI. + +It follows the same steps you would take manually: + +1. Create a connector with the default transports. +2. Discover devices, clients, and sessions. +3. Pick a session. +4. Send CDP and App commands directly. + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; + +const connector = createDefaultConnector(); + +const devices = await connector.listDevices(); +console.log("devices", devices); + +const clients = await connector.listClients(); +if (clients.length === 0) { + throw new Error("No Lynx clients found"); +} + +const client = clients[0]; +console.log("client", client); + +const sessions = await connector.sendListSessionMessage(client.id); +if (sessions.length === 0) { + throw new Error("No Lynx sessions found"); +} + +const session = sessions.at(-1); +if (!session) { + throw new Error("No Lynx sessions found"); +} + +const document = await connector.sendCDPMessage( + client.id, + session.session_id, + "DOM.getDocument", + { depth: 1 }, +); + +console.log("document", document); + +const evaluation = await connector.sendCDPMessage( + client.id, + session.session_id, + "Runtime.evaluate", + { expression: "globalThis.location?.href ?? 'unknown'" }, +); + +console.log("evaluation", evaluation); + +await connector.sendAppMessage(client.id, "App.openPage", { + url: "lynx://example/page", +}); +``` + +## When to use this pattern + +- You want to reuse one connector across multiple operations. +- You need conditional logic that is awkward to express with shell commands. +- You are building a higher-level tool on top of Lynx DevTool. + +## Related references + +- [Library Usage Reference](../references/library-usage.md) +- [Supported CDP Methods](../references/cdp/index.md) +- [Supported App Methods](../references/app/index.md) +- [Programmatic Touch: Click and Double Click](./programmatic-touch-click-and-double-click.md) diff --git a/packages/skills/lynx-devtool/examples/programmatic-touch-click-and-double-click.md b/packages/skills/lynx-devtool/examples/programmatic-touch-click-and-double-click.md new file mode 100644 index 0000000..65123d6 --- /dev/null +++ b/packages/skills/lynx-devtool/examples/programmatic-touch-click-and-double-click.md @@ -0,0 +1,232 @@ +# Programmatic Touch: Click and Double Click + +This example uses the Connector API directly (no CLI command chaining) and sends multiple `Input.emulateTouchFromMouseEvent` messages via `connector.sendCDPStream`. + +It demonstrates: + +- Single click (press + release) +- Double click via two rapid clicks in one stream +- Multi-step gesture sequencing in one stream + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; +import { ReadableStream } from "node:stream/web"; + +const connector = createDefaultConnector(); + +const clients = await connector.listClients(); +if (clients.length === 0) { + throw new Error("No Lynx clients found"); +} + +const client = clients[0]; +const sessions = await connector.sendListSessionMessage(client.id); +const session = sessions.at(-1); +if (!session) { + throw new Error("No Lynx sessions found"); +} + +const clientId = client.id; +const sessionId = session.session_id; + +async function sendInputStream(messages) { + await using outputStream = await connector.sendCDPStream( + clientId, + sessionId, + ReadableStream.from(messages), + ); + + // Drain stream to ensure requests are delivered and responses/events are consumed. + for await (const _ of outputStream) { + // No-op + } +} + +function centerOfQuad(quad) { + const xs = [quad[0], quad[2], quad[4], quad[6]]; + const ys = [quad[1], quad[3], quad[5], quad[7]]; + + return { + x: (Math.min(...xs) + Math.max(...xs)) / 2, + y: (Math.min(...ys) + Math.max(...ys)) / 2, + }; +} + +async function getPointFromDom(selector) { + const document = await connector.sendCDPMessage( + clientId, + sessionId, + "DOM.getDocument", + { depth: 0 }, + ); + const rootNodeId = document.result.root.nodeId; + + const query = await connector.sendCDPMessage( + clientId, + sessionId, + "DOM.querySelector", + { nodeId: rootNodeId, selector }, + ); + const nodeId = query.result.nodeId; + if (!nodeId) { + throw new Error(`No node matched selector: ${selector}`); + } + + await connector.sendCDPMessage( + clientId, + sessionId, + "DOM.scrollIntoViewIfNeeded", + { nodeId }, + ); + + const box = await connector.sendCDPMessage( + clientId, + sessionId, + "DOM.getBoxModel", + { nodeId }, + ); + const point = centerOfQuad(box.result.model.content); + + const hit = await connector.sendCDPMessage( + clientId, + sessionId, + "DOM.getNodeForLocation", + point, + ); + if (!hit.result.nodeId) { + throw new Error( + `Computed point did not hit a node: ${JSON.stringify(point)}`, + ); + } + console.log("tap target", { selector, nodeId, point, hit: hit.result }); + + return point; +} + +async function run() { + // Replace the selector with a stable target in your page. + // The returned point is already in the CDP logical coordinate space used by Input. + const { x, y } = await getPointFromDom("[lynx-test-tag='target']"); + + // Single click: 2 Input CDP messages in one stream + await sendInputStream([ + { + method: "Input.emulateTouchFromMouseEvent", + params: { + type: "mousePressed", + x, + y, + button: "left", + clickCount: 1, + timestamp: Date.now(), + }, + }, + { + method: "Input.emulateTouchFromMouseEvent", + params: { + type: "mouseReleased", + x, + y, + button: "left", + clickCount: 1, + timestamp: Date.now() + 1, + }, + }, + ]); + + // Double click: 4 Input CDP messages in one stream + await sendInputStream([ + { + method: "Input.emulateTouchFromMouseEvent", + params: { + type: "mousePressed", + x, + y, + button: "left", + clickCount: 1, + timestamp: Date.now(), + }, + }, + { + method: "Input.emulateTouchFromMouseEvent", + params: { + type: "mouseReleased", + x, + y, + button: "left", + clickCount: 1, + timestamp: Date.now() + 1, + }, + }, + { + method: "Input.emulateTouchFromMouseEvent", + params: { + type: "mousePressed", + x, + y, + button: "left", + clickCount: 2, + timestamp: Date.now() + 80, + }, + }, + { + method: "Input.emulateTouchFromMouseEvent", + params: { + type: "mouseReleased", + x, + y, + button: "left", + clickCount: 2, + timestamp: Date.now() + 81, + }, + }, + ]); + + // Multi-event sequence example (press -> move -> release) in one stream + await sendInputStream([ + { + method: "Input.emulateTouchFromMouseEvent", + params: { + type: "mousePressed", + x, + y, + button: "left", + clickCount: 1, + timestamp: Date.now(), + }, + }, + { + method: "Input.emulateTouchFromMouseEvent", + params: { + type: "mouseMoved", + x: x + 20, + y: y + 20, + button: "left", + clickCount: 1, + timestamp: Date.now() + 1, + }, + }, + { + method: "Input.emulateTouchFromMouseEvent", + params: { + type: "mouseReleased", + x: x + 20, + y: y + 20, + button: "left", + clickCount: 1, + timestamp: Date.now() + 2, + }, + }, + ]); +} + +await run(); +``` + +## Notes + +- `sendCDPStream` is useful when you want to enqueue multiple CDP input messages as one operation. +- Message order in the stream controls gesture order. +- Use CDP DOM methods for click coordinates; do not pick points from screenshots. +- Points computed from `DOM.getBoxModel` can be passed directly to `Input.emulateTouchFromMouseEvent`. +- If double-click behavior is not recognized by a target component, adjust timestamp gaps and `clickCount`. diff --git a/packages/skills/lynx-devtool/examples/touch-using-dom-coordinates.md b/packages/skills/lynx-devtool/examples/touch-using-dom-coordinates.md new file mode 100644 index 0000000..69885d3 --- /dev/null +++ b/packages/skills/lynx-devtool/examples/touch-using-dom-coordinates.md @@ -0,0 +1,59 @@ +# Touch Using DOM Coordinates + +This example shows the recommended flow: get the target point from CDP DOM methods and pass that same point to `Input.emulateTouchFromMouseEvent`. + +## Why this works + +- `DOM.getBoxModel`, `DOM.getNodeForLocation`, and `Input.emulateTouchFromMouseEvent` use the same CDP logical coordinate space for the current mode. +- No screenshot pixel conversion is needed when the point comes from DOM. + +## 1) Find the target node + +Get the document root: + +```bash +node /scripts/index.mjs cdp -c -s -m DOM.getDocument '{"depth":0}' +``` + +Query a stable selector under the root node: + +```bash +node /scripts/index.mjs cdp -c -s -m DOM.querySelector '{"nodeId":,"selector":"[lynx-test-tag=\"target\"]"}' +``` + +## 2) Scroll it into view + +```bash +node /scripts/index.mjs cdp -c -s -m DOM.scrollIntoViewIfNeeded '{"nodeId":}' +``` + +## 3) Compute a center point from `DOM.getBoxModel` + +```bash +node /scripts/index.mjs cdp -c -s -m DOM.getBoxModel '{"nodeId":}' +``` + +Use `result.model.content`, which is a quad: + +```js +const quad = result.model.content; +const xs = [quad[0], quad[2], quad[4], quad[6]]; +const ys = [quad[1], quad[3], quad[5], quad[7]]; +const x = (Math.min(...xs) + Math.max(...xs)) / 2; +const y = (Math.min(...ys) + Math.max(...ys)) / 2; +``` + +## 4) Validate the point + +```bash +node /scripts/index.mjs cdp -c -s -m DOM.getNodeForLocation '{"x":,"y":}' +``` + +If the returned `nodeId` is the target you expect, use the same point for touch. + +## 5) Tap with the same coordinates + +```bash +node /scripts/index.mjs cdp -c -s -m Input.emulateTouchFromMouseEvent '{"type":"mousePressed","x":,"y":,"timestamp":0,"button":"left","clickCount":1}' +node /scripts/index.mjs cdp -c -s -m Input.emulateTouchFromMouseEvent '{"type":"mouseReleased","x":,"y":,"button":"left","clickCount":1}' +``` diff --git a/packages/skills/lynx-devtool/examples/touch-using-lynx-geometry.md b/packages/skills/lynx-devtool/examples/touch-using-lynx-geometry.md new file mode 100644 index 0000000..2ab6ffa --- /dev/null +++ b/packages/skills/lynx-devtool/examples/touch-using-lynx-geometry.md @@ -0,0 +1,42 @@ +# Touch Using Lynx Geometry APIs + +`Lynx.getRectToWindow` and `Lynx.getViewLocationOnScreen` are useful for diagnosing host-window geometry, but they should not be the source of click coordinates. + +For clicking, prefer DOM CDP methods because they return the same logical coordinates consumed by `Input.emulateTouchFromMouseEvent`. + +## 1) Use Lynx geometry only for diagnostics + +```bash +node /scripts/index.mjs cdp -c -s -m Lynx.getRectToWindow '{"nodeId":}' +node /scripts/index.mjs cdp -c -s -m Lynx.getViewLocationOnScreen '{"nodeId":}' +``` + +These values can help explain host app placement when a page appears visually shifted. + +## 2) Compute the click point from DOM + +```bash +node /scripts/index.mjs cdp -c -s -m DOM.getBoxModel '{"nodeId":}' +``` + +Compute the center from `result.model.content`: + +```js +const quad = result.model.content; +const xs = [quad[0], quad[2], quad[4], quad[6]]; +const ys = [quad[1], quad[3], quad[5], quad[7]]; +const x = (Math.min(...xs) + Math.max(...xs)) / 2; +const y = (Math.min(...ys) + Math.max(...ys)) / 2; +``` + +## 3) Validate and send touch + +```bash +node /scripts/index.mjs cdp -c -s -m DOM.getNodeForLocation '{"x":,"y":}' +node /scripts/index.mjs cdp -c -s -m Input.emulateTouchFromMouseEvent '{"type":"mousePressed","x":,"y":,"timestamp":0,"button":"left","clickCount":1}' +node /scripts/index.mjs cdp -c -s -m Input.emulateTouchFromMouseEvent '{"type":"mouseReleased","x":,"y":,"button":"left","clickCount":1}' +``` + +## Practical tip + +If geometry APIs and DOM hit-testing appear to disagree, use `DOM.getBoxModel` plus `DOM.getNodeForLocation` as the click source. For deeper debugging, see [Click Coordinate Troubleshooting](../references/troubleshooting/click-coordinates.md). diff --git a/packages/skills/lynx-devtool/examples/touch-using-screenshot-coordinates.md b/packages/skills/lynx-devtool/examples/touch-using-screenshot-coordinates.md new file mode 100644 index 0000000..a08038b --- /dev/null +++ b/packages/skills/lynx-devtool/examples/touch-using-screenshot-coordinates.md @@ -0,0 +1,28 @@ +# Screenshot Coordinates Are Not Click Coordinates + +Screenshots are useful for seeing what is on screen, but they are not the source of click coordinates. + +For click automation, use CDP DOM methods instead. + +## Recommended replacement + +1. Find the target node with `DOM.querySelector`, `DOM.performSearch`, or `DOM.getDocument`. +2. Call `DOM.scrollIntoViewIfNeeded` if needed. +3. Call `DOM.getBoxModel` for the target node. +4. Compute the center of `model.content` or `model.border`. +5. Validate the point with `DOM.getNodeForLocation`. +6. Send `Input.emulateTouchFromMouseEvent` with the same `x/y`. + +## Minimal command flow + +```bash +node /scripts/index.mjs cdp -c -s -m DOM.getDocument '{"depth":0}' +node /scripts/index.mjs cdp -c -s -m DOM.querySelector '{"nodeId":,"selector":"[lynx-test-tag=\"target\"]"}' +node /scripts/index.mjs cdp -c -s -m DOM.scrollIntoViewIfNeeded '{"nodeId":}' +node /scripts/index.mjs cdp -c -s -m DOM.getBoxModel '{"nodeId":}' +node /scripts/index.mjs cdp -c -s -m DOM.getNodeForLocation '{"x":,"y":}' +node /scripts/index.mjs cdp -c -s -m Input.emulateTouchFromMouseEvent '{"type":"mousePressed","x":,"y":,"timestamp":0,"button":"left","clickCount":1}' +node /scripts/index.mjs cdp -c -s -m Input.emulateTouchFromMouseEvent '{"type":"mouseReleased","x":,"y":,"button":"left","clickCount":1}' +``` + +If a screenshot and DOM disagree, trust the DOM-derived point for clicking. For deeper debugging, see [Click Coordinate Troubleshooting](../references/troubleshooting/click-coordinates.md). diff --git a/packages/skills/lynx-devtool/package.json b/packages/skills/lynx-devtool/package.json index 43ebc81..4d04935 100644 --- a/packages/skills/lynx-devtool/package.json +++ b/packages/skills/lynx-devtool/package.json @@ -1,7 +1,6 @@ { "name": "@lynx-js/skill-lynx-devtool", - "version": "0.1.0", - "private": true, + "version": "0.13.3", "type": "module", "files": [ "SKILL.md", @@ -15,10 +14,12 @@ "build": "rslib build" }, "devDependencies": { - "@lynx-js/devtool-connector": "0.1.0", + "@lynx-js/devtool-connector": "workspace:*", "@rslib/core": "catalog:rstack", "@types/node": "24", - "commander": "14" + "commander": "14", + "core-js": "^3.49.0", + "obug": "^2.1.3" }, "engines": { "node": ">=18" diff --git a/packages/skills/lynx-devtool/references/global-switch.md b/packages/skills/lynx-devtool/references/global-switch.md new file mode 100644 index 0000000..e85fc55 --- /dev/null +++ b/packages/skills/lynx-devtool/references/global-switch.md @@ -0,0 +1,112 @@ +# Global Switch Commands + +`global-switch` provides first-class commands for DevTool global switches. + +## Supported Keys + +- `enable_devtool` +- `enable_logbox` +- `enable_debug_mode` +- `enable_dom_tree` +- `enable_quickjs_debug` +- `enable_quickjs_cache` +- `enable_v8` +- `enable_cdp_domain_dom` +- `enable_cdp_domain_css` +- `enable_cdp_domain_page` +- `enable_long_press_menu` +- `enable_highlight_touch` +- `enable_preview_screen_shot` +- `enable_pixel_copy` +- `enable_fsp_screenshot` + +## Key Meanings + +The descriptions below are based on Lynx Engine reference code in `.reference-repos/template-assembler`, mainly: + +- `lynx/platform/android/lynx_android/src/main/java/com/lynx/devtoolwrapper/DevToolSettings.java` +- `lynx/platform/android/lynx_devtool/src/main/java/com/lynx/devtool/LynxInspectorOwner.java` +- `lynx/platform/android/lynx_devtool/src/main/java/com/lynx/devtool/LynxDevtoolEnv.java` +- `lynx/platform/darwin/common/lynx_devtool/LynxDebugBridge.mm` + +- `enable_devtool`: master switch for enabling DevTool capability in app/runtime settings. +- `enable_logbox`: enable/disable LogBox panel and logbox-related debugging UI. +- `enable_debug_mode`: enable debug-mode behavior (debug library path and related debug runtime behavior). +- `enable_dom_tree`: enable DOM tree inspection support in DevTool. +- `enable_quickjs_debug`: enable QuickJS debugging bridge/capability. +- `enable_quickjs_cache`: enable QuickJS bytecode/cache-related optimization path. +- `enable_v8`: control V8 usage for JS runtime selection in DevTool scenarios. +- `enable_cdp_domain_dom`: activate CDP DOM domain via grouped env (`activated_cdp_domains`). +- `enable_cdp_domain_css`: activate CDP CSS domain via grouped env (`activated_cdp_domains`). +- `enable_cdp_domain_page`: activate CDP Page domain via grouped env (`activated_cdp_domains`). +- `enable_long_press_menu`: enable long-press developer menu behavior. +- `enable_highlight_touch`: enable touch highlight visualization in DevTool interaction. +- `enable_preview_screen_shot`: enable screenshot preview behavior used by DevTool UI flow. +- `enable_pixel_copy`: enable pixel-copy path for screenshot/capture related behavior. +- `enable_fsp_screenshot`: enable FSP screenshot collection behavior. + +## Platform Notes + +- Support is not perfectly symmetric across platforms. +- In current Darwin bridge code, only a subset of keys is handled directly (`enable_devtool`, `enable_logbox`, `enable_quickjs_debug`, `enable_dom_tree`, `enable_long_press_menu`, `enable_perf_metrics`). +- For unsupported keys on some platforms, `getGlobalSwitch` may return fallback values (for example `false`) instead of throwing. + +## Usage + +```bash +# List all keys and current values +node scripts/index.mjs global-switch list -c + +# Keep going even if some keys fail (default behavior) +node scripts/index.mjs global-switch list -c + +# Abort immediately on first read failure +node scripts/index.mjs global-switch list -c --fail-fast + +# Get one key +node scripts/index.mjs global-switch get --key enable_devtool -c + +# Set one key on +node scripts/index.mjs global-switch set --key enable_devtool --status on -c + +# Set one key off +node scripts/index.mjs global-switch set --key enable_devtool --status off -c +``` + +## Output Shape + +### `global-switch list` + +```json +{ + "switches": [ + { "key": "enable_devtool", "value": true }, + { "key": "enable_v8", "error": "transport timeout" } + ] +} +``` + +### `global-switch get` + +```json +{ + "key": "enable_devtool", + "value": true +} +``` + +### `global-switch set` + +```json +{ + "key": "enable_devtool", + "value": false +} +``` + +## Notes + +- `list` reads each key by calling connector `getGlobalSwitch` one by one. +- Without `--fail-fast`, one key failure does not stop other keys from being read. +- Some hosts do not respond to global switch requests. In that case `get` may throw `No response found`, and `list` can block while waiting for a switch response. +- Use `list-clients` first if you need to determine a concrete `clientId`. diff --git a/packages/skills/lynx-devtool/references/library-usage.md b/packages/skills/lynx-devtool/references/library-usage.md new file mode 100644 index 0000000..405333c --- /dev/null +++ b/packages/skills/lynx-devtool/references/library-usage.md @@ -0,0 +1,238 @@ +# Library Usage + +Use `scripts/connector.mjs` when you want to interact with Lynx DevTool from JavaScript instead of invoking the CLI. + +These examples mirror the common workflow in `@lynx-js/devtool-connector`: initialize a connector, discover devices and clients, select a session, then issue CDP or App requests. + +## What it exports + +`/scripts/connector.mjs` re-exports: + +- Everything from `@lynx-js/devtool-connector` +- Everything from `@lynx-js/devtool-connector/transport` +- Everything from `@lynx-js/devtool-connector/streams` + +It also adds two convenience helpers: + +- `createDefaultTransports()` +- `createDefaultConnector()` + +These helpers use the same transport setup as the CLI entry. + +## Example: create a connector and list clients + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; + +const connector = createDefaultConnector(); +const clients = await connector.listClients(); + +console.log(clients); +``` + +## Example: list devices, clients, and sessions + +This is usually the first thing to do in a script so you can understand what targets are currently reachable. + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; + +const connector = createDefaultConnector(); + +const devices = await connector.listDevices(); +console.log("devices", devices); + +const clients = await connector.listClients(); +console.log("clients", clients); + +if (clients.length === 0) { + throw new Error("No Lynx clients found"); +} + +const sessions = await connector.sendListSessionMessage(clients[0].id); +console.log("sessions", sessions); +``` + +## Example: list sessions for a client + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; + +const connector = createDefaultConnector(); +const [client] = await connector.listClients(); + +if (!client) { + throw new Error("No Lynx clients found"); +} + +const sessions = await connector.sendListSessionMessage(client.id); + +console.log(sessions); +``` + +## Example: call a CDP method + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; + +const connector = createDefaultConnector(); +const [client] = await connector.listClients(); + +if (!client) { + throw new Error("No Lynx clients found"); +} + +const [session] = await connector.sendListSessionMessage(client.id); + +if (!session) { + throw new Error("No Lynx sessions found"); +} + +const result = await connector.sendCDPMessage( + client.id, + session.session_id, + "Runtime.evaluate", + { expression: "2 + 2" }, +); + +console.log(result); +``` + +## Example: inspect the DOM tree for the active session + +This matches the common DevTool flow of selecting the first available client/session and then fetching the document root. + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; + +const connector = createDefaultConnector(); +const [client] = await connector.listClients(); + +if (!client) { + throw new Error("No Lynx clients found"); +} + +const [session] = await connector.sendListSessionMessage(client.id); + +if (!session) { + throw new Error("No Lynx sessions found"); +} + +const document = await connector.sendCDPMessage( + client.id, + session.session_id, + "DOM.getDocument", + { depth: -1 }, +); + +console.log(document); +``` + +## Example: call an App method + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; + +const connector = createDefaultConnector(); +const [client] = await connector.listClients(); + +if (!client) { + throw new Error("No Lynx clients found"); +} + +await connector.sendAppMessage(client.id, "App.openPage", { + url: "lynx://example/page", +}); +``` + +## Example: open a page and then inspect the reloaded session + +This is useful when a script needs to drive navigation before issuing CDP requests. + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; + +const connector = createDefaultConnector(); +const [client] = await connector.listClients(); + +if (!client) { + throw new Error("No Lynx clients found"); +} + +await connector.sendAppMessage(client.id, "App.openPage", { + url: "lynx://example/page", +}); + +const sessions = await connector.sendListSessionMessage(client.id); +const latestSession = sessions.at(-1); + +if (!latestSession) { + throw new Error("No Lynx sessions found after App.openPage"); +} + +const pageInfo = await connector.sendCDPMessage( + client.id, + latestSession.session_id, + "Page.getResourceTree", +); + +console.log(pageInfo); +``` + +## Example: subscribe to runtime events with a CDP stream + +Use the streaming APIs when you need event-style output, such as console events or other continuous protocol messages. + +```js +import { createDefaultConnector } from "/scripts/connector.mjs"; +import { ReadableStream } from "node:stream/web"; + +const connector = createDefaultConnector(); +const [client] = await connector.listClients(); + +if (!client) { + throw new Error("No Lynx clients found"); +} + +const [session] = await connector.sendListSessionMessage(client.id); + +if (!session) { + throw new Error("No Lynx sessions found"); +} + +const outputStream = await connector.sendCDPStream( + client.id, + session.session_id, + ReadableStream.from([ + { method: "Runtime.enable" }, + ]), +); + +try { + for await (const message of outputStream) { + console.log(message.method, message.params); + break; + } +} finally { + await outputStream[Symbol.asyncDispose](); +} +``` + +## Example: construct transports manually + +Use manual construction if you need to customize the transport list. + +```js +import { + AndroidTransport, + Connector, + DesktopTransport, + iOSTransport, +} from "/scripts/connector.mjs"; + +const connector = new Connector([ + new AndroidTransport({ host: "127.0.0.1", port: 5037 }), + new DesktopTransport(), + new iOSTransport(), +]); +``` diff --git a/packages/skills/lynx-devtool/references/recorder.md b/packages/skills/lynx-devtool/references/recorder.md new file mode 100644 index 0000000..18791c2 --- /dev/null +++ b/packages/skills/lynx-devtool/references/recorder.md @@ -0,0 +1,97 @@ +# Recording + +Record Lynx page interactions via TestBench (CDP-based). Captures all actions (template loads, touch events, JS module calls, data updates) and produces a JSON replay file. + +## Usage + +```bash +node scripts/index.mjs recorder start [options] +node scripts/index.mjs recorder end [options] +``` + +## Options + +### recorder start / recorder end + +- `-c, --client `: Client ID. If not provided, the command will use the first available client. +- `-o, --output `: Output file or directory path for `end`. Defaults to `~/.lynx-devtool/files/lynxrecorder/recording--.json`. + +## Behavior + +### recorder start + +`recorder start` first checks the `enable_debug_mode` global switch. If the switch is off, the command enables it, exits, and asks the user to restart the app before running `recorder start` again. This restart is required for the native TestBench recorder path to become available. + +After `enable_debug_mode` is already on, `Recording.start` is sent as a CDP message via the connector. For the cleanest replay, open or reload the target page after recording starts so the file includes `loadTemplate`. + +The recording target session is implicitly session `-1` (the global session). + +### recorder end + +`Recording.end` is sent via the streaming CDP endpoint. The command waits for the `Recording.recordingComplete` event, then reads the recorded stream data via `IO.read` in 1 MB chunks. Each session's stream is saved to a separate JSON file. + +If `--output` is specified as a directory path, files are named `/recording--[-session].json`. + +## Workflow + +1. Run `recorder start`. If it enables `enable_debug_mode`, restart the app and run `recorder start` again. +2. User opens and interacts with the Lynx page. +3. Run `recorder end` to stop and save. +4. Report the saved file path to the user. + +## Output + +On success `recorder end` writes the recording to disk: + +```json +{ + "success": true, + "message": "Recording ended successfully.", + "savedFiles": ["/path/to/recording-xxx.json"] +} +``` + +## Examples + +### Start recording + +```bash +node scripts/index.mjs recorder start +``` + +### Start recording with a specific client + +```bash +node scripts/index.mjs recorder start --client HDT-12345 +``` + +### Stop recording and save to a custom path + +```bash +node scripts/index.mjs recorder end --output ./my-recording.json +``` + +## Diagnosing Recording Files + +A recording JSON file is either a top-level array of actions, or an object with an `"Action List"` array. Some files are base64-encoded zlib-compressed JSON. Each action has a `"Function Name"` field. + +### Key sections in a healthy recording + +| Section | Indicator | Meaning | +| ------------- | -------------------------------------------------------------- | ------------------------------------------------------------------ | +| Template load | Action with `"Function Name": "loadTemplate"` | The page was loaded during recording — required for a valid replay | +| Touch events | `"SendTouchEvent"` or `"sendEventDarwin"` actions | User interactions were captured | +| Other actions | Any other function names (JS module calls, data updates, etc.) | Additional page lifecycle events | + +### Diagnosing recordings + +| Symptom | Likely cause | Action | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| File is empty or unparseable | Recording was interrupted, device disconnected mid-stream, or file was corrupted during write | Re-run from `recorder start` with a stable connection | +| Zero actions (`"Action List": []`) | Recording started and ended immediately with no page activity in between | Ensure the user opens and interacts with the page between `start` and `end` | +| Has touch events but no `loadTemplate` | Recording started on a page that was already open — the template load happened before recording began | Still useful for analyzing interactions (JSB calls, gestures), but cannot be replayed in Lynx Explorer. For a replayable file, start recording before opening the page | +| Has actions but no `loadTemplate` and no touch events | Partial capture — page may have been in an intermediate state | Still useful for inspecting recorded behavior. For a replayable file, re-record from a fresh app launch | + +### Automated analysis on `recorder end` + +`recorder end` automatically analyzes each saved file after writing. It warns if a file is truly unusable (empty or unparseable), and notes when files lack `loadTemplate` (not replayable but still informative). No separate diagnostic step is needed. diff --git a/packages/skills/lynx-devtool/references/take-heap-snapshot.md b/packages/skills/lynx-devtool/references/take-heap-snapshot.md new file mode 100644 index 0000000..42d720e --- /dev/null +++ b/packages/skills/lynx-devtool/references/take-heap-snapshot.md @@ -0,0 +1,52 @@ +# Take Heap Snapshot + +Take a QuickJS heap snapshot from the current Lynx session and save it as a `.heapsnapshot` file. + +## Usage + +```bash +node scripts/index.mjs take-heap-snapshot [options] +``` + +## Options + +- `-c, --client `: Client ID. If not provided, the command will use the first available client. +- `-s, --session `: Session ID. If not provided, the command will use the latest session. +- `--thread `: Target VM thread. Supported values are `background` and `main`. Defaults to `background`. +- `-o, --output `: Output file path. Defaults to `/heap--.heapsnapshot`. + +## Behavior + +The command enables `HeapProfiler`, requests `HeapProfiler.takeHeapSnapshot`, collects all `HeapProfiler.addHeapSnapshotChunk` events, and writes the merged payload to disk. + +When `--thread main` is used, the command targets the Lynx main-thread VM by sending CDP requests with `sessionId: "Main"`. + +The command waits up to 60 seconds in total and stops early if the stream goes idle for 15 seconds. + +## Output + +On success, the command writes the snapshot to disk and prints the saved path: + +``` +Heap snapshot saved to /tmp/heap-background-1234567890.heapsnapshot +``` + +## Examples + +### Save a background-thread snapshot to the default temp file + +```bash +node scripts/index.mjs take-heap-snapshot +``` + +### Save a main-thread snapshot + +```bash +node scripts/index.mjs take-heap-snapshot --thread main +``` + +### Save to a specific file + +```bash +node scripts/index.mjs take-heap-snapshot --output ./session.heapsnapshot +``` diff --git a/packages/skills/lynx-devtool/rslib.config.ts b/packages/skills/lynx-devtool/rslib.config.ts index 4a53647..2cde3b6 100644 --- a/packages/skills/lynx-devtool/rslib.config.ts +++ b/packages/skills/lynx-devtool/rslib.config.ts @@ -1,26 +1,48 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { defineConfig } from '@rslib/core'; +import { defineConfig } from "@rslib/core"; +import path from "node:path"; + +const buildTime = new Date(); +const formattedBuildTime = `${buildTime.getFullYear()}-${String(buildTime.getMonth() + 1).padStart(2, "0")}-${ + String(buildTime.getDate()).padStart(2, "0") +} ${String(buildTime.getHours()).padStart(2, "0")}:${String(buildTime.getMinutes()).padStart(2, "0")}`; export default defineConfig({ source: { + entry: { + index: "./src/index.ts", + connector: "./src/connector.ts", + }, define: { - 'process.env.NODE_ENV': JSON.stringify('production'), + "process.env.NODE_ENV": JSON.stringify("production"), + "process.env.BUILD_TIME": JSON.stringify(formattedBuildTime), }, }, lib: [ { - format: 'esm', - syntax: 'es2022', + format: "esm", + syntax: "es2022", dts: false, output: { filename: { - js: '[name].mjs', + js: "[name].mjs", }, - distPath: './scripts', + distPath: "./scripts", }, autoExtension: false, }, ], + tools: { + rspack: { + output: { + library: { + type: "modern-module", + // eslint-disable-next-line n/no-unsupported-features/node-builtins + preserveModules: path.resolve(import.meta.dirname, "src/commands"), + }, + }, + }, + }, }); diff --git a/packages/skills/lynx-devtool/src/commands/app.ts b/packages/skills/lynx-devtool/src/commands/app.ts index ac2b419..c2e078f 100644 --- a/packages/skills/lynx-devtool/src/commands/app.ts +++ b/packages/skills/lynx-devtool/src/commands/app.ts @@ -1,28 +1,20 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClient } from "./utils.ts"; -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Command } from 'commander'; -import { getFirstClient } from './utils.ts'; - -export function registerAppCommand(program: Command, connector: Connector) { +export function registerAppCommand(program: Command, context: Context) { program - .command('app') - .description('Send an App request') - .requiredOption('-m, --method ', 'App method (e.g., App.openPage)') - .option( - '-c, --client ', - 'Client ID (optional, will auto-discover if not provided)', - ) - .argument('[params]', 'JSON string of parameters') + .command("app") + .description("Send an App request") + .requiredOption("-m, --method ", "App method (e.g., App.openPage)") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .argument("[params]", "JSON string of parameters") .action(async (paramsStr, options) => { + const { connector, clientId } = await resolveClient(context, options); const { method } = options; - let { client: clientId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } const params = paramsStr ? JSON.parse(paramsStr) : {}; const result = await connector.sendAppMessage(clientId, method, params); diff --git a/packages/skills/lynx-devtool/src/commands/cdp.ts b/packages/skills/lynx-devtool/src/commands/cdp.ts index c8ce7ba..5d73049 100644 --- a/packages/skills/lynx-devtool/src/commands/cdp.ts +++ b/packages/skills/lynx-devtool/src/commands/cdp.ts @@ -1,48 +1,27 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "./utils.ts"; -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Command } from 'commander'; -import { getFirstClient, getLatestSession } from './utils.ts'; - -export function registerCdpCommand(program: Command, connector: Connector) { +export function registerCdpCommand(program: Command, context: Context) { program - .command('cdp') - .description('Send a CDP request') - .requiredOption( - '-m, --method ', - 'CDP method (e.g., DOM.getDocument)', - ) - .option( - '-c, --client ', - 'Client ID (optional, will auto-discover if not provided)', - ) - .option( - '-s, --session ', - 'Session ID (optional, will auto-discover if not provided)', - ) - .argument('[params]', 'JSON string of parameters') + .command("cdp") + .description("Send a CDP request") + .requiredOption("-m, --method ", "CDP method (e.g., DOM.getDocument)") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) + .option("--thread ", "Thread to target (e.g., 'main' or 'background'). Defaults to 'background'") + .argument("[params]", "JSON string of parameters") .action(async (paramsStr, options) => { + const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); const { method } = options; - let { client: clientId, session: sessionId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } - - if (!sessionId) { - sessionId = await getLatestSession(connector, clientId); - } + const thread = options.thread ?? "background"; const params = paramsStr ? JSON.parse(paramsStr) : {}; - const result = await connector.sendCDPMessage( - clientId, - Number(sessionId), - method, - params, - ); + const result = await connector.sendCDPMessage(clientId, Number(sessionId), method, params, thread === "main"); console.log(JSON.stringify(result, null, 2)); }); diff --git a/packages/skills/lynx-devtool/src/commands/get-console.ts b/packages/skills/lynx-devtool/src/commands/get-console.ts index b2e5d1b..058b7a1 100644 --- a/packages/skills/lynx-devtool/src/commands/get-console.ts +++ b/packages/skills/lynx-devtool/src/commands/get-console.ts @@ -1,12 +1,17 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. - -import { ReadableStream } from 'node:stream/web'; -import { setTimeout } from 'node:timers/promises'; -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Command } from 'commander'; -import { getFirstClient, getLatestSession } from './utils.ts'; +/* eslint-disable */ +import { Command } from "commander"; +import { ReadableStream } from "node:stream/web"; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + readUntilIdle, + resolveClientAndSession, + SESSION_OPTION, +} from "./utils.ts"; interface ConsoleCallFrame { url: string; @@ -32,147 +37,162 @@ interface ConsoleMessage { args: ConsoleArg[]; stackTrace?: ConsoleStackTrace; url?: string; + consoleTag?: string; } -export function registerGetConsoleCommand( - program: Command, - connector: Connector, -) { +function formatConsoleMessage({ type, args, stackTrace, consoleTag }: ConsoleMessage): string { + return `- [${type}/${consoleTag === "Lepus" ? "main-thread" : "background"}]: ${ + args.map((arg) => { + if (arg.objectId) { + return `<${arg.description || arg.className || "Object"} (objectId:${arg.objectId})>`; + } + return arg.value; + }).join(" ") + }${ + stackTrace + ? "\n" + + stackTrace.callFrames.map(({ url, lineNumber, columnNumber }) => + ` at ${url}:${lineNumber}:${columnNumber}` + ).join("\n") + : "" + }`; +} + +export function registerGetConsoleCommand(program: Command, context: Context) { program - .command('get-console') - .description('Capture console logs from the device') - .option( - '-c, --client ', - 'Client ID (optional, will auto-discover if not provided)', - ) - .option( - '-s, --session ', - 'Session ID (optional, will auto-discover if not provided)', - ) - .option( - '--offset ', - 'The number of console messages to skip before returning results.', - parseInt, - ) + .command("get-console") + .description("Capture console logs from the device") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) + .option("--offset ", "The number of console messages to skip before returning results.", parseInt) + .option("--limit ", "The maximum number of console messages to return.", parseInt) .option( - '--limit ', - 'The maximum number of console messages to return.', - parseInt, + "--include-stack-traces", + "By default, only error messages would contain stack traces. Set this to true to include stack traces for all messages in the output.", ) .option( - '--include-stack-traces', - 'By default, only error messages would contain stack traces. Set this to true to include stack traces for all messages in the output.', + "--level ", + "The log level to filter messages. Defaults to ['info', 'log', 'warning', 'error']", + (value) => value.split(",").map((s) => s.trim()), ) + .option("--thread ", "VM thread to target: background or main", ["background", "main"]) .option( - '--level ', - "The log level to filter messages. Defaults to ['info', 'log', 'warning', 'error']", - (value) => value.split(',').map((s) => s.trim()), + "-w, --watch", + "Stream console logs as they arrive, printing each message immediately, until interrupted (Ctrl+C) or --limit is reached", + false, ) .action(async (options) => { - let { client: clientId, session: sessionId, limit } = options; - const { offset = 0, includeStackTraces, level } = options; - - if (limit) { - limit = Math.max(1, Math.min(100, limit)); + const { + offset = 0, + includeStackTraces, + level, + watch, + } = options; + let { limit, thread } = options; + + if (!Array.isArray(thread)) { + thread = [thread]; } - if (!clientId) { - clientId = await getFirstClient(connector); + if (!thread.every((t: string) => t === "background" || t === "main")) { + throw new Error(`Invalid thread: ${thread}. Expected 'background' or 'main'.`); } - if (!sessionId) { - sessionId = await getLatestSession(connector, clientId); + if (limit) { + limit = Math.max(1, Math.min(100, limit)); } - const numericSessionId = Number(sessionId); + const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); await using stream = await connector.sendCDPStream( clientId, + Number(sessionId), ReadableStream.from([ - { - sessionId: numericSessionId, - method: 'Page.enable', - }, - { - sessionId: numericSessionId, - method: 'Runtime.enable', - }, + { method: "Page.enable" }, + { method: "Page.getResourceTree" }, + ...thread.map((t: string) => ({ + method: "Debugger.enable", + sessionId: t === "main" ? "Main" : undefined, + })), + ...thread.map((t: string) => ({ + method: "Runtime.enable", + sessionId: t === "main" ? "Main" : undefined, + })), ]), ); - const messages: ConsoleMessage[] = []; - const defaultLevels = ['info', 'log', 'warning', 'error']; + const defaultLevels = ["info", "log", "warning", "error"]; const allowedLevels = level || defaultLevels; let skipped = 0; + let produced = 0; + + if (watch) { + const reader = stream.getReader(); + let aborted = false; + const onSigint = () => { + aborted = true; + reader.cancel().catch(() => {}); + }; + process.once("SIGINT", onSigint); + + try { + while (!aborted) { + const { done, value } = await reader.read(); + if (done) break; + + if (value.method !== "Runtime.consoleAPICalled") continue; + const params = value.params as ConsoleMessage; + if (!allowedLevels.includes(params.type)) continue; - const reader = stream.getReader(); - const IDLE_TIMEOUT = 500; - const MAX_TOTAL_TIME = 5000; - const startTime = Date.now(); - - try { - while (Date.now() - startTime < MAX_TOTAL_TIME) { - const result = await Promise.race([ - reader.read(), - setTimeout(IDLE_TIMEOUT, 'timeout' as const), - ]); - if (result === 'timeout') { - await reader.cancel(); - break; - } + if (skipped < offset) { + skipped++; + continue; + } - const { done, value } = result; - if (done) break; + if (!includeStackTraces && params.type !== "error") { + delete params.stackTrace; + } - if (value.method === 'Runtime.consoleAPICalled') { - const params = value.params as ConsoleMessage; - if (allowedLevels.includes(params.type)) { - if (skipped < offset) { - skipped++; - continue; - } - - if (!includeStackTraces && params.type !== 'error') { - delete params.stackTrace; - } - - messages.push(params); - - if (limit && messages.length >= limit) { - await reader.cancel(); - break; - } + console.log(formatConsoleMessage(params)); + produced++; + + if (limit && produced >= limit) { + await reader.cancel(); + break; } } + } finally { + process.off("SIGINT", onSigint); + reader.releaseLock(); } - } finally { - reader.releaseLock(); + + return; } - console.log( - messages - .map( - ({ type, args, stackTrace }) => - `- [${type}]: ${args - .map((arg) => { - if (arg.objectId) { - return `<${arg.description || arg.className || 'Object'} (objectId:${arg.objectId})>`; - } - return arg.value; - }) - .join(' ')}${ - stackTrace - ? '\n' + - stackTrace.callFrames - .map( - ({ url, lineNumber, columnNumber }) => - ` at ${url}:${lineNumber}:${columnNumber}`, - ) - .join('\n') - : '' - }`, - ) - .join('\n'), - ); + const messages: ConsoleMessage[] = []; + + for await (const value of readUntilIdle(stream, { idleMs: 500, maxMs: 5000 })) { + if (value.method !== "Runtime.consoleAPICalled") continue; + const params = value.params as ConsoleMessage; + if (!allowedLevels.includes(params.type)) continue; + + if (skipped < offset) { + skipped++; + continue; + } + + if (!includeStackTraces && params.type !== "error") { + delete params.stackTrace; + } + + messages.push(params); + + if (limit && messages.length >= limit) { + break; + } + } + + console.log(messages.map(formatConsoleMessage).join("\n")); }); } diff --git a/packages/skills/lynx-devtool/src/commands/get-sources.ts b/packages/skills/lynx-devtool/src/commands/get-sources.ts index 675704a..1f25da9 100644 --- a/packages/skills/lynx-devtool/src/commands/get-sources.ts +++ b/packages/skills/lynx-devtool/src/commands/get-sources.ts @@ -1,12 +1,17 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. - -import { ReadableStream } from 'node:stream/web'; -import { setTimeout } from 'node:timers/promises'; -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Command } from 'commander'; -import { getFirstClient, getLatestSession } from './utils.ts'; +/* eslint-disable */ +import { Command } from "commander"; +import { ReadableStream } from "node:stream/web"; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + readUntilIdle, + resolveClientAndSession, + SESSION_OPTION, +} from "./utils.ts"; interface ScriptParsedEvent { scriptId: string; @@ -14,85 +19,37 @@ interface ScriptParsedEvent { [key: string]: unknown; } -export function registerGetSourcesCommand( - program: Command, - connector: Connector, -) { +export function registerGetSourcesCommand(program: Command, context: Context) { program - .command('get-sources') - .description('List all parsed scripts.') - .option( - '-c, --client ', - 'Client ID (optional, will auto-discover if not provided)', - ) - .option( - '-s, --session ', - 'Session ID (optional, will auto-discover if not provided)', - ) + .command("get-sources") + .description("List all parsed scripts.") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) .action(async (options) => { - let { client: clientId, session: sessionId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } - - if (!sessionId) { - sessionId = await getLatestSession(connector, clientId); - } + const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); const numericSessionId = Number(sessionId); - const messages: { sessionId: number; method: string }[] = [ - { - sessionId: numericSessionId, - method: 'Debugger.disable', - }, - { - sessionId: numericSessionId, - method: 'Debugger.enable', - }, + const messages: { method: string }[] = [ + { method: "Debugger.disable" }, + { method: "Debugger.enable" }, ]; await using stream = await connector.sendCDPStream( clientId, + numericSessionId, ReadableStream.from(messages), ); const scripts: ScriptParsedEvent[] = []; - const reader = stream.getReader(); - const IDLE_TIMEOUT = 2000; // Increased timeout for reload - const MAX_TOTAL_TIME = 5000; // Increased max time for reload - const startTime = Date.now(); - - try { - while (Date.now() - startTime < MAX_TOTAL_TIME) { - const result = await Promise.race([ - reader.read(), - setTimeout(IDLE_TIMEOUT, 'timeout' as const), - ]); - if (result === 'timeout') { - await reader.cancel(); - break; - } - - const { done, value } = result; - if (done) break; - - if (value.method === 'Debugger.scriptParsed') { - scripts.push(value.params as ScriptParsedEvent); - } + for await (const value of readUntilIdle(stream, { idleMs: 2000, maxMs: 5000 })) { + if (value.method === "Debugger.scriptParsed") { + scripts.push(value.params as ScriptParsedEvent); } - } finally { - reader.releaseLock(); } - console.log( - JSON.stringify( - scripts.map(({ scriptId, url }) => ({ scriptId, url })), - null, - 2, - ), - ); + console.log(JSON.stringify(scripts.map(({ scriptId, url }) => ({ scriptId, url })), null, 2)); }); } diff --git a/packages/skills/lynx-devtool/src/commands/global-switch.ts b/packages/skills/lynx-devtool/src/commands/global-switch.ts new file mode 100644 index 0000000..b9521d0 --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/global-switch.ts @@ -0,0 +1,103 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, parseOnOff, resolveClient } from "./utils.ts"; + +const GLOBAL_SWITCH_KEYS = [ + "enable_devtool", + "enable_logbox", + "enable_debug_mode", + "enable_dom_tree", + "enable_quickjs_debug", + "enable_quickjs_cache", + "enable_v8", + "enable_cdp_domain_dom", + "enable_cdp_domain_css", + "enable_cdp_domain_page", + "enable_long_press_menu", + "enable_highlight_touch", + "enable_preview_screen_shot", + "enable_pixel_copy", + "enable_fsp_screenshot", +] as const; + +type GlobalSwitchKey = (typeof GLOBAL_SWITCH_KEYS)[number]; +const GLOBAL_SWITCH_KEYS_HELP = GLOBAL_SWITCH_KEYS.join(" | "); + +function parseKey(input: string): GlobalSwitchKey { + if ((GLOBAL_SWITCH_KEYS as readonly string[]).includes(input)) { + return input as GlobalSwitchKey; + } + + throw new Error(`Invalid --key value: ${input}. Use global-switch list to inspect supported keys.`); +} + +export function registerGlobalSwitchCommand(program: Command, context: Context) { + const globalSwitch = program + .command("global-switch") + .description("Manage DevTool global switches"); + + globalSwitch + .command("list") + .description("List all global switch states") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option("--fail-fast", "Abort on first key-read failure") + .action(async (options) => { + const { connector, clientId } = await resolveClient(context, options); + + const switches: Array<{ key: GlobalSwitchKey; value?: boolean; error?: string }> = []; + for (const key of GLOBAL_SWITCH_KEYS) { + try { + const value = await connector.getGlobalSwitch(clientId, key); + switches.push({ key, value }); + } catch (error) { + if (options.failFast) { + throw error; + } + + switches.push({ + key, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + console.log(JSON.stringify({ switches }, null, 2)); + }); + + globalSwitch + .command("get") + .description("Get one global switch state") + .requiredOption("--key ", `Global switch key. Supported: ${GLOBAL_SWITCH_KEYS_HELP}`) + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .action(async (options) => { + const { connector, clientId } = await resolveClient(context, options); + + const key = parseKey(options.key); + + const value = await connector.getGlobalSwitch(clientId, key); + + console.log(JSON.stringify({ key, value }, null, 2)); + }); + + globalSwitch + .command("set") + .description("Set one global switch state") + .requiredOption("--key ", `Global switch key. Supported: ${GLOBAL_SWITCH_KEYS_HELP}`) + .requiredOption("--status ", "Switch status: on/off") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .action(async (options) => { + const { connector, clientId } = await resolveClient(context, options); + + const key = parseKey(options.key); + const value = parseOnOff(options.status); + + await connector.setGlobalSwitch(clientId, key, value); + + console.log(JSON.stringify({ key, value }, null, 2)); + }); +} diff --git a/packages/skills/lynx-devtool/src/commands/inspect.ts b/packages/skills/lynx-devtool/src/commands/inspect.ts new file mode 100644 index 0000000..69d223e --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/inspect.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "./utils.ts"; + +const DEFAULT_DAEMON_PORT = 21783; + +export function registerInspectCommand(program: Command, context: Context) { + program + .command("inspect") + .description("Output the inspector URL for a client/session") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) + .option("--port ", "Daemon port", String(DEFAULT_DAEMON_PORT)) + .action(async (options) => { + const { clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); + + const port = parseInt(options.port, 10) || DEFAULT_DAEMON_PORT; + const inspectorUrl = `http://127.0.0.1:${port}/devtool/connector/inspector?clientId=${ + encodeURIComponent(clientId) + }&sessionId=${encodeURIComponent(sessionId)}`; + + console.log(inspectorUrl); + }); +} diff --git a/packages/skills/lynx-devtool/src/commands/list-clients.ts b/packages/skills/lynx-devtool/src/commands/list-clients.ts index eb05d7f..90516b2 100644 --- a/packages/skills/lynx-devtool/src/commands/list-clients.ts +++ b/packages/skills/lynx-devtool/src/commands/list-clients.ts @@ -1,19 +1,42 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { Connector } from "@lynx-js/devtool-connector"; +import { Command } from "commander"; +import type { Context } from "./utils.ts"; -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Command } from 'commander'; +const NO_CLIENTS_FOUND_MESSAGE = [ + "No Lynx DevTool clients were found.", + "", + "Try these steps:", + "1. Make sure the target device/simulator and app are running.", + "2. If the app just launched, wait a moment and rerun `list-clients`.", + "3. If this is unexpected, rerun with `DEBUG='devtool-mcp-server:connector*'` or try `--no-daemon`.", + "", + "See `skills/lynx-devtool/references/troubleshooting/symptoms.md#list-clients-returns-` for more details.", +].join("\n"); -export function registerListClientsCommand( - program: Command, - connector: Connector, -) { +export async function runListClientsCommand( + connector: Pick, + { + print = console.log, + }: { print?: (line: string) => void } = {}, +): Promise { + const clients = await connector.listClients(); + + if (clients.length === 0) { + throw new Error(NO_CLIENTS_FOUND_MESSAGE); + } + + print(JSON.stringify(clients, null, 2)); +} + +export function registerListClientsCommand(program: Command, { transports }: Context) { program - .command('list-clients') - .description('List all available clients') + .command("list-clients") + .description("List all available clients") .action(async () => { - const clients = await connector.listClients(); - console.log(JSON.stringify(clients, null, 2)); + const connector = new Connector(transports); + await runListClientsCommand(connector); }); } diff --git a/packages/skills/lynx-devtool/src/commands/list-sessions.ts b/packages/skills/lynx-devtool/src/commands/list-sessions.ts index b3c9e47..2af9a47 100644 --- a/packages/skills/lynx-devtool/src/commands/list-sessions.ts +++ b/packages/skills/lynx-devtool/src/commands/list-sessions.ts @@ -1,30 +1,20 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClient } from "./utils.ts"; -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Command } from 'commander'; -import { getFirstClient } from './utils.ts'; - -export function registerListSessionsCommand( - program: Command, - connector: Connector, -) { +export function registerListSessionsCommand(program: Command, context: Context) { program - .command('list-sessions') - .description('List all available sessions') - .option( - '-c, --client ', - 'Client ID (optional, will auto-discover if not provided)', - ) + .command("list-sessions") + .description("List all available sessions") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) .action(async (options) => { - let { client: clientId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } + const { connector, clientId } = await resolveClient(context, options); const sessions = await connector.sendListSessionMessage(clientId); + console.log(JSON.stringify(sessions, null, 2)); }); } diff --git a/packages/skills/lynx-devtool/src/commands/open.ts b/packages/skills/lynx-devtool/src/commands/open.ts index 987bb9b..e114db2 100644 --- a/packages/skills/lynx-devtool/src/commands/open.ts +++ b/packages/skills/lynx-devtool/src/commands/open.ts @@ -1,52 +1,37 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { Command } from "commander"; +import { FilterTransformStream, isListSessionResponse } from "../connector.ts"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClient } from "./utils.ts"; -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Command } from 'commander'; -import { getFirstClient } from './utils.ts'; - -export function registerOpenCommand(program: Command, connector: Connector) { +export function registerOpenCommand(program: Command, context: Context) { program - .command('open') - .description('Open page') - .option( - '-c, --client ', - 'Client ID (optional, will auto-discover if not provided)', - ) - .argument('', 'The url of the page') + .command("open") + .description("Open page") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .argument("", "The url of the page") .action(async (url, options) => { - let { client: clientId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } + const { connector, clientId } = await resolveClient(context, options); - const openCardMessage = { - event: 'Customized', + const result = await connector.sendMessage(clientId, { + event: "Customized", data: { - type: 'OpenCard', + type: "OpenCard", data: { - type: 'url', + type: "url", url, }, sender: -1, }, from: -1, - } as const; - - let result: unknown; - try { - result = await connector.sendMessage(clientId, openCardMessage); - } catch (error) { - console.warn( - `OpenCard failed, falling back to App.openPage for ${url}`, - error, - ); - result = await connector.sendAppMessage(clientId, 'App.openPage', { - url, - }); - } + }, { + input: [], + output: [ + new FilterTransformStream(isListSessionResponse), + ], + }); console.log(JSON.stringify(result, null, 2)); }); diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/find.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/find.ts new file mode 100644 index 0000000..55c6adb --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/find.ts @@ -0,0 +1,181 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "../utils.ts"; +import { formatTree } from "./format.ts"; +import { type DevNodeType, typeTag } from "./protocol.ts"; +import type { ID, RendererState } from "./protocol.ts"; +import { buildOutboundFrame, emptyTreeDiagnostic, runReactLynxSession } from "./transport.ts"; + +interface FindOptions { + client?: string; + session?: string; + regex?: boolean; + showShells?: boolean; + json?: boolean; + limit?: number; +} + +export interface FindMatch { + label: string; + id: ID; + name: string; + type: DevNodeType; + key: string; + ancestors: Array<{ label: string; name: string }>; +} + +export function findComponents( + state: RendererState, + matcher: (name: string) => boolean, + options: { hideShells: boolean; limit: number }, +): FindMatch[] { + const formatted = formatTree(state, { hideShells: options.hideShells }); + const idToLabel = new Map(); + formatted.labels.forEach((id, idx) => { + idToLabel.set(id, `@c${idx + 1}`); + }); + + const matches: FindMatch[] = []; + for (const id of formatted.labels) { + const node = state.tree.get(id); + if (!node) continue; + if (!matcher(node.name)) continue; + + const ancestors: Array<{ label: string; name: string }> = []; + let cursorId = node.parent; + while (cursorId !== undefined && cursorId !== -1) { + const cursor = state.tree.get(cursorId); + if (!cursor) break; + const label = idToLabel.get(cursorId); + if (label) ancestors.unshift({ label, name: cursor.name }); + cursorId = cursor.parent; + } + + matches.push({ + label: idToLabel.get(id) ?? "@c?", + id: node.id, + name: node.name, + type: node.type, + key: node.key, + ancestors, + }); + + if (matches.length >= options.limit) break; + } + return matches; +} + +export function buildSubstringMatcher(pattern: string): (name: string) => boolean { + const needle = pattern.toLowerCase(); + return (name) => name.toLowerCase().includes(needle); +} + +export function buildRegexMatcher(pattern: string): (name: string) => boolean { + let re: RegExp; + try { + re = new RegExp(pattern); + } catch (err) { + throw new Error( + `--regex pattern is invalid: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + return (name) => re.test(name); +} + +export function registerFindCommand(reactlynx: Command, context: Context): void { + reactlynx + .command("find ") + .description( + "Find components by display name. Default match is case-insensitive substring; " + + "use --regex for a JavaScript regular expression.", + ) + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) + .option("--regex", "Treat as a JavaScript regular expression", false) + .option( + "--show-shells", + "Include the synthetic Fragment/Root/Anonymous wrappers ReactLynx inserts", + false, + ) + .option( + "--limit ", + "Maximum number of matches to print (default: 50)", + (v) => { + const n = Number.parseInt(v, 10); + if (!Number.isFinite(n) || n < 1) { + throw new Error(`--limit must be a positive integer (got ${v})`); + } + return n; + }, + 50, + ) + .option( + "--json", + "Emit a JSON array `[{ label, id, name, type, key, ancestors: [{label, name}] }]`", + false, + ) + .action(async (pattern: string, options: FindOptions) => { + const matcher = options.regex + ? buildRegexMatcher(pattern) + : buildSubstringMatcher(pattern); + + const { connector, clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); + + const result = await runReactLynxSession({ + connector, + clientId, + sessionId: Number(sessionId), + outbound: [buildOutboundFrame("refresh")], + }); + + if (result.state.tree.size === 0) { + process.stderr.write(`[reactlynx find] ${emptyTreeDiagnostic(result)}\n`); + process.exitCode = 1; + return; + } + + const matches = findComponents(result.state, matcher, { + hideShells: !options.showShells, + limit: options.limit ?? 50, + }); + + if (matches.length === 0) { + process.stderr.write( + `[reactlynx find] no components match ${options.regex ? "regex" : "substring"} ${JSON.stringify(pattern)} ` + + `(searched ${result.state.tree.size} components${options.showShells ? "" : ", shells hidden"})\n`, + ); + process.exitCode = 1; + return; + } + + if (options.json) { + process.stdout.write(JSON.stringify(matches, null, 2) + "\n"); + return; + } + + process.stdout.write(formatMatches(matches) + "\n"); + }); +} + +export function formatMatches(matches: FindMatch[]): string { + const lines: string[] = []; + for (const match of matches) { + let header = `${match.label} [${typeTag(match.type)}] ${match.name}`; + if (match.key) header += ` key=${match.key}`; + lines.push(header); + if (match.ancestors.length > 0) { + lines.push( + " in " + + match.ancestors.map((a) => `${a.label} ${a.name}`).join(" > "), + ); + } + } + return lines.join("\n"); +} diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/format.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/format.ts new file mode 100644 index 0000000..8fcd390 --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/format.ts @@ -0,0 +1,109 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { type ID, type RendererState, typeTag, type VNode } from "./protocol.ts"; + +export interface FormattedTree { + text: string; + labels: ID[]; +} + +const PIPE = "│ "; +const TEE = "├─ "; +const ELBOW = "└─ "; +const SPACE = " "; + +interface FormatContext { + state: RendererState; + labels: ID[]; + labelOf: Map; + maxDepth: number; + lines: string[]; + hideShells: boolean; +} + +const SHELL_NAMES = new Set(["Fragment", "Root", "Anonymous"]); + +function isShell(node: VNode): boolean { + return SHELL_NAMES.has(node.name); +} + +function visibleChildren(ctx: FormatContext, node: VNode): VNode[] { + const out: VNode[] = []; + for (const cid of node.children) { + const child = ctx.state.tree.get(cid); + if (!child) continue; + if (ctx.hideShells && isShell(child)) { + out.push(...visibleChildren(ctx, child)); + } else { + out.push(child); + } + } + return out; +} + +function formatRef(ctx: FormatContext, node: VNode): string { + const label = ctx.labelOf.get(node.id) ?? "@c?"; + let out = `${label} [${typeTag(node.type)}] ${node.name}`; + if (node.key) out += ` key=${node.key}`; + return out; +} + +function walk( + ctx: FormatContext, + node: VNode, + prefix: string, + isLast: boolean, + isRoot: boolean, + depth: number, +): void { + const connector = isRoot ? "" : isLast ? ELBOW : TEE; + ctx.lines.push(`${prefix}${connector}${formatRef(ctx, node)}`); + + if (depth >= ctx.maxDepth) return; + + const children = visibleChildren(ctx, node); + const childPrefix = isRoot ? "" : prefix + (isLast ? SPACE : PIPE); + children.forEach((child, idx) => { + walk(ctx, child, childPrefix, idx === children.length - 1, false, depth + 1); + }); +} + +export function formatTree( + state: RendererState, + options: { maxDepth?: number; hideShells?: boolean } = {}, +): FormattedTree { + const ctx: FormatContext = { + state, + labels: [], + labelOf: new Map(), + maxDepth: options.maxDepth ?? Number.POSITIVE_INFINITY, + lines: [], + hideShells: options.hideShells ?? true, + }; + + const visibleRoots: VNode[] = []; + for (const rootId of state.roots) { + const root = state.tree.get(rootId); + if (!root) continue; + if (ctx.hideShells && isShell(root)) { + visibleRoots.push(...visibleChildren(ctx, root)); + } else { + visibleRoots.push(root); + } + } + + function assign(node: VNode, depth: number): void { + ctx.labels.push(node.id); + ctx.labelOf.set(node.id, `@c${ctx.labels.length}`); + if (depth >= ctx.maxDepth) return; + for (const c of visibleChildren(ctx, node)) assign(c, depth + 1); + } + for (const r of visibleRoots) assign(r, 1); + + visibleRoots.forEach((root, idx) => { + walk(ctx, root, "", idx === visibleRoots.length - 1, true, 1); + }); + + return { text: ctx.lines.join("\n"), labels: ctx.labels }; +} diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/index.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/index.ts new file mode 100644 index 0000000..ba81e0c --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/index.ts @@ -0,0 +1,20 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { Command } from "commander"; +import type { Context } from "../utils.ts"; +import { registerFindCommand } from "./find.ts"; +import { registerComponentCommand } from "./inspect.ts"; +import { registerTreeCommand } from "./tree.ts"; +import { registerUpdateCommands } from "./update.ts"; + +export function registerReactLynxCommand(program: Command, context: Context): void { + const reactlynx = program + .command("reactlynx") + .description("Inspect a running ReactLynx app via @lynx-js/preact-devtools"); + + registerTreeCommand(reactlynx, context); + registerComponentCommand(reactlynx, context); + registerFindCommand(reactlynx, context); + registerUpdateCommands(reactlynx, context); +} diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/inspect.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/inspect.ts new file mode 100644 index 0000000..cd3707a --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/inspect.ts @@ -0,0 +1,191 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "../utils.ts"; +import { formatTree } from "./format.ts"; +import { type DevNodeType, typeTag } from "./protocol.ts"; +import type { ID } from "./protocol.ts"; +import { buildOutboundFrame, emptyTreeDiagnostic, type PreactEnvelope, runReactLynxSession } from "./transport.ts"; + +interface ComponentOptions { + client?: string; + session?: string; + json?: boolean; + showShells?: boolean; +} + +export interface InspectResult { + id: ID; + name: string; + type: DevNodeType; + key: string | null; + props: unknown; + state: unknown; + hooks: unknown; + context: unknown; + signals: unknown; + suspended?: boolean; + canSuspend?: boolean; + version?: string; + __source?: { fileName: string; lineNumber: number; columnNumber: number }; +} + +export function parseComponentRef( + ref: string, +): { kind: "label"; index: number } | { kind: "id"; id: ID } { + const labelMatch = /^@c(\d+)$/.exec(ref); + if (labelMatch) { + const index = Number.parseInt(labelMatch[1]!, 10); + if (!Number.isFinite(index) || index < 1) { + throw new Error(`Invalid label ${ref}; expected @c1, @c2, ...`); + } + return { kind: "label", index }; + } + const numeric = Number.parseInt(ref, 10); + if (!Number.isFinite(numeric) || String(numeric) !== ref.trim()) { + throw new Error( + `Invalid ${JSON.stringify(ref)}; expected @cN or a numeric id.`, + ); + } + return { kind: "id", id: numeric }; +} + +export function formatInspectResult(data: InspectResult, ref: string): string { + const lines: string[] = []; + const headerKey = data.key ? ` key=${data.key}` : ""; + lines.push(`${ref} (id=${data.id}) [${typeTag(data.type)}] ${data.name}${headerKey}`); + if (data.__source) { + lines.push( + ` source: ${data.__source.fileName}:${data.__source.lineNumber}:${data.__source.columnNumber}`, + ); + } + if (data.suspended !== undefined && data.suspended) lines.push(" suspended: true"); + + appendSection(lines, "props", data.props); + appendSection(lines, "state", data.state); + appendSection(lines, "hooks", data.hooks); + appendSection(lines, "context", data.context); + appendSection(lines, "signals", data.signals); + + return lines.join("\n"); +} + +function appendSection(lines: string[], label: string, value: unknown): void { + if (value === null || value === undefined) return; + if (Array.isArray(value) && value.length === 0) return; + if ( + typeof value === "object" + && !Array.isArray(value) + && Object.keys(value as object).length === 0 + ) { + return; + } + lines.push(` ${label}:`); + const rendered = JSON.stringify(value, null, 2) + .split("\n") + .map((l) => ` ${l}`) + .join("\n"); + lines.push(rendered); +} + +export function registerComponentCommand( + reactlynx: Command, + context: Context, +): void { + reactlynx + .command("component ") + .description( + "Inspect a single component (props/state/hooks/context). " + + " is either `@cN` (resolved against `reactlynx tree`) or a numeric id.", + ) + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) + .option( + "--show-shells", + "When resolving `@cN`, count synthetic Fragment/Root/Anonymous wrappers " + + "the same way `reactlynx tree --show-shells` does. Has no effect on numeric ids.", + false, + ) + .option("--json", "Print the raw `InspectData` payload as JSON", false) + .action(async (ref: string, options: ComponentOptions) => { + const { connector, clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); + + let targetId: ID; + const parsed = parseComponentRef(ref); + + if (parsed.kind === "label") { + const snapshot = await runReactLynxSession({ + connector, + clientId, + sessionId: Number(sessionId), + outbound: [buildOutboundFrame("refresh")], + }); + + if (snapshot.state.tree.size === 0) { + process.stderr.write( + `[reactlynx component] ${emptyTreeDiagnostic(snapshot)}\n`, + ); + process.exitCode = 1; + return; + } + + const labels = formatTree(snapshot.state, { + hideShells: !options.showShells, + }).labels; + const resolved = labels[parsed.index - 1]; + if (resolved === undefined) { + process.stderr.write( + `[reactlynx component] label ${ref} does not exist; tree has ${labels.length} labelled component(s).\n`, + ); + process.exitCode = 1; + return; + } + targetId = resolved; + } else { + targetId = parsed.id; + } + + let inspectResult: InspectResult | undefined; + const inspectSession = await runReactLynxSession({ + connector, + clientId, + sessionId: Number(sessionId), + outbound: [buildOutboundFrame("inspect", targetId)], + idleMs: 1_000, + maxMs: 5_000, + onEnvelope: (env: PreactEnvelope) => { + if (env.type === "inspect-result" && env.data && typeof env.data === "object") { + inspectResult = env.data as InspectResult; + return "stop"; + } + return "continue"; + }, + }); + + if (!inspectResult) { + const types = [...inspectSession.envelopeTypes].sort().join(",") || "(none)"; + process.stderr.write( + `[reactlynx component] no \`inspect-result\` for id ${targetId} after ${inspectSession.framesSeen} frame(s) ` + + `(types=${types}). Common causes:\n` + + ` - the id is stale (the App has unmounted that component since the snapshot was taken)\n` + + ` - the App is running an old @lynx-js/preact-devtools that doesn't honor \`inspect\`\n` + + ` - the targeted thread does not have a Preact renderer (e.g. you picked a non-ReactLynx session).\n` + + `Rerun with DEBUG=devtool-mcp-server:reactlynx to see every frame.\n`, + ); + process.exitCode = 1; + return; + } + + if (options.json) { + process.stdout.write(JSON.stringify(inspectResult, null, 2) + "\n"); + return; + } + + process.stdout.write(formatInspectResult(inspectResult, ref) + "\n"); + }); +} diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/protocol.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/protocol.ts new file mode 100644 index 0000000..b4193a2 --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/protocol.ts @@ -0,0 +1,220 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export type ID = number; + +export const DevNodeType = { + Group: 0, + Element: 1, + ClassComponent: 2, + FunctionComponent: 3, + ForwardRef: 4, + Memo: 5, + Suspense: 6, + Context: 7, + Consumer: 8, + Portal: 9, +} as const; +export type DevNodeType = (typeof DevNodeType)[keyof typeof DevNodeType]; + +const MsgType = { + ADD_ROOT: 1, + ADD_VNODE: 2, + REMOVE_VNODE: 3, + UPDATE_VNODE_TIMINGS: 4, + REORDER_CHILDREN: 5, + RENDER_REASON: 6, + COMMIT_STATS: 7, + HOC_NODES: 8, +} as const; + +export interface VNode { + id: ID; + type: DevNodeType; + name: string; + key: string; + parent: ID; + owner: ID; + children: ID[]; + startTime: number; + endTime: number; +} + +export type VTree = Map; + +export interface RendererState { + tree: VTree; + roots: ID[]; +} + +export function createRendererState(): RendererState { + return { tree: new Map(), roots: [] }; +} + +function parseStringTable(slice: number[]): string[] { + const len = slice[0]!; + const strings: string[] = []; + if (len > 0) { + for (let i = 1; i < len; i++) { + const strLen = slice[i]!; + let start = i + 1; + const end = i + strLen + 1; + let str = ""; + for (; start < end; start++) { + const code = slice[start]; + if (typeof code === "number" && code >= 0 && code <= 0x10FFFF) { + str += String.fromCodePoint(code); + } else { + str += "?"; + } + } + strings.push(str); + i += strLen; + } + } + return strings; +} + +export function applyOperationV2(state: RendererState, ops: number[]): void { + const { tree, roots } = state; + + let i = ops[1]! + 1; + const strings = parseStringTable(ops.slice(1, i + 1)); + + for (i += 1; i < ops.length; i++) { + switch (ops[i]) { + case MsgType.ADD_ROOT: { + const rootId = ops[i + 1]!; + if (!roots.includes(rootId)) roots.push(rootId); + i += 1; + break; + } + case MsgType.ADD_VNODE: { + const id = ops[i + 1]!; + const type = ops[i + 2]! as DevNodeType; + const parentId = ops[i + 3]!; + const owner = ops[i + 4]!; + const nameId = ops[i + 5]!; + const keyId = ops[i + 6]!; + const startTime = ops[i + 7]! / 1000; + const endTime = ops[i + 8]! / 1000; + + const parent = tree.get(parentId); + if (parent) { + parent.children.push(id); + } + tree.set(id, { + id, + type, + name: strings[nameId - 1] ?? "", + key: keyId > 0 ? (strings[keyId - 1] ?? "") : "", + parent: parentId, + owner, + children: [], + startTime, + endTime, + }); + i += 8; + break; + } + case MsgType.UPDATE_VNODE_TIMINGS: { + const id = ops[i + 1]!; + const node = tree.get(id); + if (node) { + node.startTime = ops[i + 2]! / 1000; + node.endTime = ops[i + 3]! / 1000; + } + i += 3; + break; + } + case MsgType.REMOVE_VNODE: { + const unmounts = ops[i + 1]!; + i += 2; + const len = i + unmounts; + for (; i < len; i++) { + const nodeId = ops[i]!; + const node = tree.get(nodeId); + if (!node) continue; + + const parent = tree.get(node.parent); + if (parent) { + const idx = parent.children.indexOf(nodeId); + if (idx > -1) parent.children.splice(idx, 1); + } + + const stack: ID[] = [nodeId]; + while (stack.length) { + const cur = stack.pop()!; + const cnode = tree.get(cur); + if (!cnode) continue; + tree.delete(cur); + stack.push(...cnode.children); + } + + const rIdx = roots.indexOf(nodeId); + if (rIdx > -1) roots.splice(rIdx, 1); + } + if (len > 0) i--; + break; + } + case MsgType.REORDER_CHILDREN: { + const parentId = ops[i + 1]!; + const count = ops[i + 2]!; + const node = tree.get(parentId); + if (node) { + node.children = ops.slice(i + 3, i + 3 + count); + } + i = i + 2 + count; + break; + } + case MsgType.RENDER_REASON: { + const count = ops[i + 3]!; + i = i + 3 + count; + break; + } + case MsgType.COMMIT_STATS: { + throw new Error( + "operation_v2 commit-stats not implemented; enable stats parsing if needed", + ); + } + case MsgType.HOC_NODES: { + const count = ops[i + 2]!; + i = i + 2 + count; + break; + } + default: + throw new Error(`Unknown operation_v2 op ${ops[i]} at index ${i}`); + } + } +} + +export function applyRootOrder(state: RendererState, rootOrder: ID[]): void { + state.roots = [...rootOrder]; +} + +export function typeTag(type: DevNodeType): string { + switch (type) { + case DevNodeType.FunctionComponent: + return "fn"; + case DevNodeType.ClassComponent: + return "cls"; + case DevNodeType.ForwardRef: + return "fRef"; + case DevNodeType.Memo: + return "memo"; + case DevNodeType.Suspense: + return "susp"; + case DevNodeType.Context: + return "ctx"; + case DevNodeType.Consumer: + return "cons"; + case DevNodeType.Portal: + return "portal"; + case DevNodeType.Element: + return "host"; + case DevNodeType.Group: + return "group"; + default: + return "?"; + } +} diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/transport.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/transport.ts new file mode 100644 index 0000000..3f14b04 --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/transport.ts @@ -0,0 +1,212 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { Connector } from "@lynx-js/devtool-connector"; +import { ReadableStream } from "node:stream/web"; +import { createDebug } from "obug"; +import { readUntilIdle } from "../utils.ts"; +import { applyOperationV2, applyRootOrder, createRendererState, type RendererState } from "./protocol.ts"; + +const debug = createDebug("devtool-mcp-server:reactlynx"); + +const PREACT_EVENT = "PreactDevtools"; +const SOURCE_PAGE_HOOK = "preact-page-hook"; +const SOURCE_DEVTOOLS_TO_CLIENT = "preact-devtools-to-client"; + +export const DEFAULT_IDLE_MS = 700; +export const DEFAULT_MAX_MS = 5_000; + +export interface PreactEnvelope { + source: string; + type: string; + data: T; +} + +interface LynxOnVMEventParams { + vmType?: string; + event?: string; + data?: string; +} + +interface OutboundCDPFrame { + method: string; + params: { vmType: string; event: string; data: string }; +} + +export function buildOutboundFrame( + type: string, + data?: T, +): OutboundCDPFrame { + return { + method: "Lynx.sendVMEvent", + params: { + vmType: "JSContext", + event: PREACT_EVENT, + data: JSON.stringify( + { + source: SOURCE_DEVTOOLS_TO_CLIENT, + type, + data: data ?? null, + } satisfies PreactEnvelope, + ), + }, + }; +} + +export interface SessionResult { + state: RendererState; + framesSeen: number; + operationFrames: number; + rootOrderFrames: number; + envelopeTypes: Set; +} + +export type EnvelopeAction = "continue" | "stop"; + +export interface RunSessionOptions { + connector: Connector; + clientId: string; + sessionId: number; + outbound: OutboundCDPFrame[]; + sendInit?: boolean; + onEnvelope?: (envelope: PreactEnvelope) => EnvelopeAction; + idleMs?: number; + maxMs?: number; + signal?: AbortSignal; +} + +export async function runReactLynxSession( + options: RunSessionOptions, +): Promise { + const { + connector, + clientId, + sessionId, + outbound, + sendInit = true, + onEnvelope = () => "continue", + idleMs = DEFAULT_IDLE_MS, + maxMs = DEFAULT_MAX_MS, + signal, + } = options; + + let stopRequested = false; + let cancelInput: () => void = () => {}; + const input = new ReadableStream({ + start(controller) { + if (sendInit) controller.enqueue(buildOutboundFrame("init")); + for (const frame of outbound) controller.enqueue(frame); + cancelInput = () => { + try { + controller.close(); + } catch { + /* already closed */ + } + }; + }, + }); + + await using stream = await connector.sendCDPStream( + clientId, + sessionId, + input as unknown as ReadableStream<{ method: string; params?: unknown }>, + signal ? { signal } : undefined, + ); + + const state = createRendererState(); + let framesSeen = 0; + let operationFrames = 0; + let rootOrderFrames = 0; + const envelopeTypes = new Set(); + + try { + for await ( + const value of readUntilIdle( + stream as unknown as ReadableStream, + { idleMs, maxMs }, + ) + ) { + if (typeof value !== "object" || value === null) continue; + + const method = (value as { method?: string }).method; + if (method !== "Lynx.onVMEvent") continue; + const params = (value as { params?: LynxOnVMEventParams }).params ?? {}; + if (params.event !== PREACT_EVENT) continue; + + let envelope: PreactEnvelope; + try { + envelope = JSON.parse(params.data ?? "null") as PreactEnvelope; + } catch { + continue; + } + if (envelope.source !== SOURCE_PAGE_HOOK) continue; + + framesSeen += 1; + envelopeTypes.add(envelope.type); + debug( + "frame %d: type=%s dataSize=%s", + framesSeen, + envelope.type, + Array.isArray(envelope.data) + ? envelope.data.length + : typeof envelope.data, + ); + + switch (envelope.type) { + case "operation_v2": + if (Array.isArray(envelope.data)) { + operationFrames += 1; + applyOperationV2(state, envelope.data as number[]); + } + break; + case "root-order": + if (Array.isArray(envelope.data)) { + rootOrderFrames += 1; + applyRootOrder(state, envelope.data as number[]); + } + break; + } + + if (onEnvelope(envelope) === "stop") { + stopRequested = true; + break; + } + } + } finally { + cancelInput(); + } + + debug( + "session done: stop=%s frames=%d op=%d root=%d types=%o", + stopRequested, + framesSeen, + operationFrames, + rootOrderFrames, + [...envelopeTypes], + ); + + return { state, framesSeen, operationFrames, rootOrderFrames, envelopeTypes }; +} + +export function emptyTreeDiagnostic(result: SessionResult): string { + if (result.framesSeen === 0) { + return ( + "saw 0 frames -- the App is silent on the PreactDevtools channel. " + + "Most likely `@lynx-js/preact-devtools` is not installed, the bundle is a production build " + + "(`setupReactLynx()` is stripped from `react-lynx/index.ts:3`), or `setupReactLynx()` has not run yet. " + + "Look for `[PREACT DEVTOOLS] Devtools initialized successfully` in the device console." + ); + } + if (result.operationFrames === 0) { + return ( + `saw ${result.framesSeen} frame(s) but no \`operation_v2\` -- ` + + "`@lynx-js/preact-devtools` is loaded but does not honor `refresh`. " + + "Upgrade to a build that includes the PR #2 (`document.body`) and PR #5 (`preactDevtoolsCtx.Node`) fixes from `lynx-family/preact-devtools`." + ); + } + return ( + `saw ${result.framesSeen} frame(s) including ${result.operationFrames} \`operation_v2\` ` + + "but the resulting tree is empty (every node was unmounted). " + + "This is unusual -- rerun with `DEBUG=devtool-mcp-server:reactlynx` to inspect each frame's payload." + ); +} diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/tree.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/tree.ts new file mode 100644 index 0000000..4b8f96e --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/tree.ts @@ -0,0 +1,83 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "../utils.ts"; +import { formatTree } from "./format.ts"; +import { buildOutboundFrame, emptyTreeDiagnostic, runReactLynxSession } from "./transport.ts"; + +export function registerTreeCommand(reactlynx: Command, context: Context): void { + reactlynx + .command("tree") + .description( + "Print the ReactLynx component tree as an ASCII diagram with @cN labels.", + ) + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) + .option( + "--depth ", + "Maximum tree depth to print (default: unbounded)", + (v) => { + const n = Number.parseInt(v, 10); + if (!Number.isFinite(n) || n < 1) { + throw new Error(`--depth must be a positive integer (got ${v})`); + } + return n; + }, + ) + .option( + "--show-shells", + "Include the synthetic Fragment/Root/Anonymous wrappers ReactLynx inserts", + false, + ) + .option( + "--json", + "Emit a JSON object { labels, roots, nodes } instead of ASCII", + false, + ) + .action(async (options) => { + const { connector, clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); + + const result = await runReactLynxSession({ + connector, + clientId, + sessionId: Number(sessionId), + outbound: [buildOutboundFrame("refresh")], + }); + + if (result.state.tree.size === 0) { + process.stderr.write(`[reactlynx tree] ${emptyTreeDiagnostic(result)}\n`); + process.exitCode = 1; + return; + } + + const formatted = formatTree(result.state, { + maxDepth: options.depth, + hideShells: !options.showShells, + }); + + if (options.json) { + const nodes = Array.from(result.state.tree.values()).map((n) => ({ + id: n.id, + type: n.type, + name: n.name, + key: n.key, + parent: n.parent, + children: n.children, + })); + process.stdout.write( + JSON.stringify( + { labels: formatted.labels, roots: result.state.roots, nodes }, + null, + 2, + ) + "\n", + ); + } else { + process.stdout.write(formatted.text + "\n"); + } + }); +} diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/update.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/update.ts new file mode 100644 index 0000000..65e64b4 --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/update.ts @@ -0,0 +1,214 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "../utils.ts"; +import { formatTree } from "./format.ts"; +import { formatInspectResult, type InspectResult, parseComponentRef } from "./inspect.ts"; +import type { ID } from "./protocol.ts"; +import { buildOutboundFrame, emptyTreeDiagnostic, type PreactEnvelope, runReactLynxSession } from "./transport.ts"; + +export type UpdateKind = "update-prop" | "update-state" | "update-context"; + +interface UpdateOptions { + client?: string; + session?: string; + showShells?: boolean; + raw?: boolean; + json?: boolean; +} + +interface UpdatePayload { + id: ID; + path: string; + value: unknown; +} + +export function parseUpdateValue(input: string, options: { raw: boolean }): unknown { + if (options.raw) return input; + try { + return JSON.parse(input); + } catch (err) { + throw new Error( + ` must be valid JSON (e.g. \`"hello"\`, \`42\`, \`true\`, \`null\`, \`{"a":1}\`); ` + + `pass --raw to send the input verbatim as a string. Underlying error: ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err }, + ); + } +} + +export function buildUpdatePath(userPath: string): string { + if (userPath.length === 0) { + throw new Error( + " must not be empty. Use dot notation, e.g. `count`, `user.name`, `items.0.title`.", + ); + } + for (const prefix of ["root.", "props.", "state.", "context."] as const) { + if (userPath.startsWith(prefix)) { + throw new Error( + ` ${JSON.stringify(userPath)} must not start with \`${prefix}\`. ` + + "The CLI prepends `root.` automatically; pass paths starting at the field name, e.g. `count`.", + ); + } + } + + for (const segment of userPath.split(".")) { + if (segment.length === 0) { + throw new Error( + ` ${JSON.stringify(userPath)} contains an empty segment. ` + + "Dot notation must look like `a.b.c`, not `a..b` or `.a`.", + ); + } + } + return `root.${userPath}`; +} + +export function registerUpdateCommands(reactlynx: Command, context: Context): void { + registerOneUpdate(reactlynx, context, { + name: "update-prop", + description: "Set a prop on a single ReactLynx component (forceUpdate is called for you)", + kind: "update-prop", + }); + registerOneUpdate(reactlynx, context, { + name: "update-state", + description: "Set a state field on a single class component (forceUpdate is called for you)", + kind: "update-state", + }); + registerOneUpdate(reactlynx, context, { + name: "update-context", + description: + "Set a context value on a single component. Best-effort; upstream may make this read-only in the future.", + kind: "update-context", + }); +} + +function registerOneUpdate( + reactlynx: Command, + context: Context, + spec: { name: string; description: string; kind: UpdateKind }, +): void { + reactlynx + .command(`${spec.name} `) + .description(spec.description) + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) + .option( + "--show-shells", + "When resolving `@cN`, count synthetic Fragment/Root/Anonymous wrappers " + + "the same way `reactlynx tree --show-shells` does. No effect for numeric ids.", + false, + ) + .option( + "--raw", + "Send verbatim as a string instead of parsing it as JSON", + false, + ) + .option( + "--json", + "Print the post-update `InspectData` as JSON instead of an ASCII summary", + false, + ) + .action( + async ( + ref: string, + userPath: string, + rawValue: string, + options: UpdateOptions, + ) => { + const path = buildUpdatePath(userPath); + const value = parseUpdateValue(rawValue, { raw: options.raw ?? false }); + + const { connector, clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); + + let targetId: ID; + const parsed = parseComponentRef(ref); + if (parsed.kind === "label") { + const snapshot = await runReactLynxSession({ + connector, + clientId, + sessionId: Number(sessionId), + outbound: [buildOutboundFrame("refresh")], + }); + + if (snapshot.state.tree.size === 0) { + process.stderr.write( + `[reactlynx ${spec.name}] ${emptyTreeDiagnostic(snapshot)}\n`, + ); + process.exitCode = 1; + return; + } + + const labels = formatTree(snapshot.state, { + hideShells: !options.showShells, + }).labels; + const resolved = labels[parsed.index - 1]; + if (resolved === undefined) { + process.stderr.write( + `[reactlynx ${spec.name}] label ${ref} does not exist; tree has ${labels.length} labelled component(s).\n`, + ); + process.exitCode = 1; + return; + } + targetId = resolved; + } else { + targetId = parsed.id; + } + + let confirmation: InspectResult | undefined; + const session = await runReactLynxSession({ + connector, + clientId, + sessionId: Number(sessionId), + outbound: [ + buildOutboundFrame(spec.kind, { + id: targetId, + path, + value, + }), + ], + idleMs: 1_000, + maxMs: 5_000, + onEnvelope: (env: PreactEnvelope) => { + if ( + env.type === "inspect-result" + && env.data + && typeof env.data === "object" + && (env.data as { id?: number }).id === targetId + ) { + confirmation = env.data as InspectResult; + return "stop"; + } + return "continue"; + }, + }); + + if (!confirmation) { + const types = [...session.envelopeTypes].sort().join(",") || "(none)"; + process.stderr.write( + `[reactlynx ${spec.name}] no confirmation \`inspect-result\` for id ${targetId} after ` + + `${session.framesSeen} frame(s) (types=${types}). Common causes:\n` + + ` - the path is wrong (the App's setInCopy walks objects/arrays; non-existent intermediate keys are created, but typos still produce a no-op forceUpdate)\n` + + ` - the id is stale (component unmounted between snapshot and update)\n` + + ` - the App is running an old @lynx-js/preact-devtools that doesn't honor \`${spec.kind}\`\n` + + ` - for update-state/update-context: the target is a function component (those have neither)\n` + + `Rerun with DEBUG=devtool-mcp-server:reactlynx to see every frame.\n`, + ); + process.exitCode = 1; + return; + } + + if (options.json) { + process.stdout.write(JSON.stringify(confirmation, null, 2) + "\n"); + return; + } + + process.stdout.write(formatInspectResult(confirmation, ref) + "\n"); + }, + ); +} diff --git a/packages/skills/lynx-devtool/src/commands/recorder-analysis.ts b/packages/skills/lynx-devtool/src/commands/recorder-analysis.ts new file mode 100644 index 0000000..d47e325 --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/recorder-analysis.ts @@ -0,0 +1,104 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import path from "node:path"; +import zlib from "node:zlib"; + +export interface FileDiagnostic { + file: string; + fileSizeBytes: number; + healthy: boolean; + actions: number; + hasTemplate: boolean; + functionDistribution: Record; + verdict: string; +} + +interface Action { + "Function Name": string; + "Record Time"?: string; + Params?: Record; +} + +type RecordingData = Action[] | { "Action List"?: Action[] }; + +export function analyzeRecordingBuffer(filePath: string, buffer: Buffer): FileDiagnostic { + const fileSizeBytes = buffer.byteLength; + let recording: RecordingData; + let parseFailed = false; + + try { + const raw = buffer.toString("utf-8").trim(); + if (raw.startsWith("{") || raw.startsWith("[")) { + recording = JSON.parse(raw); + } else { + const decoded = Buffer.from(raw, "base64"); + const inflated = zlib.inflateSync(decoded); + recording = JSON.parse(inflated.toString("utf-8")); + } + } catch { + parseFailed = true; + recording = {}; + } + + if (parseFailed) { + return { + file: filePath, + fileSizeBytes, + healthy: false, + actions: 0, + hasTemplate: false, + functionDistribution: {}, + verdict: "Cannot parse — file is not a valid TestBench recording", + }; + } + + const actions = Array.isArray(recording) ? recording : recording["Action List"] ?? []; + + const functionDistribution: Record = {}; + for (const action of actions) { + const fn = action["Function Name"]; + functionDistribution[fn] = (functionDistribution[fn] ?? 0) + 1; + } + + const hasTemplate = actions.some(a => a["Function Name"] === "loadTemplate"); + const hasTouchEvents = actions.some(a => + a["Function Name"] === "SendTouchEvent" || a["Function Name"] === "sendEventDarwin" + ); + + let healthy: boolean; + let verdict: string; + + if (actions.length === 0) { + healthy = false; + verdict = "Empty recording — no actions captured"; + } else if (hasTemplate) { + healthy = true; + verdict = "Valid recording — includes template load and interaction data"; + } else if (hasTouchEvents) { + healthy = true; + verdict = + "Recording captures touch events but no template load — still useful for analyzing interactions, but cannot be replayed in Lynx Explorer"; + } else { + healthy = true; + verdict = + "Recording has actions but no template load — useful for inspecting JSB calls or data updates, but cannot be replayed"; + } + + return { + file: filePath, + fileSizeBytes, + healthy, + actions: actions.length, + hasTemplate, + functionDistribution, + verdict, + }; +} + +export function recordingOutputPath(basePath: string, sessionId: number, index: number): string { + const suffix = sessionId > 0 ? `-session${sessionId}` : index > 0 ? `-${index}` : ""; + if (!suffix) return basePath; + const parsed = path.parse(basePath); + return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || ".json"}`); +} diff --git a/packages/skills/lynx-devtool/src/commands/recorder-end.ts b/packages/skills/lynx-devtool/src/commands/recorder-end.ts new file mode 100644 index 0000000..42b8a64 --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/recorder-end.ts @@ -0,0 +1,242 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { type CDPResponseMessage, Connector } from "@lynx-js/devtool-connector"; +import { Command } from "commander"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { ReadableStream } from "node:stream/web"; +import { analyzeRecordingBuffer, type FileDiagnostic, recordingOutputPath } from "./recorder-analysis.ts"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, isAbortError, resolveClient } from "./utils.ts"; + +const IO_READ_CHUNK_SIZE = 1024 * 1024; +const RECORDING_END_TIMEOUT_MS = 60_000; + +export function registerEndCommand(parent: Command, context: Context) { + parent + .command("end") + .description("Stop TestBench recording and save the replay file") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option( + "-o, --output ", + "Output file or directory path (defaults to ~/.lynx-devtool/files/lynxrecorder/recording--.json)", + ) + .action(async (options) => { + const { connector, clientId } = await resolveClient(context, options); + const { output } = options; + const result = await runRecordingEnd(connector, clientId, output); + console.log(JSON.stringify({ success: true, message: "Recording ended successfully.", ...result })); + }); +} + +export async function runRecordingEnd( + connector: Connector, + clientId: string, + output: string | undefined, +): Promise<{ savedFiles: string[]; recordingComplete: Record; diagnostics: FileDiagnostic[] }> { + const recordingComplete = await readRecordingCompleteEvent(connector, clientId); + + const savedFiles: string[] = []; + const diagnostics: FileDiagnostic[] = []; + const baseOutputPath = await resolveRecordingBaseOutputPath(output, clientId); + + const streams = recordingComplete["stream"] as number[] | undefined; + const sessionIDs = recordingComplete["sessionIDs"] as number[] | undefined; + + if (!Array.isArray(streams) || streams.length === 0) { + throw new Error( + "Recording.recordingComplete did not include any streams. " + + "If recording was never started, run `recorder start` first.", + ); + } + + for (const [index, streamHandle] of streams.entries()) { + const sessionId = sessionIDs?.[index]; + if (sessionId === undefined) { + throw new Error( + "Recording.recordingComplete returned mismatched `stream` and `sessionIDs` lengths. " + + "Reconnect and retry `recorder end`.", + ); + } + if (sessionId === -1) continue; + + const signal = AbortSignal.timeout(RECORDING_END_TIMEOUT_MS); + const data = await readStreamFully(connector, clientId, streamHandle, signal); + const filePath = recordingOutputPath(baseOutputPath, sessionId, savedFiles.length); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, data); + savedFiles.push(filePath); + diagnostics.push(analyzeRecordingBuffer(filePath, data)); + } + + if (savedFiles.length === 0) { + throw new Error( + buildNoPageRecordingMessage(clientId, recordingComplete, streams, sessionIDs), + ); + } + + const unhealthy = diagnostics.filter(d => !d.healthy); + if (unhealthy.length > 0) { + console.warn( + "Recording saved, but the following file(s) may be unusable:\n" + + unhealthy.map(d => ` - ${d.file}: ${d.verdict}`).join("\n"), + ); + } + + const noTemplate = diagnostics.filter(d => d.healthy && !d.hasTemplate); + if (noTemplate.length > 0) { + console.warn( + "Note: the following file(s) have no `loadTemplate` action and cannot be replayed,\n" + + "but may still be useful for inspecting recorded behavior:\n" + + noTemplate.map(d => ` - ${d.file}: ${d.verdict}`).join("\n"), + ); + } + + return { savedFiles, recordingComplete, diagnostics }; +} + +function buildNoPageRecordingMessage( + clientId: string, + recordingComplete: Record, + streams: number[], + sessionIDs: number[] | undefined, +): string { + const filenames = recordingComplete["filenames"]; + const nativeFiles = Array.isArray(filenames) && filenames.length > 0 + ? ` Native filenames: ${JSON.stringify(filenames)}.` + : ""; + + return [ + "Recording ended, but no page recording was produced.", + `Native returned sessionIDs=${JSON.stringify(sessionIDs ?? [])}, streams=${streams.length}.${nativeFiles}`, + "This usually means no Lynx page session was opened or reloaded after `recorder start`.", + "To produce a replayable file:", + `1. Run \`list-sessions --client ${clientId}\` and confirm there is a Lynx session.`, + "2. After `recorder start`, open or reload the target page.", + ` Example: \`cdp --client ${clientId} --session -m Page.reload '{"ignoreCache":true}'\``, + "3. Interact with the page, then run `recorder end` again.", + ].join("\n"); +} + +async function readRecordingCompleteEvent( + connector: Connector, + clientId: string, +): Promise> { + const timeoutSignal = AbortSignal.timeout(RECORDING_END_TIMEOUT_MS); + const isTimeoutError = (err: unknown) => isAbortError(err) || (err instanceof Error && err.name === "TimeoutError"); + + try { + await using stream = await connector.sendCDPStream( + clientId, + -1, + // eslint-disable-next-line n/no-unsupported-features/node-builtins + ReadableStream.from([{ method: "Recording.end", params: {} }]), + { signal: timeoutSignal }, + ); + + for await ( + const value of stream as unknown as AsyncIterable< + CDPResponseMessage & { + method?: string; + params?: Record; + error?: { message: string }; + } + > + ) { + if (value.method === "Recording.recordingComplete") { + return value.params ?? {}; + } + if (value.error) { + throw new Error( + `Recording.end failed: ${value.error.message}. ` + + "If recording was never started, run `recorder start` first.", + ); + } + } + } catch (err) { + if (!isTimeoutError(err)) throw err; + throw new Error( + "Recording.end timed out before receiving Recording.recordingComplete. " + + "Make sure recording was started with `recorder start` and the device is still connected.", + { cause: err }, + ); + } + + throw new Error( + "Recording.end stream closed before Recording.recordingComplete was received. " + + "Make sure recording was started with `recorder start` and the device is still connected.", + ); +} + +async function readStreamFully( + connector: Connector, + clientId: string, + handle: number, + signal: AbortSignal, +): Promise { + const chunks: Buffer[] = []; + try { + while (true) { + const chunk = await abortable( + connector.sendCDPMessage< + { data?: string; base64Encoded?: boolean; eof?: boolean }, + Record + >(clientId, -1, "IO.read", { handle, size: IO_READ_CHUNK_SIZE }), + signal, + `IO.read timed out reading recording stream handle ${handle}. ` + + "The device may have stalled; reconnect and retry `recorder end`.", + ); + if (chunk.data) { + chunks.push(Buffer.from(chunk.data, chunk.base64Encoded ? "base64" : "utf-8")); + } + if (chunk.eof) break; + } + return Buffer.concat(chunks); + } finally { + await abortable( + connector.sendCDPMessage(clientId, -1, "IO.close", { handle }), + AbortSignal.timeout(5_000), + `IO.close timed out closing recording stream handle ${handle}. Proceeding with local cleanup.`, + ).catch(() => {}); + } +} + +function abortable(promise: Promise, signal: AbortSignal, message: string): Promise { + if (signal.aborted) return Promise.reject(new Error(message)); + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error(message)); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (v) => { + signal.removeEventListener("abort", onAbort); + resolve(v); + }, + (e) => { + signal.removeEventListener("abort", onAbort); + reject(e); + }, + ); + }); +} + +async function resolveRecordingBaseOutputPath(output: string | undefined, clientId: string): Promise { + const defaultFileName = `recording-${clientId.replace(/[<>:"/\\|?*()]/g, "_")}-${Date.now()}.json`; + if (!output) { + return path.resolve(os.homedir(), ".lynx-devtool", "files", "lynxrecorder", defaultFileName); + } + + const resolvedOutput = path.resolve(output); + const outputLooksLikeDirectory = output.endsWith(path.sep) || output.endsWith("/") || output.endsWith("\\"); + const outputIsDirectory = outputLooksLikeDirectory + || await fs.stat(resolvedOutput).then(stats => stats.isDirectory()).catch(() => false); + + if (outputIsDirectory) { + await fs.mkdir(resolvedOutput, { recursive: true }); + return path.join(resolvedOutput, defaultFileName); + } + + return resolvedOutput; +} diff --git a/packages/skills/lynx-devtool/src/commands/recorder-start.ts b/packages/skills/lynx-devtool/src/commands/recorder-start.ts new file mode 100644 index 0000000..1fdb117 --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/recorder-start.ts @@ -0,0 +1,74 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { Command } from "commander"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClient } from "./utils.ts"; + +const DEBUG_MODE_KEY = "enable_debug_mode"; +const DEBUG_MODE_RESTART_MESSAGE = + "`enable_debug_mode` has been enabled. Restart the app and run `recorder start` again."; + +type RecordingStartResult = + | { started: true; restartRequired: false; message: string } + | { started: false; restartRequired: true; message: string }; + +export function registerStartCommand(parent: Command, context: Context) { + parent + .command("start") + .description("Start TestBench recording") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .action(async (options) => { + const { connector, clientId } = await resolveClient(context, options); + + const result = await runRecordingStart(connector, clientId); + console.log(JSON.stringify({ success: result.started, ...result })); + }); +} + +export async function runRecordingStart( + connector: { + getGlobalSwitch: ConnectorGlobalSwitch; + setGlobalSwitch: ConnectorSetGlobalSwitch; + sendCDPMessage: ConnectorCDPMessage; + }, + clientId: string, +): Promise { + const debugModeEnabled = await connector.getGlobalSwitch(clientId, DEBUG_MODE_KEY); + if (!debugModeEnabled) { + await connector.setGlobalSwitch(clientId, DEBUG_MODE_KEY, true); + return { + started: false, + restartRequired: true, + message: DEBUG_MODE_RESTART_MESSAGE, + }; + } + + try { + await connector.sendCDPMessage(clientId, -1, "Recording.start", {}); + } catch (err) { + if (!isRecordingStartNotImplementedError(err)) throw err; + throw new Error( + "Recording.start is not implemented even after `enable_debug_mode` is enabled. " + + "The app or engine may not include `ENABLE_TESTBENCH_RECORDER`, " + + "or it may not be a dev/recorder build.", + { cause: err }, + ); + } + + return { + started: true, + restartRequired: false, + message: "Recording started successfully. Open or reload a Lynx page before `recorder end`.", + }; +} + +type ConnectorGlobalSwitch = (clientId: string, key: typeof DEBUG_MODE_KEY) => Promise; +type ConnectorSetGlobalSwitch = (clientId: string, key: typeof DEBUG_MODE_KEY, value: boolean) => Promise; +type ConnectorCDPMessage = (clientId: string, sessionId: number, method: string, params: object) => Promise; + +function isRecordingStartNotImplementedError(err: unknown): boolean { + return err instanceof Error + && err.message.includes("Not implemented") + && err.message.includes("Recording.start"); +} diff --git a/packages/skills/lynx-devtool/src/commands/take-heap-snapshot.ts b/packages/skills/lynx-devtool/src/commands/take-heap-snapshot.ts new file mode 100644 index 0000000..469965c --- /dev/null +++ b/packages/skills/lynx-devtool/src/commands/take-heap-snapshot.ts @@ -0,0 +1,138 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +/* eslint-disable */ +import { type CDPResponseMessage, CDPResponseTransformStream } from "@lynx-js/devtool-connector"; +import { Command } from "commander"; +import { randomInt } from "node:crypto"; +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ReadableStream } from "node:stream/web"; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + readUntilIdle, + resolveClientAndSession, + SESSION_OPTION, +} from "./utils.ts"; + +export function registerTakeHeapSnapshotCommand(program: Command, context: Context) { + program + .command("take-heap-snapshot") + .description("Take a heap snapshot and save it to a .heapsnapshot file") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) + .option("--thread ", "VM thread to target: background or main", "background") + .option("-o, --output ", "Output file path (default: /heap--.heapsnapshot)") + .action(async (options) => { + const { output, thread = "background" } = options; + + if (thread !== "background" && thread !== "main") { + throw new Error(`Invalid thread: ${thread}. Expected 'background' or 'main'.`); + } + + const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); + + const expectedSessionId = thread === "main" ? "Main" : undefined; + const extraParams = expectedSessionId ? { sessionId: expectedSessionId } : {}; + const timeoutSignal = AbortSignal.timeout(60_000); + const requestId = randomInt(10_000, 50_000); + + await using stream = await connector.sendStream( + clientId, + ReadableStream.from([{ + event: "Customized", + data: { + type: "CDP", + data: { + session_id: Number(sessionId), + message: { + id: requestId - 1, + method: "HeapProfiler.enable", + params: {}, + ...extraParams, + }, + }, + }, + }, { + event: "Customized", + data: { + type: "CDP", + data: { + session_id: Number(sessionId), + message: { + id: requestId, + method: "HeapProfiler.takeHeapSnapshot", + params: { + reportProgress: true, + treatGlobalObjectsAsRoots: true, + captureNumericValue: false, + }, + ...extraParams, + }, + }, + }, + }]), + { + signal: timeoutSignal, + pipeline: { + input: [], + output: [ + new CDPResponseTransformStream(), + ], + }, + }, + ); + + let chunks: string[] = []; + let didReceiveSnapshotResponse = false; + const fileName = output ?? path.join(tmpdir(), `heap-${thread}-${Date.now()}.heapsnapshot`); + + for await (const value of readUntilIdle(stream, { idleMs: 15_000, maxMs: 60_000 })) { + const { method, params: eventParams, id, sessionId: responseSessionId } = value as CDPResponseMessage & { + method?: string; + params?: { + chunk?: string; + finished?: boolean; + }; + sessionId?: string; + }; + + if (method === "HeapProfiler.addHeapSnapshotChunk") { + if (responseSessionId !== expectedSessionId) { + continue; + } + + const chunk = eventParams?.chunk; + if (!chunk) { + continue; + } + + chunks.push(chunk); + if (didReceiveSnapshotResponse) { + break; + } + } else if (method === "HeapProfiler.reportHeapSnapshotProgress") { + if (responseSessionId !== expectedSessionId) { + continue; + } + } else if (id === requestId) { + didReceiveSnapshotResponse = true; + if (chunks.length > 0) { + break; + } + } + } + + if (chunks.length === 0) { + throw new Error("Failed to capture heap snapshot, no chunks received or timed out."); + } + + await fs.writeFile(fileName, chunks.join("")); + + console.log(`Heap snapshot saved to ${fileName}`); + }); +} diff --git a/packages/skills/lynx-devtool/src/commands/take-screenshot.ts b/packages/skills/lynx-devtool/src/commands/take-screenshot.ts index 62a64d8..fccebe2 100644 --- a/packages/skills/lynx-devtool/src/commands/take-screenshot.ts +++ b/packages/skills/lynx-devtool/src/commands/take-screenshot.ts @@ -1,96 +1,76 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +/* eslint-disable */ +import { Command } from "commander"; +import fs from "node:fs/promises"; +import { ReadableStream } from "node:stream/web"; +import { setTimeout } from "node:timers/promises"; +import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "./utils.ts"; -import fs from 'node:fs/promises'; -import { ReadableStream } from 'node:stream/web'; -import { setTimeout } from 'node:timers/promises'; -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Command } from 'commander'; -import { getFirstClient, getLatestSession } from './utils.ts'; - -export function registerTakeScreenshotCommand( - program: Command, - connector: Connector, -) { +export function registerTakeScreenshotCommand(program: Command, context: Context) { program - .command('take-screenshot') - .description('Take a screenshot of the current page') - .option( - '-c, --client ', - 'Client ID (optional, will auto-discover if not provided)', - ) - .option( - '-s, --session ', - 'Session ID (optional, will auto-discover if not provided)', - ) - .option( - '--fullscreen', - 'Capture the fullscreen screenshot instead of the lynxview', - ) - .option( - '-o, --output ', - 'Output file path (default: screenshot-.jpeg)', - ) + .command("take-screenshot") + .description("Take a screenshot of the current page") + .option(...CLIENT_OPTION) + .option(...CLIENT_NAME_OPTION) + .option(...SESSION_OPTION) + .option("--fullscreen", "Capture the fullscreen screenshot instead of the lynxview") + .option("-o, --output ", "Output file path (default: screenshot-.jpeg)") .action(async (options) => { - let { client: clientId, session: sessionId } = options; + const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); const { output, fullscreen } = options; - if (!clientId) { - clientId = await getFirstClient(connector); - } - - if (!sessionId) { - sessionId = await getLatestSession(connector, clientId); - } - const numericSessionId = Number(sessionId); const signal = AbortSignal.timeout(10_000); - const { promise, resolve } = Promise.withResolvers(); + const { promise: framePromise, resolve: resolveFrame } = Promise.withResolvers(); + const { promise: ackPromise, resolve: resolveAck } = Promise.withResolvers(); await using stream = await connector.sendCDPStream( clientId, + numericSessionId, new ReadableStream({ async start(controller) { controller.enqueue({ - method: 'Page.startScreencast', + method: "Page.startScreencast", params: { - format: 'jpeg', - quality: 80, - mode: fullscreen ? 'fullscreen' : 'lynxview', + "format": "jpeg", + "quality": 80, + "mode": fullscreen ? "fullscreen" : "lynxview", }, - sessionId: numericSessionId, }); - await Promise.race([ - promise, - setTimeout(10_000, void 0, { ref: false }), + const hasFrame = await Promise.race([ + framePromise.then(() => true), + setTimeout(10_000, false, { ref: false }), ]); - controller.enqueue({ - method: 'Page.stopScreencast', - sessionId: numericSessionId, - }); + if (hasFrame) { + controller.enqueue({ + method: "Page.screencastFrameAck", + }); + } controller.close(); + resolveAck(); }, }), { signal }, ); for await (const { method, params: eventParams } of stream) { - if (method === 'Page.screencastFrame') { + if (method === "Page.screencastFrame") { const { data } = eventParams as { data: string }; if (data) { - resolve(); + resolveFrame(); + await ackPromise; const fileName = output ?? `screenshot-${Date.now()}.jpeg`; - await fs.writeFile(fileName, Buffer.from(data, 'base64')); + await fs.writeFile(fileName, Buffer.from(data, "base64")); + console.log(`Screenshot saved to ${fileName}`); return; } } } - throw new Error( - 'Failed to capture screenshot, no Page.screencastFrame event received within 10 seconds.', - ); + throw new Error("Failed to capture screenshot, no Page.screencastFrame event received within 10 seconds."); }); } diff --git a/packages/skills/lynx-devtool/src/commands/utils.ts b/packages/skills/lynx-devtool/src/commands/utils.ts index 008a6d9..c8de165 100644 --- a/packages/skills/lynx-devtool/src/commands/utils.ts +++ b/packages/skills/lynx-devtool/src/commands/utils.ts @@ -1,27 +1,210 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from '@lynx-js/devtool-connector'; +import { Connector } from "@lynx-js/devtool-connector"; +import type { Client, Transport } from "@lynx-js/devtool-connector/transport"; +import type { ReadableStream, ReadableStreamDefaultReader } from "node:stream/web"; +import { setTimeout as delay } from "node:timers/promises"; + +export interface Context { + transports: Transport[]; +} + +export const CLIENT_OPTION = [ + "-c, --client ", + "Client ID (optional, auto-discovered if omitted).", +] as const; + +export const CLIENT_NAME_OPTION = [ + "--client-name ", + "Client package/app name (optional, resolved from list-clients; e.g. com.example.app)", +] as const; + +export const SESSION_OPTION = [ + "-s, --session ", + "Session ID (optional, will auto-discover if not provided)", +] as const; export async function getFirstClient(connector: Connector): Promise { const clients = await connector.listClients(); const firstClient = clients[0]; if (!firstClient) { - throw new Error('No available clients found.'); + throw new Error("No available clients found."); } return firstClient.id; } -export async function getLatestSession( - connector: Connector, - clientId: string, +function uniqueNonEmptyStrings(values: Array): string[] { + return Array.from( + new Set( + values + .filter((value): value is string => typeof value === "string") + .map(value => value.trim()) + .filter(Boolean), + ), + ); +} + +function getClientNames(client: Client): string[] { + return uniqueNonEmptyStrings([ + client.info.AppProcessName, + client.info.bundleId, + client.info.bundleName, + client.info.App, + ]); +} + +function formatClientForError(client: Client): string { + const names = getClientNames(client); + const suffix = names.length > 0 ? ` (${names.join(", ")})` : ""; + return ` ${client.id}${suffix}`; +} + +export async function getClientByName( + connector: Pick, + clientName: string, ): Promise { + const clients = await connector.listClients(); + const matches = clients.filter(client => + getClientNames(client).includes(clientName) + ); + + if (matches.length === 1) { + const matchedClient = matches[0]!; + return matchedClient.id; + } + + if (matches.length > 1) { + throw new Error( + `Multiple clients found matching --client-name "${clientName}". Use --client with one of:\n` + + matches.map(formatClientForError).join("\n"), + ); + } + + const availableClients = clients + .map(formatClientForError) + .join("\n"); + throw new Error( + `No client found matching --client-name "${clientName}".` + + (availableClients ? `\nAvailable clients:\n${availableClients}` : "\nNo real-device clients are available."), + ); +} + +export async function getLatestSession(connector: Connector, clientId: string): Promise { const sessions = await connector.sendListSessionMessage(clientId); if (!sessions || sessions.length === 0) { throw new Error(`No available sessions found for client: ${clientId}`); } const latestSession = sessions.reduce((max, session) => - session.session_id > max.session_id ? session : max, + Number(session.session_id) > Number(max.session_id) ? session : max ); return String(latestSession.session_id); } + +export async function resolveClient( + { transports }: Context, + options: { client?: string; clientName?: string }, +): Promise<{ connector: Connector; clientId: string }> { + const connector = new Connector(transports); + if (options.client && options.clientName) { + throw new Error("Use either --client or --client-name, not both."); + } + const clientId = options.client + ?? (options.clientName ? await getClientByName(connector, options.clientName) : await getFirstClient(connector)); + return { connector, clientId }; +} + +export async function resolveClientAndSession( + context: Context, + options: { client?: string; clientName?: string; session?: string }, +): Promise<{ connector: Connector; clientId: string; sessionId: string }> { + const { connector, clientId } = await resolveClient(context, options); + const sessionId = options.session ?? await getLatestSession(connector, clientId); + return { connector, clientId, sessionId }; +} + +export function isAbortError(err: unknown): boolean { + return err instanceof Error && err.name === "AbortError"; +} + +export function parseOnOff(input: string, optionName = "--status"): boolean { + const normalized = input.trim().toLowerCase(); + if (normalized === "on" || normalized === "true" || normalized === "1") { + return true; + } + if (normalized === "off" || normalized === "false" || normalized === "0") { + return false; + } + throw new Error(`Invalid ${optionName} value: ${input}. Use on/off.`); +} + +export function buildWatchSignal( + watch: boolean, + fallbackTimeoutMs: number, +): { signal: AbortSignal; cleanup: () => void } { + if (!watch) { + return { signal: AbortSignal.timeout(fallbackTimeoutMs), cleanup: () => {} }; + } + + const controller = new AbortController(); + const onSigint = () => { + controller.abort(); + }; + process.once("SIGINT", onSigint); + return { + signal: controller.signal, + cleanup: () => process.off("SIGINT", onSigint), + }; +} + +type ReadOrTimeoutResult = ReadableStreamReadResult | "timeout"; + +async function readOrTimeout( + reader: ReadableStreamDefaultReader, + idleMs: number, +): Promise> { + const idleAbortController = new AbortController(); + const idle = delay(idleMs, "timeout" as const, { signal: idleAbortController.signal }); + + try { + return await Promise.race([ + reader.read(), + idle, + ]); + } finally { + idleAbortController.abort(); + await idle.catch(() => {}); + } +} + +export async function* readUntilIdle( + stream: ReadableStream, + opts: { idleMs: number; maxMs: number }, +): AsyncGenerator { + const reader = stream.getReader(); + const startTime = Date.now(); + let terminated = false; + try { + while (Date.now() - startTime < opts.maxMs) { + const result = await readOrTimeout(reader, opts.idleMs); + if (result === "timeout") { + await reader.cancel(); + terminated = true; + return; + } + const { done, value } = result; + if (done) { + terminated = true; + return; + } + yield value; + } + await reader.cancel(); + terminated = true; + } finally { + if (!terminated) { + await reader.cancel().catch(() => {}); + } + reader.releaseLock(); + } +} diff --git a/packages/skills/lynx-devtool/src/connector.ts b/packages/skills/lynx-devtool/src/connector.ts new file mode 100644 index 0000000..e1c4fd5 --- /dev/null +++ b/packages/skills/lynx-devtool/src/connector.ts @@ -0,0 +1,35 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { Connector } from "@lynx-js/devtool-connector"; +import { + AndroidTransport, + DesktopTransport, + iOSTransport, + type Transport, +} from "@lynx-js/devtool-connector/transport"; + +export * from "@lynx-js/devtool-connector"; +export * from "@lynx-js/devtool-connector/streams"; +export * from "@lynx-js/devtool-connector/transport"; + +function getAndroidTransportSpec(): { host: string; port: number } { + const port = Number.parseInt(process.env["ADB_SERVER_PORT"] ?? "5037", 10); + + return { + host: process.env["ADB_SERVER_HOST"] ?? "127.0.0.1", + port: Number.isInteger(port) && port > 0 ? port : 5037, + }; +} + +export function createDefaultTransports(): Transport[] { + return [ + new AndroidTransport(getAndroidTransportSpec()), + new DesktopTransport(), + new iOSTransport(), + ]; +} + +export function createDefaultConnector(transports: Transport[] = createDefaultTransports()): Connector { + return new Connector(transports); +} diff --git a/packages/skills/lynx-devtool/src/devtool.ts b/packages/skills/lynx-devtool/src/devtool.ts index c4fd88b..c672086 100644 --- a/packages/skills/lynx-devtool/src/devtool.ts +++ b/packages/skills/lynx-devtool/src/devtool.ts @@ -1,41 +1,85 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Transport } from '@lynx-js/devtool-connector/transport'; -import { Command } from 'commander'; -import pkg from '../package.json' with { type: 'json' }; -import { registerAppCommand } from './commands/app.ts'; -import { registerCdpCommand } from './commands/cdp.ts'; -import { registerGetConsoleCommand } from './commands/get-console.ts'; -import { registerGetSourcesCommand } from './commands/get-sources.ts'; -import { registerListClientsCommand } from './commands/list-clients.ts'; -import { registerListSessionsCommand } from './commands/list-sessions.ts'; -import { registerOpenCommand } from './commands/open.ts'; -import { registerTakeScreenshotCommand } from './commands/take-screenshot.ts'; - -export function createProgram( - connector: Connector, - transports: Transport[], -): Command { +import { + AndroidTransport, + DaemonTransport, + DesktopTransport, + iOSTransport, +} from "@lynx-js/devtool-connector/transport"; +import { Command } from "commander"; +import pkg from "../package.json" with { type: "json" }; +import { registerAppCommand } from "./commands/app.ts"; +import { registerCdpCommand } from "./commands/cdp.ts"; +import { registerGetConsoleCommand } from "./commands/get-console.ts"; +import { registerGetSourcesCommand } from "./commands/get-sources.ts"; +import { registerGlobalSwitchCommand } from "./commands/global-switch.ts"; +import { registerInspectCommand } from "./commands/inspect.ts"; +import { registerListClientsCommand } from "./commands/list-clients.ts"; +import { registerListSessionsCommand } from "./commands/list-sessions.ts"; +import { registerOpenCommand } from "./commands/open.ts"; +import { registerReactLynxCommand } from "./commands/reactlynx/index.ts"; +import { registerEndCommand } from "./commands/recorder-end.ts"; +import { registerStartCommand } from "./commands/recorder-start.ts"; +import { registerTakeHeapSnapshotCommand } from "./commands/take-heap-snapshot.ts"; +import { registerTakeScreenshotCommand } from "./commands/take-screenshot.ts"; +import type { Context } from "./commands/utils.ts"; + +function getAndroidTransportSpec(env: NodeJS.ProcessEnv): { host: string; port: number } { + const port = Number.parseInt(env["ADB_SERVER_PORT"] ?? "5037", 10); + + return { + host: env["ADB_SERVER_HOST"] ?? "127.0.0.1", + port: Number.isInteger(port) && port > 0 ? port : 5037, + }; +} + +export function createProgram(options: { env?: NodeJS.ProcessEnv } = {}): Command { + const env = options.env ?? process.env; const program = new Command(); + const context: Context = { + transports: [ + new AndroidTransport(getAndroidTransportSpec(env)), + new DesktopTransport(), + new iOSTransport(), + ], + }; program - .name('devtool') - .description('CLI to interact with Lynx DevTool Connector') + .name("lynx-devtool") + .description("CLI to interact with Lynx DevTool Connector") .version(pkg.version) - .hook('postAction', async () => { - await Promise.allSettled(transports.map((t) => t.close())); + .option( + "--no-daemon", + "Run in non-daemon mode, which will not start the background service", + ) + .hook("preAction", async (thisCommand) => { + const rootOptions = thisCommand.opts<{ daemon?: boolean }>(); + if (rootOptions.daemon) { + context.transports.push(new DaemonTransport()); + } + }) + .hook("postAction", async () => { + await Promise.allSettled(context.transports.map(t => t.close())); }); - registerListClientsCommand(program, connector); - registerListSessionsCommand(program, connector); - registerCdpCommand(program, connector); - registerAppCommand(program, connector); - registerOpenCommand(program, connector); - registerGetConsoleCommand(program, connector); - registerGetSourcesCommand(program, connector); - registerTakeScreenshotCommand(program, connector); + registerListClientsCommand(program, context); + registerListSessionsCommand(program, context); + registerCdpCommand(program, context); + registerAppCommand(program, context); + registerOpenCommand(program, context); + registerInspectCommand(program, context); + registerGetConsoleCommand(program, context); + registerGetSourcesCommand(program, context); + registerTakeScreenshotCommand(program, context); + registerTakeHeapSnapshotCommand(program, context); + registerGlobalSwitchCommand(program, context); + + const record = program.command("recorder"); + registerStartCommand(record, context); + registerEndCommand(record, context); + + registerReactLynxCommand(program, context); return program; } diff --git a/packages/skills/lynx-devtool/src/index.ts b/packages/skills/lynx-devtool/src/index.ts index 4079f5b..ae5f439 100644 --- a/packages/skills/lynx-devtool/src/index.ts +++ b/packages/skills/lynx-devtool/src/index.ts @@ -1,30 +1,11 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. +// Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Connector } from '@lynx-js/devtool-connector'; -import { - AndroidTransport, - DesktopTransport, - iOSTransport, - type Transport, -} from '@lynx-js/devtool-connector/transport'; -import { createProgram } from './devtool.ts'; - -function getAndroidTransportSpec(): { host: string; port: number } { - const port = Number.parseInt(process.env.ADB_SERVER_PORT ?? '5037', 10); - - return { - host: process.env.ADB_SERVER_HOST ?? '127.0.0.1', - port: Number.isInteger(port) && port > 0 ? port : 5037, - }; -} - -const transports: Transport[] = [ - new AndroidTransport(getAndroidTransportSpec()), - new DesktopTransport(), - new iOSTransport(), -]; - -const connector = new Connector(transports); - -await createProgram(connector, transports).parseAsync(process.argv); +import "core-js/modules/es.promise.with-resolvers.js"; +import { createProgram } from "./devtool.ts"; + +createProgram({ env: process.env }) + .parseAsync(process.argv) + .catch(error => { + throw error; + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 788d0eb..ae1ebb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,16 @@ settings: excludeLinksFromLockfile: false catalogs: + adb: + '@yume-chan/adb': + specifier: ^2.6.0 + version: 2.6.0 + '@yume-chan/adb-server-node-tcp': + specifier: ^2.5.2 + version: 2.5.2 + '@yume-chan/stream-extra': + specifier: ^2.6.1 + version: 2.6.1 rstack: '@rslib/core': specifier: 0.19.2 @@ -59,7 +69,7 @@ importers: version: 2.3.8 '@changesets/cli': specifier: ^2.29.8 - version: 2.29.8(@types/node@24.10.10) + version: 2.29.8(@types/node@24.13.2) build-marketplace: specifier: workspace:* version: link:packages/cmd/build-marketplace @@ -94,6 +104,68 @@ importers: specifier: ^2.8.2 version: 2.8.2 + mcp-servers/devtool-connector: + dependencies: + ws: + specifier: ^8.21.0 + version: 8.21.0 + devDependencies: + '@rslib/core': + specifier: catalog:rstack + version: 0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.13.2))(typescript@5.9.3) + '@types/node': + specifier: ^24.13.2 + version: 24.13.2 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@yume-chan/adb': + specifier: catalog:adb + version: 2.6.0 + '@yume-chan/adb-server-node-tcp': + specifier: catalog:adb + version: 2.5.2 + '@yume-chan/stream-extra': + specifier: catalog:adb + version: 2.6.1 + obug: + specifier: ^2.1.3 + version: 2.1.3 + plist: + specifier: ^5.0.0 + version: 5.0.0 + rsbuild-plugin-publint: + specifier: ^1.0.0 + version: 1.0.0(@rsbuild/core@1.7.1) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + mcp-servers/devtool-mcp-server: + dependencies: + '@lynx-js/devtool-connector': + specifier: workspace:* + version: link:../devtool-connector + devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.25.2 + version: 1.29.0(zod@3.25.76) + '@rslib/core': + specifier: catalog:rstack + version: 0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.13.2))(typescript@5.9.3) + core-js: + specifier: ^3.49.0 + version: 3.49.0 + obug: + specifier: ^2.1.3 + version: 2.1.3 + rsbuild-plugin-publint: + specifier: ^1.0.0 + version: 1.0.0(@rsbuild/core@1.7.1) + zod: + specifier: ^3.25.76 + version: 3.25.76 + packages/cmd/build-marketplace: dependencies: npm-packlist: @@ -179,8 +251,8 @@ importers: packages/skills/lynx-devtool: devDependencies: '@lynx-js/devtool-connector': - specifier: 0.1.0 - version: 0.1.0 + specifier: workspace:* + version: link:../../../mcp-servers/devtool-connector '@rslib/core': specifier: catalog:rstack version: 0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.10.10))(typescript@5.9.3) @@ -190,6 +262,12 @@ importers: commander: specifier: '14' version: 14.0.3 + core-js: + specifier: ^3.49.0 + version: 3.49.0 + obug: + specifier: ^2.1.3 + version: 2.1.3 packages/skills/lynx-typescript: {} @@ -641,6 +719,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -678,10 +762,6 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} - '@lynx-js/devtool-connector@0.1.0': - resolution: {integrity: sha512-zYE7aUC510EGDGA/wsGdNn8tVRy5XDwS8OmpFTTKMmzw9ww1LI3xYWlop5MF1cockZR98QHEmtzgxpXYa0YN9g==} - engines: {node: '>=18.19'} - '@lynx-js/skill-lynx-trace-analysis@0.0.6': resolution: {integrity: sha512-2vvoMidxnmaxxtrkLFOXy6GK7nXtWC5m7dqLWPacnHm7FmNBRjVPrQCC49v5X8tAcuN2LQjkcTu+ebIYvfq4/w==} engines: {node: '>=18'} @@ -712,6 +792,16 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@module-federation/error-codes@0.22.0': resolution: {integrity: sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==} @@ -745,6 +835,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@publint/pack@0.1.5': + resolution: {integrity: sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==} + engines: {node: '>=18'} + '@rsbuild/core@1.7.1': resolution: {integrity: sha512-ULIE/Qh+Ne80Pm/aUPbRHUvwvIzpap07jYNFB47azI8w5Q3sDEC4Gn574jsluT/42iNDsZTFADRBog9FEvtN9Q==} engines: {node: '>=18.12.0'} @@ -916,6 +1010,12 @@ packages: '@types/node@24.10.8': resolution: {integrity: sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==} + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.55.0': resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -975,6 +1075,35 @@ packages: resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + + '@yume-chan/adb-server-node-tcp@2.5.2': + resolution: {integrity: sha512-ga9icFBRUk1n6lO+ymlj83qE+90kMLV8sQA3w9mZAisfdxxEEIq1cvXitFQJvDgL0NuFQruVI4JoZA/wrgpKOw==} + + '@yume-chan/adb@2.6.0': + resolution: {integrity: sha512-1bM4/YwLUr7UHkvC3pe5fIPNNIgOQ97nUIvC4ooPZwRKG9pyHG39EihivZVmu3pm28XwHSGh5PC3sQi4ycoUXA==} + + '@yume-chan/async@4.1.3': + resolution: {integrity: sha512-0vzhNJMkWUPyjKzUK4rqHEeCU6YQtF78RsB1kFRB6Y2BLupmEQNxcSb0mjKabPL9jZpCCiLa5KL8oTOJClUVaw==} + + '@yume-chan/event@2.0.0': + resolution: {integrity: sha512-z56MDOcX1QlgLUCuA6th3r10negVb7A3gzY//TwSC9ZOvzuRlrAqXcxZf1T3hHfNMk/NFO9RIgQgegXYSfaqLw==} + + '@yume-chan/no-data-view@2.0.0': + resolution: {integrity: sha512-0GRJrrt6wtZlbiE92jocHOnaAvjQ+Y7xwwhwOPqLkwf90Kj1JIHJ5Zh4wJVQSQIkzfRSOpM+jeEQdC2K15snlA==} + + '@yume-chan/stream-extra@2.6.1': + resolution: {integrity: sha512-GQ+Q0SQf9p6Fl7ZK0UO5vHVGBJgkTJm/WXKurUMpjHQV4VUpjZ2hrQ744aCfT7WwSol7Bz/uEiXWmk240Hr/hg==} + + '@yume-chan/struct@2.3.2': + resolution: {integrity: sha512-afoCnSKV+5HRK7e4innVd9YTYDyNWdjA1CVQa1j8rWYnmr7HGZfdkHMQr+AESLk6GxWFMzOIPQIG6nYOTGMFIw==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1010,6 +1139,9 @@ packages: ajv@8.13.0: resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1055,6 +1187,10 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1065,6 +1201,18 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1109,9 +1257,36 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-js@3.47.0: resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1128,6 +1303,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -1148,9 +1327,20 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1159,11 +1349,26 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1226,9 +1431,31 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -1245,6 +1472,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1265,6 +1495,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -1280,6 +1514,14 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@11.3.3: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} @@ -1302,6 +1544,14 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + git-hooks-list@4.1.1: resolution: {integrity: sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA==} @@ -1326,6 +1576,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1333,10 +1587,22 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -1384,6 +1650,14 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -1408,6 +1682,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -1426,6 +1703,9 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -1443,6 +1723,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1493,6 +1776,18 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1501,6 +1796,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -1542,6 +1845,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + npm-bundled@2.0.1: resolution: {integrity: sha512-gZLxXdjEzE/+mOstGDqR6b0EkhJ+kM6fxM6vUuckuctuVPh80Q6pw/rSZj9s4Gex9GxWtIicO1pc8DB9KZWudw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -1555,6 +1862,22 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1600,10 +1923,17 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1615,6 +1945,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -1639,6 +1972,14 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + plist@5.0.0: + resolution: {integrity: sha512-20N+g1DvMm/DFRbsvER7tT4wDryq0WunK7VMkDaiJcKNapAnUMkTsAnacFYf8n420F4Hf6/hefgmJRkMb1M0fg==} + engines: {node: '>=18'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1648,16 +1989,37 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + publint@0.3.21: + resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} + engines: {node: '>=18'} + hasBin: true + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} @@ -1690,6 +2052,10 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rsbuild-plugin-dts@0.19.2: resolution: {integrity: sha512-neuTRt+H/isd2FDYMigF5TEoLUoR/hF0RKdVv1U7nDz0CfLRUfL+NnxePv9nS7dU2VvM3CYcLH7tZs43ol7g4Q==} engines: {node: '>=18.12.0'} @@ -1706,9 +2072,22 @@ packages: typescript: optional: true + rsbuild-plugin-publint@1.0.0: + resolution: {integrity: sha512-QNu6zL0TOmFZfrCiKSmkw092jDhhPpiA1QYDRkvhkW1wDLowuVEYOqdV87Eo/I6geV0nuKKnCgLHT+Fevbz6pA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rsbuild/core': ^1.0.0 || ^2.0.0-0 + peerDependenciesMeta: + '@rsbuild/core': + optional: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1722,6 +2101,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1730,6 +2120,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1760,6 +2166,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -1808,6 +2218,10 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1820,6 +2234,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -1867,6 +2285,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript-eslint@8.55.0: resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1890,6 +2312,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -1898,9 +2323,17 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1917,6 +2350,22 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -1933,6 +2382,14 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zx@8.8.5: resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==} engines: {node: '>= 12.17.0'} @@ -2045,7 +2502,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.8(@types/node@24.10.10)': + '@changesets/cli@2.29.8(@types/node@24.13.2)': dependencies: '@changesets/apply-release-plan': 7.0.14 '@changesets/assemble-release-plan': 6.0.9 @@ -2061,7 +2518,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@24.10.10) + '@inquirer/external-editor': 1.0.3(@types/node@24.13.2) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -2306,6 +2763,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@hono/node-server@1.19.14(hono@4.12.27)': + dependencies: + hono: 4.12.27 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2317,12 +2778,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inquirer/external-editor@1.0.3(@types/node@24.10.10)': + '@inquirer/external-editor@1.0.3(@types/node@24.13.2)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 24.10.10 + '@types/node': 24.13.2 '@isaacs/balanced-match@4.0.1': {} @@ -2334,8 +2795,6 @@ snapshots: dependencies: minipass: 7.1.2 - '@lynx-js/devtool-connector@0.1.0': {} - '@lynx-js/skill-lynx-trace-analysis@0.0.6': {} '@lynx-js/skill-lynx-trace-record@0.0.3': {} @@ -2375,6 +2834,15 @@ snapshots: - '@types/node' optional: true + '@microsoft/api-extractor-model@7.32.2(@types/node@24.13.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.0 + '@rushstack/node-core-library': 5.19.1(@types/node@24.13.2) + transitivePeerDependencies: + - '@types/node' + optional: true + '@microsoft/api-extractor@7.56.2(@types/node@24.10.10)': dependencies: '@microsoft/api-extractor-model': 7.32.2(@types/node@24.10.10) @@ -2414,6 +2882,26 @@ snapshots: - '@types/node' optional: true + '@microsoft/api-extractor@7.56.2(@types/node@24.13.2)': + dependencies: + '@microsoft/api-extractor-model': 7.32.2(@types/node@24.13.2) + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.0 + '@rushstack/node-core-library': 5.19.1(@types/node@24.13.2) + '@rushstack/rig-package': 0.6.0 + '@rushstack/terminal': 0.21.0(@types/node@24.13.2) + '@rushstack/ts-command-line': 5.2.0(@types/node@24.13.2) + diff: 8.0.3 + lodash: 4.17.23 + minimatch: 10.1.2 + resolve: 1.22.11 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.8.2 + transitivePeerDependencies: + - '@types/node' + optional: true + '@microsoft/tsdoc-config@0.18.0': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -2423,6 +2911,28 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.27 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@module-federation/error-codes@0.22.0': {} '@module-federation/runtime-core@0.22.0': @@ -2467,6 +2977,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@publint/pack@0.1.5': + dependencies: + tinyexec: 1.2.4 + '@rsbuild/core@1.7.1': dependencies: '@rspack/core': 1.7.0(@swc/helpers@0.5.18) @@ -2495,6 +3009,16 @@ snapshots: transitivePeerDependencies: - '@typescript/native-preview' + '@rslib/core@0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.13.2))(typescript@5.9.3)': + dependencies: + '@rsbuild/core': 1.7.1 + rsbuild-plugin-dts: 0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.13.2))(@rsbuild/core@1.7.1)(typescript@5.9.3) + optionalDependencies: + '@microsoft/api-extractor': 7.56.2(@types/node@24.13.2) + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript/native-preview' + '@rspack/binding-darwin-arm64@1.7.0': optional: true @@ -2589,6 +3113,20 @@ snapshots: '@types/node': 24.10.8 optional: true + '@rushstack/node-core-library@5.19.1(@types/node@24.13.2)': + dependencies: + ajv: 8.13.0 + ajv-draft-04: 1.0.0(ajv@8.13.0) + ajv-formats: 3.0.1(ajv@8.13.0) + fs-extra: 11.3.3 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.11 + semver: 7.5.4 + optionalDependencies: + '@types/node': 24.13.2 + optional: true + '@rushstack/problem-matcher@0.1.1(@types/node@24.10.10)': optionalDependencies: '@types/node': 24.10.10 @@ -2598,6 +3136,11 @@ snapshots: '@types/node': 24.10.8 optional: true + '@rushstack/problem-matcher@0.1.1(@types/node@24.13.2)': + optionalDependencies: + '@types/node': 24.13.2 + optional: true + '@rushstack/rig-package@0.6.0': dependencies: resolve: 1.22.11 @@ -2620,6 +3163,15 @@ snapshots: '@types/node': 24.10.8 optional: true + '@rushstack/terminal@0.21.0(@types/node@24.13.2)': + dependencies: + '@rushstack/node-core-library': 5.19.1(@types/node@24.13.2) + '@rushstack/problem-matcher': 0.1.1(@types/node@24.13.2) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 24.13.2 + optional: true + '@rushstack/ts-command-line@5.2.0(@types/node@24.10.10)': dependencies: '@rushstack/terminal': 0.21.0(@types/node@24.10.10) @@ -2639,6 +3191,16 @@ snapshots: - '@types/node' optional: true + '@rushstack/ts-command-line@5.2.0(@types/node@24.13.2)': + dependencies: + '@rushstack/terminal': 0.21.0(@types/node@24.13.2) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + optional: true + '@swc/helpers@0.5.18': dependencies: tslib: 2.8.1 @@ -2675,6 +3237,14 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/node@24.13.2': + dependencies: + undici-types: 7.18.2 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.13.2 + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2766,6 +3336,46 @@ snapshots: '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 + '@xmldom/xmldom@0.9.10': {} + + '@yume-chan/adb-server-node-tcp@2.5.2': + dependencies: + '@yume-chan/adb': 2.6.0 + '@yume-chan/async': 4.1.3 + '@yume-chan/stream-extra': 2.6.1 + '@yume-chan/struct': 2.3.2 + + '@yume-chan/adb@2.6.0': + dependencies: + '@yume-chan/async': 4.1.3 + '@yume-chan/event': 2.0.0 + '@yume-chan/no-data-view': 2.0.0 + '@yume-chan/stream-extra': 2.6.1 + '@yume-chan/struct': 2.3.2 + + '@yume-chan/async@4.1.3': {} + + '@yume-chan/event@2.0.0': + dependencies: + '@yume-chan/async': 4.1.3 + + '@yume-chan/no-data-view@2.0.0': {} + + '@yume-chan/stream-extra@2.6.1': + dependencies: + '@yume-chan/async': 4.1.3 + '@yume-chan/struct': 2.3.2 + + '@yume-chan/struct@2.3.2': + dependencies: + '@yume-chan/async': 4.1.3 + '@yume-chan/no-data-view': 2.0.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -2780,6 +3390,10 @@ snapshots: optionalDependencies: ajv: 8.13.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -2801,6 +3415,13 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-escapes@7.2.0: @@ -2833,6 +3454,20 @@ snapshots: dependencies: is-windows: 1.0.2 + body-parser@2.3.0: + 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 + transitivePeerDependencies: + - supports-color + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2846,6 +3481,18 @@ snapshots: dependencies: fill-range: 7.1.1 + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} chalk@4.1.2: @@ -2880,8 +3527,25 @@ snapshots: concat-map@0.0.1: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-js@3.47.0: {} + core-js@3.49.0: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2894,6 +3558,8 @@ snapshots: deep-is@0.1.4: {} + depd@2.0.0: {} + detect-indent@6.1.0: {} detect-indent@7.0.2: {} @@ -2906,8 +3572,18 @@ snapshots: dependencies: path-type: 4.0.0 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + emoji-regex@10.6.0: {} + encodeurl@2.0.0: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -2915,6 +3591,14 @@ snapshots: environment@1.1.0: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -2944,6 +3628,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-config-flat-gitignore@2.1.0(eslint@9.39.2(jiti@2.6.1)): @@ -3025,8 +3711,54 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + eventemitter3@5.0.4: {} + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extendable-error@0.1.7: {} fast-deep-equal@3.1.3: {} @@ -3043,6 +3775,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.2: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3059,6 +3793,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -3076,6 +3821,10 @@ snapshots: flatted@3.3.3: {} + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 @@ -3100,6 +3849,24 @@ snapshots: get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + 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 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + git-hooks-list@4.1.1: {} glob-parent@5.1.2: @@ -3129,14 +3896,28 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 + hono@4.12.27: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + human-id@4.1.3: {} husky@9.1.7: {} @@ -3171,6 +3952,10 @@ snapshots: inherits@2.0.4: {} + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -3189,6 +3974,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -3201,6 +3988,8 @@ snapshots: jju@1.4.0: {} + jose@6.2.3: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -3216,6 +4005,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} jsonfile@4.0.0: @@ -3282,6 +4073,12 @@ snapshots: dependencies: yallist: 4.0.0 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -3289,6 +4086,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-function@5.0.1: {} minimatch@10.1.2: @@ -3321,6 +4124,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + npm-bundled@2.0.1: dependencies: npm-normalize-package-bin: 2.0.0 @@ -3334,6 +4139,16 @@ snapshots: npm-bundled: 2.0.1 npm-normalize-package-bin: 2.0.0 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.1.3: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -3381,16 +4196,22 @@ snapshots: dependencies: quansync: 0.2.11 + package-manager-detector@1.6.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} path-parse@1.0.7: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} picocolors@1.1.1: {} @@ -3403,16 +4224,48 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: {} + + plist@5.0.0: + dependencies: + '@xmldom/xmldom': 0.9.10 + xmlbuilder: 15.1.1 + prelude-ls@1.2.1: {} prettier@2.8.8: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + publint@0.3.21: + dependencies: + '@publint/pack': 0.1.5 + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + sade: 1.8.1 + punycode@2.3.1: {} + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + quansync@0.2.11: {} queue-microtask@1.2.3: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -3441,6 +4294,16 @@ snapshots: rfdc@1.4.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rsbuild-plugin-dts@0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.10.10))(@rsbuild/core@1.7.1)(typescript@5.9.3): dependencies: '@ast-grep/napi': 0.37.0 @@ -3457,10 +4320,28 @@ snapshots: '@microsoft/api-extractor': 7.56.2(@types/node@24.10.8) typescript: 5.9.3 + rsbuild-plugin-dts@0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.13.2))(@rsbuild/core@1.7.1)(typescript@5.9.3): + dependencies: + '@ast-grep/napi': 0.37.0 + '@rsbuild/core': 1.7.1 + optionalDependencies: + '@microsoft/api-extractor': 7.56.2(@types/node@24.13.2) + typescript: 5.9.3 + + rsbuild-plugin-publint@1.0.0(@rsbuild/core@1.7.1): + dependencies: + publint: 0.3.21 + optionalDependencies: + '@rsbuild/core': 1.7.1 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + sade@1.8.1: + dependencies: + mri: 1.2.0 + safer-buffer@2.1.2: {} semver@7.5.4: @@ -3469,12 +4350,67 @@ snapshots: semver@7.7.3: {} + send@1.2.1: + 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 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + 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 + signal-exit@4.1.0: {} slash@3.0.0: {} @@ -3505,6 +4441,8 @@ snapshots: sprintf-js@1.0.3: {} + statuses@2.0.2: {} + string-argv@0.3.2: {} string-width@7.2.0: @@ -3550,6 +4488,8 @@ snapshots: term-size@2.2.1: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -3561,6 +4501,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3598,6 +4540,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -3617,14 +4565,20 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.18.2: {} + universalify@0.1.2: {} universalify@2.0.1: {} + unpipe@1.0.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 + vary@1.1.2: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3639,6 +4593,10 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.0: {} + + xmlbuilder@15.1.1: {} + yallist@4.0.0: {} yallist@5.0.0: {} @@ -3647,4 +4605,10 @@ snapshots: yocto-queue@0.1.0: {} + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + zx@8.8.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f56935..516a9f5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,12 +5,17 @@ packages: - packages/cmd/* - packages/skills/* - packages/plugins/* + - mcp-servers/* catalogs: rstack: '@rslib/core': 0.19.2 '@rsbuild/core': 1.7.1 '@rstest/core': 0.7.9 + adb: + '@yume-chan/adb': ^2.6.0 + '@yume-chan/adb-server-node-tcp': ^2.5.2 + '@yume-chan/stream-extra': ^2.6.1 onlyBuiltDependencies: - esbuild From 5c5c4e9430b71c0592b66ff136d34aa89d8e0230 Mon Sep 17 00:00:00 2001 From: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:13:12 +0800 Subject: [PATCH 2/5] ci: add dedicated lynx-devtool e2e workflow with EmbeddedLynx - Add .github/workflows/lynx-devtool.yml that downloads and launches the EmbeddedLynx headless runtime, waits for its debug-router port, then runs the connector and mcp-server e2e suites against it. - Remove the inline e2e job from test.yml (the binary was never launched there, so the tests could not find a client/session). - Add adapted devtool-mcp-server e2e (tools.test.ts) covering the open-source tool set against EmbeddedLynx. - Remove internal-only webview-cdp e2e (Douyin WebView parity test depending on internal CDP reference docs). - Skip background-thread heap snapshot on EmbeddedLynx (unsupported). --- .github/workflows/lynx-devtool.yml | 93 +++ .github/workflows/test.yml | 38 - .../devtool-connector/e2e/webview-cdp.test.ts | 673 ----------------- .../devtool-mcp-server/e2e/tools.test.ts | 705 ++++++++++++++++++ 4 files changed, 798 insertions(+), 711 deletions(-) create mode 100644 .github/workflows/lynx-devtool.yml delete mode 100644 mcp-servers/devtool-connector/e2e/webview-cdp.test.ts create mode 100644 mcp-servers/devtool-mcp-server/e2e/tools.test.ts diff --git a/.github/workflows/lynx-devtool.yml b/.github/workflows/lynx-devtool.yml new file mode 100644 index 0000000..19b3541 --- /dev/null +++ b/.github/workflows/lynx-devtool.yml @@ -0,0 +1,93 @@ +name: Lynx DevTool E2E + +on: + pull_request: + branches: [main] + paths: + - "mcp-servers/devtool-connector/**" + - "mcp-servers/devtool-mcp-server/**" + - "packages/skills/lynx-devtool/**" + - ".github/workflows/lynx-devtool.yml" + push: + branches: [main] + paths: + - "mcp-servers/devtool-connector/**" + - "mcp-servers/devtool-mcp-server/**" + - "packages/skills/lynx-devtool/**" + - ".github/workflows/lynx-devtool.yml" + workflow_dispatch: + +env: + EMBEDDED_LYNX_TARBALL: https://github.com/lynx-community/skills/releases/download/embedded-lynx-202606041609/embedded-lynx-linux-x86_64.tar.gz + LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS: EmbeddedLynx + LYNX_DEVTOOL_MCP_TESTING_APP_PACKAGE: EmbeddedLynx + LYNX_DEVTOOL_MCP_TESTING_PAGE_URL: https://lynxjs.org/lynx-examples/swiper/dist/Swiper.lynx.bundle + LYNX_DEVTOOL_MCP_TESTING_OPEN_URL: https://lynxjs.org/lynx-examples/swiper/dist/Swiper.lynx.bundle + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Pnpm + run: npm i -g corepack@latest --force && corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: "pnpm" + + - name: Install Dependencies + run: pnpm install + + - name: Start EmbeddedLynx runtime + run: | + set -euo pipefail + curl -sL "$EMBEDDED_LYNX_TARBALL" | tar -xzf - -C /tmp + BINARY="/tmp/embedded-lynx-linux-x86_64/embedded_lynx" + chmod +x "$BINARY" + # Launch the headless Lynx runtime in its own session so it survives + # the end of this step. It loads the page and exposes a debug-router + # on 127.0.0.1:8901 that DesktopTransport (EmbeddedLynx) connects to. + setsid "$BINARY" --url "$LYNX_DEVTOOL_MCP_TESTING_PAGE_URL" \ + < /dev/null > /tmp/embedded-lynx.log 2>&1 & + echo "EMBEDDED_LYNX_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for debug-router port + run: | + set -euo pipefail + for i in $(seq 1 30); do + if (exec 3<>/dev/tcp/127.0.0.1/8901) 2>/dev/null; then + exec 3>&- 3<&- + echo "EmbeddedLynx debug-router is up on port 8901" + exit 0 + fi + sleep 1 + done + echo "EmbeddedLynx did not open port 8901 in time" >&2 + cat /tmp/embedded-lynx.log >&2 || true + exit 1 + + - name: E2E (devtool-connector) + working-directory: mcp-servers/devtool-connector + run: node --test --test-concurrency=1 'e2e/**/*.test.ts' + + - name: E2E (devtool-mcp-server) + working-directory: mcp-servers/devtool-mcp-server + run: node --test --test-concurrency=1 'e2e/**/*.test.ts' + + - name: Stop EmbeddedLynx runtime + if: always() + run: kill "${EMBEDDED_LYNX_PID}" 2>/dev/null || true + + - name: Upload EmbeddedLynx log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: embedded-lynx-log + path: /tmp/embedded-lynx.log + if-no-files-found: ignore diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8bccf7a..bad3109 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,41 +87,3 @@ jobs: - name: Run Tests run: pnpm test - - e2e: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Install Pnpm - run: npm i -g corepack@latest --force && corepack enable - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: "pnpm" - - - name: Install Dependencies - run: pnpm install - - - name: Download EmbeddedLynx - run: | - curl -sL https://github.com/lynx-community/skills/releases/download/embedded-lynx-202606041609/embedded-lynx-linux-x86_64.tar.gz | tar -xzf - -C /tmp - chmod +x /tmp/embedded-lynx - echo "EMBEDDED_LYNX_BINARY=/tmp/embedded-lynx" >> "$GITHUB_ENV" - - - name: Run E2E Tests (devtool-connector) - working-directory: mcp-servers/devtool-connector - env: - LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS: EmbeddedLynx - run: node --test --test-concurrency=1 'e2e/**/*.test.ts' - - - name: Run E2E Tests (devtool-mcp-server) - working-directory: mcp-servers/devtool-mcp-server - env: - LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS: EmbeddedLynx - EMBEDDED_LYNX_BINARY: /tmp/embedded-lynx - run: node --test --test-concurrency=1 'e2e/**/*.test.ts' diff --git a/mcp-servers/devtool-connector/e2e/webview-cdp.test.ts b/mcp-servers/devtool-connector/e2e/webview-cdp.test.ts deleted file mode 100644 index a0b2513..0000000 --- a/mcp-servers/devtool-connector/e2e/webview-cdp.test.ts +++ /dev/null @@ -1,673 +0,0 @@ -// Copyright 2025 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. - -import { readdir } from "node:fs/promises"; -import { ReadableStream } from "node:stream/web"; -import { test, type TestContext } from "node:test"; -import { setTimeout as delay } from "node:timers/promises"; -import type { Connector } from "../src/index.ts"; -import type { Session } from "../src/types.ts"; -import { type TestingTarget, testWithClient } from "../test/testWithClient.ts"; - -const DOUYIN_PACKAGE_NAMES = new Set([ - "com.example.app", - "com.example.app.lite", - "com.example.app.ep", -]); - -const WEBVIEW_UNSUPPORTED_CDP_METHODS = [ - "DOM.getDocumentWithBoxModel", - "DOM.getOriginalNodeIndex", - "DOM.innerText", - "Lynx.getRectToWindow", - "Lynx.getVersion", - "Lynx.getViewLocationOnScreen", - "Lynx.sendVMEvent", - "Memory.getAllMemoryUsage", - "Performance.getAllTimingInfo", - "Performance.getAllPerformanceEntries", - "WhiteBoard.enable", - "WhiteBoard.disable", - "WhiteBoard.setSharedData", - "WhiteBoard.getSharedData", - "WhiteBoard.removeSharedData", - "WhiteBoard.clear", - "UITree.enable", - "UITree.getLynxUITree", -] as const; - -const WEBVIEW_TESTED_CDP_METHODS = [ - "CSS.getBackgroundColors", - "CSS.getComputedStyleForNode", - "CSS.getInlineStylesForNode", - "CSS.getMatchedStylesForNode", - "DOM.describeNode", - "DOM.disable", - "DOM.discardSearchResults", - "DOM.enable", - "DOM.getAttributes", - "DOM.getBoxModel", - "DOM.getDocument", - "DOM.getNodeForLocation", - "DOM.getOuterHTML", - "DOM.getSearchResults", - "DOM.performSearch", - "DOM.querySelector", - "DOM.querySelectorAll", - "DOM.requestChildNodes", - "DOM.scrollIntoViewIfNeeded", - "DOM.setAttributesAsText", - "Debugger.getScriptSource", - "Input.emulateTouchFromMouseEvent", - "Overlay.hideHighlight", - "Overlay.highlightNode", - "Page.getResourceContent", - "Page.getResourceTree", - "Page.reload", - "Performance.disable", - "Performance.enable", - "Runtime.callFunctionOn", - "Runtime.compileScript", - "Runtime.disable", - "Runtime.discardConsoleEntries", - "Runtime.enable", - "Runtime.evaluate", - "Runtime.getHeapUsage", - "Runtime.getProperties", - "Runtime.globalLexicalScopeNames", - "Runtime.runScript", - "Runtime.setAsyncCallStackDepth", -] as const; - -const CDP_REFERENCE_DIR = new URL("../../../skills/lynx-devtool/references/cdp/", import.meta.url); -const TARGET_APP_PACKAGE_NAME = process.env["LYNX_DEVTOOL_MCP_TESTING_APP_PACKAGE"]?.trim() ?? ""; -const testWithDouyinWebViewClient = DOUYIN_PACKAGE_NAMES.has(TARGET_APP_PACKAGE_NAME) - ? testWithClient - : testWithClient.skip; - -type CdpRequest = { method: string; params?: unknown }; - -interface RemoteObject { - type: string; - value?: unknown; - objectId?: string; - description?: string; -} - -interface RuntimeResult { - result: RemoteObject; - exceptionDetails?: unknown; -} - -interface RuntimeCompileScriptResult { - scriptId?: string; - exceptionDetails?: unknown; -} - -interface DomNode { - nodeId: number; - backendNodeId?: number; - nodeType: number; - nodeName: string; - localName: string; - nodeValue: string; - attributes?: string[]; - childNodeCount?: number; - children?: DomNode[]; -} - -interface BoxModel { - content: number[]; - padding: number[]; - border: number[]; - margin: number[]; - width: number; - height: number; -} - -interface FrameResourceTree { - frame: { - id: string; - url: string; - }; - resources?: Array<{ url: string }>; - childFrames?: FrameResourceTree[]; -} - -interface ScriptParsedEvent { - scriptId: string; - url: string; -} - -function isDouyinTarget(target: TestingTarget): boolean { - return DOUYIN_PACKAGE_NAMES.has(target.appPackageName); -} - -function isHttpUrl(value: string): boolean { - return value.startsWith("http://") || value.startsWith("https://"); -} - -function urlMatchesTarget(url: string, target: TestingTarget): boolean { - return url === target.openUrl || url === target.pageUrl || url.includes(target.openUrl) - || url.includes(target.pageUrl); -} - -function isTargetWebViewSession(session: Session, target: TestingTarget): boolean { - if (session.type === "web") return urlMatchesTarget(session.url, target); - return isHttpUrl(session.url) && urlMatchesTarget(session.url, target) && !session.url.endsWith(".js"); -} - -function isWebViewLikeSession(session: Session): boolean { - return session.type === "web" || (isHttpUrl(session.url) && !session.url.endsWith(".js")); -} - -async function listDocumentedCdpMethods(dir = CDP_REFERENCE_DIR): Promise { - const entries = await readdir(dir, { withFileTypes: true }); - const methods = await Promise.all(entries.map(async (entry) => { - if (entry.isDirectory()) { - return listDocumentedCdpMethods(new URL(`${entry.name}/`, dir)); - } - if (!entry.isFile() || !entry.name.endsWith(".md")) { - return []; - } - - const method = entry.name.slice(0, -".md".length); - if (method === "index" || !method.includes(".")) { - return []; - } - return [method]; - })); - - return methods.flat().sort(); -} - -function sortedUnique(values: readonly string[]): string[] { - return [...new Set(values)].sort(); -} - -function streamFrom(items: T[]): ReadableStream { - return new ReadableStream({ - start(controller) { - for (const item of items) { - controller.enqueue(item); - } - controller.close(); - }, - }); -} - -async function* readUntilIdle( - stream: ReadableStream, - opts: { idleMs: number; maxMs: number }, -): AsyncGenerator { - const reader = stream.getReader(); - const startTime = Date.now(); - let terminated = false; - - try { - while (Date.now() - startTime < opts.maxMs) { - const result = await Promise.race([ - reader.read(), - delay(opts.idleMs, "timeout" as const), - ]); - - if (result === "timeout") { - await reader.cancel(); - terminated = true; - return; - } - - const { done, value } = result; - if (done) { - terminated = true; - return; - } - - yield value; - } - - await reader.cancel(); - terminated = true; - } finally { - if (!terminated) { - await reader.cancel().catch(() => {}); - } - reader.releaseLock(); - } -} - -async function waitForWebViewSession( - connector: Connector, - clientId: string, - target: TestingTarget, - initialSessionIds: Set, -): Promise { - let fallbackSession: Session | undefined; - - for (let i = 0; i < 30; i++) { - await delay(500); - const sessions = await connector.sendListSessionMessage(clientId); - const newWebSession = sessions.find((session) => - !initialSessionIds.has(session.session_id) - && (isTargetWebViewSession(session, target) || isWebViewLikeSession(session)) - ); - if (newWebSession) return newWebSession; - - fallbackSession = sessions.find((session) => isTargetWebViewSession(session, target)) ?? fallbackSession; - } - - return fallbackSession; -} - -async function openDouyinWebViewSession( - t: TestContext, - connector: Connector, - clientId: string, - target: TestingTarget, -): Promise { - if (!isHttpUrl(target.openUrl)) { - throw new Error(`LYNX_DEVTOOL_MCP_TESTING_OPEN_URL must be an HTTP(S) WebView URL, got ${target.openUrl}`); - } - - await connector.sendAppMessage(clientId, "App.enableWebviewDebug", {}); - await connector.sendAppMessage(clientId, "App.closeSecSwitch", {}).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - t.diagnostic(`App.closeSecSwitch failed before WebView open: ${message}`); - }); - - const initialSessions = await connector.sendListSessionMessage(clientId); - const initialSessionIds = new Set(initialSessions.map((session) => session.session_id)); - - await connector.sendAppMessage(clientId, "App.openPage", { url: target.openUrl }); - - const session = await waitForWebViewSession(connector, clientId, target, initialSessionIds); - if (!session) { - const sessions = await connector.sendListSessionMessage(clientId); - throw new Error( - `Timed out waiting for a Douyin WebView session for ${target.openUrl}. Available sessions: ${ - sessions.map(({ session_id, type, url }) => `${session_id}:${type}:${url}`).join("; ") - }`, - ); - } - - await delay(1_000); - return session; -} - -function assertNoRuntimeException(t: TestContext, result: RuntimeResult | RuntimeCompileScriptResult, label: string) { - if (result.exceptionDetails) { - t.assert.fail(`${label} returned exceptionDetails: ${JSON.stringify(result.exceptionDetails)}`); - } -} - -function centerOfQuad(quad: number[]): { x: number; y: number } { - const xs = [quad[0], quad[2], quad[4], quad[6]]; - const ys = [quad[1], quad[3], quad[5], quad[7]]; - return { - x: Math.round((Math.min(...xs) + Math.max(...xs)) / 2), - y: Math.round((Math.min(...ys) + Math.max(...ys)) / 2), - }; -} - -function firstResource(tree: FrameResourceTree): { frameId: string; url: string } { - if (tree.frame.url) { - return { frameId: tree.frame.id, url: tree.frame.url }; - } - - const resource = tree.resources?.find((item) => item.url); - if (resource) { - return { frameId: tree.frame.id, url: resource.url }; - } - - for (const child of tree.childFrames ?? []) { - return firstResource(child); - } - - throw new Error("No resource URL found in Page.getResourceTree response"); -} - -async function collectScriptParsedEvents( - connector: Connector, - clientId: string, - sessionId: number, - signal: AbortSignal, -): Promise { - await using stream = await connector.sendCDPStream( - clientId, - sessionId, - streamFrom([ - { method: "Debugger.disable" }, - { method: "Debugger.enable" }, - ]), - { signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]) }, - ); - - const scripts: ScriptParsedEvent[] = []; - for await (const value of readUntilIdle(stream, { idleMs: 1_000, maxMs: 5_000 })) { - if ( - typeof value === "object" && value !== null && "method" in value && value.method === "Debugger.scriptParsed" - && "params" in value - ) { - const params = value.params as Partial; - if (typeof params.scriptId === "string") { - scripts.push({ - scriptId: params.scriptId, - url: typeof params.url === "string" ? params.url : "", - }); - } - } - } - - return scripts; -} - -test("Douyin WebView CDP test matrix matches documented methods minus unsupported WebView extensions", async (t) => { - const documentedMethods = await listDocumentedCdpMethods(); - const unsupportedMethods = new Set(WEBVIEW_UNSUPPORTED_CDP_METHODS); - const expectedMethods = documentedMethods.filter((method) => !unsupportedMethods.has(method)).sort(); - - t.assert.deepStrictEqual( - sortedUnique(WEBVIEW_TESTED_CDP_METHODS), - expectedMethods, - "Every documented CDP method should be tested for WebView unless it is explicitly unsupported", - ); - t.assert.equal( - WEBVIEW_UNSUPPORTED_CDP_METHODS.length, - 18, - "The WebView unsupported method allowlist should stay explicit", - ); -}); - -testWithDouyinWebViewClient("Douyin WebView CDP", async (suite, connector, client, target) => { - const clientId = client.id; - - await suite.test("supports documented CDP methods except explicit WebView gaps", { - skip: isDouyinTarget(target) - ? false - : `Douyin WebView CDP coverage only runs for Douyin targets, got ${target.appPackageName}`, - }, async (t: TestContext) => { - if (!isHttpUrl(target.openUrl)) { - t.skip(`LYNX_DEVTOOL_MCP_TESTING_OPEN_URL must be an HTTP(S) WebView URL, got ${target.openUrl}`); - return; - } - - const session = await openDouyinWebViewSession(t, connector, clientId, target); - const sessionId = session.session_id; - const cdp = , Params = Record>( - method: string, - params?: Params, - ) => connector.sendCDPMessage(clientId, sessionId, method, params); - - await t.test("Runtime methods", async (t) => { - await cdp("Runtime.enable", {}); - - const fixtureHtml = [ - `
`, - ``, - `
`, - ].join(""); - const evaluation = await cdp("Runtime.evaluate", { - expression: `(() => { - if (!document.body) { - document.documentElement.appendChild(document.createElement("body")); - } - document.title = "WebView CDP Fixture"; - document.body.innerHTML = ${JSON.stringify(fixtureHtml)}; - globalThis.__webviewCdpValue = { answer: 42, label: "webview" }; - return { - title: document.title, - hasFixture: Boolean(document.querySelector("#webview-cdp-fixture")), - }; - })()`, - awaitPromise: true, - returnByValue: true, - }); - assertNoRuntimeException(t, evaluation, "Runtime.evaluate"); - t.assert.deepStrictEqual(evaluation.result.value, { - title: "WebView CDP Fixture", - hasFixture: true, - }); - - const objectEvaluation = await cdp("Runtime.evaluate", { - expression: "globalThis.__webviewCdpValue", - objectGroup: "webview-cdp-test", - }); - assertNoRuntimeException(t, objectEvaluation, "Runtime.evaluate object"); - t.assert.ok(objectEvaluation.result.objectId, "Runtime.evaluate should return an objectId"); - - const properties = await cdp<{ result: Array<{ name: string; value?: RemoteObject }> }>( - "Runtime.getProperties", - { - objectId: objectEvaluation.result.objectId, - ownProperties: true, - }, - ); - t.assert.ok( - properties.result.some((property) => property.name === "answer" && property.value?.value === 42), - "Runtime.getProperties should expose object properties", - ); - - const callResult = await cdp("Runtime.callFunctionOn", { - objectId: objectEvaluation.result.objectId, - functionDeclaration: "function() { return `${this.label}:${this.answer}`; }", - returnByValue: true, - }); - assertNoRuntimeException(t, callResult, "Runtime.callFunctionOn"); - t.assert.equal(callResult.result.value, "webview:42"); - - const lexicalScopeNames = await cdp<{ names: string[] }>("Runtime.globalLexicalScopeNames", {}); - t.assert.ok(Array.isArray(lexicalScopeNames.names), "Runtime.globalLexicalScopeNames should return names"); - - const compiled = await cdp("Runtime.compileScript", { - expression: "globalThis.__webviewCdpCompiled = 41 + 1; globalThis.__webviewCdpCompiled;", - sourceURL: "webview-cdp-compiled.js", - persistScript: true, - }); - assertNoRuntimeException(t, compiled, "Runtime.compileScript"); - t.assert.ok(compiled.scriptId, "Runtime.compileScript should return a scriptId"); - - const runResult = await cdp("Runtime.runScript", { - scriptId: compiled.scriptId, - returnByValue: true, - }); - assertNoRuntimeException(t, runResult, "Runtime.runScript"); - t.assert.equal(runResult.result.value, 42); - - const heap = await cdp<{ usedSize: number; totalSize: number }>("Runtime.getHeapUsage", {}); - t.assert.equal(typeof heap.usedSize, "number"); - t.assert.equal(typeof heap.totalSize, "number"); - - await cdp("Runtime.setAsyncCallStackDepth", { maxDepth: 0 }); - await cdp("Runtime.discardConsoleEntries", {}); - }); - - let rootNodeId = 0; - let fixtureNodeId = 0; - let buttonNodeId = 0; - let boxCenter = { x: 0, y: 0 }; - - await t.test("DOM methods", async (t) => { - await cdp("DOM.enable", {}); - - const document = await cdp<{ root: DomNode }>("DOM.getDocument", { depth: -1, pierce: true }); - rootNodeId = document.root.nodeId; - t.assert.equal(document.root.nodeName, "#document"); - - const fixture = await cdp<{ nodeId: number }>("DOM.querySelector", { - nodeId: rootNodeId, - selector: "#webview-cdp-fixture", - }); - fixtureNodeId = fixture.nodeId; - t.assert.ok(fixtureNodeId > 0, "DOM.querySelector should find the fixture"); - - const button = await cdp<{ nodeId: number }>("DOM.querySelector", { - nodeId: rootNodeId, - selector: "#webview-cdp-button", - }); - buttonNodeId = button.nodeId; - t.assert.ok(buttonNodeId > 0, "DOM.querySelector should find the button"); - - const allFixtureNodes = await cdp<{ nodeIds: number[] }>("DOM.querySelectorAll", { - nodeId: rootNodeId, - selector: "[data-cdp]", - }); - t.assert.ok(allFixtureNodes.nodeIds.length >= 2, "DOM.querySelectorAll should find fixture nodes"); - - const description = await cdp<{ node: DomNode }>("DOM.describeNode", { - nodeId: fixtureNodeId, - depth: 1, - }); - t.assert.equal(description.node.nodeName, "MAIN"); - - const attributes = await cdp<{ attributes: string[] }>("DOM.getAttributes", { nodeId: fixtureNodeId }); - t.assert.ok(attributes.attributes.includes("data-cdp"), "DOM.getAttributes should include fixture attributes"); - - await cdp("DOM.setAttributesAsText", { - nodeId: fixtureNodeId, - text: "data-cdp-updated=\"true\"", - name: "data-cdp-updated", - }); - const updatedAttributes = await cdp<{ attributes: string[] }>("DOM.getAttributes", { nodeId: fixtureNodeId }); - t.assert.ok( - updatedAttributes.attributes.includes("data-cdp-updated"), - "DOM.setAttributesAsText should update attributes", - ); - - const outerHtml = await cdp<{ outerHTML: string }>("DOM.getOuterHTML", { nodeId: fixtureNodeId }); - t.assert.match(outerHtml.outerHTML, /webview-cdp-fixture/); - - await cdp("DOM.requestChildNodes", { nodeId: fixtureNodeId, depth: 1 }); - await cdp("DOM.scrollIntoViewIfNeeded", { nodeId: fixtureNodeId }); - - const box = await cdp<{ model: BoxModel }>("DOM.getBoxModel", { nodeId: fixtureNodeId }); - t.assert.ok(Array.isArray(box.model.content), "DOM.getBoxModel should return content quad"); - boxCenter = centerOfQuad(box.model.content); - - const nodeForLocation = await cdp<{ nodeId?: number; backendNodeId?: number }>("DOM.getNodeForLocation", { - x: boxCenter.x, - y: boxCenter.y, - }); - t.assert.ok( - typeof nodeForLocation.nodeId === "number" || typeof nodeForLocation.backendNodeId === "number", - "DOM.getNodeForLocation should identify a node", - ); - - const search = await cdp<{ searchId: string; resultCount: number }>("DOM.performSearch", { - query: "webview-cdp-fixture", - }); - t.assert.ok(search.searchId, "DOM.performSearch should return searchId"); - t.assert.ok(search.resultCount > 0, "DOM.performSearch should find the fixture"); - - const results = await cdp<{ nodeIds: number[] }>("DOM.getSearchResults", { - searchId: search.searchId, - fromIndex: 0, - toIndex: Math.min(search.resultCount, 1), - }); - t.assert.ok(Array.isArray(results.nodeIds), "DOM.getSearchResults should return nodeIds"); - - await cdp("DOM.discardSearchResults", { searchId: search.searchId }); - }); - - await t.test("CSS methods", async (t) => { - await cdp("CSS.enable", {}); - - const computed = await cdp<{ computedStyle: Array<{ name: string; value: string }> }>( - "CSS.getComputedStyleForNode", - { nodeId: fixtureNodeId }, - ); - t.assert.ok( - computed.computedStyle.some((item) => item.name === "background-color"), - "CSS.getComputedStyleForNode should include background-color", - ); - - const inline = await cdp<{ inlineStyle?: unknown; attributesStyle?: unknown }>( - "CSS.getInlineStylesForNode", - { nodeId: fixtureNodeId }, - ); - t.assert.ok( - typeof inline === "object" && inline !== null, - "CSS.getInlineStylesForNode should return a style object", - ); - - const matched = await cdp>("CSS.getMatchedStylesForNode", { nodeId: fixtureNodeId }); - t.assert.ok( - typeof matched === "object" && matched !== null, - "CSS.getMatchedStylesForNode should return matched style data", - ); - - const backgrounds = await cdp>("CSS.getBackgroundColors", { nodeId: fixtureNodeId }); - t.assert.ok( - Object.hasOwn(backgrounds, "backgroundColors") || Object.hasOwn(backgrounds, "computedFontSize") - || Object.keys(backgrounds).length === 0, - "CSS.getBackgroundColors should return a valid CDP result object", - ); - - await cdp("CSS.disable", {}).catch(() => {}); - }); - - await t.test("Overlay and Input methods", async () => { - await cdp("Overlay.enable", {}); - await cdp("Overlay.highlightNode", { - nodeId: fixtureNodeId, - highlightConfig: { - showInfo: true, - contentColor: { r: 10, g: 120, b: 200, a: 0.35 }, - borderColor: { r: 255, g: 255, b: 255, a: 0.8 }, - }, - }); - await cdp("Overlay.hideHighlight", {}); - - await cdp("Input.emulateTouchFromMouseEvent", { - type: "mouseMoved", - x: boxCenter.x, - y: boxCenter.y, - button: "none", - timestamp: Date.now() / 1000, - }); - }); - - await t.test("Page methods", async (t) => { - await cdp("Page.enable", {}); - - const resourceTree = await cdp<{ frameTree: FrameResourceTree }>("Page.getResourceTree", {}); - const resource = firstResource(resourceTree.frameTree); - - const content = await cdp<{ content: string; base64Encoded: boolean }>("Page.getResourceContent", { - frameId: resource.frameId, - url: resource.url, - }); - t.assert.equal(typeof content.content, "string"); - t.assert.equal(typeof content.base64Encoded, "boolean"); - - await cdp("Page.disable", {}).catch(() => {}); - }); - - await t.test("Performance methods", async (t) => { - const result = await cdp>("Performance.enable", {}); - t.assert.deepStrictEqual(result, {}, "Performance.enable should return an empty result"); - await cdp("Performance.disable", {}); - }); - - await t.test("Debugger methods", async (t) => { - const scripts = await collectScriptParsedEvents(connector, clientId, sessionId, t.signal); - t.assert.ok(scripts.length > 0, "Debugger.enable should emit scriptParsed events"); - - const script = scripts.find((item) => item.url.includes("webview-cdp-compiled.js")) - ?? scripts.find((item) => item.url) - ?? scripts[0]; - const source = await cdp<{ scriptSource: string }>("Debugger.getScriptSource", { - scriptId: script.scriptId, - }); - t.assert.equal(typeof source.scriptSource, "string"); - }); - - await t.test("Disable and reload methods", async () => { - await cdp("Runtime.disable", {}); - await cdp("DOM.disable", {}); - await cdp("Page.reload", { ignoreCache: true }); - }); - }); -}); diff --git a/mcp-servers/devtool-mcp-server/e2e/tools.test.ts b/mcp-servers/devtool-mcp-server/e2e/tools.test.ts new file mode 100644 index 0000000..83be9d6 --- /dev/null +++ b/mcp-servers/devtool-mcp-server/e2e/tools.test.ts @@ -0,0 +1,705 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { testWithClient } from "@lynx-js/devtool-connector/test-with-client"; +import fs from "node:fs/promises"; +import type { TestContext } from "node:test"; +import { setTimeout } from "node:timers/promises"; +import { DescribeNode } from "../src/tools/DOM/DescribeNode.ts"; +import { GetAttributes } from "../src/tools/DOM/GetAttributes.ts"; +import { GetBoxModel } from "../src/tools/DOM/GetBoxModel.ts"; +import { GetDocument } from "../src/tools/DOM/GetDocument.ts"; +import { GetDocumentWithBoxModel } from "../src/tools/DOM/GetDocumentWithBoxModel.ts"; +import { GetNodeForLocation } from "../src/tools/DOM/GetNodeForLocation.ts"; +import { GetOriginalNodeIndex } from "../src/tools/DOM/GetOriginalNodeIndex.ts"; +import { GetSearchResults } from "../src/tools/DOM/GetSearchResults.ts"; +import { InnerText } from "../src/tools/DOM/InnerText.ts"; +import { PerformSearch } from "../src/tools/DOM/PerformSearch.ts"; +import { PushNodesByBackendIdsToFrontend } from "../src/tools/DOM/PushNodesByBackendIdsToFrontend.ts"; +import { QuerySelector } from "../src/tools/DOM/QuerySelector.ts"; +import { QuerySelectorAll } from "../src/tools/DOM/QuerySelectorAll.ts"; +import { RequestChildNodes } from "../src/tools/DOM/RequestChildNodes.ts"; +import { ScrollIntoViewIfNeeded } from "../src/tools/DOM/ScrollIntoViewIfNeeded.ts"; +import { SetAttributesAsText } from "../src/tools/DOM/SetAttributesAsText.ts"; +import { TakeHeapSnapshot } from "../src/tools/HeapProfiler/TakeHeapSnapshot.ts"; +import { GetVersion } from "../src/tools/Lynx/GetVersion.ts"; +import { GetResourceContent } from "../src/tools/Page/GetResourceContent.ts"; +import { GetResourceTree } from "../src/tools/Page/GetResourceTree.ts"; +import { GetAllPerformanceEntries } from "../src/tools/Performance/GetAllPerformanceEntries.ts"; +import { GetAllTimingInfo } from "../src/tools/Performance/GetAllTimingInfo.ts"; +import { Evaluate } from "../src/tools/Runtime/Evaluate.ts"; +import { GetHeapUsage } from "../src/tools/Runtime/GetHeapUsage.ts"; +import { GetProperties } from "../src/tools/Runtime/GetProperties.ts"; +import { GetLynxUITree } from "../src/tools/UITree/GetLynxUITree.ts"; +import type { + DescribeNodeResponse, + GetAllPerformanceEntriesResponse, + GetAttributesResponse, + GetBoxModelResponse, + GetDocumentResponse, + GetLynxUITreeResponse, + GetOriginalNodeIndexResponse, + GetSearchResultsResponse, + InnerTextResponse, + Node, + PerformSearchResponse, + PushNodesByBackendIdsToFrontendResponse, + QuerySelectorAllResponse, + QuerySelectorResponse, + UITreeNode, +} from "../test/utils/cdp-types.ts"; +import { createToolContext } from "../test/utils/testTool.ts"; + +function flattenUITree(node: UITreeNode): UITreeNode[] { + return [node, ...(node.children ?? []).flatMap(flattenUITree)]; +} + +function findFirstElementNode(node: Node): Node | undefined { + if (node.nodeType === 1) { + return node; + } + + for (const child of node.children ?? []) { + const found = findFirstElementNode(child); + if (found) return found; + } + + return undefined; +} + +function hasLynx4Metadata(node: UITreeNode): node is UITreeNode & { + tagName: string; + nodeIndex: number; + props: Record; + label: string; +} { + return typeof node.tagName === "string" + && typeof node.nodeIndex === "number" + && typeof node.props === "object" + && node.props !== null + && !Array.isArray(node.props) + && typeof node.label === "string"; +} + +testWithClient("Tools", async (suite, connector, client, target) => { + await setTimeout(1000); + const clientId = client.id; + + const latestSessionId = async () => { + const sessions = await connector.sendListSessionMessage(clientId); + return sessions[sessions.length - 1]?.session_id; + }; + + await suite.test("DOM.getDocument", async (t: TestContext) => { + const { call } = createToolContext(GetDocument, connector, clientId); + const tree = await call({}); + + if (typeof tree === "object" && tree !== null) { + t.assert.ok(tree.root); + t.assert.equal(tree.root.nodeName, "#document"); + } else { + t.assert.fail("Response should be a DOM tree object"); + } + + const countNodes = (node: Node): number => + 1 + (node.children ?? []).reduce((sum, child) => sum + countNodes(child), 0); + const depthZeroTree = await call({ depth: 0 }); + const fullTree = await call({ depth: -1 }); + + t.assert.ok(depthZeroTree.root, "Depth 0 should return a root node"); + t.assert.equal(depthZeroTree.root.nodeName, "#document", "Depth 0 should return the document root"); + t.assert.ok(fullTree.root, "Depth -1 should return a root node"); + t.assert.equal(fullTree.root.nodeName, "#document", "Depth -1 should return the document root"); + t.assert.ok( + countNodes(fullTree.root) > countNodes(depthZeroTree.root), + "Depth -1 should include more descendants than depth 0", + ); + }); + + await suite.test("DOM.describeNode", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + "DOM.getDocument", + { + depth: -1, + }, + ); + t.assert.ok(root, "Should have a root node"); + + const findElementWithChildren = (node: Node): Node | undefined => { + if ( + node.nodeType === 1 && node.nodeName !== "PAGE" && (node.childNodeCount ?? 0) > 0 + && node.children?.length === node.childNodeCount + ) { + return node; + } + for (const child of node.children ?? []) { + const found = findElementWithChildren(child); + if (found) return found; + } + return undefined; + }; + + const targetNode = findElementWithChildren(root); + t.assert.ok(targetNode, "Should find an element with children"); + + const { call } = createToolContext(DescribeNode, connector, clientId); + const depthZeroResult = await call({ + nodeId: targetNode.nodeId, + depth: 0, + }); + const depthOneResult = await call({ + nodeId: targetNode.nodeId, + depth: 1, + }); + + t.assert.equal(depthZeroResult.compress, false, "Should disable compression for tool output"); + t.assert.equal(depthZeroResult.node?.nodeId, targetNode.nodeId, "Should describe the requested node"); + t.assert.equal( + depthZeroResult.node?.childNodeCount, + targetNode.childNodeCount, + "Should keep child count at depth 0", + ); + t.assert.equal(depthZeroResult.node?.children, undefined, "Depth 0 should not include children"); + + t.assert.equal(depthOneResult.node?.nodeId, targetNode.nodeId, "Depth 1 should describe the same node"); + t.assert.ok(Array.isArray(depthOneResult.node?.children), "Depth 1 should include direct children"); + t.assert.equal( + depthOneResult.node?.children?.length, + targetNode.childNodeCount, + "Depth 1 should include exactly the direct children", + ); + t.assert.equal( + depthOneResult.node?.children?.[0]?.children, + undefined, + "Depth 1 should not include grandchildren", + ); + }); + + await suite.test("DOM.querySelector", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); + t.assert.ok(root, "Should have a root node"); + const rootNodeId = root.nodeId; + + const { call } = createToolContext(QuerySelector, connector, clientId); + const result = await call({ + nodeId: rootNodeId, + selector: "*", + }); + + t.assert.ok(result.nodeId, "Should return a nodeId"); + }); + + await suite.test("DOM.querySelectorAll", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); + t.assert.ok(root, "Should have a root node"); + const rootNodeId = root.nodeId; + + const { call } = createToolContext(QuerySelectorAll, connector, clientId); + const result = await call({ + nodeId: rootNodeId, + selector: "*", + }); + + t.assert.ok(Array.isArray(result.nodeIds), "Should return an array of nodeIds"); + }); + + await suite.test("DOM.getAttributes", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); + t.assert.ok(root, "Should have a root node"); + + const nodeId = root.children?.[0]?.nodeId ?? root.nodeId; + + const { call } = createToolContext(GetAttributes, connector, clientId); + const result = await call({ + nodeId, + }); + + t.assert.ok(Array.isArray(result.attributes), "Should return an array of attributes"); + }); + + await suite.test("DOM.setAttributesAsText updates node attributes", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + "DOM.getDocument", + { depth: -1 }, + ); + const targetNode = findFirstElementNode(root); + t.assert.ok(targetNode, "Should find an element node"); + + const { call: setAttributes } = createToolContext(SetAttributesAsText, connector, clientId); + await setAttributes>({ + nodeId: targetNode.nodeId, + text: "style=\"opacity: 0.99;\"", + name: "style", + }); + + const { call: getAttributes } = createToolContext(GetAttributes, connector, clientId); + const result = await getAttributes({ + nodeId: targetNode.nodeId, + }); + + t.assert.ok(result.attributes.includes("style"), "Updated node should include the style attribute"); + }); + + await suite.test("DOM.getBoxModel", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + "DOM.getDocument", + { + depth: -1, + }, + ); + t.assert.ok(root, "Should have a root node"); + + const findLayoutNode = (node: Node): number | undefined => { + if ( + node.nodeType === 1 && node.nodeName !== "HTML" && node.nodeName !== "BODY" && node.nodeName !== "#document" + ) { + return node.nodeId; + } + if (node.children) { + for (const child of node.children) { + const found = findLayoutNode(child); + if (found) return found; + } + } + return undefined; + }; + + const nodeId = findLayoutNode(root); + t.assert.ok(nodeId, "Should find a node with layout"); + + const { call } = createToolContext(GetBoxModel, connector, clientId); + const result = await call({ + nodeId, + }); + + t.assert.ok(result.model, "Should return a box model"); + t.assert.ok(result.model.content, "Should have content box"); + }); + + await suite.test("DOM.getDocumentWithBoxModel", async (t: TestContext) => { + const { call } = createToolContext(GetDocumentWithBoxModel, connector, clientId); + const result = await call({}); + + t.assert.ok(result.root, "Should return a root node"); + + const hasBoxModel = (node: Node): boolean => { + if (node.box_model) return true; + if (node.children) { + return node.children.some(hasBoxModel); + } + return false; + }; + + t.assert.ok(hasBoxModel(result.root), "Some node in the tree should have box_model"); + }); + + await suite.test("DOM.getNodeForLocation", async (t: TestContext) => { + const { call } = createToolContext(GetNodeForLocation, connector, clientId); + const result = await call({ + x: 100, + y: 100, + }); + + t.assert.ok(result.nodeId, "Should return a nodeId"); + }); + + await suite.test("DOM.innerText", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); + t.assert.ok(root, "Should have a root node"); + + const nodeId = root.nodeId; + + const { call } = createToolContext(InnerText, connector, clientId); + const result = await call({ + nodeId, + }); + + t.assert.ok(result.nodeId, "Should return nodeId"); + t.assert.ok(Array.isArray(result.rawTextValues), "Should return rawTextValues array"); + }); + + await suite.test("DOM.getOriginalNodeIndex", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); + t.assert.ok(root, "Should have a root node"); + + const nodeId = root.children?.[0]?.nodeId ?? root.nodeId; + + const { call } = createToolContext(GetOriginalNodeIndex, connector, clientId); + const result = await call({ + nodeId, + }); + + t.assert.ok(result.nodeIndex !== undefined, "Should return a nodeIndex"); + }); + + await suite.test("DOM.performSearch", async (t: TestContext) => { + const { call } = createToolContext(PerformSearch, connector, clientId); + const result = await call({ + query: "*", + }); + + t.assert.ok(result.searchId, "Should return a searchId"); + t.assert.ok(result.resultCount !== undefined, "Should return a resultCount"); + }); + + await suite.test("DOM.getSearchResults", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { searchId, resultCount } = await connector.sendCDPMessage( + clientId, + sessionId!, + "DOM.performSearch", + { + query: "*", + }, + ); + t.assert.ok(searchId, "Should have a searchId"); + + const { call } = createToolContext(GetSearchResults, connector, clientId); + const result = await call({ + searchId, + fromIndex: 0, + toIndex: Math.min(resultCount, 1), + }); + + t.assert.ok(Array.isArray(result.nodeIds), "Should return an array of nodeIds"); + }); + + await suite.test("DOM.pushNodesByBackendIdsToFrontend", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); + t.assert.ok(root, "Should have a root node"); + t.assert.ok(root.backendNodeId, "Root should have backendNodeId"); + + const { call } = createToolContext(PushNodesByBackendIdsToFrontend, connector, clientId); + const result = await call({ + backendNodeIds: [root.backendNodeId], + }); + + t.assert.ok(Array.isArray(result.nodeIds), "Should return an array of nodeIds"); + }); + + await suite.test("DOM.requestChildNodes", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); + t.assert.ok(root, "Should have a root node"); + + const { call } = createToolContext(RequestChildNodes, connector, clientId); + const result = await call>({ + nodeId: root.nodeId, + depth: 1, + }); + + t.assert.ok(result, "Should return a result (likely empty object)"); + }); + + await suite.test("DOM.scrollIntoViewIfNeeded", async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, "Should have a sessionId"); + + const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); + t.assert.ok(root, "Should have a root node"); + + const nodeId = root.children?.[0]?.nodeId ?? root.nodeId; + + const { call } = createToolContext(ScrollIntoViewIfNeeded, connector, clientId); + const result = await call>({ + nodeId, + }); + + t.assert.ok(result, "Should return a result"); + }); + + await suite.test("Page.getResourceTree and Page.getResourceContent", async (t: TestContext) => { + const { call: getTree } = createToolContext(GetResourceTree, connector, clientId); + const tree = await getTree<{ frameTree?: { frame?: { id?: string; url?: string }; resources?: unknown[] } }>({}); + + t.assert.ok(typeof tree === "object" && tree !== null, "Should return a resource tree object"); + t.assert.ok(tree.frameTree, "Should include frameTree"); + + const { call: getContent } = createToolContext(GetResourceContent, connector, clientId); + const content = await getContent<{ content: string; base64Encoded: boolean }>({ + url: tree.frameTree?.frame?.url ?? target.pageUrl, + frameId: tree.frameTree?.frame?.id, + }); + + t.assert.equal(typeof content.content, "string", "Should return resource content"); + t.assert.equal(typeof content.base64Encoded, "boolean", "Should report whether the content is base64 encoded"); + }); + + await suite.test("Lynx.getVersion", { + skip: target.appPackageName === "EmbeddedLynx" + ? "EmbeddedLynx does not support Lynx.getVersion (for now)" + : false, + }, async (t: TestContext) => { + const { call } = createToolContext(GetVersion, connector, clientId); + const version = await call({}); + + t.assert.equal(typeof version, "string", "Should return a version string"); + t.assert.ok(version.length > 0, "Version string should not be empty"); + }); + + await suite.test("Runtime.evaluate and Runtime.getProperties inspect an object", async (t: TestContext) => { + const { call: evaluate } = createToolContext(Evaluate, connector, clientId); + const evaluated = await evaluate<{ + result?: { objectId?: string; type?: string; description?: string }; + }>({ + expression: "({ answer: 42, label: 'lynx-use' })", + objectGroup: "mcp-tools-test", + generatePreview: true, + }); + + const objectId = evaluated.result?.objectId; + t.assert.equal(typeof objectId, "string", "Evaluation result should include an objectId"); + + const { call: getProperties } = createToolContext(GetProperties, connector, clientId); + const properties = await getProperties<{ + result?: Array<{ name: string; value?: { value?: unknown; type?: string } }>; + }>({ + objectId: objectId!, + ownProperties: true, + }); + + const answer = properties.result?.find(({ name }) => name === "answer"); + t.assert.ok(answer, "Should include the evaluated object's answer property"); + if (answer?.value && "value" in answer.value) { + t.assert.equal(answer.value.value, 42, "answer should preserve its numeric value when returned by value"); + } + }); + + await suite.test("Runtime.getHeapUsage", async (t: TestContext) => { + const { call } = createToolContext(GetHeapUsage, connector, clientId); + const result = await call<{ usedSize: number; totalSize: number }>({}); + + t.assert.ok(typeof result.usedSize === "number", "Should return usedSize"); + t.assert.ok(typeof result.totalSize === "number", "Should return totalSize"); + }); + + await suite.test("Runtime.getHeapUsage supports the main thread", async (t: TestContext) => { + const { call } = createToolContext(GetHeapUsage, connector, clientId); + const result = await call<{ usedSize: number; totalSize: number }>({ + thread: "main", + }); + + t.assert.ok(typeof result.usedSize === "number", "Should return usedSize"); + t.assert.ok(typeof result.totalSize === "number", "Should return totalSize"); + }); + + await suite.test("UITree.getLynxUITree returns an uncompressed native UI tree", async (t: TestContext) => { + if (target.appPackageName === "EmbeddedLynx") { + t.skip("EmbeddedLynx does not support UITree.getLynxUITree yet"); + return; + } + + const { call } = createToolContext(GetLynxUITree, connector, clientId); + const result = await call({}); + + t.assert.equal(result.compress, false, "Should request an uncompressed UITree response"); + t.assert.ok(result.root, "Should return a UITree root"); + t.assert.ok(typeof result.root.name === "string", "Root should include the native class name"); + t.assert.ok(typeof result.root.id === "number", "Root should include the native UI id"); + t.assert.ok(Array.isArray(result.root.children), "Root should include child UI nodes"); + + const nodes = flattenUITree(result.root); + t.assert.ok(nodes.length > 0, "Should include at least one UI node"); + + const nodeWithFrame = nodes.find(node => Array.isArray(node.frame)); + if (!nodeWithFrame?.frame) { + t.assert.fail("Should include frame data for at least one UI node"); + return; + } + t.assert.equal(nodeWithFrame.frame.length, 4, "Frame should be [x, y, width, height]"); + for (const value of nodeWithFrame.frame) { + t.assert.ok(typeof value === "number", "Frame values should be numbers"); + } + }); + + await suite.test("UITree.getLynxUITree exposes Lynx 4 metadata fields", async (t: TestContext) => { + if (target.appPackageName === "EmbeddedLynx") { + t.skip("EmbeddedLynx does not support UITree.getLynxUITree yet"); + return; + } + + const { call } = createToolContext(GetLynxUITree, connector, clientId); + const result = await call({}); + const nodes = flattenUITree(result.root); + + const metadataNode = nodes.find(hasLynx4Metadata); + + if (!metadataNode) { + t.assert.fail("Should include Lynx 4 UI metadata on at least one node"); + return; + } + t.assert.ok(metadataNode.nodeIndex >= 0, "nodeIndex should map to a non-negative DOM node index"); + t.assert.ok(metadataNode.tagName.length > 0, "tagName should be readable"); + }); + + await suite.test("Performance.getAllTimingInfo", async (t: TestContext) => { + if (target.appPackageName === "EmbeddedLynx") { + t.skip("EmbeddedLynx does not support Performance.getAllTimingInfo yet"); + return; + } + const { call } = createToolContext(GetAllTimingInfo, connector, clientId); + const result = await call>({}); + + t.assert.ok(typeof result === "object" && result !== null, "Should return a timing info object"); + + const timing = result as Record; + + t.assert.ok(typeof timing.url === "string", "Should include url"); + t.assert.ok(typeof timing.has_reload === "number", "Should include has_reload"); + t.assert.ok(typeof timing.thread_strategy === "number", "Should include thread_strategy"); + + const metrics = timing.metrics; + t.assert.ok(typeof metrics === "object" && metrics !== null, "Should include metrics object"); + // Some engine timing metrics are flaky across transport CI runs. + t.assert.ok( + typeof (metrics as Record).lynx_tti === "number", + "metrics.lynx_tti should be a number", + ); + // Container-level metrics depend on extra timing from the host app. They are useful when present, + // but not guaranteed by the engine timing contract. + for (const key of ["fcp", "tti", "total_fcp", "total_tti"]) { + if (Object.hasOwn(metrics as Record, key)) { + t.assert.ok( + typeof (metrics as Record)[key] === "number", + `metrics.${key} should be a number when present`, + ); + } + } + + const setupTiming = timing.setup_timing; + t.assert.ok(typeof setupTiming === "object" && setupTiming !== null, "Should include setup_timing object"); + for (const key of ["pipeline_start", "load_template_start", "load_template_end", "draw_end"]) { + t.assert.ok( + typeof (setupTiming as Record)[key] === "number", + `setup_timing.${key} should be a number`, + ); + } + + const extraTiming = timing.extra_timing; + t.assert.ok(typeof extraTiming === "object" && extraTiming !== null, "Should include extra_timing object"); + for (const key of ["open_time", "container_init_start", "container_init_end"]) { + t.assert.ok( + typeof (extraTiming as Record)[key] === "number", + `extra_timing.${key} should be a number`, + ); + } + + t.assert.ok(typeof timing.update_timings === "object", "Should include update_timings object"); + }); + + await suite.test("Performance.getAllPerformanceEntries", async (t: TestContext) => { + const { call } = createToolContext(GetAllPerformanceEntries, connector, clientId); + const result = await call({}); + + t.assert.ok(typeof result === "object" && result !== null, "Should return a performance entries object"); + t.assert.ok(Array.isArray(result.entries), "Should include entries array"); + + for (const entry of result.entries) { + t.assert.ok(typeof entry === "object" && entry !== null, "Each entry should be an object"); + + if (Object.hasOwn(entry, "entryType")) { + t.assert.ok(typeof entry.entryType === "string", "entry.entryType should be a string when present"); + } + if (Object.hasOwn(entry, "name")) { + t.assert.ok(typeof entry.name === "string", "entry.name should be a string when present"); + } + if (Object.hasOwn(entry, "instanceId")) { + t.assert.ok(typeof entry.instanceId === "number", "entry.instanceId should be a number when present"); + } + } + }); + + await suite.test("HeapProfiler.takeHeapSnapshot saves a background snapshot filename by default", async (t: TestContext) => { + if (target.appPackageName === "EmbeddedLynx") { + t.skip("EmbeddedLynx does not support background-thread heap snapshots yet"); + return; + } + const { call } = createToolContext(TakeHeapSnapshot, connector, clientId); + const result = await call({}); + + t.assert.ok(typeof result === "string", "Should return a string"); + t.assert.match( + result, + /Heap snapshot saved to .*heap-background-\d+\.heapsnapshot$/, + "Should save a background snapshot by default", + ); + }); + + await suite.test("HeapProfiler.takeHeapSnapshot saves a main-thread snapshot filename when requested", async (t: TestContext) => { + const { call } = createToolContext(TakeHeapSnapshot, connector, clientId); + const result = await call({ + thread: "main", + }); + + t.assert.ok(typeof result === "string", "Should return a string"); + t.assert.match( + result, + /Heap snapshot saved to .*heap-main-\d+\.heapsnapshot$/, + "Should save a main-thread snapshot when requested", + ); + + const filePath = result.replace("Heap snapshot saved to ", ""); + + try { + const content = await fs.readFile(filePath, "utf8"); + const snapshot = JSON.parse(content) as { strings?: string[] }; + + t.assert.ok( + Array.isArray(snapshot.strings), + "Main-thread heap snapshot should include strings array", + ); + t.assert.ok( + snapshot.strings.includes("renderPage"), + "Main-thread heap snapshot should include renderPage", + ); + t.assert.ok( + snapshot.strings.includes("updatePage"), + "Main-thread heap snapshot should include updatePage", + ); + t.assert.ok( + snapshot.strings.includes("updateGlobalProps"), + "Main-thread heap snapshot should include updateGlobalProps", + ); + } finally { + await fs.unlink(filePath).catch(() => {}); + } + }); +}); From e89ff9d0259e02ec184eeadcfcb30768eab2e4fd Mon Sep 17 00:00:00 2001 From: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:38:13 +0800 Subject: [PATCH 3/5] refactor: move mcp-servers under packages/ Relocate devtool-connector and devtool-mcp-server from top-level mcp-servers/ to packages/mcp-servers/ for consistency with the rest of the workspace layout. Update pnpm-workspace.yaml glob, the lynx-devtool e2e workflow paths, and the connector tsconfig extends path accordingly. --- .github/workflows/lynx-devtool.yml | 12 +-- .../mcp-servers}/devtool-connector/README.md | 0 .../devtool-connector/e2e/index.test.ts | 0 .../devtool-connector/package.json | 0 .../public/inspector-wrapper.html | 0 .../devtool-connector/rslib.config.ts | 0 .../devtool-connector/src/client-id.ts | 0 .../src/daemon/device-connection.ts | 0 .../devtool-connector/src/daemon/entry.ts | 0 .../devtool-connector/src/daemon/index.ts | 0 .../devtool-connector/src/daemon/manager.ts | 0 .../devtool-connector/src/daemon/protocol.ts | 0 .../devtool-connector/src/daemon/server.ts | 0 .../src/daemon/static-server.ts | 0 .../src/daemon/tarball-cache.ts | 0 .../devtool-connector/src/daemon/version.ts | 0 .../devtool-connector/src/index.ts | 0 .../devtool-connector/src/streams/cdp.ts | 0 .../src/streams/customized.ts | 0 .../devtool-connector/src/streams/index.ts | 0 .../devtool-connector/src/streams/utils.ts | 0 .../devtool-connector/src/takeover.ts | 0 .../src/transport/android.ts | 0 .../devtool-connector/src/transport/base.ts | 0 .../devtool-connector/src/transport/daemon.ts | 0 .../src/transport/desktop.ts | 0 .../devtool-connector/src/transport/index.ts | 0 .../devtool-connector/src/transport/ios.ts | 0 .../src/transport/peertalk.ts | 0 .../src/transport/transport.ts | 0 .../devtool-connector/src/transport/usbmux.ts | 0 .../src/transport/ws-stream.ts | 0 .../devtool-connector/src/types.ts | 0 .../devtool-connector/test/clientId.test.ts | 0 .../test/connector-lifecycle.test.ts | 0 .../test/daemon-connect-timeout.test.ts | 0 .../test/daemon-device-connection.test.ts | 0 .../test/daemon-manager.test.ts | 0 .../test/daemon-protocol.test.ts | 0 .../test/daemon-server.test.ts | 0 .../test/daemon-transport.test.ts | 0 .../devtool-connector/test/ios.test.ts | 0 .../test/list-clients-fallback.test.ts | 0 .../test/list-clients-setup.test.ts | 0 .../test/open-app-daemon.test.ts | 0 .../test/testWithClient.test.ts | 0 .../devtool-connector/test/testWithClient.ts | 0 .../test/transport-selection.test.ts | 0 .../devtool-connector/test/usbmux.test.ts | 0 .../devtool-connector/tsconfig.json | 2 +- .../mcp-servers}/devtool-mcp-server/LICENSE | 0 .../mcp-servers}/devtool-mcp-server/README.md | 0 .../devtool-mcp-server/e2e/tools.test.ts | 0 .../devtool-mcp-server/package.json | 0 .../devtool-mcp-server/rslib.config.ts | 0 .../devtool-mcp-server/src/McpContext.ts | 0 .../devtool-mcp-server/src/McpResponse.ts | 0 .../devtool-mcp-server/src/connector.ts | 0 .../devtool-mcp-server/src/index.ts | 0 .../devtool-mcp-server/src/main.ts | 0 .../devtool-mcp-server/src/schema/index.ts | 0 .../src/tools/App/GetGlobalSwitch.ts | 0 .../src/tools/App/ListGlobalSwitch.ts | 0 .../src/tools/App/SetGlobalSwitch.ts | 0 .../src/tools/App/globalSwitch.ts | 0 .../src/tools/CSS/GetBackgroundColors.ts | 0 .../src/tools/CSS/GetComputedStyleForNode.ts | 0 .../src/tools/CSS/GetInlineStylesForNode.ts | 0 .../src/tools/CSS/GetMatchedStylesForNode.ts | 0 .../src/tools/CSS/GetStyleSheetText.ts | 0 .../src/tools/DOM/DescribeNode.ts | 0 .../src/tools/DOM/GetAttributes.ts | 0 .../src/tools/DOM/GetBoxModel.ts | 0 .../src/tools/DOM/GetDocument.ts | 0 .../src/tools/DOM/GetDocumentWithBoxModel.ts | 0 .../src/tools/DOM/GetNodeForLocation.ts | 0 .../src/tools/DOM/GetOriginalNodeIndex.ts | 0 .../src/tools/DOM/GetSearchResults.ts | 0 .../src/tools/DOM/InnerText.ts | 0 .../src/tools/DOM/PerformSearch.ts | 0 .../DOM/PushNodesByBackendIdsToFrontend.ts | 0 .../src/tools/DOM/QuerySelector.ts | 0 .../src/tools/DOM/QuerySelectorAll.ts | 0 .../src/tools/DOM/RequestChildNodes.ts | 0 .../src/tools/DOM/ScrollIntoViewIfNeeded.ts | 0 .../src/tools/DOM/SetAttributesAsText.ts | 0 .../src/tools/Debugger/GetScriptSource.ts | 0 .../src/tools/Debugger/ListScripts.ts | 0 .../src/tools/Device/ClosePage.ts | 0 .../src/tools/Device/ListClients.ts | 0 .../src/tools/Device/ListDevices.ts | 0 .../src/tools/Device/ListSessions.ts | 0 .../src/tools/Device/OpenPage.ts | 0 .../tools/HeapProfiler/TakeHeapSnapshot.ts | 0 .../tools/Input/EmulateTouchFromMouseEvent.ts | 0 .../src/tools/Lynx/GetVersion.ts | 0 .../src/tools/Memory/GetAllMemoryUsage.ts | 0 .../src/tools/Page/GetResourceContent.ts | 0 .../src/tools/Page/GetResourceTree.ts | 0 .../src/tools/Page/Reload.ts | 0 .../src/tools/Page/TakeScreenshot.ts | 0 .../Performance/GetAllPerformanceEntries.ts | 0 .../src/tools/Performance/GetAllTimingInfo.ts | 0 .../src/tools/Runtime/Evaluate.ts | 0 .../src/tools/Runtime/GetHeapUsage.ts | 0 .../src/tools/Runtime/GetProperties.ts | 0 .../src/tools/Runtime/ListConsole.ts | 0 .../src/tools/UITree/GetLynxUITree.ts | 0 .../src/tools/defineTool.ts | 0 .../test/McpResponse.test.ts | 0 .../devtool-mcp-server/test/tools/AGENTS.md | 0 .../test/tools/App_globalSwitch.test.ts | 0 .../test/tools/DOM_describeNode.test.ts | 0 .../test/tools/DOM_getDocument.test.ts | 0 .../tools/DOM_setAttributesAsText.test.ts | 0 .../test/tools/Device_openPage.test.ts | 0 .../HeapProfiler_takeHeapSnapshot.test.ts | 0 .../tools/Memory_getAllMemoryUsage.test.ts | 0 .../tools/Page_Lynx_resourceVersion.test.ts | 0 ...rformance_getAllPerformanceEntries.test.ts | 0 .../Runtime_evaluate_getProperties.test.ts | 0 .../test/tools/Runtime_getHeapUsage.test.ts | 0 .../test/tools/UITree_getLynxUITree.test.ts | 0 .../test/tools/proxyTestUtils.ts | 0 .../test/utils/cdp-types.ts | 0 .../devtool-mcp-server/test/utils/testTool.ts | 0 .../devtool-mcp-server/tsconfig.json | 0 pnpm-lock.yaml | 82 +++++++++---------- pnpm-workspace.yaml | 2 +- 129 files changed, 49 insertions(+), 49 deletions(-) rename {mcp-servers => packages/mcp-servers}/devtool-connector/README.md (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/e2e/index.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/package.json (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/public/inspector-wrapper.html (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/rslib.config.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/client-id.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/daemon/device-connection.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/daemon/entry.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/daemon/index.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/daemon/manager.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/daemon/protocol.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/daemon/server.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/daemon/static-server.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/daemon/tarball-cache.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/daemon/version.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/index.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/streams/cdp.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/streams/customized.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/streams/index.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/streams/utils.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/takeover.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/android.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/base.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/daemon.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/desktop.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/index.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/ios.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/peertalk.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/transport.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/usbmux.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/transport/ws-stream.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/src/types.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/clientId.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/connector-lifecycle.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/daemon-connect-timeout.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/daemon-device-connection.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/daemon-manager.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/daemon-protocol.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/daemon-server.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/daemon-transport.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/ios.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/list-clients-fallback.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/list-clients-setup.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/open-app-daemon.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/testWithClient.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/testWithClient.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/transport-selection.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/test/usbmux.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-connector/tsconfig.json (75%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/LICENSE (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/README.md (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/e2e/tools.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/package.json (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/rslib.config.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/McpContext.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/McpResponse.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/connector.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/index.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/main.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/schema/index.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/App/globalSwitch.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/DescribeNode.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/GetAttributes.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/GetDocument.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/InnerText.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/PerformSearch.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/QuerySelector.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Debugger/ListScripts.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Device/ClosePage.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Device/ListClients.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Device/ListDevices.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Device/ListSessions.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Device/OpenPage.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Lynx/GetVersion.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Page/GetResourceContent.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Page/GetResourceTree.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Page/Reload.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Runtime/Evaluate.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Runtime/GetProperties.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/Runtime/ListConsole.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/src/tools/defineTool.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/McpResponse.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/AGENTS.md (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/App_globalSwitch.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/DOM_describeNode.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/DOM_getDocument.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/Device_openPage.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/tools/proxyTestUtils.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/utils/cdp-types.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/test/utils/testTool.ts (100%) rename {mcp-servers => packages/mcp-servers}/devtool-mcp-server/tsconfig.json (100%) diff --git a/.github/workflows/lynx-devtool.yml b/.github/workflows/lynx-devtool.yml index 19b3541..bb174c3 100644 --- a/.github/workflows/lynx-devtool.yml +++ b/.github/workflows/lynx-devtool.yml @@ -4,15 +4,15 @@ on: pull_request: branches: [main] paths: - - "mcp-servers/devtool-connector/**" - - "mcp-servers/devtool-mcp-server/**" + - "packages/mcp-servers/devtool-connector/**" + - "packages/mcp-servers/devtool-mcp-server/**" - "packages/skills/lynx-devtool/**" - ".github/workflows/lynx-devtool.yml" push: branches: [main] paths: - - "mcp-servers/devtool-connector/**" - - "mcp-servers/devtool-mcp-server/**" + - "packages/mcp-servers/devtool-connector/**" + - "packages/mcp-servers/devtool-mcp-server/**" - "packages/skills/lynx-devtool/**" - ".github/workflows/lynx-devtool.yml" workflow_dispatch: @@ -73,11 +73,11 @@ jobs: exit 1 - name: E2E (devtool-connector) - working-directory: mcp-servers/devtool-connector + working-directory: packages/mcp-servers/devtool-connector run: node --test --test-concurrency=1 'e2e/**/*.test.ts' - name: E2E (devtool-mcp-server) - working-directory: mcp-servers/devtool-mcp-server + working-directory: packages/mcp-servers/devtool-mcp-server run: node --test --test-concurrency=1 'e2e/**/*.test.ts' - name: Stop EmbeddedLynx runtime diff --git a/mcp-servers/devtool-connector/README.md b/packages/mcp-servers/devtool-connector/README.md similarity index 100% rename from mcp-servers/devtool-connector/README.md rename to packages/mcp-servers/devtool-connector/README.md diff --git a/mcp-servers/devtool-connector/e2e/index.test.ts b/packages/mcp-servers/devtool-connector/e2e/index.test.ts similarity index 100% rename from mcp-servers/devtool-connector/e2e/index.test.ts rename to packages/mcp-servers/devtool-connector/e2e/index.test.ts diff --git a/mcp-servers/devtool-connector/package.json b/packages/mcp-servers/devtool-connector/package.json similarity index 100% rename from mcp-servers/devtool-connector/package.json rename to packages/mcp-servers/devtool-connector/package.json diff --git a/mcp-servers/devtool-connector/public/inspector-wrapper.html b/packages/mcp-servers/devtool-connector/public/inspector-wrapper.html similarity index 100% rename from mcp-servers/devtool-connector/public/inspector-wrapper.html rename to packages/mcp-servers/devtool-connector/public/inspector-wrapper.html diff --git a/mcp-servers/devtool-connector/rslib.config.ts b/packages/mcp-servers/devtool-connector/rslib.config.ts similarity index 100% rename from mcp-servers/devtool-connector/rslib.config.ts rename to packages/mcp-servers/devtool-connector/rslib.config.ts diff --git a/mcp-servers/devtool-connector/src/client-id.ts b/packages/mcp-servers/devtool-connector/src/client-id.ts similarity index 100% rename from mcp-servers/devtool-connector/src/client-id.ts rename to packages/mcp-servers/devtool-connector/src/client-id.ts diff --git a/mcp-servers/devtool-connector/src/daemon/device-connection.ts b/packages/mcp-servers/devtool-connector/src/daemon/device-connection.ts similarity index 100% rename from mcp-servers/devtool-connector/src/daemon/device-connection.ts rename to packages/mcp-servers/devtool-connector/src/daemon/device-connection.ts diff --git a/mcp-servers/devtool-connector/src/daemon/entry.ts b/packages/mcp-servers/devtool-connector/src/daemon/entry.ts similarity index 100% rename from mcp-servers/devtool-connector/src/daemon/entry.ts rename to packages/mcp-servers/devtool-connector/src/daemon/entry.ts diff --git a/mcp-servers/devtool-connector/src/daemon/index.ts b/packages/mcp-servers/devtool-connector/src/daemon/index.ts similarity index 100% rename from mcp-servers/devtool-connector/src/daemon/index.ts rename to packages/mcp-servers/devtool-connector/src/daemon/index.ts diff --git a/mcp-servers/devtool-connector/src/daemon/manager.ts b/packages/mcp-servers/devtool-connector/src/daemon/manager.ts similarity index 100% rename from mcp-servers/devtool-connector/src/daemon/manager.ts rename to packages/mcp-servers/devtool-connector/src/daemon/manager.ts diff --git a/mcp-servers/devtool-connector/src/daemon/protocol.ts b/packages/mcp-servers/devtool-connector/src/daemon/protocol.ts similarity index 100% rename from mcp-servers/devtool-connector/src/daemon/protocol.ts rename to packages/mcp-servers/devtool-connector/src/daemon/protocol.ts diff --git a/mcp-servers/devtool-connector/src/daemon/server.ts b/packages/mcp-servers/devtool-connector/src/daemon/server.ts similarity index 100% rename from mcp-servers/devtool-connector/src/daemon/server.ts rename to packages/mcp-servers/devtool-connector/src/daemon/server.ts diff --git a/mcp-servers/devtool-connector/src/daemon/static-server.ts b/packages/mcp-servers/devtool-connector/src/daemon/static-server.ts similarity index 100% rename from mcp-servers/devtool-connector/src/daemon/static-server.ts rename to packages/mcp-servers/devtool-connector/src/daemon/static-server.ts diff --git a/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts b/packages/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts similarity index 100% rename from mcp-servers/devtool-connector/src/daemon/tarball-cache.ts rename to packages/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts diff --git a/mcp-servers/devtool-connector/src/daemon/version.ts b/packages/mcp-servers/devtool-connector/src/daemon/version.ts similarity index 100% rename from mcp-servers/devtool-connector/src/daemon/version.ts rename to packages/mcp-servers/devtool-connector/src/daemon/version.ts diff --git a/mcp-servers/devtool-connector/src/index.ts b/packages/mcp-servers/devtool-connector/src/index.ts similarity index 100% rename from mcp-servers/devtool-connector/src/index.ts rename to packages/mcp-servers/devtool-connector/src/index.ts diff --git a/mcp-servers/devtool-connector/src/streams/cdp.ts b/packages/mcp-servers/devtool-connector/src/streams/cdp.ts similarity index 100% rename from mcp-servers/devtool-connector/src/streams/cdp.ts rename to packages/mcp-servers/devtool-connector/src/streams/cdp.ts diff --git a/mcp-servers/devtool-connector/src/streams/customized.ts b/packages/mcp-servers/devtool-connector/src/streams/customized.ts similarity index 100% rename from mcp-servers/devtool-connector/src/streams/customized.ts rename to packages/mcp-servers/devtool-connector/src/streams/customized.ts diff --git a/mcp-servers/devtool-connector/src/streams/index.ts b/packages/mcp-servers/devtool-connector/src/streams/index.ts similarity index 100% rename from mcp-servers/devtool-connector/src/streams/index.ts rename to packages/mcp-servers/devtool-connector/src/streams/index.ts diff --git a/mcp-servers/devtool-connector/src/streams/utils.ts b/packages/mcp-servers/devtool-connector/src/streams/utils.ts similarity index 100% rename from mcp-servers/devtool-connector/src/streams/utils.ts rename to packages/mcp-servers/devtool-connector/src/streams/utils.ts diff --git a/mcp-servers/devtool-connector/src/takeover.ts b/packages/mcp-servers/devtool-connector/src/takeover.ts similarity index 100% rename from mcp-servers/devtool-connector/src/takeover.ts rename to packages/mcp-servers/devtool-connector/src/takeover.ts diff --git a/mcp-servers/devtool-connector/src/transport/android.ts b/packages/mcp-servers/devtool-connector/src/transport/android.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/android.ts rename to packages/mcp-servers/devtool-connector/src/transport/android.ts diff --git a/mcp-servers/devtool-connector/src/transport/base.ts b/packages/mcp-servers/devtool-connector/src/transport/base.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/base.ts rename to packages/mcp-servers/devtool-connector/src/transport/base.ts diff --git a/mcp-servers/devtool-connector/src/transport/daemon.ts b/packages/mcp-servers/devtool-connector/src/transport/daemon.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/daemon.ts rename to packages/mcp-servers/devtool-connector/src/transport/daemon.ts diff --git a/mcp-servers/devtool-connector/src/transport/desktop.ts b/packages/mcp-servers/devtool-connector/src/transport/desktop.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/desktop.ts rename to packages/mcp-servers/devtool-connector/src/transport/desktop.ts diff --git a/mcp-servers/devtool-connector/src/transport/index.ts b/packages/mcp-servers/devtool-connector/src/transport/index.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/index.ts rename to packages/mcp-servers/devtool-connector/src/transport/index.ts diff --git a/mcp-servers/devtool-connector/src/transport/ios.ts b/packages/mcp-servers/devtool-connector/src/transport/ios.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/ios.ts rename to packages/mcp-servers/devtool-connector/src/transport/ios.ts diff --git a/mcp-servers/devtool-connector/src/transport/peertalk.ts b/packages/mcp-servers/devtool-connector/src/transport/peertalk.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/peertalk.ts rename to packages/mcp-servers/devtool-connector/src/transport/peertalk.ts diff --git a/mcp-servers/devtool-connector/src/transport/transport.ts b/packages/mcp-servers/devtool-connector/src/transport/transport.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/transport.ts rename to packages/mcp-servers/devtool-connector/src/transport/transport.ts diff --git a/mcp-servers/devtool-connector/src/transport/usbmux.ts b/packages/mcp-servers/devtool-connector/src/transport/usbmux.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/usbmux.ts rename to packages/mcp-servers/devtool-connector/src/transport/usbmux.ts diff --git a/mcp-servers/devtool-connector/src/transport/ws-stream.ts b/packages/mcp-servers/devtool-connector/src/transport/ws-stream.ts similarity index 100% rename from mcp-servers/devtool-connector/src/transport/ws-stream.ts rename to packages/mcp-servers/devtool-connector/src/transport/ws-stream.ts diff --git a/mcp-servers/devtool-connector/src/types.ts b/packages/mcp-servers/devtool-connector/src/types.ts similarity index 100% rename from mcp-servers/devtool-connector/src/types.ts rename to packages/mcp-servers/devtool-connector/src/types.ts diff --git a/mcp-servers/devtool-connector/test/clientId.test.ts b/packages/mcp-servers/devtool-connector/test/clientId.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/clientId.test.ts rename to packages/mcp-servers/devtool-connector/test/clientId.test.ts diff --git a/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts b/packages/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/connector-lifecycle.test.ts rename to packages/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts diff --git a/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts rename to packages/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts diff --git a/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/daemon-device-connection.test.ts rename to packages/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts diff --git a/mcp-servers/devtool-connector/test/daemon-manager.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-manager.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/daemon-manager.test.ts rename to packages/mcp-servers/devtool-connector/test/daemon-manager.test.ts diff --git a/mcp-servers/devtool-connector/test/daemon-protocol.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-protocol.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/daemon-protocol.test.ts rename to packages/mcp-servers/devtool-connector/test/daemon-protocol.test.ts diff --git a/mcp-servers/devtool-connector/test/daemon-server.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-server.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/daemon-server.test.ts rename to packages/mcp-servers/devtool-connector/test/daemon-server.test.ts diff --git a/mcp-servers/devtool-connector/test/daemon-transport.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-transport.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/daemon-transport.test.ts rename to packages/mcp-servers/devtool-connector/test/daemon-transport.test.ts diff --git a/mcp-servers/devtool-connector/test/ios.test.ts b/packages/mcp-servers/devtool-connector/test/ios.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/ios.test.ts rename to packages/mcp-servers/devtool-connector/test/ios.test.ts diff --git a/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts b/packages/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/list-clients-fallback.test.ts rename to packages/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts diff --git a/mcp-servers/devtool-connector/test/list-clients-setup.test.ts b/packages/mcp-servers/devtool-connector/test/list-clients-setup.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/list-clients-setup.test.ts rename to packages/mcp-servers/devtool-connector/test/list-clients-setup.test.ts diff --git a/mcp-servers/devtool-connector/test/open-app-daemon.test.ts b/packages/mcp-servers/devtool-connector/test/open-app-daemon.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/open-app-daemon.test.ts rename to packages/mcp-servers/devtool-connector/test/open-app-daemon.test.ts diff --git a/mcp-servers/devtool-connector/test/testWithClient.test.ts b/packages/mcp-servers/devtool-connector/test/testWithClient.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/testWithClient.test.ts rename to packages/mcp-servers/devtool-connector/test/testWithClient.test.ts diff --git a/mcp-servers/devtool-connector/test/testWithClient.ts b/packages/mcp-servers/devtool-connector/test/testWithClient.ts similarity index 100% rename from mcp-servers/devtool-connector/test/testWithClient.ts rename to packages/mcp-servers/devtool-connector/test/testWithClient.ts diff --git a/mcp-servers/devtool-connector/test/transport-selection.test.ts b/packages/mcp-servers/devtool-connector/test/transport-selection.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/transport-selection.test.ts rename to packages/mcp-servers/devtool-connector/test/transport-selection.test.ts diff --git a/mcp-servers/devtool-connector/test/usbmux.test.ts b/packages/mcp-servers/devtool-connector/test/usbmux.test.ts similarity index 100% rename from mcp-servers/devtool-connector/test/usbmux.test.ts rename to packages/mcp-servers/devtool-connector/test/usbmux.test.ts diff --git a/mcp-servers/devtool-connector/tsconfig.json b/packages/mcp-servers/devtool-connector/tsconfig.json similarity index 75% rename from mcp-servers/devtool-connector/tsconfig.json rename to packages/mcp-servers/devtool-connector/tsconfig.json index 25e00f4..d842fdd 100644 --- a/mcp-servers/devtool-connector/tsconfig.json +++ b/packages/mcp-servers/devtool-connector/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../../tsconfig.json", "compilerOptions": { "rootDir": "./src", "noEmit": true, diff --git a/mcp-servers/devtool-mcp-server/LICENSE b/packages/mcp-servers/devtool-mcp-server/LICENSE similarity index 100% rename from mcp-servers/devtool-mcp-server/LICENSE rename to packages/mcp-servers/devtool-mcp-server/LICENSE diff --git a/mcp-servers/devtool-mcp-server/README.md b/packages/mcp-servers/devtool-mcp-server/README.md similarity index 100% rename from mcp-servers/devtool-mcp-server/README.md rename to packages/mcp-servers/devtool-mcp-server/README.md diff --git a/mcp-servers/devtool-mcp-server/e2e/tools.test.ts b/packages/mcp-servers/devtool-mcp-server/e2e/tools.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/e2e/tools.test.ts rename to packages/mcp-servers/devtool-mcp-server/e2e/tools.test.ts diff --git a/mcp-servers/devtool-mcp-server/package.json b/packages/mcp-servers/devtool-mcp-server/package.json similarity index 100% rename from mcp-servers/devtool-mcp-server/package.json rename to packages/mcp-servers/devtool-mcp-server/package.json diff --git a/mcp-servers/devtool-mcp-server/rslib.config.ts b/packages/mcp-servers/devtool-mcp-server/rslib.config.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/rslib.config.ts rename to packages/mcp-servers/devtool-mcp-server/rslib.config.ts diff --git a/mcp-servers/devtool-mcp-server/src/McpContext.ts b/packages/mcp-servers/devtool-mcp-server/src/McpContext.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/McpContext.ts rename to packages/mcp-servers/devtool-mcp-server/src/McpContext.ts diff --git a/mcp-servers/devtool-mcp-server/src/McpResponse.ts b/packages/mcp-servers/devtool-mcp-server/src/McpResponse.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/McpResponse.ts rename to packages/mcp-servers/devtool-mcp-server/src/McpResponse.ts diff --git a/mcp-servers/devtool-mcp-server/src/connector.ts b/packages/mcp-servers/devtool-mcp-server/src/connector.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/connector.ts rename to packages/mcp-servers/devtool-mcp-server/src/connector.ts diff --git a/mcp-servers/devtool-mcp-server/src/index.ts b/packages/mcp-servers/devtool-mcp-server/src/index.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/index.ts rename to packages/mcp-servers/devtool-mcp-server/src/index.ts diff --git a/mcp-servers/devtool-mcp-server/src/main.ts b/packages/mcp-servers/devtool-mcp-server/src/main.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/main.ts rename to packages/mcp-servers/devtool-mcp-server/src/main.ts diff --git a/mcp-servers/devtool-mcp-server/src/schema/index.ts b/packages/mcp-servers/devtool-mcp-server/src/schema/index.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/schema/index.ts rename to packages/mcp-servers/devtool-mcp-server/src/schema/index.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts diff --git a/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/src/tools/defineTool.ts rename to packages/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts diff --git a/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts b/packages/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/McpResponse.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/AGENTS.md b/packages/mcp-servers/devtool-mcp-server/test/tools/AGENTS.md similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/AGENTS.md rename to packages/mcp-servers/devtool-mcp-server/test/tools/AGENTS.md diff --git a/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts diff --git a/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts rename to packages/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts diff --git a/mcp-servers/devtool-mcp-server/test/utils/cdp-types.ts b/packages/mcp-servers/devtool-mcp-server/test/utils/cdp-types.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/utils/cdp-types.ts rename to packages/mcp-servers/devtool-mcp-server/test/utils/cdp-types.ts diff --git a/mcp-servers/devtool-mcp-server/test/utils/testTool.ts b/packages/mcp-servers/devtool-mcp-server/test/utils/testTool.ts similarity index 100% rename from mcp-servers/devtool-mcp-server/test/utils/testTool.ts rename to packages/mcp-servers/devtool-mcp-server/test/utils/testTool.ts diff --git a/mcp-servers/devtool-mcp-server/tsconfig.json b/packages/mcp-servers/devtool-mcp-server/tsconfig.json similarity index 100% rename from mcp-servers/devtool-mcp-server/tsconfig.json rename to packages/mcp-servers/devtool-mcp-server/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae1ebb8..1fbd67f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,7 +104,45 @@ importers: specifier: ^2.8.2 version: 2.8.2 - mcp-servers/devtool-connector: + packages/cmd/build-marketplace: + dependencies: + npm-packlist: + specifier: ^5.1.3 + version: 5.1.3 + devDependencies: + '@rslib/core': + specifier: catalog:rstack + version: 0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.10.8))(typescript@5.9.3) + '@types/node': + specifier: '24' + version: 24.10.8 + build-plugin: + specifier: workspace:* + version: link:../build-plugin + commander: + specifier: '14' + version: 14.0.3 + zx: + specifier: 8.8.5 + version: 8.8.5 + + packages/cmd/build-plugin: + dependencies: + npm-packlist: + specifier: ^5.1.3 + version: 5.1.3 + devDependencies: + '@rslib/core': + specifier: catalog:rstack + version: 0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.10.8))(typescript@5.9.3) + '@types/node': + specifier: '24' + version: 24.10.8 + commander: + specifier: '14' + version: 14.0.3 + + packages/mcp-servers/devtool-connector: dependencies: ws: specifier: ^8.21.0 @@ -141,7 +179,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - mcp-servers/devtool-mcp-server: + packages/mcp-servers/devtool-mcp-server: dependencies: '@lynx-js/devtool-connector': specifier: workspace:* @@ -166,44 +204,6 @@ importers: specifier: ^3.25.76 version: 3.25.76 - packages/cmd/build-marketplace: - dependencies: - npm-packlist: - specifier: ^5.1.3 - version: 5.1.3 - devDependencies: - '@rslib/core': - specifier: catalog:rstack - version: 0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.10.8))(typescript@5.9.3) - '@types/node': - specifier: '24' - version: 24.10.8 - build-plugin: - specifier: workspace:* - version: link:../build-plugin - commander: - specifier: '14' - version: 14.0.3 - zx: - specifier: 8.8.5 - version: 8.8.5 - - packages/cmd/build-plugin: - dependencies: - npm-packlist: - specifier: ^5.1.3 - version: 5.1.3 - devDependencies: - '@rslib/core': - specifier: catalog:rstack - version: 0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.10.8))(typescript@5.9.3) - '@types/node': - specifier: '24' - version: 24.10.8 - commander: - specifier: '14' - version: 14.0.3 - packages/plugins/lynx-debug: dependencies: '@lynx-js/skill-lynx-debug-info-remapping': @@ -252,7 +252,7 @@ importers: devDependencies: '@lynx-js/devtool-connector': specifier: workspace:* - version: link:../../../mcp-servers/devtool-connector + version: link:../../mcp-servers/devtool-connector '@rslib/core': specifier: catalog:rstack version: 0.19.2(@microsoft/api-extractor@7.56.2(@types/node@24.10.10))(typescript@5.9.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 516a9f5..0f73a9f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,7 @@ packages: - packages/cmd/* - packages/skills/* - packages/plugins/* - - mcp-servers/* + - packages/mcp-servers/* catalogs: rstack: From 6aab941a38c2ecf878152d7dfa9af61bd64568e4 Mon Sep 17 00:00:00 2001 From: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:13:09 +0800 Subject: [PATCH 4/5] test(lynx-devtool): enable reactlynx e2e against public bundle - Add reactlynx e2e to the skill package, driving the full init -> refresh -> operation_v2 -> inspect -> update-prop pipeline against a real ReactLynx page. - Use the public @lynx-example/react-devtool bundle (ships @lynx-js/preact-devtools) so the test runs on EmbeddedLynx; the former EmbeddedLynx skip is removed. - Match 'Provider' (present in the minified bundle) instead of the internal-specific 'view' component name. - Extend the e2e workflow to launch EmbeddedLynx twice: the Swiper bundle for connector/mcp-server tools, then the ReactLynx bundle for the reactlynx suite. Factor the port-readiness wait into a shared .github/scripts/wait-debug-router.sh. --- .github/scripts/wait-debug-router.sh | 21 ++ .github/workflows/lynx-devtool.yml | 74 +++--- .../skills/lynx-devtool/e2e/reactlynx.test.ts | 213 ++++++++++++++++++ packages/skills/lynx-devtool/package.json | 3 +- 4 files changed, 282 insertions(+), 29 deletions(-) create mode 100755 .github/scripts/wait-debug-router.sh create mode 100644 packages/skills/lynx-devtool/e2e/reactlynx.test.ts diff --git a/.github/scripts/wait-debug-router.sh b/.github/scripts/wait-debug-router.sh new file mode 100755 index 0000000..ec209c4 --- /dev/null +++ b/.github/scripts/wait-debug-router.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Wait until the EmbeddedLynx debug-router is listening on 127.0.0.1:8901. +# Usage: wait-debug-router.sh +set -euo pipefail + +LOG_FILE="${1:-}" + +for _ in $(seq 1 30); do + if (exec 3<>/dev/tcp/127.0.0.1/8901) 2>/dev/null; then + exec 3>&- 3<&- + echo "EmbeddedLynx debug-router is up on port 8901" + exit 0 + fi + sleep 1 +done + +echo "EmbeddedLynx did not open port 8901 in time" >&2 +if [ -n "$LOG_FILE" ] && [ -f "$LOG_FILE" ]; then + tail -n 50 "$LOG_FILE" >&2 || true +fi +exit 1 diff --git a/.github/workflows/lynx-devtool.yml b/.github/workflows/lynx-devtool.yml index bb174c3..ae9d240 100644 --- a/.github/workflows/lynx-devtool.yml +++ b/.github/workflows/lynx-devtool.yml @@ -19,15 +19,18 @@ on: env: EMBEDDED_LYNX_TARBALL: https://github.com/lynx-community/skills/releases/download/embedded-lynx-202606041609/embedded-lynx-linux-x86_64.tar.gz + EMBEDDED_LYNX_BINARY: /tmp/embedded-lynx-linux-x86_64/embedded_lynx LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS: EmbeddedLynx LYNX_DEVTOOL_MCP_TESTING_APP_PACKAGE: EmbeddedLynx - LYNX_DEVTOOL_MCP_TESTING_PAGE_URL: https://lynxjs.org/lynx-examples/swiper/dist/Swiper.lynx.bundle - LYNX_DEVTOOL_MCP_TESTING_OPEN_URL: https://lynxjs.org/lynx-examples/swiper/dist/Swiper.lynx.bundle + # Generic Lynx page for connector / mcp-server tools. + SWIPER_BUNDLE_URL: https://lynxjs.org/lynx-examples/swiper/dist/Swiper.lynx.bundle + # ReactLynx page bundled with @lynx-js/preact-devtools for reactlynx tooling. + REACT_DEVTOOL_BUNDLE_URL: https://unpkg.com/@lynx-example/react-devtool@0.2.0/dist/main.lynx.bundle jobs: e2e: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@v5 @@ -44,50 +47,65 @@ jobs: - name: Install Dependencies run: pnpm install - - name: Start EmbeddedLynx runtime + - name: Download EmbeddedLynx run: | set -euo pipefail curl -sL "$EMBEDDED_LYNX_TARBALL" | tar -xzf - -C /tmp - BINARY="/tmp/embedded-lynx-linux-x86_64/embedded_lynx" - chmod +x "$BINARY" - # Launch the headless Lynx runtime in its own session so it survives - # the end of this step. It loads the page and exposes a debug-router - # on 127.0.0.1:8901 that DesktopTransport (EmbeddedLynx) connects to. - setsid "$BINARY" --url "$LYNX_DEVTOOL_MCP_TESTING_PAGE_URL" \ - < /dev/null > /tmp/embedded-lynx.log 2>&1 & - echo "EMBEDDED_LYNX_PID=$!" >> "$GITHUB_ENV" + chmod +x "$EMBEDDED_LYNX_BINARY" + + # --- Generic tools: connector + mcp-server against the Swiper bundle --- - - name: Wait for debug-router port + - name: Start EmbeddedLynx (Swiper bundle) run: | set -euo pipefail - for i in $(seq 1 30); do - if (exec 3<>/dev/tcp/127.0.0.1/8901) 2>/dev/null; then - exec 3>&- 3<&- - echo "EmbeddedLynx debug-router is up on port 8901" - exit 0 - fi - sleep 1 - done - echo "EmbeddedLynx did not open port 8901 in time" >&2 - cat /tmp/embedded-lynx.log >&2 || true - exit 1 + setsid "$EMBEDDED_LYNX_BINARY" --url "$SWIPER_BUNDLE_URL" \ + < /dev/null > /tmp/embedded-lynx-swiper.log 2>&1 & + echo "EMBEDDED_LYNX_PID=$!" >> "$GITHUB_ENV" + ./.github/scripts/wait-debug-router.sh /tmp/embedded-lynx-swiper.log - name: E2E (devtool-connector) working-directory: packages/mcp-servers/devtool-connector + env: + LYNX_DEVTOOL_MCP_TESTING_PAGE_URL: ${{ env.SWIPER_BUNDLE_URL }} + LYNX_DEVTOOL_MCP_TESTING_OPEN_URL: ${{ env.SWIPER_BUNDLE_URL }} run: node --test --test-concurrency=1 'e2e/**/*.test.ts' - name: E2E (devtool-mcp-server) working-directory: packages/mcp-servers/devtool-mcp-server + env: + LYNX_DEVTOOL_MCP_TESTING_PAGE_URL: ${{ env.SWIPER_BUNDLE_URL }} + LYNX_DEVTOOL_MCP_TESTING_OPEN_URL: ${{ env.SWIPER_BUNDLE_URL }} run: node --test --test-concurrency=1 'e2e/**/*.test.ts' - - name: Stop EmbeddedLynx runtime + - name: Stop EmbeddedLynx (Swiper bundle) if: always() run: kill "${EMBEDDED_LYNX_PID}" 2>/dev/null || true - - name: Upload EmbeddedLynx log + # --- ReactLynx: skill against the preact-devtools-enabled bundle --- + + - name: Start EmbeddedLynx (ReactLynx bundle) + run: | + set -euo pipefail + setsid "$EMBEDDED_LYNX_BINARY" --url "$REACT_DEVTOOL_BUNDLE_URL" \ + < /dev/null > /tmp/embedded-lynx-reactlynx.log 2>&1 & + echo "EMBEDDED_LYNX_REACT_PID=$!" >> "$GITHUB_ENV" + ./.github/scripts/wait-debug-router.sh /tmp/embedded-lynx-reactlynx.log + + - name: E2E (skill reactlynx) + working-directory: packages/skills/lynx-devtool + env: + LYNX_DEVTOOL_MCP_TESTING_PAGE_URL: ${{ env.REACT_DEVTOOL_BUNDLE_URL }} + LYNX_DEVTOOL_MCP_TESTING_OPEN_URL: ${{ env.REACT_DEVTOOL_BUNDLE_URL }} + run: node --test --test-concurrency=1 'e2e/**/*.test.ts' + + - name: Stop EmbeddedLynx (ReactLynx bundle) + if: always() + run: kill "${EMBEDDED_LYNX_REACT_PID}" 2>/dev/null || true + + - name: Upload EmbeddedLynx logs if: failure() uses: actions/upload-artifact@v4 with: - name: embedded-lynx-log - path: /tmp/embedded-lynx.log + name: embedded-lynx-logs + path: /tmp/embedded-lynx-*.log if-no-files-found: ignore diff --git a/packages/skills/lynx-devtool/e2e/reactlynx.test.ts b/packages/skills/lynx-devtool/e2e/reactlynx.test.ts new file mode 100644 index 0000000..dbbb328 --- /dev/null +++ b/packages/skills/lynx-devtool/e2e/reactlynx.test.ts @@ -0,0 +1,213 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Integration test for `lynx-devtool reactlynx tree`. + * + * Drives the full wire protocol against a real ReactLynx page running on a + * connected client, using the standard `testWithClient` harness so the test + * runs across every supported transport. + * + * The page bundle pinned via `LYNX_DEVTOOL_MCP_TESTING_PAGE_URL` is built from + * a ReactLynx app that ships an upgraded `@lynx-js/preact-devtools` containing + * the `document.body` and `preactDevtoolsCtx.Node` fixes -- the App therefore + * honors `refresh` and the test can assert the full + * init -> refresh -> operation_v2 -> root-order pipeline. + * + * Default page URL: + * https://unpkg.com/@lynx-example/react-devtool@0.2.0/dist/main.lynx.bundle + * + * Override per-environment with `LYNX_DEVTOOL_MCP_TESTING_PAGE_URL`. + */ + +import { getTestingSession, testWithClient } from "@lynx-js/devtool-connector/test-with-client"; +import type { TestContext } from "node:test"; +import { buildSubstringMatcher, findComponents } from "../src/commands/reactlynx/find.ts"; +import { formatTree } from "../src/commands/reactlynx/format.ts"; +import type { RendererState } from "../src/commands/reactlynx/protocol.ts"; +import { buildOutboundFrame, type PreactEnvelope, runReactLynxSession } from "../src/commands/reactlynx/transport.ts"; + +const run = testWithClient; + +run("reactlynx tree", async (t, connector, client, target) => { + let capturedTree: RendererState | undefined; + + const session = await getTestingSession(connector, client.id); + + await t.test("init + refresh produces a non-empty tree", async (t: TestContext) => { + const collected = await runReactLynxSession({ + connector, + clientId: client.id, + sessionId: session.session_id, + outbound: [buildOutboundFrame("refresh")], + idleMs: 1_000, + maxMs: 15_000, + signal: t.signal, + }); + + t.assert.ok( + collected.framesSeen > 0, + `Expected at least one PreactDevtools frame from page ${target.pageUrl}; saw 0. ` + + `Verify the page bundle contains a recent @lynx-js/preact-devtools dev build.`, + ); + t.assert.ok( + collected.operationFrames > 0, + `Expected at least one operation_v2 frame after refresh; saw types=${[...collected.envelopeTypes].join(",")}. ` + + `If only root-order/root-order-page arrived, the App is running an outdated ` + + `@lynx-js/preact-devtools without the PR #2 / PR #5 fixes.`, + ); + t.assert.ok( + collected.rootOrderFrames > 0, + `Expected at least one root-order frame after refresh; saw types=${[...collected.envelopeTypes].join(",")}`, + ); + + t.assert.ok( + collected.state.tree.size > 0, + "Decoded tree must not be empty after a successful operation_v2 mount", + ); + t.assert.ok( + collected.state.roots.length > 0, + "Decoded tree must report at least one root", + ); + + const formatted = formatTree(collected.state, { hideShells: true }); + t.assert.ok(formatted.text.length > 0, "Formatted tree must be non-empty"); + t.assert.ok( + formatted.text.startsWith("@c1 ["), + `Formatted tree must start with @c1; got: ${formatted.text.split("\n")[0]}`, + ); + t.assert.ok( + formatted.labels.length > 0, + "Formatted tree must expose at least one @cN label", + ); + t.assert.equal( + formatted.labels.length, + formatted.text.split("\n").length, + "Every visible printed line must correspond to one @cN label", + ); + + t.diagnostic( + `frames=${collected.framesSeen} operation_v2=${collected.operationFrames} ` + + `root-order=${collected.rootOrderFrames} types=${ + [...collected.envelopeTypes].sort().join(",") + } treeSize=${collected.state.tree.size}`, + ); + t.diagnostic(`first line: ${formatted.text.split("\n")[0]}`); + + capturedTree = collected.state; + }); + + await t.test("findComponents finds at least one match in the live tree", async (t: TestContext) => { + if (!capturedTree) { + t.skip("tree snapshot not captured (preceding subtest failed)"); + return; + } + const matches = findComponents(capturedTree, buildSubstringMatcher("Provider"), { + hideShells: true, + limit: 50, + }); + t.assert.ok( + matches.length > 0, + "Expected at least one component containing 'Provider' in the bundle", + ); + for (const match of matches) { + t.assert.match(match.label, /^@c\d+$/, `match.label must be @cN form, got ${match.label}`); + t.assert.ok(match.name.toLowerCase().includes("provider")); + } + t.diagnostic(`find matches: ${matches.map((m) => `${m.label} ${m.name}`).join(", ")}`); + }); + + await t.test("inspect round-trip returns InspectData for first labelled component", async (t: TestContext) => { + if (!capturedTree) { + t.skip("tree snapshot not captured (preceding subtest failed)"); + return; + } + const labels = formatTree(capturedTree, { hideShells: true }).labels; + const targetId = labels[0]; + t.assert.ok(targetId !== undefined, "tree must expose at least one labelled root"); + if (targetId === undefined) return; + + let inspectData: { id: number; name: string; props: unknown } | undefined; + await runReactLynxSession({ + connector, + clientId: client.id, + sessionId: session.session_id, + outbound: [buildOutboundFrame("inspect", targetId)], + idleMs: 1_000, + maxMs: 5_000, + signal: t.signal, + onEnvelope: (envelope: PreactEnvelope) => { + if (envelope.type !== "inspect-result") return "continue"; + inspectData = envelope.data as typeof inspectData; + return "stop"; + }, + }); + + t.assert.ok( + inspectData !== undefined, + `Expected an inspect-result frame for id ${targetId} within 5s`, + ); + if (!inspectData) return; + t.assert.equal(inspectData.id, targetId, "inspect-result.id must match the requested id"); + t.assert.equal(typeof inspectData.name, "string"); + t.assert.ok(inspectData.name.length > 0, "inspect-result.name must be non-empty"); + t.diagnostic(`inspect-result: id=${inspectData.id} name=${inspectData.name}`); + }); + + await t.test("update-prop round-trip applies the new value and confirms via inspect-result", async (t: TestContext) => { + if (!capturedTree) { + t.skip("tree snapshot not captured (preceding subtest failed)"); + return; + } + const labels = formatTree(capturedTree, { hideShells: true }).labels; + const targetId = labels[0]; + t.assert.ok(targetId !== undefined, "tree must expose at least one labelled root"); + if (targetId === undefined) return; + + const TEST_KEY = "__lynxDevtoolUpdatePropTest"; + const TEST_VALUE = `marker-${Date.now()}`; + + let confirmed: { id: number; props: Record } | undefined; + await runReactLynxSession({ + connector, + clientId: client.id, + sessionId: session.session_id, + outbound: [ + buildOutboundFrame("update-prop", { + id: targetId, + path: `root.${TEST_KEY}`, + value: TEST_VALUE, + }), + ], + idleMs: 1_000, + maxMs: 5_000, + signal: t.signal, + onEnvelope: (envelope: PreactEnvelope) => { + if (envelope.type !== "inspect-result") return "continue"; + const candidate = envelope.data as { id?: number; props?: Record }; + if (candidate.id !== targetId) return "continue"; + confirmed = candidate as typeof confirmed; + return "stop"; + }, + }); + + t.assert.ok( + confirmed !== undefined, + `Expected an inspect-result for id ${targetId} after update-prop within 5s`, + ); + if (!confirmed) return; + t.assert.ok( + confirmed.props && typeof confirmed.props === "object", + "post-update inspect-result.props must be an object", + ); + t.assert.equal( + confirmed.props[TEST_KEY], + TEST_VALUE, + `post-update inspect-result.props.${TEST_KEY} must equal the value we sent (${TEST_VALUE})`, + ); + t.diagnostic( + `update-prop confirmed: id=${confirmed.id} props.${TEST_KEY}=${JSON.stringify(confirmed.props[TEST_KEY])}`, + ); + }); +}); diff --git a/packages/skills/lynx-devtool/package.json b/packages/skills/lynx-devtool/package.json index 4d04935..4ce2aa1 100644 --- a/packages/skills/lynx-devtool/package.json +++ b/packages/skills/lynx-devtool/package.json @@ -11,7 +11,8 @@ "examples.md" ], "scripts": { - "build": "rslib build" + "build": "rslib build", + "test:e2e": "node --test --test-concurrency=1 'e2e/**/*.test.ts'" }, "devDependencies": { "@lynx-js/devtool-connector": "workspace:*", From 39b3a4986eef217559dd87522be0dc8fbc4d812f Mon Sep 17 00:00:00 2001 From: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:43:03 +0800 Subject: [PATCH 5/5] style: apply biome/eslint formatting and add changeset - Format all migrated devtool files to the skills repo style (single quotes, no trailing commas) via biome. - Remove eslint-disable references to the eslint-plugin-n rules (n/no-unsupported-features/node-builtins, n/no-process-exit) that are not configured in this repo; fix no-explicit-any / no-unused-vars in migrated test utilities. - Add a changeset for the three devtool packages. --- .changeset/add-lynx-devtool-packages.md | 7 + .changeset/config.json | 6 +- .../devtool-connector/e2e/index.test.ts | 406 ++++--- .../public/inspector-wrapper.html | 2 +- .../devtool-connector/rslib.config.ts | 22 +- .../devtool-connector/src/client-id.ts | 6 +- .../src/daemon/device-connection.ts | 55 +- .../devtool-connector/src/daemon/entry.ts | 28 +- .../devtool-connector/src/daemon/index.ts | 2 +- .../devtool-connector/src/daemon/manager.ts | 94 +- .../devtool-connector/src/daemon/protocol.ts | 63 +- .../devtool-connector/src/daemon/server.ts | 272 +++-- .../src/daemon/static-server.ts | 111 +- .../src/daemon/tarball-cache.ts | 57 +- .../devtool-connector/src/daemon/version.ts | 2 +- .../devtool-connector/src/index.ts | 425 ++++--- .../devtool-connector/src/streams/cdp.ts | 39 +- .../src/streams/customized.ts | 53 +- .../devtool-connector/src/streams/index.ts | 6 +- .../devtool-connector/src/streams/utils.ts | 18 +- .../devtool-connector/src/takeover.ts | 25 +- .../src/transport/android.ts | 92 +- .../devtool-connector/src/transport/base.ts | 64 +- .../devtool-connector/src/transport/daemon.ts | 165 ++- .../src/transport/desktop.ts | 37 +- .../devtool-connector/src/transport/index.ts | 18 +- .../devtool-connector/src/transport/ios.ts | 55 +- .../src/transport/peertalk.ts | 20 +- .../src/transport/transport.ts | 19 +- .../devtool-connector/src/transport/usbmux.ts | 84 +- .../src/transport/ws-stream.ts | 30 +- .../devtool-connector/src/types.ts | 277 +++-- .../devtool-connector/test/clientId.test.ts | 63 +- .../test/connector-lifecycle.test.ts | 45 +- .../test/daemon-connect-timeout.test.ts | 22 +- .../test/daemon-device-connection.test.ts | 178 ++- .../test/daemon-manager.test.ts | 50 +- .../test/daemon-protocol.test.ts | 86 +- .../test/daemon-server.test.ts | 655 +++++++---- .../test/daemon-transport.test.ts | 165 +-- .../devtool-connector/test/ios.test.ts | 154 +-- .../test/list-clients-fallback.test.ts | 18 +- .../test/list-clients-setup.test.ts | 115 +- .../test/open-app-daemon.test.ts | 59 +- .../test/testWithClient.test.ts | 54 +- .../devtool-connector/test/testWithClient.ts | 101 +- .../test/transport-selection.test.ts | 70 +- .../devtool-connector/test/usbmux.test.ts | 76 +- .../devtool-connector/tsconfig.json | 4 +- .../devtool-mcp-server/e2e/tools.test.ts | 1044 +++++++++++------ .../devtool-mcp-server/rslib.config.ts | 25 +- .../devtool-mcp-server/src/McpContext.ts | 4 +- .../devtool-mcp-server/src/McpResponse.ts | 19 +- .../devtool-mcp-server/src/connector.ts | 8 +- .../devtool-mcp-server/src/index.ts | 138 +-- .../devtool-mcp-server/src/main.ts | 24 +- .../devtool-mcp-server/src/schema/index.ts | 89 +- .../src/tools/App/GetGlobalSwitch.ts | 10 +- .../src/tools/App/ListGlobalSwitch.ts | 13 +- .../src/tools/App/SetGlobalSwitch.ts | 18 +- .../src/tools/App/globalSwitch.ts | 36 +- .../src/tools/CSS/GetBackgroundColors.ts | 25 +- .../src/tools/CSS/GetComputedStyleForNode.ts | 26 +- .../src/tools/CSS/GetInlineStylesForNode.ts | 25 +- .../src/tools/CSS/GetMatchedStylesForNode.ts | 25 +- .../src/tools/CSS/GetStyleSheetText.ts | 25 +- .../src/tools/DOM/DescribeNode.ts | 40 +- .../src/tools/DOM/GetAttributes.ts | 25 +- .../src/tools/DOM/GetBoxModel.ts | 25 +- .../src/tools/DOM/GetDocument.ts | 12 +- .../src/tools/DOM/GetDocumentWithBoxModel.ts | 17 +- .../src/tools/DOM/GetNodeForLocation.ts | 21 +- .../src/tools/DOM/GetOriginalNodeIndex.ts | 25 +- .../src/tools/DOM/GetSearchResults.ts | 35 +- .../src/tools/DOM/InnerText.ts | 25 +- .../src/tools/DOM/PerformSearch.ts | 32 +- .../DOM/PushNodesByBackendIdsToFrontend.ts | 25 +- .../src/tools/DOM/QuerySelector.ts | 35 +- .../src/tools/DOM/QuerySelectorAll.ts | 35 +- .../src/tools/DOM/RequestChildNodes.ts | 35 +- .../src/tools/DOM/ScrollIntoViewIfNeeded.ts | 36 +- .../src/tools/DOM/SetAttributesAsText.ts | 24 +- .../src/tools/Debugger/GetScriptSource.ts | 46 +- .../src/tools/Debugger/ListScripts.ts | 30 +- .../src/tools/Device/ClosePage.ts | 10 +- .../src/tools/Device/ListClients.ts | 7 +- .../src/tools/Device/ListDevices.ts | 18 +- .../src/tools/Device/ListSessions.ts | 8 +- .../src/tools/Device/OpenPage.ts | 50 +- .../tools/HeapProfiler/TakeHeapSnapshot.ts | 114 +- .../tools/Input/EmulateTouchFromMouseEvent.ts | 70 +- .../src/tools/Lynx/GetVersion.ts | 15 +- .../src/tools/Memory/GetAllMemoryUsage.ts | 18 +- .../src/tools/Page/GetResourceContent.ts | 36 +- .../src/tools/Page/GetResourceTree.ts | 15 +- .../src/tools/Page/Reload.ts | 37 +- .../src/tools/Page/TakeScreenshot.ts | 42 +- .../Performance/GetAllPerformanceEntries.ts | 12 +- .../src/tools/Performance/GetAllTimingInfo.ts | 12 +- .../src/tools/Runtime/Evaluate.ts | 77 +- .../src/tools/Runtime/GetHeapUsage.ts | 16 +- .../src/tools/Runtime/GetProperties.ts | 52 +- .../src/tools/Runtime/ListConsole.ts | 116 +- .../src/tools/UITree/GetLynxUITree.ts | 16 +- .../src/tools/defineTool.ts | 11 +- .../test/McpResponse.test.ts | 16 +- .../test/tools/App_globalSwitch.test.ts | 107 +- .../test/tools/DOM_describeNode.test.ts | 107 +- .../test/tools/DOM_getDocument.test.ts | 42 +- .../tools/DOM_setAttributesAsText.test.ts | 28 +- .../test/tools/Device_openPage.test.ts | 71 +- .../HeapProfiler_takeHeapSnapshot.test.ts | 159 ++- .../tools/Memory_getAllMemoryUsage.test.ts | 62 +- .../tools/Page_Lynx_resourceVersion.test.ts | 106 +- ...rformance_getAllPerformanceEntries.test.ts | 51 +- .../Runtime_evaluate_getProperties.test.ts | 107 +- .../test/tools/Runtime_getHeapUsage.test.ts | 86 +- .../test/tools/UITree_getLynxUITree.test.ts | 46 +- .../test/tools/proxyTestUtils.ts | 24 +- .../devtool-mcp-server/test/utils/testTool.ts | 29 +- .../devtool-mcp-server/tsconfig.json | 4 +- .../skills/lynx-devtool/e2e/reactlynx.test.ts | 419 ++++--- packages/skills/lynx-devtool/rslib.config.ts | 35 +- .../skills/lynx-devtool/src/commands/app.ts | 17 +- .../skills/lynx-devtool/src/commands/cdp.ts | 41 +- .../lynx-devtool/src/commands/get-console.ts | 122 +- .../lynx-devtool/src/commands/get-sources.ts | 36 +- .../src/commands/global-switch.ts | 87 +- .../lynx-devtool/src/commands/inspect.ts | 22 +- .../lynx-devtool/src/commands/list-clients.ts | 37 +- .../src/commands/list-sessions.ts | 18 +- .../skills/lynx-devtool/src/commands/open.ts | 47 +- .../src/commands/reactlynx/find.ts | 77 +- .../src/commands/reactlynx/format.ts | 36 +- .../src/commands/reactlynx/index.ts | 23 +- .../src/commands/reactlynx/inspect.ts | 109 +- .../src/commands/reactlynx/protocol.ts | 34 +- .../src/commands/reactlynx/transport.ts | 92 +- .../src/commands/reactlynx/tree.ts | 49 +- .../src/commands/reactlynx/update.ts | 135 ++- .../src/commands/recorder-analysis.ts | 63 +- .../lynx-devtool/src/commands/recorder-end.ts | 206 ++-- .../src/commands/recorder-start.ts | 59 +- .../src/commands/take-heap-snapshot.ts | 144 ++- .../src/commands/take-screenshot.ts | 66 +- .../skills/lynx-devtool/src/commands/utils.ts | 101 +- packages/skills/lynx-devtool/src/connector.ts | 18 +- packages/skills/lynx-devtool/src/devtool.ts | 65 +- packages/skills/lynx-devtool/src/index.ts | 6 +- 149 files changed, 6418 insertions(+), 4095 deletions(-) create mode 100644 .changeset/add-lynx-devtool-packages.md diff --git a/.changeset/add-lynx-devtool-packages.md b/.changeset/add-lynx-devtool-packages.md new file mode 100644 index 0000000..693b12f --- /dev/null +++ b/.changeset/add-lynx-devtool-packages.md @@ -0,0 +1,7 @@ +--- +"@lynx-js/devtool-connector": minor +"@lynx-js/devtool-mcp-server": minor +"@lynx-js/skill-lynx-devtool": minor +--- + +Add the Lynx DevTool packages: `@lynx-js/devtool-connector` (device transport layer with background daemon), `@lynx-js/devtool-mcp-server` (MCP server exposing DOM/CSS/Runtime/Performance and more tools), and an updated `@lynx-js/skill-lynx-devtool` skill with screenshot, console, heap snapshot, recorder, and ReactLynx inspection commands. diff --git a/.changeset/config.json b/.changeset/config.json index 6d13450..fdd5b04 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -3,7 +3,11 @@ "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [ - ["@lynx-js/devtool-connector", "@lynx-js/devtool-mcp-server", "@lynx-js/skill-lynx-devtool"] + [ + "@lynx-js/devtool-connector", + "@lynx-js/devtool-mcp-server", + "@lynx-js/skill-lynx-devtool" + ] ], "linked": [], "access": "public", diff --git a/packages/mcp-servers/devtool-connector/e2e/index.test.ts b/packages/mcp-servers/devtool-connector/e2e/index.test.ts index b0d89d5..82eb246 100644 --- a/packages/mcp-servers/devtool-connector/e2e/index.test.ts +++ b/packages/mcp-servers/devtool-connector/e2e/index.test.ts @@ -2,10 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { ReadableStream } from "node:stream/web"; -import type { TestContext } from "node:test"; -import { ClientId, type Connector, type GlobalKeys } from "../src/index.ts"; -import { getTestingSession, testWithClient } from "../test/testWithClient.ts"; +import { ReadableStream } from 'node:stream/web'; +import type { TestContext } from 'node:test'; +import { ClientId, type Connector, type GlobalKeys } from '../src/index.ts'; +import { getTestingSession, testWithClient } from '../test/testWithClient.ts'; const GLOBAL_SWITCH_READY_TIMEOUT_MS = 5_000; const GLOBAL_SWITCH_READY_POLL_INTERVAL_MS = 250; @@ -18,7 +18,7 @@ async function waitForGlobalSwitch( expected: boolean, message: string, ): Promise { - const { setTimeout } = await import("node:timers/promises"); + const { setTimeout } = await import('node:timers/promises'); const deadline = Date.now() + GLOBAL_SWITCH_READY_TIMEOUT_MS; let lastValue: boolean | undefined; let lastError: unknown; @@ -36,19 +36,20 @@ async function waitForGlobalSwitch( await setTimeout(GLOBAL_SWITCH_READY_POLL_INTERVAL_MS); } - const lastErrorMessage = lastError instanceof Error ? lastError.message : String(lastError); + const lastErrorMessage = + lastError instanceof Error ? lastError.message : String(lastError); t.assert.fail( - `${message}; expected ${key}=${expected}, last value: ${lastValue ?? "unavailable"}, last error: ${ - lastError === undefined ? "none" : lastErrorMessage + `${message}; expected ${key}=${expected}, last value: ${lastValue ?? 'unavailable'}, last error: ${ + lastError === undefined ? 'none' : lastErrorMessage }`, ); } -testWithClient("Connector", async (t, connector, client, testingTarget) => { +testWithClient('Connector', async (t, connector, client, testingTarget) => { const clientId = client.id; - await t.test("sendMessage", async (t) => { - await t.test("ListSession", async (t: TestContext) => { + await t.test('sendMessage', async (t) => { + await t.test('ListSession', async (t: TestContext) => { await getTestingSession(connector, clientId); const response = await connector.sendListSessionMessage(clientId); @@ -57,22 +58,28 @@ testWithClient("Connector", async (t, connector, client, testingTarget) => { t.assert.ok(response.length > 0); }); - await t.test("listClients", async (t) => { + await t.test('listClients', async (t) => { const clients = await connector.listClients(); t.assert.equal(Array.isArray(clients), true); - t.assert.equal(clients.every(client => ClientId.deserialize(client.id) !== null), true); - t.assert.equal(clients.some(({ id }) => id === clientId), true); + t.assert.equal( + clients.every((client) => ClientId.deserialize(client.id) !== null), + true, + ); + t.assert.equal( + clients.some(({ id }) => id === clientId), + true, + ); }); - await t.test("listClients should enable devtool", async (t) => { - await connector.setGlobalSwitch(clientId, "enable_devtool", false); + await t.test('listClients should enable devtool', async (t) => { + await connector.setGlobalSwitch(clientId, 'enable_devtool', false); await waitForGlobalSwitch( t, connector, clientId, - "enable_devtool", + 'enable_devtool', false, - "setGlobalSwitch should disable devtool before listClients setup", + 'setGlobalSwitch should disable devtool before listClients setup', ); await connector.listClients(); @@ -81,107 +88,138 @@ testWithClient("Connector", async (t, connector, client, testingTarget) => { t, connector, clientId, - "enable_devtool", + 'enable_devtool', true, - "listClients should enable devtool", + 'listClients should enable devtool', ); }); }); - await t.test("sendAppMessage", { - skip: testingTarget.appPackageName === "com.lynx.explorer" - ? false - : "The host app may not expose `App.*` methods", - }, async (t) => { - await t.test("App.openPage and App.closePage round-trip", { - skip: testingTarget.appPackageName === "com.lynx.explorer" - ? "App keeps the Lynx session alive after App.closePage" - : false, - }, async (t) => { - const initialSessions = await connector.sendListSessionMessage(clientId); - - await connector.sendAppMessage(clientId, "App.openPage", { url: testingTarget.openUrl }); - - const { setTimeout } = await import("node:timers/promises"); - let sessions = await connector.sendListSessionMessage(clientId); - for (let i = 0; i < 10 && sessions.length <= initialSessions.length; i++) { - await setTimeout(500); - sessions = await connector.sendListSessionMessage(clientId); - } - - t.assert.ok( - sessions.length > initialSessions.length, - `App.openPage should create a new session (before: ${initialSessions.length}, after: ${sessions.length})`, - ); - - for (let i = 0; i < sessions.length; i++) { - await connector.sendAppMessage(clientId, "App.closePage", {}); - } - - await setTimeout(1000); - - const afterClose = await connector.sendListSessionMessage(clientId); - t.assert.equal(afterClose.length, 0, "App.closePage should remove all sessions"); - - await connector.sendAppMessage(clientId, "App.openPage", { url: testingTarget.openUrl }); - await setTimeout(1000); - }); - - // TODO(Android): need restart App - await t.test("App.setBOE on", { skip: true }, async (t: TestContext) => { - await connector.sendAppMessage( - clientId, - "App.setBOE", - { value: "prod", switch: true }, - ); - const result = await connector.sendAppMessage<{ switch: string; value: string }>( - clientId, - "App.getBOE", + await t.test( + 'sendAppMessage', + { + skip: + testingTarget.appPackageName === 'com.lynx.explorer' + ? false + : 'The host app may not expose `App.*` methods', + }, + async (t) => { + await t.test( + 'App.openPage and App.closePage round-trip', + { + skip: + testingTarget.appPackageName === 'com.lynx.explorer' + ? 'App keeps the Lynx session alive after App.closePage' + : false, + }, + async (t) => { + const initialSessions = + await connector.sendListSessionMessage(clientId); + + await connector.sendAppMessage(clientId, 'App.openPage', { + url: testingTarget.openUrl, + }); + + const { setTimeout } = await import('node:timers/promises'); + let sessions = await connector.sendListSessionMessage(clientId); + for ( + let i = 0; + i < 10 && sessions.length <= initialSessions.length; + i++ + ) { + await setTimeout(500); + sessions = await connector.sendListSessionMessage(clientId); + } + + t.assert.ok( + sessions.length > initialSessions.length, + `App.openPage should create a new session (before: ${initialSessions.length}, after: ${sessions.length})`, + ); + + for (let i = 0; i < sessions.length; i++) { + await connector.sendAppMessage(clientId, 'App.closePage', {}); + } + + await setTimeout(1000); + + const afterClose = await connector.sendListSessionMessage(clientId); + t.assert.equal( + afterClose.length, + 0, + 'App.closePage should remove all sessions', + ); + + await connector.sendAppMessage(clientId, 'App.openPage', { + url: testingTarget.openUrl, + }); + await setTimeout(1000); + }, ); - t.assert.equal(result.value, "prod"); - t.assert.ok(/**Android */ result.switch === "true" || /** iOS */ result.switch === "1"); - }); - - // TODO(Android): need restart App - await t.test("App.setBOE off", { skip: true }, async (t: TestContext) => { - await connector.sendAppMessage( - clientId, - "App.setBOE", - { switch: false }, - ); + // TODO(Android): need restart App + await t.test('App.setBOE on', { skip: true }, async (t: TestContext) => { + await connector.sendAppMessage(clientId, 'App.setBOE', { + value: 'prod', + switch: true, + }); + const result = await connector.sendAppMessage<{ + switch: string; + value: string; + }>(clientId, 'App.getBOE'); + + t.assert.equal(result.value, 'prod'); + t.assert.ok( + /**Android */ result.switch === 'true' || + /** iOS */ result.switch === '1', + ); + }); - const result = await connector.sendAppMessage<{ switch: string; value: string }>( - clientId, - "App.getBOE", - ); - t.assert.ok(/**Android */ result.switch === "false" || /** iOS */ result.switch === "0"); - }); + // TODO(Android): need restart App + await t.test('App.setBOE off', { skip: true }, async (t: TestContext) => { + await connector.sendAppMessage(clientId, 'App.setBOE', { + switch: false, + }); + + const result = await connector.sendAppMessage<{ + switch: string; + value: string; + }>(clientId, 'App.getBOE'); + t.assert.ok( + /**Android */ result.switch === 'false' || + /** iOS */ result.switch === '0', + ); + }); - await t.test("non exist method without params", async (t) => { - await t.assert.rejects(() => connector.sendAppMessage(clientId, "App.fooBar"), { - name: "Error", - message: "App request App.fooBar error: not implemented", + await t.test('non exist method without params', async (t) => { + await t.assert.rejects( + () => connector.sendAppMessage(clientId, 'App.fooBar'), + { + name: 'Error', + message: 'App request App.fooBar error: not implemented', + }, + ); }); - }); - await t.test("non exist method", async (t) => { - await t.assert.rejects(() => connector.sendAppMessage(clientId, "App.fooBar", {}), { - name: "Error", - message: "App request App.fooBar error: not implemented", + await t.test('non exist method', async (t) => { + await t.assert.rejects( + () => connector.sendAppMessage(clientId, 'App.fooBar', {}), + { + name: 'Error', + message: 'App request App.fooBar error: not implemented', + }, + ); }); - }); - }); + }, + ); - await t.test("sendCDPMessage", async (t: TestContext) => { - await t.test("DOM.getDocument", async (t) => { + await t.test('sendCDPMessage', async (t: TestContext) => { + await t.test('DOM.getDocument', async (t) => { const session = await getTestingSession(connector, clientId); - const result = await connector.sendCDPMessage( - clientId, - session.session_id, - "DOM.getDocument", - ); + const result = await connector.sendCDPMessage< + undefined, + { root: unknown } + >(clientId, session.session_id, 'DOM.getDocument'); t.assert.partialDeepStrictEqual(result, { root: {} }); }); @@ -190,77 +228,106 @@ testWithClient("Connector", async (t, connector, client, testingTarget) => { const session = await getTestingSession(connector, clientId); const result = await connector.sendCDPMessage< - { result: { value: unknown; type: "undefined" | "number" | "string" } }, + { result: { value: unknown; type: 'undefined' | 'number' | 'string' } }, { expression: string } >( clientId, session.session_id, - "Runtime.evaluate", - { expression: "SystemInfo.runtimeType" }, + 'Runtime.evaluate', + { expression: 'SystemInfo.runtimeType' }, false, ); - t.assert.equal(result.result.type, "string"); - t.assert.equal(result.result.value, "quickjs"); + t.assert.equal(result.result.type, 'string'); + t.assert.equal(result.result.value, 'quickjs'); }); await t.test("Runtime.evaluate with sessionId: 'Main'", async () => { const session = await getTestingSession(connector, clientId); const result = await connector.sendCDPMessage< - { result: { description: string; value: unknown; type: "number" | "string" } }, + { + result: { + description: string; + value: unknown; + type: 'number' | 'string'; + }; + }, { expression: string } >( clientId, session.session_id, - "Runtime.evaluate", - { expression: "SystemInfo.runtimeType" }, + 'Runtime.evaluate', + { expression: 'SystemInfo.runtimeType' }, true, ); - t.assert.equal(result.result.type, "undefined"); + t.assert.equal(result.result.type, 'undefined'); t.assert.equal(result.result.value, undefined); }); - await t.test("DOM.getDocument with invalid sessionId", async (t) => { - await t.assert.rejects(() => connector.sendCDPMessage(clientId, -1, "DOM.getDocument"), { - name: "Error", - message: "CDP request error: Not implemented: DOM.getDocument", - }); + await t.test('DOM.getDocument with invalid sessionId', async (t) => { + await t.assert.rejects( + () => connector.sendCDPMessage(clientId, -1, 'DOM.getDocument'), + { + name: 'Error', + message: 'CDP request error: Not implemented: DOM.getDocument', + }, + ); }); - await t.test("non exist method without params", async (t: TestContext) => { + await t.test('non exist method without params', async (t: TestContext) => { const session = await getTestingSession(connector, clientId); - await t.assert.rejects(() => connector.sendCDPMessage(clientId, session.session_id, "DOM.nonExistMethod"), { - name: "Error", - message: "CDP request error: Not implemented: DOM.nonExistMethod", - }); + await t.assert.rejects( + () => + connector.sendCDPMessage( + clientId, + session.session_id, + 'DOM.nonExistMethod', + ), + { + name: 'Error', + message: 'CDP request error: Not implemented: DOM.nonExistMethod', + }, + ); }); - await t.test("non exist method", async (t: TestContext) => { + await t.test('non exist method', async (t: TestContext) => { const session = await getTestingSession(connector, clientId); await t.assert.rejects( - () => connector.sendCDPMessage(clientId, session.session_id, "DOM.nonExistMethod", {}), + () => + connector.sendCDPMessage( + clientId, + session.session_id, + 'DOM.nonExistMethod', + {}, + ), { - name: "Error", - message: "CDP request error: Not implemented: DOM.nonExistMethod", + name: 'Error', + message: 'CDP request error: Not implemented: DOM.nonExistMethod', }, ); }); }); - await t.test("getGlobalSwitch", async (t) => { - await t.test("enable_devtool", async (t) => { - const response = await connector.getGlobalSwitch(clientId, "enable_devtool"); + await t.test('getGlobalSwitch', async (t) => { + await t.test('enable_devtool', async (t) => { + const response = await connector.getGlobalSwitch( + clientId, + 'enable_devtool', + ); - t.assert.equal(typeof response, "boolean"); + t.assert.equal(typeof response, 'boolean'); }); - await t.test("unknown key", async (t) => { + await t.test('unknown key', async (t) => { // Newer LynxExample returns false for unknown keys; older Android builds never reply. try { - const response = await connector.getGlobalSwitch(clientId, "unknown_key_v0" as never); + const response = await connector.getGlobalSwitch( + clientId, + 'unknown_key_v0' as never, + ); t.assert.equal(response, false); } catch (error) { t.assert.ok(error instanceof Error); @@ -269,34 +336,39 @@ testWithClient("Connector", async (t, connector, client, testingTarget) => { }); }); - await t.test("setGlobalSwitch", async (t) => { - await t.test("enable_devtool", async (t) => { - await connector.setGlobalSwitch(clientId, "enable_devtool", true); - const devtoolEnabled = await connector.getGlobalSwitch(clientId, "enable_devtool"); + await t.test('setGlobalSwitch', async (t) => { + await t.test('enable_devtool', async (t) => { + await connector.setGlobalSwitch(clientId, 'enable_devtool', true); + const devtoolEnabled = await connector.getGlobalSwitch( + clientId, + 'enable_devtool', + ); t.assert.equal(devtoolEnabled, true); }); - await t.test("unknown key", async () => { - await connector.setGlobalSwitch(clientId, "unknown_key" as never, false); + await t.test('unknown key', async () => { + await connector.setGlobalSwitch(clientId, 'unknown_key' as never, false); }); }); - await t.test("sendStream", async (t) => { - await t.test("timeout", { skip: true }, async (t) => { + await t.test('sendStream', async (t) => { + await t.test('timeout', { skip: true }, async (t) => { await t.assert.rejects(() => connector.sendStream(clientId, new ReadableStream(), { signal: AbortSignal.timeout(100), - }) + }), ); }); - await t.test("Page.takeScreenshot", async (t: TestContext) => { + await t.test('Page.takeScreenshot', async (t: TestContext) => { const session = await getTestingSession(connector, clientId); const sessionId = session.session_id; - // eslint-disable-next-line n/no-unsupported-features/es-syntax - const { promise, resolve } = Promise.withResolvers<{ data: string; metadata: Record }>(); + const { promise, resolve } = Promise.withResolvers<{ + data: string; + metadata: Record; + }>(); await using stream = await connector.sendCDPStream( clientId, @@ -304,18 +376,18 @@ testWithClient("Connector", async (t, connector, client, testingTarget) => { new ReadableStream({ async start(controller) { controller.enqueue({ - method: "Page.startScreencast", + method: 'Page.startScreencast', params: { - "format": "jpeg", - "quality": 80, - "mode": "lynxview", + format: 'jpeg', + quality: 80, + mode: 'lynxview', }, }); await promise; controller.enqueue({ - method: "Page.stopScreencast", + method: 'Page.stopScreencast', }); controller.close(); }, @@ -324,7 +396,7 @@ testWithClient("Connector", async (t, connector, client, testingTarget) => { ); for await (const { method, params } of stream) { - if (method === "Page.screencastFrame") { + if (method === 'Page.screencastFrame') { resolve(params as never); break; } @@ -332,24 +404,24 @@ testWithClient("Connector", async (t, connector, client, testingTarget) => { const { data, metadata } = await promise; - t.assert.equal(typeof data, "string"); - t.assert.ok(data.length > 0, "Screenshot data should not be empty"); + t.assert.equal(typeof data, 'string'); + t.assert.ok(data.length > 0, 'Screenshot data should not be empty'); // JPEG base64 starts with /9j/ - t.assert.ok(data.startsWith("/9j/"), "Screenshot data should be a JPEG image"); - t.assert.ok(typeof metadata["timestamp"] === "number"); + t.assert.ok( + data.startsWith('/9j/'), + 'Screenshot data should be a JPEG image', + ); + t.assert.ok(typeof metadata['timestamp'] === 'number'); }); - await t.test("Lynx.getScreenshot", async (t: TestContext) => { + await t.test('Lynx.getScreenshot', async (t: TestContext) => { const session = await getTestingSession(connector, clientId); const sessionId = session.session_id; await using stream = await connector.sendCDPStream( clientId, sessionId, - // eslint-disable-next-line n/no-unsupported-features/node-builtins - ReadableStream.from([ - { method: "Lynx.getScreenshot" }, - ]), + ReadableStream.from([{ method: 'Lynx.getScreenshot' }]), { signal: AbortSignal.any([t.signal, AbortSignal.timeout(30_000)]), }, @@ -357,11 +429,17 @@ testWithClient("Connector", async (t, connector, client, testingTarget) => { t.plan(3); for await (const { method, params } of stream) { - if (method === "Lynx.screenshotCaptured") { + if (method === 'Lynx.screenshotCaptured') { const result = params as { data: string }; - t.assert.equal(typeof result.data, "string"); - t.assert.ok(result.data.length > 0, "Screenshot data should not be empty"); - t.assert.ok(result.data.startsWith("/9j/"), "Screenshot data should be a JPEG image"); + t.assert.equal(typeof result.data, 'string'); + t.assert.ok( + result.data.length > 0, + 'Screenshot data should not be empty', + ); + t.assert.ok( + result.data.startsWith('/9j/'), + 'Screenshot data should be a JPEG image', + ); break; } } diff --git a/packages/mcp-servers/devtool-connector/public/inspector-wrapper.html b/packages/mcp-servers/devtool-connector/public/inspector-wrapper.html index e18a5cd..51bf8de 100644 --- a/packages/mcp-servers/devtool-connector/public/inspector-wrapper.html +++ b/packages/mcp-servers/devtool-connector/public/inspector-wrapper.html @@ -75,7 +75,7 @@ let ws = null; let registered = false; let controlIdCounter = 0; - let pendingControlCallbacks = new Map(); + const pendingControlCallbacks = new Map(); let currentClientId = null; let currentPort = null; let currentSessionId = null; diff --git a/packages/mcp-servers/devtool-connector/rslib.config.ts b/packages/mcp-servers/devtool-connector/rslib.config.ts index 4340987..7e135cd 100644 --- a/packages/mcp-servers/devtool-connector/rslib.config.ts +++ b/packages/mcp-servers/devtool-connector/rslib.config.ts @@ -2,26 +2,24 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { defineConfig } from "@rslib/core"; -import { pluginPublint } from "rsbuild-plugin-publint"; +import { defineConfig } from '@rslib/core'; +import { pluginPublint } from 'rsbuild-plugin-publint'; export default defineConfig({ - plugins: [ - pluginPublint({ throwOn: "suggestion" }), - ], + plugins: [pluginPublint({ throwOn: 'suggestion' })], source: { entry: { - "daemon/entry": "./src/daemon/entry.ts", - "daemon/index": "./src/daemon/index.ts", - index: "./src/index.ts", - "transport/index": "./src/transport/index.ts", - "streams/index": "./src/streams/index.ts", + 'daemon/entry': './src/daemon/entry.ts', + 'daemon/index': './src/daemon/index.ts', + index: './src/index.ts', + 'transport/index': './src/transport/index.ts', + 'streams/index': './src/streams/index.ts', }, }, lib: [ { - format: "esm", - syntax: "es2022", + format: 'esm', + syntax: 'es2022', dts: { bundle: false, }, diff --git a/packages/mcp-servers/devtool-connector/src/client-id.ts b/packages/mcp-servers/devtool-connector/src/client-id.ts index f01438a..088b4a7 100644 --- a/packages/mcp-servers/devtool-connector/src/client-id.ts +++ b/packages/mcp-servers/devtool-connector/src/client-id.ts @@ -7,9 +7,11 @@ export class ClientId { return `${encodeURIComponent(deviceId)}:${port}`; } - static deserialize(clientId: string): { deviceId: string; port: number } | null { + static deserialize( + clientId: string, + ): { deviceId: string; port: number } | null { try { - const lastColonIndex = clientId.lastIndexOf(":"); + const lastColonIndex = clientId.lastIndexOf(':'); if (lastColonIndex === -1) return null; const port = Number.parseInt(clientId.substring(lastColonIndex + 1), 10); diff --git a/packages/mcp-servers/devtool-connector/src/daemon/device-connection.ts b/packages/mcp-servers/devtool-connector/src/daemon/device-connection.ts index 3140f8f..cc4969b 100644 --- a/packages/mcp-servers/devtool-connector/src/daemon/device-connection.ts +++ b/packages/mcp-servers/devtool-connector/src/daemon/device-connection.ts @@ -2,11 +2,15 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { createDebug } from "obug"; -import type { Connection, Transport, TransportConnectOptions } from "../transport/transport.ts"; -import { type AppInfo, isInitializeResponse, type Response } from "../types.ts"; +import { createDebug } from 'obug'; +import type { + Connection, + Transport, + TransportConnectOptions, +} from '../transport/transport.ts'; +import { type AppInfo, isInitializeResponse, type Response } from '../types.ts'; -const debug = createDebug("devtool-mcp-server:daemon:device-connection"); +const debug = createDebug('devtool-mcp-server:daemon:device-connection'); export interface DeviceConnectionSubscriber { readonly id: number; @@ -28,7 +32,6 @@ export class DeviceConnection { readonly port: number; #conn: Connection | null = null; - // eslint-disable-next-line n/no-unsupported-features/node-builtins #writer: WritableStreamDefaultWriter | null = null; #subscribers = new Map(); #transport: Transport; @@ -52,31 +55,45 @@ export class DeviceConnection { * in a try/catch — a failure means the device:port is unreachable. */ async connect(): Promise { - debug("connecting to %s", this.key); + debug('connecting to %s', this.key); if (this.#disposed) { - throw new Error(`DeviceConnection ${this.key} was disposed before connect started`); + throw new Error( + `DeviceConnection ${this.key} was disposed before connect started`, + ); } const conn = await this.#transport.connect(this.#options); if (this.#disposed) { await conn[Symbol.asyncDispose](); - throw new Error(`DeviceConnection ${this.key} was disposed before connect completed`); + throw new Error( + `DeviceConnection ${this.key} was disposed before connect completed`, + ); } this.#conn = conn; this.#writer = this.#conn.writable.getWriter(); this.#readLoopPromise = this.#readLoop(); - debug("connected to %s", this.key); + debug('connected to %s', this.key); } addSubscriber(subscriber: DeviceConnectionSubscriber): void { this.#subscribers.set(subscriber.id, subscriber); - debug("subscriber %d added to %s (total: %d)", subscriber.id, this.key, this.#subscribers.size); + debug( + 'subscriber %d added to %s (total: %d)', + subscriber.id, + this.key, + this.#subscribers.size, + ); } removeSubscriber(id: number): void { this.#subscribers.delete(id); - debug("subscriber %d removed from %s (total: %d)", id, this.key, this.#subscribers.size); + debug( + 'subscriber %d removed from %s (total: %d)', + id, + this.key, + this.#subscribers.size, + ); } get subscriberCount(): number { @@ -98,7 +115,7 @@ export class DeviceConnection { try { await this.#writer.write(message); } catch (err) { - debug("send to %s failed: %O", this.key, err); + debug('send to %s failed: %O', this.key, err); throw err; } } @@ -106,7 +123,7 @@ export class DeviceConnection { async dispose(): Promise { if (this.#disposed) return; this.#disposed = true; - debug("disposing device connection %s", this.key); + debug('disposing device connection %s', this.key); try { this.#writer?.releaseLock(); @@ -117,7 +134,7 @@ export class DeviceConnection { try { await this.#conn?.[Symbol.asyncDispose](); } catch (err) { - debug("error disposing connection %s: %O", this.key, err); + debug('error disposing connection %s: %O', this.key, err); } await this.#readLoopPromise; @@ -133,7 +150,7 @@ export class DeviceConnection { const response = message as Response; if (isInitializeResponse(response)) { this.appInfo = response.data.info; - debug("captured appInfo for %s: %O", this.key, this.appInfo); + debug('captured appInfo for %s: %O', this.key, this.appInfo); continue; } } @@ -142,12 +159,12 @@ export class DeviceConnection { } } catch (err) { if (!this.#disposed) { - debug("read loop error on %s: %O", this.key, err); + debug('read loop error on %s: %O', this.key, err); } } if (!this.#disposed) { - debug("device connection %s closed by remote", this.key); + debug('device connection %s closed by remote', this.key); this.#disposed = true; this.#closeAllSubscribers(); } @@ -158,7 +175,7 @@ export class DeviceConnection { try { subscriber.close(); } catch (err) { - debug("failed to close subscriber %d: %O", subscriber.id, err); + debug('failed to close subscriber %d: %O', subscriber.id, err); } } } @@ -168,7 +185,7 @@ export class DeviceConnection { try { subscriber.send(message); } catch (err) { - debug("failed to send to subscriber %d: %O", subscriber.id, err); + debug('failed to send to subscriber %d: %O', subscriber.id, err); } } } diff --git a/packages/mcp-servers/devtool-connector/src/daemon/entry.ts b/packages/mcp-servers/devtool-connector/src/daemon/entry.ts index 62b1344..94a5f64 100644 --- a/packages/mcp-servers/devtool-connector/src/daemon/entry.ts +++ b/packages/mcp-servers/devtool-connector/src/daemon/entry.ts @@ -8,26 +8,28 @@ * Spawned by DaemonManager as a detached child process. * Usage: node daemon/entry.ts --port 21783 */ -/* eslint-disable n/no-unsupported-features/node-builtins, n/no-process-exit */ -import { parseArgs } from "node:util"; -import { AndroidTransport } from "../transport/android.ts"; -import { DesktopTransport } from "../transport/desktop.ts"; -import { iOSTransport } from "../transport/ios.ts"; -import { DEFAULT_DAEMON_PORT } from "./manager.ts"; -import { DevtoolDaemon } from "./server.ts"; +import { parseArgs } from 'node:util'; +import { AndroidTransport } from '../transport/android.ts'; +import { DesktopTransport } from '../transport/desktop.ts'; +import { iOSTransport } from '../transport/ios.ts'; +import { DEFAULT_DAEMON_PORT } from './manager.ts'; +import { DevtoolDaemon } from './server.ts'; -function getAndroidTransportSpec(env: NodeJS.ProcessEnv): { host: string; port: number } { - const port = Number.parseInt(env["ADB_SERVER_PORT"] ?? "5037", 10); +function getAndroidTransportSpec(env: NodeJS.ProcessEnv): { + host: string; + port: number; +} { + const port = Number.parseInt(env['ADB_SERVER_PORT'] ?? '5037', 10); return { - host: env["ADB_SERVER_HOST"] ?? "127.0.0.1", + host: env['ADB_SERVER_HOST'] ?? '127.0.0.1', port: Number.isInteger(port) && port > 0 ? port : 5037, }; } const { values } = parseArgs({ options: { - port: { type: "string", default: String(DEFAULT_DAEMON_PORT) }, + port: { type: 'string', default: String(DEFAULT_DAEMON_PORT) }, }, strict: true, }); @@ -55,13 +57,13 @@ const daemon = new DevtoolDaemon( await daemon.start(port); // Handle graceful shutdown -process.on("SIGTERM", () => { +process.on('SIGTERM', () => { void daemon.close().then(() => { process.exit(0); }); }); -process.on("SIGINT", () => { +process.on('SIGINT', () => { void daemon.close().then(() => { process.exit(0); }); diff --git a/packages/mcp-servers/devtool-connector/src/daemon/index.ts b/packages/mcp-servers/devtool-connector/src/daemon/index.ts index 47bd291..a91a19d 100644 --- a/packages/mcp-servers/devtool-connector/src/daemon/index.ts +++ b/packages/mcp-servers/devtool-connector/src/daemon/index.ts @@ -2,4 +2,4 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -export { DevtoolDaemon } from "./server.ts"; +export { DevtoolDaemon } from './server.ts'; diff --git a/packages/mcp-servers/devtool-connector/src/daemon/manager.ts b/packages/mcp-servers/devtool-connector/src/daemon/manager.ts index 2120289..88cf231 100644 --- a/packages/mcp-servers/devtool-connector/src/daemon/manager.ts +++ b/packages/mcp-servers/devtool-connector/src/daemon/manager.ts @@ -2,28 +2,30 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { spawn } from "node:child_process"; -import { closeSync, openSync } from "node:fs"; -import fs from "node:fs/promises"; -import { createRequire } from "node:module"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { setTimeout as sleep } from "node:timers/promises"; -import { createDebug } from "obug"; -import { DAEMON_WS_PATH, DEFAULT_DAEMON_PORT } from "./protocol.ts"; - -export { DEFAULT_DAEMON_PORT } from "./protocol.ts"; - -const debug = createDebug("devtool-mcp-server:daemon:manager"); - -const DEBUG_ROUTER_DIR = path.join(os.homedir(), ".DebugRouterConnector"); -const PIDFILE = path.join(DEBUG_ROUTER_DIR, "daemon.pid"); -const LOG = path.join(DEBUG_ROUTER_DIR, "daemon.log"); -const ERR = path.join(DEBUG_ROUTER_DIR, "daemon.err"); - -export function resolveDaemonEntryPath(moduleUrl: string = import.meta.url): string { - return createRequire(moduleUrl).resolve("#daemon-entry"); +import { spawn } from 'node:child_process'; +import { closeSync, openSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { createDebug } from 'obug'; +import { DAEMON_WS_PATH, DEFAULT_DAEMON_PORT } from './protocol.ts'; + +export { DEFAULT_DAEMON_PORT } from './protocol.ts'; + +const debug = createDebug('devtool-mcp-server:daemon:manager'); + +const DEBUG_ROUTER_DIR = path.join(os.homedir(), '.DebugRouterConnector'); +const PIDFILE = path.join(DEBUG_ROUTER_DIR, 'daemon.pid'); +const LOG = path.join(DEBUG_ROUTER_DIR, 'daemon.log'); +const ERR = path.join(DEBUG_ROUTER_DIR, 'daemon.err'); + +export function resolveDaemonEntryPath( + moduleUrl: string = import.meta.url, +): string { + return createRequire(moduleUrl).resolve('#daemon-entry'); } /** @@ -34,46 +36,48 @@ export function resolveDaemonEntryPath(moduleUrl: string = import.meta.url): str * - `kill()`: sends SIGTERM to the daemon */ export class DaemonManager { - static async ensureRunning(port: number = DEFAULT_DAEMON_PORT): Promise { + static async ensureRunning( + port: number = DEFAULT_DAEMON_PORT, + ): Promise { const url = `ws://127.0.0.1:${port}${DAEMON_WS_PATH}`; // 1. Quick probe — if the daemon is already running, we're done - if (await this.#isAlive(port)) { - debug("daemon already running on port %d", port); + if (await DaemonManager.#isAlive(port)) { + debug('daemon already running on port %d', port); return url; } // 2. Spawn a new daemon - debug("daemon not running, spawning..."); - await this.#spawn(port); + debug('daemon not running, spawning...'); + await DaemonManager.#spawn(port); // 3. Wait for it to become ready - await this.#waitReady(port, 5_000); - debug("daemon is ready on port %d", port); + await DaemonManager.#waitReady(port, 5_000); + debug('daemon is ready on port %d', port); return url; } static async kill(): Promise { try { - const pidStr = await fs.readFile(PIDFILE, "utf-8"); + const pidStr = await fs.readFile(PIDFILE, 'utf-8'); const pid = Number.parseInt(pidStr.trim(), 10); if (!Number.isNaN(pid)) { - debug("killing daemon pid %d", pid); - process.kill(pid, "SIGTERM"); + debug('killing daemon pid %d', pid); + process.kill(pid, 'SIGTERM'); } } catch { - debug("no pidfile found or cannot read it"); + debug('no pidfile found or cannot read it'); } } static async #isAlive(port: number): Promise { return new Promise((resolve) => { - const socket = net.createConnection({ host: "127.0.0.1", port }, () => { + const socket = net.createConnection({ host: '127.0.0.1', port }, () => { socket.destroy(); resolve(true); }); - socket.on("error", () => { + socket.on('error', () => { socket.destroy(); resolve(false); }); @@ -89,16 +93,16 @@ export class DaemonManager { const entryPath = resolveDaemonEntryPath(); - const out = openSync(LOG, "w"); - const err = openSync(ERR, "w"); + const out = openSync(LOG, 'w'); + const err = openSync(ERR, 'w'); - const child = spawn(process.execPath, [entryPath, "--port", String(port)], { + const child = spawn(process.execPath, [entryPath, '--port', String(port)], { detached: true, - stdio: ["ignore", out, err], + stdio: ['ignore', out, err], env: { ...process.env, // Propagate debug namespace if set - DEBUG: process.env["DEBUG"] ?? "", + DEBUG: process.env['DEBUG'] ?? '', }, }); @@ -109,8 +113,8 @@ export class DaemonManager { // Write pidfile if (child.pid !== undefined) { - await fs.writeFile(PIDFILE, String(child.pid), "utf-8"); - debug("spawned daemon with pid %d", child.pid); + await fs.writeFile(PIDFILE, String(child.pid), 'utf-8'); + debug('spawned daemon with pid %d', child.pid); } } @@ -118,12 +122,14 @@ export class DaemonManager { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (await this.#isAlive(port)) { + if (await DaemonManager.#isAlive(port)) { return; } await sleep(200); } - throw new Error(`Daemon failed to start within ${timeoutMs}ms on port ${port}`); + throw new Error( + `Daemon failed to start within ${timeoutMs}ms on port ${port}`, + ); } } diff --git a/packages/mcp-servers/devtool-connector/src/daemon/protocol.ts b/packages/mcp-servers/devtool-connector/src/daemon/protocol.ts index 7cad183..393b1e9 100644 --- a/packages/mcp-servers/devtool-connector/src/daemon/protocol.ts +++ b/packages/mcp-servers/devtool-connector/src/daemon/protocol.ts @@ -2,56 +2,61 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { AppInfo } from "../types.ts"; +import type { AppInfo } from '../types.ts'; // ===== Standard debug-router protocol (reused from HDT) ===== export interface InitializeEvent { - event: "Initialize"; + event: 'Initialize'; data: number; } export interface RegisterEvent { - event: "Register"; - data: { id: number; type: "Driver" }; + event: 'Register'; + data: { id: number; type: 'Driver' }; } export interface ClientListEvent { - event: "ClientList"; + event: 'ClientList'; data: ClientListEntry[]; } export interface ClientListEntry { id: string; info: AppInfo; - type: "runtime"; + type: 'runtime'; } export interface ListClientsRequest { - event: "ListClients"; + event: 'ListClients'; } export interface PingEvent { - event: "Ping"; + event: 'Ping'; } export interface PongEvent { - event: "Pong"; + event: 'Pong'; } // ===== Extended control protocol (daemon-specific) ===== export interface ControlRequest { - event: "Control"; + event: 'Control'; data: { id: number; - method: "listClients" | "listDevices" | "listAvailableApps" | "openApp" | "subscribe"; + method: + | 'listClients' + | 'listDevices' + | 'listAvailableApps' + | 'openApp' + | 'subscribe'; params?: Record; }; } export interface ControlResponse { - event: "ControlResponse"; + event: 'ControlResponse'; data: { id: number; result?: unknown; @@ -76,7 +81,7 @@ export type DaemonOutgoingMessage = | CustomizedMessage; export interface CustomizedMessage { - event: "Customized"; + event: 'Customized'; data: { type: string; data: { @@ -92,29 +97,49 @@ export interface CustomizedMessage { } export function isCustomizedMessage(msg: unknown): msg is CustomizedMessage { - return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "Customized"; + return ( + typeof msg === 'object' && + msg !== null && + (msg as { event?: string }).event === 'Customized' + ); } export function isControlRequest(msg: unknown): msg is ControlRequest { - return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "Control"; + return ( + typeof msg === 'object' && + msg !== null && + (msg as { event?: string }).event === 'Control' + ); } export function isListClientsRequest(msg: unknown): msg is ListClientsRequest { - return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "ListClients"; + return ( + typeof msg === 'object' && + msg !== null && + (msg as { event?: string }).event === 'ListClients' + ); } export function isPingEvent(msg: unknown): msg is PingEvent { - return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "Ping"; + return ( + typeof msg === 'object' && + msg !== null && + (msg as { event?: string }).event === 'Ping' + ); } export function isRegisterEvent(msg: unknown): msg is RegisterEvent { - return typeof msg === "object" && msg !== null && (msg as { event?: string }).event === "Register"; + return ( + typeof msg === 'object' && + msg !== null && + (msg as { event?: string }).event === 'Register' + ); } // ===== Constants ===== export const DEFAULT_DAEMON_PORT = 21783; -export const DAEMON_WS_PATH = "/devtool/connector"; +export const DAEMON_WS_PATH = '/devtool/connector'; export const DAEMON_VERSION_PATH: string = `${DAEMON_WS_PATH}/version`; export const DAEMON_SHUTDOWN_PATH: string = `${DAEMON_WS_PATH}/shutdown`; export const DAEMON_INSPECTOR_PATH: string = `${DAEMON_WS_PATH}/inspector`; diff --git a/packages/mcp-servers/devtool-connector/src/daemon/server.ts b/packages/mcp-servers/devtool-connector/src/daemon/server.ts index fd19c1e..56f17f2 100644 --- a/packages/mcp-servers/devtool-connector/src/daemon/server.ts +++ b/packages/mcp-servers/devtool-connector/src/daemon/server.ts @@ -2,14 +2,17 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import http from "node:http"; -import type { AddressInfo } from "node:net"; -import { setTimeout as sleep } from "node:timers/promises"; -import { createDebug } from "obug"; -import { type WebSocket, WebSocketServer } from "ws"; -import { ClientId } from "../client-id.ts"; -import type { Device, Transport } from "../transport/transport.ts"; -import { DeviceConnection, type DeviceConnectionSubscriber } from "./device-connection.ts"; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { createDebug } from 'obug'; +import { type WebSocket, WebSocketServer } from 'ws'; +import { ClientId } from '../client-id.ts'; +import type { Device, Transport } from '../transport/transport.ts'; +import { + DeviceConnection, + type DeviceConnectionSubscriber, +} from './device-connection.ts'; import { type ClientListEntry, type ControlRequest, @@ -22,11 +25,11 @@ import { isListClientsRequest, isPingEvent, isRegisterEvent, -} from "./protocol.ts"; -import { StaticServer } from "./static-server.ts"; -import { CONNECTOR_VERSION } from "./version.ts"; +} from './protocol.ts'; +import { StaticServer } from './static-server.ts'; +import { CONNECTOR_VERSION } from './version.ts'; -const debug = createDebug("devtool-mcp-server:daemon:server"); +const debug = createDebug('devtool-mcp-server:daemon:server'); const IDLE_TIMEOUT_MS = 300_000; /** Grace period before disposing an unsubscribed device connection. */ @@ -53,7 +56,10 @@ export class DevtoolDaemon { #transports: Transport[]; #deviceConnections = new Map(); #pendingDeviceConnections = new Map>(); - #deviceConnectionCleanupTimers = new Map>(); + #deviceConnectionCleanupTimers = new Map< + string, + ReturnType + >(); #wsClients = new Map(); #nextClientId = 0; #idleTimer: ReturnType | null = null; @@ -62,12 +68,15 @@ export class DevtoolDaemon { #onShutdown: (() => void) | undefined; #staticServer = new StaticServer(); - constructor(transports: Transport[], options?: { onIdle?: () => void; onShutdown?: () => void }) { + constructor( + transports: Transport[], + options?: { onIdle?: () => void; onShutdown?: () => void }, + ) { this.#transports = transports; this.#onIdle = options?.onIdle; this.#onShutdown = options?.onShutdown; this.#httpServer = http.createServer((req, res) => { - if (req.method === "GET" && this.#isVersionRequest(req.url)) { + if (req.method === 'GET' && this.#isVersionRequest(req.url)) { this.#sendJson(res, 200, { version: CONNECTOR_VERSION }); return; } @@ -76,11 +85,11 @@ export class DevtoolDaemon { return; } - if (req.method === "POST" && this.#isShutdownRequest(req.url)) { + if (req.method === 'POST' && this.#isShutdownRequest(req.url)) { this.#sendJson(res, 202, { ok: true }, () => { void this.close() .catch((err: unknown) => { - debug("failed to close daemon after shutdown request: %O", err); + debug('failed to close daemon after shutdown request: %O', err); }) .finally(() => { this.#onShutdown?.(); @@ -98,7 +107,7 @@ export class DevtoolDaemon { const wss = new WebSocketServer({ noServer: true }); this.#wss = wss; - this.#httpServer.on("upgrade", (request, socket, head) => { + this.#httpServer.on('upgrade', (request, socket, head) => { if (!request.url?.startsWith(DAEMON_WS_PATH)) { socket.destroy(); return; @@ -109,12 +118,16 @@ export class DevtoolDaemon { }); return new Promise((resolve, reject) => { - this.#httpServer.once("error", reject); - this.#httpServer.listen(port, "127.0.0.1", () => { - this.#httpServer.removeListener("error", reject); + this.#httpServer.once('error', reject); + this.#httpServer.listen(port, '127.0.0.1', () => { + this.#httpServer.removeListener('error', reject); this.#resetIdleTimer(); const address = this.#httpServer.address() as AddressInfo; - debug("daemon listening on ws://127.0.0.1:%d%s", address.port, DAEMON_WS_PATH); + debug( + 'daemon listening on ws://127.0.0.1:%d%s', + address.port, + DAEMON_WS_PATH, + ); resolve(address.port); }); }); @@ -155,26 +168,35 @@ export class DevtoolDaemon { // --------------------------------------------------------------------------- #isVersionRequest(url: string | undefined): boolean { - return new URL(url ?? "/", "http://127.0.0.1").pathname === DAEMON_VERSION_PATH; + return ( + new URL(url ?? '/', 'http://127.0.0.1').pathname === DAEMON_VERSION_PATH + ); } #isShutdownRequest(url: string | undefined): boolean { - return new URL(url ?? "/", "http://127.0.0.1").pathname === DAEMON_SHUTDOWN_PATH; + return ( + new URL(url ?? '/', 'http://127.0.0.1').pathname === DAEMON_SHUTDOWN_PATH + ); } - #sendJson(res: http.ServerResponse, statusCode: number, data: unknown, callback?: () => void): void { + #sendJson( + res: http.ServerResponse, + statusCode: number, + data: unknown, + callback?: () => void, + ): void { const body = JSON.stringify(data); res.writeHead(statusCode, { - "content-type": "application/json; charset=utf-8", - "content-length": Buffer.byteLength(body), - "cache-control": "no-store", + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(body), + 'cache-control': 'no-store', }); res.end(body, callback); } #handleConnection(ws: WebSocket): void { const clientId = ++this.#nextClientId; - debug("new ws client %d", clientId); + debug('new ws client %d', clientId); this.#clearIdleTimer(); const session: WsClientSession = { @@ -187,24 +209,24 @@ export class DevtoolDaemon { } }, close() { - ws.close(1001, "device disconnected"); + ws.close(1001, 'device disconnected'); }, }; // Standard debug-router handshake: send Initialize - session.send({ event: "Initialize", data: clientId }); + session.send({ event: 'Initialize', data: clientId }); - ws.on("message", (raw: Buffer | string) => { + ws.on('message', (raw: Buffer | string) => { try { const msg: unknown = JSON.parse(String(raw)); this.#handleMessage(session, msg); } catch (err) { - debug("failed to parse message from client %d: %O", clientId, err); + debug('failed to parse message from client %d: %O', clientId, err); } }); - ws.on("close", () => { - debug("ws client %d disconnected", clientId); + ws.on('close', () => { + debug('ws client %d disconnected', clientId); this.#wsClients.delete(clientId); for (const key of session.subscriptions) { @@ -219,8 +241,8 @@ export class DevtoolDaemon { this.#resetIdleTimer(); }); - ws.on("error", (err: Error) => { - debug("ws client %d error: %O", clientId, err); + ws.on('error', (err: Error) => { + debug('ws client %d error: %O', clientId, err); }); } @@ -231,7 +253,7 @@ export class DevtoolDaemon { #handleMessage(session: WsClientSession, msg: unknown): void { if (isRegisterEvent(msg)) { this.#wsClients.set(session.id, session); - debug("client %d registered", session.id); + debug('client %d registered', session.id); return; } if (isListClientsRequest(msg)) { @@ -239,7 +261,7 @@ export class DevtoolDaemon { return; } if (isPingEvent(msg)) { - session.send({ event: "Pong" }); + session.send({ event: 'Pong' }); return; } if (isControlRequest(msg)) { @@ -250,19 +272,22 @@ export class DevtoolDaemon { void this.#handleCustomizedMessage(session, msg); return; } - debug("unknown message from client %d: %O", session.id, msg); + debug('unknown message from client %d: %O', session.id, msg); } // --------------------------------------------------------------------------- // Customized message forwarding (Client → Device) // --------------------------------------------------------------------------- - async #handleCustomizedMessage(session: WsClientSession, msg: CustomizedMessage): Promise { + async #handleCustomizedMessage( + session: WsClientSession, + msg: CustomizedMessage, + ): Promise { // DaemonTransport sets `to` = port. // CustomizedClientIdTransformStream also sets client_id = port. const targetPort = msg.to ?? msg.data?.data?.client_id; - if (typeof targetPort !== "number") { - debug("cannot determine target port from message: %O", msg); + if (typeof targetPort !== 'number') { + debug('cannot determine target port from message: %O', msg); return; } @@ -281,65 +306,85 @@ export class DevtoolDaemon { }, }); } catch (err) { - debug("failed to forward message to %s: %O", key, err); + debug('failed to forward message to %s: %O', key, err); } return; } } - debug("no matching device connection for client %d, port %d", session.id, targetPort); + debug( + 'no matching device connection for client %d, port %d', + session.id, + targetPort, + ); } // --------------------------------------------------------------------------- // Control RPC // --------------------------------------------------------------------------- - async #handleControlRequest(session: WsClientSession, req: ControlRequest): Promise { + async #handleControlRequest( + session: WsClientSession, + req: ControlRequest, + ): Promise { const { id, method, params } = req.data; try { let result: unknown; switch (method) { - case "listClients": { + case 'listClients': { result = await this.#discoverClients(); break; } - case "listDevices": { + case 'listDevices': { const devices: Device[] = []; const allResults = await Promise.allSettled( - this.#transports.map(t => t.listDevices()), + this.#transports.map((t) => t.listDevices()), ); for (const r of allResults) { - if (r.status === "fulfilled") devices.push(...r.value); + if (r.status === 'fulfilled') devices.push(...r.value); } result = devices; break; } - case "listAvailableApps": { - const deviceId = (params as { deviceId?: string } | undefined)?.deviceId; - if (!deviceId) throw new Error("deviceId is required"); + case 'listAvailableApps': { + const deviceId = (params as { deviceId?: string } | undefined) + ?.deviceId; + if (!deviceId) throw new Error('deviceId is required'); const transport = await this.#findTransportWithDeviceId(deviceId); result = await transport.listAvailableApps(deviceId); break; } - case "openApp": { - const p = (params ?? {}) as { deviceId?: string; packageName?: string; withDataCleared?: boolean }; - if (!p.deviceId || !p.packageName) throw new Error("deviceId and packageName are required"); + case 'openApp': { + const p = (params ?? {}) as { + deviceId?: string; + packageName?: string; + withDataCleared?: boolean; + }; + if (!p.deviceId || !p.packageName) + throw new Error('deviceId and packageName are required'); const transport = await this.#findTransportWithDeviceId(p.deviceId); - await transport.openApp(p.deviceId, p.packageName, { withDataCleared: p.withDataCleared }); + await transport.openApp(p.deviceId, p.packageName, { + withDataCleared: p.withDataCleared, + }); result = null; break; } - case "subscribe": { + case 'subscribe': { const s = (params ?? {}) as { deviceId?: string; port?: number }; - if (!s.deviceId || s.port === undefined) throw new Error("deviceId and port are required"); + if (!s.deviceId || s.port === undefined) + throw new Error('deviceId and port are required'); const transport = await this.#findTransportWithDeviceId(s.deviceId); - const conn = await this.#getOrCreateDeviceConnection(transport, s.deviceId, s.port); + const conn = await this.#getOrCreateDeviceConnection( + transport, + s.deviceId, + s.port, + ); conn.addSubscriber(session); session.subscriptions.add(conn.key); result = null; @@ -350,10 +395,10 @@ export class DevtoolDaemon { throw new Error(`Unknown control method: ${method}`); } - session.send({ event: "ControlResponse", data: { id, result } }); + session.send({ event: 'ControlResponse', data: { id, result } }); } catch (err) { const message = err instanceof Error ? err.message : String(err); - session.send({ event: "ControlResponse", data: { id, error: message } }); + session.send({ event: 'ControlResponse', data: { id, error: message } }); } } @@ -364,10 +409,10 @@ export class DevtoolDaemon { async #sendClientList(session: WsClientSession): Promise { try { const clients = await this.#discoverClients(); - session.send({ event: "ClientList", data: clients }); + session.send({ event: 'ClientList', data: clients }); } catch (err) { - debug("failed to send client list: %O", err); - session.send({ event: "ClientList", data: [] }); + debug('failed to send client list: %O', err); + session.send({ event: 'ClientList', data: [] }); } } @@ -376,16 +421,19 @@ export class DevtoolDaemon { // 0. Collect clients from transports with listClients() capability. const clientListTransports = this.#transports.filter( - (t): t is Transport & { listClients(): Promise<{ id: string; info: Record }[]> } => - typeof t.listClients === "function", + ( + t, + ): t is Transport & { + listClients(): Promise<{ id: string; info: Record }[]>; + } => typeof t.listClients === 'function', ); const clientListResults = await Promise.allSettled( - clientListTransports.map(t => t.listClients()), + clientListTransports.map((t) => t.listClients()), ); for (const r of clientListResults) { - if (r.status === "fulfilled") { + if (r.status === 'fulfilled') { for (const { id, info } of r.value) { - entries.push({ id, info, type: "runtime" }); + entries.push({ id, info, type: 'runtime' }); } } } @@ -398,7 +446,7 @@ export class DevtoolDaemon { entries.push({ id, info: conn.appInfo, - type: "runtime", + type: 'runtime', }); } } @@ -407,14 +455,14 @@ export class DevtoolDaemon { const allDevices: { transport: Transport; devices: Device[] }[] = []; const transportResults = await Promise.allSettled( this.#transports - .filter(transport => typeof transport.listClients !== "function") + .filter((transport) => typeof transport.listClients !== 'function') .map(async (transport) => ({ transport, devices: await transport.listDevices(), })), ); for (const r of transportResults) { - if (r.status === "fulfilled") allDevices.push(r.value); + if (r.status === 'fulfilled') allDevices.push(r.value); } const MIN_PORT = 8901; @@ -424,29 +472,37 @@ export class DevtoolDaemon { const probeResults = await Promise.allSettled( allDevices.flatMap(({ transport, devices }) => devices.flatMap((device) => - PORTS - .filter((port) => !existingKeys.has(`${device.id}:${port}`)) - .map(async (port) => { - const conn = await this.#getOrCreateDeviceConnection(transport, device.id, port); + PORTS.filter((port) => !existingKeys.has(`${device.id}:${port}`)).map( + async (port) => { + const conn = await this.#getOrCreateDeviceConnection( + transport, + device.id, + port, + ); // Match direct transport discovery timeout so cold-start daemon scans // do not give up before the device finishes the Initialize/Register handshake. const deadline = Date.now() + DEVICE_DISCOVERY_TIMEOUT_MS; - while (!conn.appInfo && !conn.isDisposed && Date.now() < deadline) { + while ( + !conn.appInfo && + !conn.isDisposed && + Date.now() < deadline + ) { await new Promise((resolve) => setTimeout(resolve, 100)); } return conn; - }) - ) + }, + ), + ), ), ); for (const r of probeResults) { - if (r.status === "fulfilled") { + if (r.status === 'fulfilled') { const conn = r.value; if (conn.appInfo && !conn.isDisposed) { const clientId = ClientId.serialize(conn.deviceId, conn.port); if (!entries.some((e) => e.id === clientId)) { - entries.push({ id: clientId, info: conn.appInfo, type: "runtime" }); + entries.push({ id: clientId, info: conn.appInfo, type: 'runtime' }); } } } @@ -481,7 +537,9 @@ export class DevtoolDaemon { // The signal is passed into transports, so keep the setup deadline clearable. // AbortSignal.timeout() would abort later even after this pooled connection succeeds. const setupTimeout = setTimeout(() => { - setupAbortController.abort(createDeviceConnectionSetupTimeoutError(key)); + setupAbortController.abort( + createDeviceConnectionSetupTimeoutError(key), + ); }, DEVICE_CONN_SETUP_TIMEOUT_MS); const conn = new DeviceConnection(transport, { deviceId, @@ -494,13 +552,13 @@ export class DevtoolDaemon { await withAbortSignal(connectPromise, setupAbortController.signal); // Trigger the Initialize handshake so the device sends back Register await withAbortSignal( - conn.send({ event: "Initialize", data: port }), + conn.send({ event: 'Initialize', data: port }), setupAbortController.signal, ); this.#deviceConnections.set(key, conn); return conn; } catch (err) { - debug("failed to connect to %s: %O", key, err); + debug('failed to connect to %s: %O', key, err); this.#deviceConnections.delete(key); await this.#disposeDeviceConnectionBestEffort(key, conn); throw err; @@ -514,10 +572,17 @@ export class DevtoolDaemon { return await connectionPromise; } - async #disposeDeviceConnectionBestEffort(key: string, conn: DeviceConnection): Promise { + async #disposeDeviceConnectionBestEffort( + key: string, + conn: DeviceConnection, + ): Promise { const timeoutAbortController = new AbortController(); const disposePromise = conn.dispose().catch((err: unknown) => { - debug("failed to dispose device connection %s after setup failure: %O", key, err); + debug( + 'failed to dispose device connection %s after setup failure: %O', + key, + err, + ); }); const timeoutPromise = sleep(DEVICE_CONN_DISPOSE_TIMEOUT_MS, undefined, { signal: timeoutAbortController.signal, @@ -526,12 +591,9 @@ export class DevtoolDaemon { }); try { - await Promise.race([ - disposePromise, - timeoutPromise, - ]); + await Promise.race([disposePromise, timeoutPromise]); } catch (err) { - debug("best-effort dispose for %s did not complete: %O", key, err); + debug('best-effort dispose for %s did not complete: %O', key, err); } finally { timeoutAbortController.abort(); } @@ -544,17 +606,17 @@ export class DevtoolDaemon { // connection on this key, otherwise it could later dispose this // persistent connection — contradicting "persistent is never cleaned up". this.#clearDeviceConnectionCleanup(key); - debug("skipping cleanup for persistent connection %s", key); + debug('skipping cleanup for persistent connection %s', key); return; } - debug("scheduling cleanup for %s in %dms", key, DEVICE_CONN_GRACE_MS); + debug('scheduling cleanup for %s in %dms', key, DEVICE_CONN_GRACE_MS); this.#clearDeviceConnectionCleanup(key); const timer = setTimeout(() => { this.#deviceConnectionCleanupTimers.delete(key); const conn = this.#deviceConnections.get(key); if (conn && conn.subscriberCount === 0) { - debug("disposing idle device connection %s", key); + debug('disposing idle device connection %s', key); this.#deviceConnections.delete(key); void conn.dispose(); } @@ -590,10 +652,13 @@ export class DevtoolDaemon { #resetIdleTimer(): void { this.#clearIdleTimer(); if (this.#wsClients.size === 0 && !this.#closed) { - debug("no clients connected, starting idle timer (%dms)", IDLE_TIMEOUT_MS); + debug( + 'no clients connected, starting idle timer (%dms)', + IDLE_TIMEOUT_MS, + ); this.#idleTimer = setTimeout(() => { if (this.#wsClients.size === 0) { - debug("idle timeout reached, shutting down daemon"); + debug('idle timeout reached, shutting down daemon'); this.#onIdle?.(); } }, IDLE_TIMEOUT_MS); @@ -614,22 +679,25 @@ function createDeviceConnectionSetupTimeoutError(key: string): Error { ); } -async function withAbortSignal(promise: Promise, signal: AbortSignal): Promise { +async function withAbortSignal( + promise: Promise, + signal: AbortSignal, +): Promise { signal.throwIfAborted(); return await new Promise((resolve, reject) => { const abortHandler = () => { - reject(signal.reason ?? new Error("The operation was aborted")); + reject(signal.reason ?? new Error('The operation was aborted')); }; - signal.addEventListener("abort", abortHandler, { once: true }); + signal.addEventListener('abort', abortHandler, { once: true }); promise.then( (value) => { - signal.removeEventListener("abort", abortHandler); + signal.removeEventListener('abort', abortHandler); resolve(value); }, (error: unknown) => { - signal.removeEventListener("abort", abortHandler); + signal.removeEventListener('abort', abortHandler); reject(error); }, ); diff --git a/packages/mcp-servers/devtool-connector/src/daemon/static-server.ts b/packages/mcp-servers/devtool-connector/src/daemon/static-server.ts index c17dc54..79a9c74 100644 --- a/packages/mcp-servers/devtool-connector/src/daemon/static-server.ts +++ b/packages/mcp-servers/devtool-connector/src/daemon/static-server.ts @@ -2,39 +2,40 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import fs from "node:fs"; -import type http from "node:http"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import zlib from "node:zlib"; -import { createDebug } from "obug"; -import { DAEMON_INSPECTOR_PATH } from "./protocol.ts"; -import { TarballCache } from "./tarball-cache.ts"; +import fs from 'node:fs'; +import type http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import zlib from 'node:zlib'; +import { createDebug } from 'obug'; +import { DAEMON_INSPECTOR_PATH } from './protocol.ts'; +import { TarballCache } from './tarball-cache.ts'; -const debug = createDebug("devtool-mcp-server:daemon:static-server"); +const debug = createDebug('devtool-mcp-server:daemon:static-server'); -const DEVTOOL_FRONTEND_TARBALL_URL = "https://github.com/lynx-family/lynx-devtool/releases/download/devtools-frontend-lynx-7/devtool.frontend.lynx_1.0.1779085629.tar.gz"; -const DEVTOOL_FRONTEND_PATH_PREFIX = "/devtool-frontend/"; +const DEVTOOL_FRONTEND_TARBALL_URL = + 'https://github.com/lynx-family/lynx-devtool/releases/download/devtools-frontend-lynx-7/devtool.frontend.lynx_1.0.1779085629.tar.gz'; +const DEVTOOL_FRONTEND_PATH_PREFIX = '/devtool-frontend/'; const MIME_TYPES: Record = { - ".html": "text/html; charset=utf-8", - ".js": "application/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".png": "image/png", - ".jpg": "image/jpeg", - ".svg": "image/svg+xml", - ".woff2": "font/woff2", - ".woff": "font/woff", - ".ttf": "font/ttf", + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', }; export class StaticServer { #frontendCache: TarballCache | null = null; tryHandle(req: http.IncomingMessage, res: http.ServerResponse): boolean { - if (req.method !== "GET") return false; - const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname; + if (req.method !== 'GET') return false; + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; if (pathname === DAEMON_INSPECTOR_PATH) { this.#serveInspectorWrapper(res); @@ -51,74 +52,82 @@ export class StaticServer { #serveInspectorWrapper(res: http.ServerResponse): void { const base = path.dirname(fileURLToPath(import.meta.url)); - const primary = path.resolve(base, "../../public"); - const secondary = path.resolve(base, "../public"); + const primary = path.resolve(base, '../../public'); + const secondary = path.resolve(base, '../public'); const candidates = [primary, secondary]; - const filePath = candidates - .map((d) => path.join(d, "inspector-wrapper.html")) - .find((f) => fs.existsSync(f)) ?? path.join(primary, "inspector-wrapper.html"); - fs.readFile(filePath, "utf-8", (err, content) => { + const filePath = + candidates + .map((d) => path.join(d, 'inspector-wrapper.html')) + .find((f) => fs.existsSync(f)) ?? + path.join(primary, 'inspector-wrapper.html'); + fs.readFile(filePath, 'utf-8', (err, content) => { if (err) { res.writeHead(404); - res.end("Not found"); + res.end('Not found'); return; } res.writeHead(200, { - "content-type": "text/html; charset=utf-8", - "content-length": Buffer.byteLength(content), - "cache-control": "no-store", + 'content-type': 'text/html; charset=utf-8', + 'content-length': Buffer.byteLength(content), + 'cache-control': 'no-store', }); res.end(content); }); } - async #serveFrontendRes(req: http.IncomingMessage, res: http.ServerResponse, pathname: string): Promise { + async #serveFrontendRes( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string, + ): Promise { try { if (!this.#frontendCache) { this.#frontendCache = new TarballCache(); this.#frontendCache.start(DEVTOOL_FRONTEND_TARBALL_URL); } const relativePath = pathname.slice(DEVTOOL_FRONTEND_PATH_PREFIX.length); - if (relativePath.includes("..")) { + if (relativePath.includes('..')) { res.writeHead(404); - res.end("Not found"); + res.end('Not found'); return; } const ext = path.extname(relativePath).toLowerCase(); - if (ext === ".map") { + if (ext === '.map') { res.writeHead(404); - res.end("Not found"); + res.end('Not found'); return; } - const entry = this.#frontendCache.get(relativePath) ?? await this.#frontendCache.waitFor(relativePath); + const entry = + this.#frontendCache.get(relativePath) ?? + (await this.#frontendCache.waitFor(relativePath)); if (!entry) { res.writeHead(404); - res.end("Not found"); + res.end('Not found'); return; } - const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; - const acceptGzip = req.headers["accept-encoding"]?.includes("gzip"); + const contentType = MIME_TYPES[ext] ?? 'application/octet-stream'; + const acceptGzip = req.headers['accept-encoding']?.includes('gzip'); if (acceptGzip) { res.writeHead(200, { - "content-type": contentType, - "content-encoding": "gzip", - "content-length": entry.gzipped.length, - "cache-control": "public, max-age=31536000, immutable", + 'content-type': contentType, + 'content-encoding': 'gzip', + 'content-length': entry.gzipped.length, + 'cache-control': 'public, max-age=31536000, immutable', }); res.end(entry.gzipped); } else { const raw = zlib.gunzipSync(entry.gzipped); res.writeHead(200, { - "content-type": contentType, - "content-length": raw.length, - "cache-control": "public, max-age=31536000, immutable", + 'content-type': contentType, + 'content-length': raw.length, + 'cache-control': 'public, max-age=31536000, immutable', }); res.end(raw); } } catch (err) { - debug("failed to serve frontend file: %O", err); + debug('failed to serve frontend file: %O', err); res.writeHead(502); - res.end("Failed to load resource"); + res.end('Failed to load resource'); } } } diff --git a/packages/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts b/packages/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts index b405735..66dd047 100644 --- a/packages/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts +++ b/packages/mcp-servers/devtool-connector/src/daemon/tarball-cache.ts @@ -2,13 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import path from "node:path"; -import zlib from "node:zlib"; -import { createDebug } from "obug"; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { createDebug } from 'obug'; -const debug = createDebug("devtool-mcp-server:daemon:tarball-cache"); +const debug = createDebug('devtool-mcp-server:daemon:tarball-cache'); -const TAR_FILTER_PREFIX = ""; +const TAR_FILTER_PREFIX = ''; export interface TarballEntry { gzipped: Buffer; @@ -22,7 +22,13 @@ export interface TarballEntry { */ export class TarballCache { #files = new Map(); - #pending = new Map void; reject: (err: Error) => void }>>(); + #pending = new Map< + string, + Array<{ + resolve: (entry: TarballEntry | null) => void; + reject: (err: Error) => void; + }> + >(); #done = false; #error: Error | null = null; #loading: Promise | null = null; @@ -57,13 +63,13 @@ export class TarballCache { async #load(url: string): Promise { try { - // eslint-disable-next-line n/no-unsupported-features/node-builtins -- Node 18+ exposes fetch; keep undici out of the bundle. const response = await fetch(url); - if (!response.ok) throw new Error(`Failed to fetch tarball: ${response.status}`); - if (!response.body) throw new Error("No response body"); + if (!response.ok) + throw new Error(`Failed to fetch tarball: ${response.status}`); + if (!response.body) throw new Error('No response body'); const gunzip = zlib.createGunzip(); - const { Readable } = await import("node:stream"); + const { Readable } = await import('node:stream'); Readable.fromWeb(response.body as never).pipe(gunzip); let buf: Buffer = Buffer.alloc(0); @@ -74,7 +80,7 @@ export class TarballCache { this.#consumeTar(buf); } catch (err) { this.#error = err instanceof Error ? err : new Error(String(err)); - debug("tarball stream error: %O", this.#error); + debug('tarball stream error: %O', this.#error); } finally { this.#done = true; for (const [, waiters] of this.#pending) { @@ -84,7 +90,7 @@ export class TarballCache { } } this.#pending.clear(); - debug("tarball cache done: %d files", this.#files.size); + debug('tarball cache done: %d files', this.#files.size); } } @@ -96,21 +102,36 @@ export class TarballCache { continue; } - const rawName = header.subarray(0, 100).toString("utf-8").replace(/\0.*$/, ""); - const prefix = header.subarray(345, 500).toString("utf-8").replace(/\0.*$/, ""); + const rawName = header + .subarray(0, 100) + .toString('utf-8') + .replace(/\0.*$/, ''); + const prefix = header + .subarray(345, 500) + .toString('utf-8') + .replace(/\0.*$/, ''); const name = prefix ? `${prefix}/${rawName}` : rawName; - const sizeStr = header.subarray(124, 136).toString("utf-8").replace(/\0.*$/, "").trim(); + const sizeStr = header + .subarray(124, 136) + .toString('utf-8') + .replace(/\0.*$/, '') + .trim(); const size = parseInt(sizeStr, 8) || 0; const typeFlag = header[156]; const paddedSize = Math.ceil(size / 512) * 512; if (buf.length < 512 + paddedSize) break; - if ((typeFlag === 48 || typeFlag === 0) && name.startsWith(TAR_FILTER_PREFIX)) { + if ( + (typeFlag === 48 || typeFlag === 0) && + name.startsWith(TAR_FILTER_PREFIX) + ) { const fileData = buf.subarray(512, 512 + size); const ext = path.extname(name).toLowerCase(); - if (ext !== ".map") { - const gzipped = zlib.gzipSync(fileData, { level: zlib.constants.Z_BEST_SPEED }); + if (ext !== '.map') { + const gzipped = zlib.gzipSync(fileData, { + level: zlib.constants.Z_BEST_SPEED, + }); const entry: TarballEntry = { gzipped, rawSize: size }; this.#files.set(name, entry); const waiters = this.#pending.get(name); diff --git a/packages/mcp-servers/devtool-connector/src/daemon/version.ts b/packages/mcp-servers/devtool-connector/src/daemon/version.ts index ef4ec49..4041108 100644 --- a/packages/mcp-servers/devtool-connector/src/daemon/version.ts +++ b/packages/mcp-servers/devtool-connector/src/daemon/version.ts @@ -2,6 +2,6 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import packageJson from "../../package.json" with { type: "json" }; +import packageJson from '../../package.json' with { type: 'json' }; export const CONNECTOR_VERSION: string = packageJson.version; diff --git a/packages/mcp-servers/devtool-connector/src/index.ts b/packages/mcp-servers/devtool-connector/src/index.ts index 1fc9059..fadd782 100644 --- a/packages/mcp-servers/devtool-connector/src/index.ts +++ b/packages/mcp-servers/devtool-connector/src/index.ts @@ -2,25 +2,43 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -/* eslint-disable n/no-unpublished-import */ -import { randomInt } from "node:crypto"; -import { ReadableStream, TransformStream } from "node:stream/web"; -import { createDebug } from "obug"; -import { CDPOutputTransformStream, CDPRequestTransformStream, CDPResponseTransformStream } from "./streams/cdp.ts"; +import { randomInt } from 'node:crypto'; +import { ReadableStream, type TransformStream } from 'node:stream/web'; +import { createDebug } from 'obug'; +import { + CDPOutputTransformStream, + CDPRequestTransformStream, + CDPResponseTransformStream, +} from './streams/cdp.ts'; import { AppResponseTransformStream, CustomizedClientIdTransformStream, CustomizedRequestTransformStream, CustomizedResponseTransformStream, GlobalSwitchRequestTransformStream, -} from "./streams/customized.ts"; -import { FilterTransformStream, InspectStream, SessionGuardTransformStream } from "./streams/utils.ts"; - -export { CDPOutputTransformStream, CDPRequestTransformStream, CDPResponseTransformStream } from "./streams/cdp.ts"; - -import { ClientId } from "./client-id.ts"; -import { DaemonTransport } from "./transport/daemon.ts"; -import type { App, Client, Device, OpenAppOptions, Transport, TransportConnectOptions } from "./transport/transport.ts"; +} from './streams/customized.ts'; +import { + FilterTransformStream, + InspectStream, + SessionGuardTransformStream, +} from './streams/utils.ts'; + +export { + CDPOutputTransformStream, + CDPRequestTransformStream, + CDPResponseTransformStream, +} from './streams/cdp.ts'; + +import { ClientId } from './client-id.ts'; +import { DaemonTransport } from './transport/daemon.ts'; +import type { + App, + Client, + Device, + OpenAppOptions, + Transport, + TransportConnectOptions, +} from './transport/transport.ts'; import { type AppInfo, type CDPRequestMessage, @@ -39,9 +57,9 @@ import { type ListSessionRequest, type ListSessionResponse, type Session, -} from "./types.ts"; +} from './types.ts'; -const debug = createDebug("devtool-mcp-server:connector"); +const debug = createDebug('devtool-mcp-server:connector'); interface OutputStream extends AsyncDisposable, ReadableStream { inputClosed: Promise; @@ -59,7 +77,7 @@ type ClientListTransport = Transport & { }; function hasClientList(transport: Transport): transport is ClientListTransport { - return typeof transport.listClients === "function"; + return typeof transport.listClients === 'function'; } export class Connector { @@ -68,7 +86,9 @@ export class Connector { constructor(transports: Transport[]) { this.#transports = transports; - this.#daemonTransports = transports.filter((t): t is DaemonTransport => t instanceof DaemonTransport); + this.#daemonTransports = transports.filter( + (t): t is DaemonTransport => t instanceof DaemonTransport, + ); } async listClients(): Promise { @@ -82,19 +102,20 @@ export class Connector { return clients; }), ); - const fulfilledDaemonClientResults = daemonClientResults - .filter((r) => r.status === "fulfilled"); + const fulfilledDaemonClientResults = daemonClientResults.filter( + (r) => r.status === 'fulfilled', + ); const daemonClients = fulfilledDaemonClientResults.flatMap((r) => r.value); if (fulfilledDaemonClientResults.length > 0) { - debug("Using clients from daemon transport: %o", daemonClients); + debug('Using clients from daemon transport: %o', daemonClients); return daemonClients; } // 1. Try direct connection for other transports. const transportDevices = await Promise.allSettled( this.#transports - .filter(t => !(t instanceof DaemonTransport)) + .filter((t) => !(t instanceof DaemonTransport)) .map(async (transport) => ({ transport, devices: await transport.listDevices(), @@ -102,30 +123,37 @@ export class Connector { ); for (const result of transportDevices) { - if (result.status === "rejected") { - debug("listClients: listDevices failed on one transport: %O", result.reason); + if (result.status === 'rejected') { + debug( + 'listClients: listDevices failed on one transport: %O', + result.reason, + ); } } const results = await Promise.allSettled( transportDevices - .filter(r => r.status === "fulfilled") - .map(r => r.value) - .flatMap(({ transport, devices }) => devices.flatMap(({ id }) => this.#listClientsForDevice(transport, id))), + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value) + .flatMap(({ transport, devices }) => + devices.flatMap(({ id }) => + this.#listClientsForDevice(transport, id), + ), + ), ); return results - .filter((r) => r.status === "fulfilled") + .filter((r) => r.status === 'fulfilled') .flatMap((r) => r.value); } async listDevices(): Promise { const results = await Promise.allSettled( - this.#transports.map(t => t.listDevices()), + this.#transports.map((t) => t.listDevices()), ); return results - .filter(result => result.status === "fulfilled") + .filter((result) => result.status === 'fulfilled') .flatMap(({ value }) => value); } @@ -135,29 +163,36 @@ export class Connector { return await transport.listAvailableApps(deviceId); } - async openApp(deviceId: string, packageName: string, options?: OpenAppOptions): Promise { + async openApp( + deviceId: string, + packageName: string, + options?: OpenAppOptions, + ): Promise { const transport = await this.#findTransportWithDeviceId(deviceId); await transport.openApp(deviceId, packageName, options); - const signal = AbortSignal.any([ - options?.signal, - AbortSignal.timeout(60_000), - ].filter(i => i !== undefined)); + const signal = AbortSignal.any( + [options?.signal, AbortSignal.timeout(60_000)].filter( + (i) => i !== undefined, + ), + ); - const { setTimeout } = await import("node:timers/promises"); + const { setTimeout } = await import('node:timers/promises'); while (!signal.aborted) { try { const clients = hasClientList(transport) - ? (await transport.listClients()) - .filter(({ id }) => ClientId.deserialize(id)?.deviceId === deviceId) + ? (await transport.listClients()).filter( + ({ id }) => ClientId.deserialize(id)?.deviceId === deviceId, + ) : await this.#listClientsForDevice(transport, deviceId); if ( - clients.some(({ info }) => - /** Android */ info.AppProcessName === packageName - /** iOS */ || info.bundleId === packageName - || info.bundleName === packageName + clients.some( + ({ info }) => + /** Android */ info.AppProcessName === packageName || + /** iOS */ info.bundleId === packageName || + info.bundleName === packageName, ) ) { break; @@ -186,10 +221,7 @@ export class Connector { * disposes of the connection. This is useful for fire-and-forget messages * such as `xdb_proxy_config` where the device does not send a reply. */ - async sendMessageNoReply( - clientId: string, - message: T, - ): Promise { + async sendMessageNoReply(clientId: string, message: T): Promise { const { deviceId, port } = this.#resolveClientId(clientId); const transport = await this.#findTransportWithDeviceId(deviceId); const signal = AbortSignal.timeout(5_000); @@ -201,11 +233,13 @@ export class Connector { const inputStream = [ new CustomizedClientIdTransformStream(port), new InspectStream((msg: unknown) => - debug(`sendMessageNoReply ${deviceId}:${port} send %o`, JSON.stringify(msg)) + debug( + `sendMessageNoReply ${deviceId}:${port} send %o`, + JSON.stringify(msg), + ), ), ].reduce( (stream, transform) => stream.pipeThrough(transform), - // eslint-disable-next-line n/no-unsupported-features/node-builtins ReadableStream.from([message]), ); @@ -223,22 +257,28 @@ export class Connector { ): Promise { const id = randomInt(10_000, 50_000); - return await this.#sendMessage, Output>(clientId, { - method, - params: /** App message requires params to be an object */ { ...params }, - }, { - input: [ - new CustomizedRequestTransformStream({ - type: "App", - sessionId: -1, - messageBuilder: (message) => ({ id, ...message }), - }), - ], - output: [ - new CustomizedResponseTransformStream("App", id), - new AppResponseTransformStream(method), - ], - }); + return await this.#sendMessage, Output>( + clientId, + { + method, + params: /** App message requires params to be an object */ { + ...params, + }, + }, + { + input: [ + new CustomizedRequestTransformStream({ + type: 'App', + sessionId: -1, + messageBuilder: (message) => ({ id, ...message }), + }), + ], + output: [ + new CustomizedResponseTransformStream('App', id), + new AppResponseTransformStream(method), + ], + }, + ); } async sendCDPMessage( @@ -250,32 +290,40 @@ export class Connector { ): Promise { const id = randomInt(10_000, 50_000); - const SUPPORTED_DOMAIN = ["Debugger", "Runtime", "HeapProfiler", "Profiler"]; + const SUPPORTED_DOMAIN = [ + 'Debugger', + 'Runtime', + 'HeapProfiler', + 'Profiler', + ]; - if (isMainThread && !SUPPORTED_DOMAIN.some(domain => method.startsWith(domain + "."))) { + if ( + isMainThread && + !SUPPORTED_DOMAIN.some((domain) => method.startsWith(domain + '.')) + ) { throw new Error( - `Method ${method} is not supported for main thread. Supported domains: ${SUPPORTED_DOMAIN.join(", ")}`, + `Method ${method} is not supported for main thread. Supported domains: ${SUPPORTED_DOMAIN.join(', ')}`, ); } - return await this.#sendMessage, Output>(clientId, { - method, - params, - sessionId: isMainThread ? "Main" : undefined, - }, { - input: [ - new CDPRequestTransformStream(sessionId, id), - ], - output: [ - new CDPResponseTransformStream(id), - new CDPOutputTransformStream(), - ], - }); + return await this.#sendMessage, Output>( + clientId, + { + method, + params, + sessionId: isMainThread ? 'Main' : undefined, + }, + { + input: [new CDPRequestTransformStream(sessionId, id)], + output: [ + new CDPResponseTransformStream(id), + new CDPOutputTransformStream(), + ], + }, + ); } - async sendListSessionMessage( - clientId: string, - ): Promise { + async sendListSessionMessage(clientId: string): Promise { return await this.#sendListSessionMessage(clientId); } @@ -287,20 +335,23 @@ export class Connector { * request that could be cut off mid-download. */ async prepareHeadless(clientId: string): Promise { - const { data: { data: state } } = await this.#sendMessage( + const { + data: { data: state }, + } = await this.#sendMessage< + HeadlessPrepareRequest, + HeadlessPrepareResponse + >( clientId, { - event: "Customized", + event: 'Customized', data: { - type: "HeadlessPrepare", + type: 'HeadlessPrepare', data: {}, }, }, { input: [], - output: [ - new FilterTransformStream(isHeadlessPrepareResponse), - ], + output: [new FilterTransformStream(isHeadlessPrepareResponse)], }, ); @@ -322,14 +373,14 @@ export class Connector { ): Promise { const timeoutMs = options.timeoutMs ?? 5 * 60_000; const pollIntervalMs = options.pollIntervalMs ?? 1_000; - const { setTimeout: delay } = await import("node:timers/promises"); + const { setTimeout: delay } = await import('node:timers/promises'); const deadline = Date.now() + timeoutMs; let lastError: string | undefined; for (;;) { const state = await this.prepareHeadless(clientId); - if (state.status === "ready") return; - if (state.status === "error") { - lastError = state.message ?? "unknown error"; + if (state.status === 'ready') return; + if (state.status === 'error') { + lastError = state.message ?? 'unknown error'; } if (Date.now() >= deadline) { throw new Error( @@ -342,51 +393,48 @@ export class Connector { } } - async #sendListSessionMessage( - clientId: string, - ): Promise { - const { data: { data: sessions } } = await this.#sendMessage( + async #sendListSessionMessage(clientId: string): Promise { + const { + data: { data: sessions }, + } = await this.#sendMessage( clientId, { - event: "Customized", + event: 'Customized', data: { - type: "ListSession", + type: 'ListSession', data: {}, }, }, { input: [], - output: [ - new FilterTransformStream(isListSessionResponse), - ], + output: [new FilterTransformStream(isListSessionResponse)], }, ); - return sessions.map(session => ({ + return sessions.map((session) => ({ ...session, - type: session.type === "" ? "lynx" : session.type, + type: session.type === '' ? 'lynx' : session.type, })); } - async getGlobalSwitch( - clientId: string, - key: GlobalKeys, - ): Promise { + async getGlobalSwitch(clientId: string, key: GlobalKeys): Promise { const { - data: { data: { message } }, - } = await this.#sendMessage<{ key: GlobalKeys }, GetGlobalSwitchResponse>(clientId, { key }, { - input: [ - new GlobalSwitchRequestTransformStream("GetGlobalSwitch"), - ], - output: [ - new FilterTransformStream(isGetGlobalSwitchResponse), - ], - }); + data: { + data: { message }, + }, + } = await this.#sendMessage<{ key: GlobalKeys }, GetGlobalSwitchResponse>( + clientId, + { key }, + { + input: [new GlobalSwitchRequestTransformStream('GetGlobalSwitch')], + output: [new FilterTransformStream(isGetGlobalSwitchResponse)], + }, + ); - if (typeof message === "object") { - return message?.global_value === "true" || message?.global_value === true; + if (typeof message === 'object') { + return message?.global_value === 'true' || message?.global_value === true; } else { - return message === "true" || message === true; + return message === 'true' || message === true; } } @@ -395,20 +443,26 @@ export class Connector { key: GlobalKeys, value: boolean, ): Promise { - await this.#sendMessage(clientId, { key, value }, { - input: [ - new GlobalSwitchRequestTransformStream("SetGlobalSwitch"), - ], - output: [ - new FilterTransformStream(isSetGlobalSwitchResponse), - ], - }); + await this.#sendMessage( + clientId, + { key, value }, + { + input: [new GlobalSwitchRequestTransformStream('SetGlobalSwitch')], + output: [new FilterTransformStream(isSetGlobalSwitchResponse)], + }, + ); } async sendStream( clientId: string, inputStream: ReadableStream, - { signal, pipeline }: { signal?: AbortSignal | undefined; pipeline?: Pipeline | undefined } = {}, + { + signal, + pipeline, + }: { + signal?: AbortSignal | undefined; + pipeline?: Pipeline | undefined; + } = {}, ): Promise> { const { deviceId, port } = this.#resolveClientId(clientId); const transport = await this.#findTransportWithDeviceId(deviceId); @@ -430,9 +484,7 @@ export class Connector { return await this.sendStream(clientId, inputStream, { signal, pipeline: { - input: [ - new CDPRequestTransformStream(sessionId), - ], + input: [new CDPRequestTransformStream(sessionId)], output: [ new SessionGuardTransformStream(sessionId), new CDPResponseTransformStream(), @@ -454,13 +506,16 @@ export class Connector { // this device, stick to it for follow-up requests. Otherwise a faster direct // transport can win the race here and bypass the stable daemon path that was // used during discovery, which is exactly what made list-sessions flaky. - const daemonTransport = await this.#findTransportWithDeviceIdInPool(this.#daemonTransports, deviceId); + const daemonTransport = await this.#findTransportWithDeviceIdInPool( + this.#daemonTransports, + deviceId, + ); if (daemonTransport) { return daemonTransport; } const transport = await this.#findTransportWithDeviceIdInPool( - this.#transports.filter(t => !(t instanceof DaemonTransport)), + this.#transports.filter((t) => !(t instanceof DaemonTransport)), deviceId, ); if (transport) { @@ -470,12 +525,15 @@ export class Connector { throw new Error(`Device with id: ${deviceId} not found`); } - async #findTransportWithDeviceIdInPool(transports: Transport[], deviceId: string): Promise { + async #findTransportWithDeviceIdInPool( + transports: Transport[], + deviceId: string, + ): Promise { return await Promise.any( transports.map(async (transport) => { const devices = await transport.listDevices(); if (devices.some(({ id }) => id === deviceId)) return transport; - throw new Error("Not found in this transport"); + throw new Error('Not found in this transport'); }), ).catch(() => null); } @@ -495,20 +553,32 @@ export class Connector { const inputClosed = [ ...pipeline.input, new CustomizedClientIdTransformStream(port), - new InspectStream((msg) => debug(`connect ${deviceId}:${port} input stream send %o`, JSON.stringify(msg))), - ].reduce((stream, transform) => stream.pipeThrough(transform), inputStream) - .pipeTo(conn.writable, { preventClose: true, signal: inputAbortController.signal }) + new InspectStream((msg) => + debug( + `connect ${deviceId}:${port} input stream send %o`, + JSON.stringify(msg), + ), + ), + ] + .reduce((stream, transform) => stream.pipeThrough(transform), inputStream) + .pipeTo(conn.writable, { + preventClose: true, + signal: inputAbortController.signal, + }) .catch((err) => { - if (err?.name !== "AbortError") { + if (err?.name !== 'AbortError') { debug(`connect ${deviceId}:${port} input stream err %O`, err); } }); const outputStream = [ - new InspectStream((msg) => debug(`connect ${deviceId}:${port} output stream receive %O`, msg)), + new InspectStream((msg) => + debug(`connect ${deviceId}:${port} output stream receive %O`, msg), + ), ...pipeline.output, ].reduce( - (stream, transform) => stream.pipeThrough(transform, { preventCancel: true }), + (stream, transform) => + stream.pipeThrough(transform, { preventCancel: true }), conn.readable, ); @@ -551,7 +621,6 @@ export class Connector { transport, options, // We have polyfill for this - // eslint-disable-next-line n/no-unsupported-features/node-builtins ReadableStream.from([input]), pipeline, ); @@ -573,53 +642,55 @@ export class Connector { const MIN_PORT = 8901; const PORTS = Array.from({ length: 10 }, (_, i) => MIN_PORT + i); const signal = AbortSignal.timeout(5_000); - const results = await Promise.allSettled(PORTS.map(async (port: number) => { - const { data: { info } } = await this.#sendMessageWithTransport( - transport, - { deviceId, port, signal }, - { event: "Initialize", data: port }, - { - input: [], - output: [ - new FilterTransformStream(isInitializeResponse), - ], - }, - ); + const results = await Promise.allSettled( + PORTS.map(async (port: number) => { + const { + data: { info }, + } = await this.#sendMessageWithTransport< + InitializeRequest, + InitializeResponse + >( + transport, + { deviceId, port, signal }, + { event: 'Initialize', data: port }, + { + input: [], + output: [new FilterTransformStream(isInitializeResponse)], + }, + ); - const clientId = ClientId.serialize(deviceId, port); - await this.#setupClient(transport, clientId); + const clientId = ClientId.serialize(deviceId, port); + await this.#setupClient(transport, clientId); - return { id: clientId, info, port }; - })); + return { id: clientId, info, port }; + }), + ); return results - .filter(result => result.status === "fulfilled") - .map(result => result.value); + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value); } async #setupClient(transport: Transport, clientId: string): Promise { const { deviceId, port } = this.#resolveClientId(clientId); - for ( - const input of [ - { key: "enable_devtool", value: true }, - // `enable_quickjs_debug` is required for `Runtime.*` and `HeapProfiler.*` to work, - // so we enable it by default. It won't have effect if the devtool doesn't support quickjs debug. - // And it will not turn off `enable_v8` if it's already on, so it won't break v8 debug. - { key: "enable_quickjs_debug", value: true }, - ] as const - ) { + for (const input of [ + { key: 'enable_devtool', value: true }, + // `enable_quickjs_debug` is required for `Runtime.*` and `HeapProfiler.*` to work, + // so we enable it by default. It won't have effect if the devtool doesn't support quickjs debug. + // And it will not turn off `enable_v8` if it's already on, so it won't break v8 debug. + { key: 'enable_quickjs_debug', value: true }, + ] as const) { try { - await this.#sendMessageWithTransport<{ key: GlobalKeys; value: boolean }, never>( + await this.#sendMessageWithTransport< + { key: GlobalKeys; value: boolean }, + never + >( transport, { deviceId, port, signal: AbortSignal.timeout(3_000) }, input, { - input: [ - new GlobalSwitchRequestTransformStream("SetGlobalSwitch"), - ], - output: [ - new FilterTransformStream(isSetGlobalSwitchResponse), - ], + input: [new GlobalSwitchRequestTransformStream('SetGlobalSwitch')], + output: [new FilterTransformStream(isSetGlobalSwitchResponse)], }, ); } catch (err) { @@ -629,4 +700,4 @@ export class Connector { } } -export * from "./types.ts"; +export * from './types.ts'; diff --git a/packages/mcp-servers/devtool-connector/src/streams/cdp.ts b/packages/mcp-servers/devtool-connector/src/streams/cdp.ts index e4f305c..659f714 100644 --- a/packages/mcp-servers/devtool-connector/src/streams/cdp.ts +++ b/packages/mcp-servers/devtool-connector/src/streams/cdp.ts @@ -2,20 +2,18 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { randomInt } from "node:crypto"; -import type { CDPRequestMessage, CDPResponseMessage } from "../types.ts"; +import { randomInt } from 'node:crypto'; +import type { CDPRequestMessage, CDPResponseMessage } from '../types.ts'; import { CustomizedRequestTransformStream, CustomizedResponseTransformStream, ResponseParserTransformStream, -} from "./customized.ts"; +} from './customized.ts'; -export class CDPRequestTransformStream extends CustomizedRequestTransformStream< - CDPRequestMessage -> { +export class CDPRequestTransformStream extends CustomizedRequestTransformStream { constructor(sessionId: number, fixedId?: number) { super({ - type: "CDP", + type: 'CDP', sessionId, messageBuilder: (chunk) => { const id = fixedId ?? randomInt(10_000, 50_000); @@ -25,31 +23,34 @@ export class CDPRequestTransformStream extends CustomizedRequestTransformStream< } } -export class CDPResponseTransformStream - extends CustomizedResponseTransformStream<"CDP", Output> -{ +export class CDPResponseTransformStream< + Output = CDPResponseMessage, +> extends CustomizedResponseTransformStream<'CDP', Output> { constructor(id?: number) { - super("CDP", id); + super('CDP', id); } } -export class CDPOutputTransformStream extends ResponseParserTransformStream { +export class CDPOutputTransformStream< + Output, +> extends ResponseParserTransformStream { constructor() { super({ checkError: (message) => { - if ("error" in message) { - return new Error( - `CDP request error: ${message.error.message}`, - { cause: message }, - ); + if ('error' in message) { + return new Error(`CDP request error: ${message.error.message}`, { + cause: message, + }); } return null; }, parseResult: (message) => { - if ("result" in message) { + if ('result' in message) { return message.result as Output; } - throw new Error("No result in CDP response message", { cause: message }); + throw new Error('No result in CDP response message', { + cause: message, + }); }, }); } diff --git a/packages/mcp-servers/devtool-connector/src/streams/customized.ts b/packages/mcp-servers/devtool-connector/src/streams/customized.ts index 210dc97..afa76d2 100644 --- a/packages/mcp-servers/devtool-connector/src/streams/customized.ts +++ b/packages/mcp-servers/devtool-connector/src/streams/customized.ts @@ -2,20 +2,20 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { TransformStream } from "node:stream/web"; +import { TransformStream } from 'node:stream/web'; import { type AppResponseMessage, type CustomizedResponseMap, type CustomizedResponseMessageMap, isCustomizedResponseWithType, type Response, -} from "../types.ts"; +} from '../types.ts'; export class CustomizedClientIdTransformStream extends TransformStream { constructor(clientId: number) { super({ transform(chunk, controller) { - if (chunk.event === "Customized") { + if (chunk.event === 'Customized') { controller.enqueue({ ...chunk, data: { @@ -35,7 +35,9 @@ export class CustomizedClientIdTransformStream extends TransformStream { } } -export class CustomizedRequestTransformStream extends TransformStream { +export class CustomizedRequestTransformStream< + T = unknown, +> extends TransformStream { constructor(options: { type: string; sessionId?: number | ((chunk: T) => number); @@ -44,9 +46,10 @@ export class CustomizedRequestTransformStream extends TransformStre const { type, sessionId = -1, messageBuilder } = options; super({ transform(chunk, controller) { - const sid = typeof sessionId === "function" ? sessionId(chunk) : sessionId; + const sid = + typeof sessionId === 'function' ? sessionId(chunk) : sessionId; controller.enqueue({ - event: "Customized", + event: 'Customized', data: { type, data: { @@ -72,7 +75,9 @@ export class CustomizedResponseTransformStream< } try { - const message = JSON.parse(response.data.data.message) as Output & { id?: number }; + const message = JSON.parse(response.data.data.message) as Output & { + id?: number; + }; if (id === undefined || message?.id === id) { controller.enqueue(message); } @@ -91,7 +96,10 @@ export class CustomizedResponseTransformStream< /** * Common response parser that handles JSON parsing and error checking in the payload. */ -export class ResponseParserTransformStream extends TransformStream { +export class ResponseParserTransformStream< + Input, + Output, +> extends TransformStream { constructor(options: { parseResult: (input: Input) => Output; checkError: (input: Input) => Error | null; @@ -115,18 +123,30 @@ export class ResponseParserTransformStream extends TransformStrea } } -export class AppResponseTransformStream extends ResponseParserTransformStream { +export class AppResponseTransformStream< + Output, +> extends ResponseParserTransformStream { constructor(method: string) { super({ checkError: (message) => { try { - const result = JSON.parse(message.result) as { code: number | string; message: string }; - if (/** Android */ result.code !== 0 && /** iOS */ result.code !== "0") { - return new Error(`App request ${method} error: ${result.message}`, { cause: message }); + const result = JSON.parse(message.result) as { + code: number | string; + message: string; + }; + if ( + /** Android */ result.code !== 0 && + /** iOS */ result.code !== '0' + ) { + return new Error(`App request ${method} error: ${result.message}`, { + cause: message, + }); } return null; } catch (err) { - return new Error("Failed to parse App response message", { cause: err }); + return new Error('Failed to parse App response message', { + cause: err, + }); } }, parseResult: (message) => { @@ -140,11 +160,14 @@ export class GlobalSwitchRequestTransformStream extends CustomizedRequestTransfo key: string; value?: boolean; }> { - constructor(type: "SetGlobalSwitch" | "GetGlobalSwitch") { + constructor(type: 'SetGlobalSwitch' | 'GetGlobalSwitch') { super({ type, sessionId: -1, - messageBuilder: ({ key, value }) => ({ global_key: key, global_value: value }), + messageBuilder: ({ key, value }) => ({ + global_key: key, + global_value: value, + }), }); } } diff --git a/packages/mcp-servers/devtool-connector/src/streams/index.ts b/packages/mcp-servers/devtool-connector/src/streams/index.ts index 39c47b4..ac9467b 100644 --- a/packages/mcp-servers/devtool-connector/src/streams/index.ts +++ b/packages/mcp-servers/devtool-connector/src/streams/index.ts @@ -2,6 +2,6 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -export * from "./cdp.ts"; -export * from "./customized.ts"; -export * from "./utils.ts"; +export * from './cdp.ts'; +export * from './customized.ts'; +export * from './utils.ts'; diff --git a/packages/mcp-servers/devtool-connector/src/streams/utils.ts b/packages/mcp-servers/devtool-connector/src/streams/utils.ts index 32d5e7c..9e49961 100644 --- a/packages/mcp-servers/devtool-connector/src/streams/utils.ts +++ b/packages/mcp-servers/devtool-connector/src/streams/utils.ts @@ -2,11 +2,14 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { TransformStream } from "node:stream/web"; -import type { Response, Session } from "../types.ts"; -import { isListSessionResponse } from "../types.ts"; +import { TransformStream } from 'node:stream/web'; +import type { Response, Session } from '../types.ts'; +import { isListSessionResponse } from '../types.ts'; -export class FilterTransformStream extends TransformStream { +export class FilterTransformStream extends TransformStream< + T, + P +> { constructor(filter: (chunk: T) => chunk is P) { super({ transform(chunk, controller) { @@ -36,7 +39,10 @@ export class InspectStream extends TransformStream { * though the underlying transport connection remains open (shared with other * sessions on the same device:port). */ -export class SessionGuardTransformStream extends TransformStream { +export class SessionGuardTransformStream extends TransformStream< + Response, + Response +> { constructor(sessionId: number) { super({ transform(chunk, controller) { @@ -46,7 +52,7 @@ export class SessionGuardTransformStream extends TransformStream s?.session_id === sessionId)) { + if (!sessions.some((s) => s?.session_id === sessionId)) { controller.terminate(); return; } diff --git a/packages/mcp-servers/devtool-connector/src/takeover.ts b/packages/mcp-servers/devtool-connector/src/takeover.ts index e29ba9e..1656617 100644 --- a/packages/mcp-servers/devtool-connector/src/takeover.ts +++ b/packages/mcp-servers/devtool-connector/src/takeover.ts @@ -2,16 +2,19 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { createDebug } from "obug"; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createDebug } from 'obug'; -const debug = createDebug("devtool-mcp-server:takeover"); +const debug = createDebug('devtool-mcp-server:takeover'); -const DEBUG_ROUTER_DIR = path.join(os.homedir(), ".DebugRouterConnector"); -const DEBUG_ROUTER_LOCK_DIR = path.join(DEBUG_ROUTER_DIR, "lockfile"); -const DEBUG_ROUTER_LATEST_FILE = path.join(DEBUG_ROUTER_DIR, "LatestDriverProcess"); +const DEBUG_ROUTER_DIR = path.join(os.homedir(), '.DebugRouterConnector'); +const DEBUG_ROUTER_LOCK_DIR = path.join(DEBUG_ROUTER_DIR, 'lockfile'); +const DEBUG_ROUTER_LATEST_FILE = path.join( + DEBUG_ROUTER_DIR, + 'LatestDriverProcess', +); export async function takeoverDebugRouterLock(): Promise { try { @@ -21,15 +24,15 @@ export async function takeoverDebugRouterLock(): Promise { await fs.mkdir(DEBUG_ROUTER_LOCK_DIR, { recursive: true }); - await fs.writeFile(DEBUG_ROUTER_LATEST_FILE, `${process.pid}`, "utf-8"); + await fs.writeFile(DEBUG_ROUTER_LATEST_FILE, `${process.pid}`, 'utf-8'); debug(`wrote PID=${process.pid}`); } catch (err) { - debug("skipped due to filesystem error %O", err); + debug('skipped due to filesystem error %O', err); } finally { try { await fs.rm(DEBUG_ROUTER_LOCK_DIR, { recursive: true, force: true }); } catch (_cleanupError) { - debug("failed to remove lock directory %O", _cleanupError); + debug('failed to remove lock directory %O', _cleanupError); } } } diff --git a/packages/mcp-servers/devtool-connector/src/transport/android.ts b/packages/mcp-servers/devtool-connector/src/transport/android.ts index 6a8d76b..3583ee4 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/android.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/android.ts @@ -2,18 +2,25 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Adb, AdbServerClient } from "@yume-chan/adb"; -import { AdbServerNodeTcpConnector } from "@yume-chan/adb-server-node-tcp"; -import type { SocketConnectOpts } from "node:net"; -import { createDebug } from "obug"; -import { connectWithPeertalk } from "./base.ts"; -import type { App, Connection, Device, OpenAppOptions, Transport, TransportConnectOptions } from "./transport.ts"; - -const debug = createDebug("devtool-mcp-server:connector:android"); +import type { SocketConnectOpts } from 'node:net'; +import { type Adb, AdbServerClient } from '@yume-chan/adb'; +import { AdbServerNodeTcpConnector } from '@yume-chan/adb-server-node-tcp'; +import { createDebug } from 'obug'; +import { connectWithPeertalk } from './base.ts'; +import type { + App, + Connection, + Device, + OpenAppOptions, + Transport, + TransportConnectOptions, +} from './transport.ts'; + +const debug = createDebug('devtool-mcp-server:connector:android'); const KNOWNS_APPS: Array = [ - { packageName: "com.lynx.uiapp", name: "Lynx Example" }, - { packageName: "com.lynx.explorer", name: "Lynx Explorer" }, + { packageName: 'com.lynx.uiapp', name: 'Lynx Example' }, + { packageName: 'com.lynx.explorer', name: 'Lynx Explorer' }, ]; export class AndroidTransport implements Transport { @@ -26,7 +33,10 @@ export class AndroidTransport implements Transport { async connect( options: TransportConnectOptions, ): Promise> { - return connectWithPeertalk(opts => this.#connectRaw(opts), options); + return connectWithPeertalk( + (opts) => this.#connectRaw(opts), + options, + ); } async #createAdb(deviceId: string): Promise { @@ -40,13 +50,15 @@ export class AndroidTransport implements Transport { async close(): Promise { // noop - debug("Android transport closed"); + debug('Android transport closed'); return; } - async #connectRaw( - { deviceId, port, signal }: TransportConnectOptions, - ): Promise { + async #connectRaw({ + deviceId, + port, + signal, + }: TransportConnectOptions): Promise { const adb = await this.client.createAdb({ serial: deviceId }); debug(`connect: create connection to deviceId: ${deviceId}, port: ${port}`); @@ -55,7 +67,7 @@ export class AndroidTransport implements Transport { const service = `tcp:${port}`; - let socket: Awaited>; + let socket: Awaited>; try { socket = await adb.createSocket(service); } catch (err) { @@ -75,7 +87,7 @@ export class AndroidTransport implements Transport { debug(`connect: socket ${service} close on abort err: %o`, err); }); }; - signal?.addEventListener("abort", abortHandler, { once: true }); + signal?.addEventListener('abort', abortHandler, { once: true }); void Promise.resolve(socket.closed).catch((err: unknown) => { debug(`connect: socket ${service} closed with err: %o`, err); @@ -85,8 +97,10 @@ export class AndroidTransport implements Transport { readable: socket.readable as never, writable: socket.writable as never, async [Symbol.asyncDispose]() { - signal?.removeEventListener("abort", abortHandler); - debug(`connect: close connection to deviceId: ${deviceId}, port: ${port}`); + signal?.removeEventListener('abort', abortHandler); + debug( + `connect: close connection to deviceId: ${deviceId}, port: ${port}`, + ); try { await socket.close(); } finally { @@ -99,10 +113,10 @@ export class AndroidTransport implements Transport { async listDevices(): Promise { const devices = await this.client.getDevices(); - debug("listDevices: devices %o", devices); + debug('listDevices: devices %o', devices); return devices.map(({ serial }) => ({ - os: "Android", + os: 'Android', id: serial, })); } @@ -111,16 +125,16 @@ export class AndroidTransport implements Transport { await using adb = await this.#createAdb(deviceId); const output = await adb.subprocess.noneProtocol.spawnWaitText([ // adb shell pm list packages - "pm", - "list", - "packages", - "-3", // third-party apps only + 'pm', + 'list', + 'packages', + '-3', // third-party apps only ]); const packages = new Set( output - .split("\n") - .map((line) => line.replace("package:", "").trim()) - .filter(i => i !== ""), + .split('\n') + .map((line) => line.replace('package:', '').trim()) + .filter((i) => i !== ''), ); debug(`listAvailableApps all packages: %o`, packages); @@ -142,8 +156,8 @@ export class AndroidTransport implements Transport { if (withDataCleared) { const output = await adb.subprocess.noneProtocol.spawnWaitText([ // adb shell pm clear - "pm", - "clear", + 'pm', + 'clear', packageName, ]); debug(`openApp clear data output ${output}`); @@ -151,18 +165,20 @@ export class AndroidTransport implements Transport { const output = await adb.subprocess.noneProtocol.spawnWaitText([ // adb shell monkey -p -c android.intent.category.LAUNCHER 1 - "monkey", - "-p", + 'monkey', + '-p', packageName, - "-c", - "android.intent.category.LAUNCHER", - "1", + '-c', + 'android.intent.category.LAUNCHER', + '1', ]); debug(`openApp LAUNCHER output ${output}`); - if (output.includes("No activities found")) { - throw new Error(`No launchable activity found for package ${packageName}.`); + if (output.includes('No activities found')) { + throw new Error( + `No launchable activity found for package ${packageName}.`, + ); } - if (output.includes("monkey aborted")) { + if (output.includes('monkey aborted')) { throw new Error(`Failed to open app ${packageName}.`); } } diff --git a/packages/mcp-servers/devtool-connector/src/transport/base.ts b/packages/mcp-servers/devtool-connector/src/transport/base.ts index 334af47..dd7c6ee 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/base.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/base.ts @@ -2,27 +2,48 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { TransformStream } from "node:stream/web"; -import { takeoverDebugRouterLock } from "../takeover.ts"; -import { MessageToPeertalkTransformStream, PeertalkToMessageTransformStream } from "./peertalk.ts"; -import type { Connection, TransportConnectOptions } from "./transport.ts"; +import type { TransformStream } from 'node:stream/web'; +import { takeoverDebugRouterLock } from '../takeover.ts'; +import { + MessageToPeertalkTransformStream, + PeertalkToMessageTransformStream, +} from './peertalk.ts'; +import type { Connection, TransportConnectOptions } from './transport.ts'; export interface MessageCodecFactory { - createEncodeTransformStream(): TransformStream; - createDecodeTransformStream(): TransformStream; + createEncodeTransformStream(): TransformStream< + TInput, + Uint8Array + >; + createDecodeTransformStream(): TransformStream< + Uint8Array, + TOutput + >; } export const peertalkCodecFactory: MessageCodecFactory = { - createEncodeTransformStream(): TransformStream { + createEncodeTransformStream(): TransformStream< + TInput, + Uint8Array + > { return new MessageToPeertalkTransformStream(); }, - createDecodeTransformStream(): TransformStream { - return new PeertalkToMessageTransformStream() as TransformStream; + createDecodeTransformStream(): TransformStream< + Uint8Array, + TOutput + > { + return new PeertalkToMessageTransformStream() as TransformStream< + Uint8Array, + TOutput + >; }, }; -export async function createMessageConnection( +export async function createMessageConnection< + TInput = unknown, + TOutput = unknown, +>( connectRaw: (options: TransportConnectOptions) => Promise, codecFactory: MessageCodecFactory, options: TransportConnectOptions, @@ -32,15 +53,20 @@ export async function createMessageConnection { - if (err?.name !== "AbortError") { + void encoder.readable + .pipeTo(conn.writable, { + preventClose: true, + signal: pipeAbortController.signal, + }) + .catch((err) => { + if (err?.name !== 'AbortError') { void conn[Symbol.asyncDispose](); } - }, - ); + }); - const readable = conn.readable.pipeThrough(codecFactory.createDecodeTransformStream()); + const readable = conn.readable.pipeThrough( + codecFactory.createDecodeTransformStream(), + ); return { readable, @@ -57,5 +83,9 @@ export async function connectWithPeertalk( options: TransportConnectOptions, ): Promise> { await takeoverDebugRouterLock(); - return createMessageConnection(connectRaw, peertalkCodecFactory, options); + return createMessageConnection( + connectRaw, + peertalkCodecFactory, + options, + ); } diff --git a/packages/mcp-servers/devtool-connector/src/transport/daemon.ts b/packages/mcp-servers/devtool-connector/src/transport/daemon.ts index 661a6ec..9875f6b 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/daemon.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/daemon.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { randomInt } from "node:crypto"; -import { TransformStream, WritableStream } from "node:stream/web"; -import type { ReadableStream } from "node:stream/web"; -import { createDebug } from "obug"; -import { DaemonManager, DEFAULT_DAEMON_PORT } from "../daemon/manager.ts"; -import type { ClientListEntry, ControlResponse } from "../daemon/protocol.ts"; +import { randomInt } from 'node:crypto'; +import type { ReadableStream } from 'node:stream/web'; +import { TransformStream, WritableStream } from 'node:stream/web'; +import { createDebug } from 'obug'; +import { DaemonManager, DEFAULT_DAEMON_PORT } from '../daemon/manager.ts'; +import type { ClientListEntry, ControlResponse } from '../daemon/protocol.ts'; import type { App, Client, @@ -16,9 +16,9 @@ import type { OpenAppOptions, Transport, TransportConnectOptions, -} from "./transport.ts"; +} from './transport.ts'; -const debug = createDebug("devtool-mcp-server:daemon:transport"); +const debug = createDebug('devtool-mcp-server:daemon:transport'); /** * A Transport implementation that communicates with devices through the @@ -42,22 +42,33 @@ export class DaemonTransport implements Transport { } async listDevices(): Promise { - const result = await this.#controlRequest("listDevices"); + const result = await this.#controlRequest('listDevices'); return result; } async listAvailableApps(deviceId: string): Promise { - const result = await this.#controlRequest("listAvailableApps", { deviceId }); + const result = await this.#controlRequest('listAvailableApps', { + deviceId, + }); return result; } - async openApp(deviceId: string, packageName: string, options?: OpenAppOptions): Promise { - await this.#controlRequest("openApp", { deviceId, packageName, withDataCleared: options?.withDataCleared }); + async openApp( + deviceId: string, + packageName: string, + options?: OpenAppOptions, + ): Promise { + await this.#controlRequest('openApp', { + deviceId, + packageName, + withDataCleared: options?.withDataCleared, + }); } async listClients(): Promise { - const entries = await this.#controlRequest("listClients"); - debug("received ClientList with %d entries", entries.length); + const entries = + await this.#controlRequest('listClients'); + debug('received ClientList with %d entries', entries.length); return entries.map(({ id, info }) => ({ id, info })); } @@ -65,20 +76,19 @@ export class DaemonTransport implements Transport { options: TransportConnectOptions, ): Promise> { const { deviceId, port, signal } = options; - debug("connect to %s:%d via daemon", deviceId, port); + debug('connect to %s:%d via daemon', deviceId, port); await DaemonManager.ensureRunning(this.#port); const conn = await this.#createWebSocketConnection(signal); try { // Subscribe this WS session to the target device:port on the daemon - await this.#controlRequestOnConn(conn, "subscribe", { deviceId, port }); + await this.#controlRequestOnConn(conn, 'subscribe', { deviceId, port }); } catch (error) { await conn[Symbol.asyncDispose](); - throw new Error( - `Failed to subscribe to ${deviceId}:${port}`, - { cause: error }, - ); + throw new Error(`Failed to subscribe to ${deviceId}:${port}`, { + cause: error, + }); } const writable = new WritableStream({ @@ -92,8 +102,9 @@ export class DaemonTransport implements Transport { }); // Set up output pipeline: WS → JSON parse → TOutput - const outputReadable = conn.readable - .pipeThrough(new JSONStringToObjectStream()) as ReadableStream; + const outputReadable = conn.readable.pipeThrough( + new JSONStringToObjectStream(), + ) as ReadableStream; return { readable: outputReadable, @@ -124,7 +135,10 @@ export class DaemonTransport implements Transport { * the matching ControlResponse. */ async #controlRequestOnConn( - conn: { readable: ReadableStream; writable: WritableStream }, + conn: { + readable: ReadableStream; + writable: WritableStream; + }, method: string, params?: Record, ): Promise { @@ -135,7 +149,7 @@ export class DaemonTransport implements Transport { await this.#writeMessage( conn.writable, JSON.stringify({ - event: "Control", + event: 'Control', data: { id, method, params }, }), signal, @@ -144,8 +158,8 @@ export class DaemonTransport implements Transport { for await (const value of this.#readMessages(conn.readable, signal)) { const msg = value as { event: string; data: unknown }; - if (msg.event === "ControlResponse") { - const resp = msg.data as ControlResponse["data"]; + if (msg.event === 'ControlResponse') { + const resp = msg.data as ControlResponse['data']; if (resp.id === id) { if (resp.error) { throw new Error(resp.error); @@ -168,20 +182,23 @@ export class DaemonTransport implements Transport { signal?.throwIfAborted(); - const { wsStreams } = await import("./ws-stream.ts"); + const { wsStreams } = await import('./ws-stream.ts'); const wss = wsStreams.create(url); const abortHandler = () => { wss.close(); }; - signal?.addEventListener("abort", abortHandler, { once: true }); + signal?.addEventListener('abort', abortHandler, { once: true }); wss.closed.catch(() => { - debug("WebSocket to daemon closed"); + debug('WebSocket to daemon closed'); }); try { - const { readable, writable } = await this.#withAbortSignal(wss.opened, signal); + const { readable, writable } = await this.#withAbortSignal( + wss.opened, + signal, + ); // Read Initialize message const reader = readable.getReader(); @@ -189,11 +206,14 @@ export class DaemonTransport implements Transport { reader.releaseLock(); if (done) { - throw new Error("WebSocket closed before initialization."); + throw new Error('WebSocket closed before initialization.'); } - const initMsg = JSON.parse(value as string) as { event: "Initialize"; data: number }; - if (initMsg.event !== "Initialize") { + const initMsg = JSON.parse(value as string) as { + event: 'Initialize'; + data: number; + }; + if (initMsg.event !== 'Initialize') { throw new Error(`Expected Initialize, got ${initMsg.event}`); } @@ -203,8 +223,8 @@ export class DaemonTransport implements Transport { await this.#writeMessage( writable, JSON.stringify({ - event: "Register", - data: { id: assignedId, type: "Driver" }, + event: 'Register', + data: { id: assignedId, type: 'Driver' }, }), signal, ); @@ -214,29 +234,36 @@ export class DaemonTransport implements Transport { readable, writable, async [Symbol.asyncDispose]() { - signal?.removeEventListener("abort", abortHandler); + signal?.removeEventListener('abort', abortHandler); wss.close(); await wss.closed.catch(() => {}); }, }; } catch (err) { - signal?.removeEventListener("abort", abortHandler); + signal?.removeEventListener('abort', abortHandler); try { wss.close(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } try { await wss.closed; - } catch { /* ignore */ } + } catch { + /* ignore */ + } throw err; } } - async *#readMessages(readable: ReadableStream, signal: AbortSignal): AsyncGenerator { + async *#readMessages( + readable: ReadableStream, + signal: AbortSignal, + ): AsyncGenerator { const reader = readable.getReader(); const abortHandler = () => { void reader.cancel(signal.reason); }; - signal.addEventListener("abort", abortHandler, { once: true }); + signal.addEventListener('abort', abortHandler, { once: true }); try { while (!signal.aborted) { @@ -249,16 +276,23 @@ export class DaemonTransport implements Transport { } } } finally { - signal.removeEventListener("abort", abortHandler); + signal.removeEventListener('abort', abortHandler); reader.releaseLock(); } } - async #writeMessage(writable: WritableStream, chunk: string, signal?: AbortSignal) { + async #writeMessage( + writable: WritableStream, + chunk: string, + signal?: AbortSignal, + ) { await writeMessage(writable, chunk, signal); } - async #withAbortSignal(promise: Promise, signal?: AbortSignal): Promise { + async #withAbortSignal( + promise: Promise, + signal?: AbortSignal, + ): Promise { if (!signal) return promise; signal.throwIfAborted(); @@ -266,14 +300,14 @@ export class DaemonTransport implements Transport { const abortHandler = () => { reject(signal.reason); }; - signal.addEventListener("abort", abortHandler, { once: true }); + signal.addEventListener('abort', abortHandler, { once: true }); promise.then( (value) => { - signal.removeEventListener("abort", abortHandler); + signal.removeEventListener('abort', abortHandler); resolve(value); }, (error: unknown) => { - signal.removeEventListener("abort", abortHandler); + signal.removeEventListener('abort', abortHandler); reject(error); }, ); @@ -281,9 +315,13 @@ export class DaemonTransport implements Transport { } } -function routeDaemonMessage(chunk: T, sender: number, port: number): unknown { - if (isRecord(chunk) && chunk["event"] === "Customized") { - const data = isRecord(chunk["data"]) ? chunk["data"] : {}; +function routeDaemonMessage( + chunk: T, + sender: number, + port: number, +): unknown { + if (isRecord(chunk) && chunk['event'] === 'Customized') { + const data = isRecord(chunk['data']) ? chunk['data'] : {}; return { ...chunk, data: { @@ -301,29 +339,36 @@ function stringifyMessage(message: unknown): string { try { return JSON.stringify(message); } catch (err) { - throw new Error(`Failed to stringify object: ${err instanceof Error ? err.message : String(err)}`, { - cause: err, - }); + throw new Error( + `Failed to stringify object: ${err instanceof Error ? err.message : String(err)}`, + { + cause: err, + }, + ); } } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; + return typeof value === 'object' && value !== null; } -async function writeMessage(writable: WritableStream, chunk: string, signal?: AbortSignal) { +async function writeMessage( + writable: WritableStream, + chunk: string, + signal?: AbortSignal, +) { signal?.throwIfAborted(); const writer = writable.getWriter(); const abortHandler = () => { void writer.abort(signal?.reason); }; - signal?.addEventListener("abort", abortHandler, { once: true }); + signal?.addEventListener('abort', abortHandler, { once: true }); try { await writer.write(chunk); } finally { - signal?.removeEventListener("abort", abortHandler); + signal?.removeEventListener('abort', abortHandler); writer.releaseLock(); } } @@ -335,7 +380,11 @@ class JSONStringToObjectStream extends TransformStream { try { controller.enqueue(JSON.parse(chunk)); } catch (err) { - controller.error(new Error(`Failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`)); + controller.error( + new Error( + `Failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`, + ), + ); } }, }); diff --git a/packages/mcp-servers/devtool-connector/src/transport/desktop.ts b/packages/mcp-servers/devtool-connector/src/transport/desktop.ts index 2248e9a..6784592 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/desktop.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/desktop.ts @@ -2,27 +2,36 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import net from "node:net"; -import { Duplex } from "node:stream"; -import { createDebug } from "obug"; -import { connectWithPeertalk } from "./base.ts"; -import type { App, Connection, Device, Transport, TransportConnectOptions } from "./transport.ts"; +import net from 'node:net'; +import { Duplex } from 'node:stream'; +import { createDebug } from 'obug'; +import { connectWithPeertalk } from './base.ts'; +import type { + App, + Connection, + Device, + Transport, + TransportConnectOptions, +} from './transport.ts'; -const debug = createDebug("devtool-mcp-server:connector:desktop"); +const debug = createDebug('devtool-mcp-server:connector:desktop'); export class DesktopTransport implements Transport { async connect( options: TransportConnectOptions, ): Promise> { - return connectWithPeertalk(opts => this.#connectRaw(opts), options); + return connectWithPeertalk( + (opts) => this.#connectRaw(opts), + options, + ); } async close(): Promise { - debug("Desktop transport closed"); + debug('Desktop transport closed'); } async listDevices(): Promise { - return [{ id: "localhost", os: "Desktop" }]; + return [{ id: 'localhost', os: 'Desktop' }]; } async listAvailableApps(deviceId: string): Promise { @@ -33,7 +42,7 @@ export class DesktopTransport implements Transport { async openApp(deviceId: string, packageName: string): Promise { void deviceId; void packageName; - throw new Error("openApp is not supported on DesktopTransport"); + throw new Error('openApp is not supported on DesktopTransport'); } async #connectRaw({ @@ -41,7 +50,7 @@ export class DesktopTransport implements Transport { port, signal, }: TransportConnectOptions): Promise { - if (deviceId !== "localhost") { + if (deviceId !== 'localhost') { throw new Error( `DesktopTransport only supports 'localhost' deviceId, got: ${deviceId}`, ); @@ -49,15 +58,15 @@ export class DesktopTransport implements Transport { debug(`connect: connecting to 127.0.0.1:${port}`); - const socket = net.createConnection({ host: "127.0.0.1", port, signal }); + const socket = net.createConnection({ host: '127.0.0.1', port, signal }); try { if (!socket.connecting) { // already connected or failed immediately } else { await new Promise((resolve, reject) => { - socket.once("connect", resolve); - socket.once("error", reject); + socket.once('connect', resolve); + socket.once('error', reject); }); } diff --git a/packages/mcp-servers/devtool-connector/src/transport/index.ts b/packages/mcp-servers/devtool-connector/src/transport/index.ts index b24fe56..31f3a03 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/index.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/index.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -export * from "./android.ts"; -export * from "./base.ts"; -export * from "./daemon.ts"; -export * from "./desktop.ts"; -export * from "./ios.ts"; -export * from "./peertalk.ts"; -export * from "./transport.ts"; -export * from "./usbmux.ts"; -export * from "./ws-stream.ts"; +export * from './android.ts'; +export * from './base.ts'; +export * from './daemon.ts'; +export * from './desktop.ts'; +export * from './ios.ts'; +export * from './peertalk.ts'; +export * from './transport.ts'; +export * from './usbmux.ts'; +export * from './ws-stream.ts'; diff --git a/packages/mcp-servers/devtool-connector/src/transport/ios.ts b/packages/mcp-servers/devtool-connector/src/transport/ios.ts index 7033fe2..a16462e 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/ios.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/ios.ts @@ -2,13 +2,20 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { NetConnectOpts } from "node:net"; -import { createDebug } from "obug"; -import { connectWithPeertalk } from "./base.ts"; -import type { App, Connection, Device, OpenAppOptions, Transport, TransportConnectOptions } from "./transport.ts"; -import { Usbmux } from "./usbmux.ts"; +import type { NetConnectOpts } from 'node:net'; +import { createDebug } from 'obug'; +import { connectWithPeertalk } from './base.ts'; +import type { + App, + Connection, + Device, + OpenAppOptions, + Transport, + TransportConnectOptions, +} from './transport.ts'; +import { Usbmux } from './usbmux.ts'; -const debug = createDebug("devtool-mcp-server:connector:ios"); +const debug = createDebug('devtool-mcp-server:connector:ios'); export class iOSTransport implements Transport { #client: Usbmux; @@ -20,16 +27,21 @@ export class iOSTransport implements Transport { async connect( options: TransportConnectOptions, ): Promise> { - return connectWithPeertalk(opts => this.#connectRaw(opts), options); + return connectWithPeertalk( + (opts) => this.#connectRaw(opts), + options, + ); } async close(): Promise { - debug("iOS transport closed"); + debug('iOS transport closed'); } - async #connectRaw( - { deviceId, port, signal }: TransportConnectOptions, - ): Promise { + async #connectRaw({ + deviceId, + port, + signal, + }: TransportConnectOptions): Promise { debug(`connect: create connection to deviceId: ${deviceId}, port: ${port}`); const id = await this.#resolveUsbmuxDeviceId(deviceId, signal); @@ -39,20 +51,27 @@ export class iOSTransport implements Transport { readable: conn.readable, writable: conn.writable, async [Symbol.asyncDispose]() { - debug(`connect: close connection to deviceId: ${deviceId}, port: ${port}`); + debug( + `connect: close connection to deviceId: ${deviceId}, port: ${port}`, + ); conn.dispose(); }, }; } - async #resolveUsbmuxDeviceId(deviceId: string, signal?: AbortSignal): Promise { + async #resolveUsbmuxDeviceId( + deviceId: string, + signal?: AbortSignal, + ): Promise { const numericDeviceId = Number(deviceId); if (Number.isInteger(numericDeviceId)) { return numericDeviceId; } const devices = await this.#client.listDevices(signal); - const device = devices.find(({ Properties }) => Properties.SerialNumber === deviceId); + const device = devices.find( + ({ Properties }) => Properties.SerialNumber === deviceId, + ); if (!device) { throw new Error(`iOS device with id: ${deviceId} not found`); } @@ -62,19 +81,19 @@ export class iOSTransport implements Transport { async listDevices(): Promise { const devices = await this.#client.listDevices(AbortSignal.timeout(1_000)); - debug("listDevices: devices %o", devices); + debug('listDevices: devices %o', devices); return devices.map(({ Properties }) => ({ - os: "iOS", + os: 'iOS', id: Properties.SerialNumber, })); } async listAvailableApps(): Promise { - throw new Error("Not implemented"); + throw new Error('Not implemented'); } // eslint-disable-next-line @typescript-eslint/no-unused-vars async openApp(_: string, __?: string, ___?: OpenAppOptions): Promise { - throw new Error("Not implemented"); + throw new Error('Not implemented'); } } diff --git a/packages/mcp-servers/devtool-connector/src/transport/peertalk.ts b/packages/mcp-servers/devtool-connector/src/transport/peertalk.ts index 9dde0a7..d66eb33 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/peertalk.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/peertalk.ts @@ -2,10 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { TransformStream } from "node:stream/web"; -import type { Response } from "../types.ts"; +import { TransformStream } from 'node:stream/web'; +import type { Response } from '../types.ts'; -export class PeertalkToMessageTransformStream extends TransformStream { +export class PeertalkToMessageTransformStream extends TransformStream< + Uint8Array, + Response +> { constructor() { let buffer = new Uint8Array(0); const decoder = new TextDecoder(); @@ -24,7 +27,9 @@ export class PeertalkToMessageTransformStream extends TransformStream extends TransformStream< - TMessage, - Uint8Array -> { +export class MessageToPeertalkTransformStream< + TMessage = unknown, +> extends TransformStream { constructor() { const encoder = new TextEncoder(); diff --git a/packages/mcp-servers/devtool-connector/src/transport/transport.ts b/packages/mcp-servers/devtool-connector/src/transport/transport.ts index 20365e0..e0ab200 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/transport.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/transport.ts @@ -2,8 +2,8 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { ReadableStream, WritableStream } from "node:stream/web"; -import type { AppInfo } from "../types.ts"; +import type { ReadableStream, WritableStream } from 'node:stream/web'; +import type { AppInfo } from '../types.ts'; export interface TransportConnectOptions { deviceId: string; @@ -25,16 +25,22 @@ export interface Transport { listAvailableApps(deviceId: string): Promise; - openApp(deviceId: string, packageName: string, options?: OpenAppOptions): Promise; + openApp( + deviceId: string, + packageName: string, + options?: OpenAppOptions, + ): Promise; - connect(options: TransportConnectOptions): Promise>; + connect( + options: TransportConnectOptions, + ): Promise>; readonly persistent?: boolean; } export interface Device { id: string; - os: "iOS" | "Android" | "Desktop"; + os: 'iOS' | 'Android' | 'Desktop'; } export interface Client { @@ -47,7 +53,8 @@ export interface App { name: string; } -export interface Connection extends AsyncDisposable { +export interface Connection + extends AsyncDisposable { readable: ReadableStream; writable: WritableStream; } diff --git a/packages/mcp-servers/devtool-connector/src/transport/usbmux.ts b/packages/mcp-servers/devtool-connector/src/transport/usbmux.ts index c209540..36bb657 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/usbmux.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/usbmux.ts @@ -2,11 +2,11 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { on, once } from "node:events"; -import * as net from "node:net"; -import { Duplex } from "node:stream"; -import { ReadableStream, WritableStream } from "node:stream/web"; -import { build, parse, type PlistValue } from "plist"; +import { on, once } from 'node:events'; +import * as net from 'node:net'; +import { Duplex } from 'node:stream'; +import type { ReadableStream, WritableStream } from 'node:stream/web'; +import { build, type PlistValue, parse } from 'plist'; const HEADER_SIZE = 16; const USBMUXD_VERSION = 1; @@ -29,19 +29,21 @@ export interface UsbmuxdDeviceRecord { Properties: UsbmuxdDeviceProperties; } -export type UsbmuxdResponse = UsbmuxdListDevicesResponse | UsbmuxdResultResponse; +export type UsbmuxdResponse = + | UsbmuxdListDevicesResponse + | UsbmuxdResultResponse; export interface UsbmuxdListDevicesResponse { DeviceList: UsbmuxdDeviceRecord[]; } export interface UsbmuxdResultResponse { - MessageType: "Result"; + MessageType: 'Result'; Number: number; } export interface UsbmuxdConnectRequest { - MessageType: "Connect"; + MessageType: 'Connect'; ClientVersionString: string; ProgName: string; DeviceID: number; @@ -49,7 +51,7 @@ export interface UsbmuxdConnectRequest { } export interface UsbmuxdListDevicesRequest { - MessageType: "ListDevices"; + MessageType: 'ListDevices'; ClientVersionString: string; ProgName: string; } @@ -66,24 +68,29 @@ export class Usbmux { private connectOptions: net.NetConnectOpts; constructor(connectOptions?: net.NetConnectOpts | string) { - if (typeof connectOptions === "string") { + if (typeof connectOptions === 'string') { this.connectOptions = { path: connectOptions }; } else if (connectOptions) { this.connectOptions = connectOptions; } else { - this.connectOptions = { path: "/var/run/usbmuxd" }; + this.connectOptions = { path: '/var/run/usbmuxd' }; } } - public async listDevices(signal?: AbortSignal): Promise { + public async listDevices( + signal?: AbortSignal, + ): Promise { const { socket, response } = await this.#sendAndReceive< UsbmuxdListDevicesRequest, UsbmuxdListDevicesResponse - >({ - MessageType: "ListDevices", - ClientVersionString: "usbmux-driver", - ProgName: "usbmux-driver", - }, signal); + >( + { + MessageType: 'ListDevices', + ClientVersionString: 'usbmux-driver', + ProgName: 'usbmux-driver', + }, + signal, + ); socket.destroy(); @@ -96,20 +103,23 @@ export class Usbmux { signal?: AbortSignal, ): Promise { // Port must be in network byte order (big-endian) - const networkPort = ((port >> 8) & 0xFF) | ((port << 8) & 0xFF00); + const networkPort = ((port >> 8) & 0xff) | ((port << 8) & 0xff00); const { socket, response, tail } = await this.#sendAndReceive< UsbmuxdConnectRequest, UsbmuxdResultResponse - >({ - MessageType: "Connect", - ClientVersionString: "usbmux-driver", - ProgName: "usbmux-driver", - DeviceID: Number(deviceId), - PortNumber: networkPort, - }, signal); - - if (response.MessageType === "Result" && response.Number === 0) { + >( + { + MessageType: 'Connect', + ClientVersionString: 'usbmux-driver', + ProgName: 'usbmux-driver', + DeviceID: Number(deviceId), + PortNumber: networkPort, + }, + signal, + ); + + if (response.MessageType === 'Result' && response.Number === 0) { if (tail.length > 0) { socket.unshift(tail); } @@ -123,7 +133,9 @@ export class Usbmux { } socket.destroy(); - throw new Error(`Invalid response for Connect: ${JSON.stringify(response)}`); + throw new Error( + `Invalid response for Connect: ${JSON.stringify(response)}`, + ); } async #sendAndReceive( @@ -134,12 +146,14 @@ export class Usbmux { if (signal) { const abortHandler = () => socket.destroy(); - signal.addEventListener("abort", abortHandler, { once: true }); - socket.once("close", () => signal.removeEventListener("abort", abortHandler)); + signal.addEventListener('abort', abortHandler, { once: true }); + socket.once('close', () => + signal.removeEventListener('abort', abortHandler), + ); } try { - await once(socket, "connect", { signal }); + await once(socket, 'connect', { signal }); socket.write(encodeRequest()); @@ -148,7 +162,7 @@ export class Usbmux { // We still use Node.js streams for the handshake part because `Duplex.toWeb` // consumes the stream, making it hard to "peek" or "unshift" without extra overhead. // Once handshake is done, we convert to Web Streams in `connect`. - for await (const [chunk] of on(socket, "data", { signal })) { + for await (const [chunk] of on(socket, 'data', { signal })) { buffer = Buffer.concat([buffer, chunk]); if (buffer.length < HEADER_SIZE) continue; @@ -158,11 +172,11 @@ export class Usbmux { const responseBuffer = buffer.subarray(HEADER_SIZE, length); const tail = buffer.subarray(length); - const response = parse(responseBuffer.toString("utf8")) as R; + const response = parse(responseBuffer.toString('utf8')) as R; return { socket, response, tail }; } - throw new Error("Connection closed before response received"); + throw new Error('Connection closed before response received'); } catch (error) { socket.destroy(); throw error; @@ -170,7 +184,7 @@ export class Usbmux { function encodeRequest(): Buffer { const xml = build(payload as PlistValue); - const body = Buffer.from(xml, "utf8"); + const body = Buffer.from(xml, 'utf8'); const length = HEADER_SIZE + body.length; const header = Buffer.alloc(HEADER_SIZE); header.writeUInt32LE(length, 0); diff --git a/packages/mcp-servers/devtool-connector/src/transport/ws-stream.ts b/packages/mcp-servers/devtool-connector/src/transport/ws-stream.ts index 70cb0be..aeb5324 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/ws-stream.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/ws-stream.ts @@ -2,8 +2,8 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { ReadableStream, WritableStream } from "node:stream/web"; -import { WebSocket } from "ws"; +import { ReadableStream, WritableStream } from 'node:stream/web'; +import { WebSocket } from 'ws'; /** * A lightweight wrapper around the `ws` WebSocket that exposes a @@ -41,26 +41,28 @@ export class WsWebSocketStream { const onClose = () => { cleanup(); - reject(new Error("WebSocket closed before opening.")); + reject(new Error('WebSocket closed before opening.')); this.#resolveClosed(); }; const cleanup = () => { - ws.removeListener("error", onError); - ws.removeListener("close", onClose); + ws.removeListener('error', onError); + ws.removeListener('close', onClose); }; - ws.once("open", () => { + ws.once('open', () => { cleanup(); // --- readable --- const readable = new ReadableStream({ start(controller) { - ws.on("message", (data) => { - controller.enqueue(typeof data === "string" ? data : data.toString()); + ws.on('message', (data) => { + controller.enqueue( + typeof data === 'string' ? data : data.toString(), + ); }); - ws.on("close", () => { + ws.on('close', () => { try { controller.close(); } catch { @@ -68,7 +70,7 @@ export class WsWebSocketStream { } }); - ws.on("error", (err) => { + ws.on('error', (err) => { try { controller.error(err); } catch { @@ -102,17 +104,17 @@ export class WsWebSocketStream { resolve({ readable, writable }); // Wire up closed promise - ws.on("close", () => { + ws.on('close', () => { this.#resolveClosed(); }); - ws.on("error", (err) => { + ws.on('error', (err) => { this.#rejectClosed(err); }); }); - ws.once("error", onError); - ws.once("close", onClose); + ws.once('error', onError); + ws.once('close', onClose); }); } diff --git a/packages/mcp-servers/devtool-connector/src/types.ts b/packages/mcp-servers/devtool-connector/src/types.ts index 7bc694f..2f6b4b8 100644 --- a/packages/mcp-servers/devtool-connector/src/types.ts +++ b/packages/mcp-servers/devtool-connector/src/types.ts @@ -7,10 +7,13 @@ type Event = { data: D; }; -type CustomizedEvent = Event<"Customized", { - type: TType; - data: TData; -}>; +type CustomizedEvent = Event< + 'Customized', + { + type: TType; + data: TData; + } +>; export type AppInfo = { App: string; @@ -29,36 +32,54 @@ export type AppInfo = { sdkVersion: string; osType?: string; }; -export type InitializeRequest = Event<"Initialize", number>; -export type InitializeResponse = Event<"Register", { - id: number; - info: AppInfo; -}>; +export type InitializeRequest = Event<'Initialize', number>; +export type InitializeResponse = Event< + 'Register', + { + id: number; + info: AppInfo; + } +>; -export type AppResponse = CustomizedEvent<"App", { - /** JSON string. See {@link AppResponseMessage} for parsed result. */ - message: string; -}>; +export type AppResponse = CustomizedEvent< + 'App', + { + /** JSON string. See {@link AppResponseMessage} for parsed result. */ + message: string; + } +>; export type AppResponseMessage = { id: number; /** JSON string */ result: string; }; -export type CDPRequestMessage = { method: string; params?: T | undefined }; -export type CDPRequest = CustomizedEvent<"CDP", { - client_id: number; - session_id: number; - message: CDPRequestMessage & { id: number }; -}>; -export type CDPResponse = CustomizedEvent<"CDP", { - /** JSON string. See {@link CDPResponseMessage} for parsed result. */ - message: string; -}>; -export type CDPResponseMessage = { id: number } & ({ result: unknown } | { error: { code: number; message: string } }); +export type CDPRequestMessage = { + method: string; + params?: T | undefined; +}; +export type CDPRequest = CustomizedEvent< + 'CDP', + { + client_id: number; + session_id: number; + message: CDPRequestMessage & { id: number }; + } +>; +export type CDPResponse = CustomizedEvent< + 'CDP', + { + /** JSON string. See {@link CDPResponseMessage} for parsed result. */ + message: string; + } +>; +export type CDPResponseMessage = { id: number } & ( + | { result: unknown } + | { error: { code: number; message: string } } +); export type Session = { session_id: number; - type: "" | "lynx" | "web"; + type: '' | 'lynx' | 'web'; url: string; /** * Headless (embedded-lynx) runtime metadata, present only for sessions @@ -71,8 +92,11 @@ export type Session = { logFile: string; }; }; -export type ListSessionRequest = CustomizedEvent<"ListSession", Record>; -export type ListSessionResponse = CustomizedEvent<"SessionList", Session[]>; +export type ListSessionRequest = CustomizedEvent< + 'ListSession', + Record +>; +export type ListSessionResponse = CustomizedEvent<'SessionList', Session[]>; /** * Readiness probe for the headless (embedded-lynx) runtime. The headless @@ -81,76 +105,106 @@ export type ListSessionResponse = CustomizedEvent<"SessionList", Session[]>; * timeout instead of a single request that could be cut off. */ export type HeadlessPrepareState = { - status: "ready" | "preparing" | "error"; + status: 'ready' | 'preparing' | 'error'; message?: string; }; -export type HeadlessPrepareRequest = CustomizedEvent<"HeadlessPrepare", Record>; -export type HeadlessPrepareResponse = CustomizedEvent<"HeadlessPrepare", HeadlessPrepareState>; +export type HeadlessPrepareRequest = CustomizedEvent< + 'HeadlessPrepare', + Record +>; +export type HeadlessPrepareResponse = CustomizedEvent< + 'HeadlessPrepare', + HeadlessPrepareState +>; // See: https://github.com/lynx-family/lynx/blob/f36190e701964032d92e70e9515538497460ea31/platform/android/lynx_android/src/main/java/com/lynx/devtoolwrapper/DevToolSettings.java#L31-L44 export type GlobalKeys = - | "enable_devtool" - | "enable_logbox" - | "enable_debug_mode" - | "enable_dom_tree" - | "enable_quickjs_debug" - | "enable_quickjs_cache" - | "enable_v8" - | "enable_cdp_domain_dom" - | "enable_cdp_domain_css" - | "enable_cdp_domain_page" - | "enable_long_press_menu" - | "enable_highlight_touch" - | "enable_preview_screen_shot" - | "enable_pixel_copy" - | "enable_fsp_screenshot"; -export type GetGlobalSwitchRequest = CustomizedEvent<"GetGlobalSwitch", { - client_id: number; - session_id: number; - message: { global_key: GlobalKeys }; -}>; -export type GetGlobalSwitchResponse = CustomizedEvent<"GetGlobalSwitch", { - client_id: number; - session_id: number; - message: string | boolean | { global_value: string | boolean }; -}>; -export type SetGlobalSwitchRequest = CustomizedEvent<"SetGlobalSwitch", { - client_id: number; - session_id: number; - message: { global_key: GlobalKeys; global_value: boolean }; -}>; -export type SetGlobalSwitchResponse = CustomizedEvent<"SetGlobalSwitch", { - client_id: number; - session_id: number; - /** JSON string */ - message: string; -}>; + | 'enable_devtool' + | 'enable_logbox' + | 'enable_debug_mode' + | 'enable_dom_tree' + | 'enable_quickjs_debug' + | 'enable_quickjs_cache' + | 'enable_v8' + | 'enable_cdp_domain_dom' + | 'enable_cdp_domain_css' + | 'enable_cdp_domain_page' + | 'enable_long_press_menu' + | 'enable_highlight_touch' + | 'enable_preview_screen_shot' + | 'enable_pixel_copy' + | 'enable_fsp_screenshot'; +export type GetGlobalSwitchRequest = CustomizedEvent< + 'GetGlobalSwitch', + { + client_id: number; + session_id: number; + message: { global_key: GlobalKeys }; + } +>; +export type GetGlobalSwitchResponse = CustomizedEvent< + 'GetGlobalSwitch', + { + client_id: number; + session_id: number; + message: string | boolean | { global_value: string | boolean }; + } +>; +export type SetGlobalSwitchRequest = CustomizedEvent< + 'SetGlobalSwitch', + { + client_id: number; + session_id: number; + message: { global_key: GlobalKeys; global_value: boolean }; + } +>; +export type SetGlobalSwitchResponse = CustomizedEvent< + 'SetGlobalSwitch', + { + client_id: number; + session_id: number; + /** JSON string */ + message: string; + } +>; -export type XdbJsbRequest = CustomizedEvent<"xdb_jsb", { - client_id: number; - session_id: number; - message: { type: string }; -}>; +export type XdbJsbRequest = CustomizedEvent< + 'xdb_jsb', + { + client_id: number; + session_id: number; + message: { type: string }; + } +>; -export type XdbJsbResponse = CustomizedEvent<"xdb_jsb", { - client_id: number; - session_id: number; - /** JSON string */ - message: string; -}>; +export type XdbJsbResponse = CustomizedEvent< + 'xdb_jsb', + { + client_id: number; + session_id: number; + /** JSON string */ + message: string; + } +>; -export type XdbGlobalPropsRequest = CustomizedEvent<"xdb_globalprops", { - client_id: number; - session_id: number; - message: { type: string; timestamp: string }; -}>; +export type XdbGlobalPropsRequest = CustomizedEvent< + 'xdb_globalprops', + { + client_id: number; + session_id: number; + message: { type: string; timestamp: string }; + } +>; -export type XdbGlobalPropsResponse = CustomizedEvent<"xdb_globalprops", { - client_id: number; - session_id: number; - /** JSON string */ - message: string; -}>; +export type XdbGlobalPropsResponse = CustomizedEvent< + 'xdb_globalprops', + { + client_id: number; + session_id: number; + /** JSON string */ + message: string; + } +>; export type CustomizedResponseMap = { App: AppResponse; @@ -176,29 +230,46 @@ export type Response = | XdbGlobalPropsResponse | HeadlessPrepareResponse; -export function isInitializeResponse(response: Response): response is InitializeResponse { - return response.event === "Register"; +export function isInitializeResponse( + response: Response, +): response is InitializeResponse { + return response.event === 'Register'; } -export function isHeadlessPrepareResponse(response: Response): response is HeadlessPrepareResponse { - return response.event === "Customized" && response.data.type === "HeadlessPrepare"; +export function isHeadlessPrepareResponse( + response: Response, +): response is HeadlessPrepareResponse { + return ( + response.event === 'Customized' && response.data.type === 'HeadlessPrepare' + ); } -export function isListSessionResponse(response: Response): response is ListSessionResponse { - return response.event === "Customized" && response.data.type === "SessionList"; +export function isListSessionResponse( + response: Response, +): response is ListSessionResponse { + return ( + response.event === 'Customized' && response.data.type === 'SessionList' + ); } -export function isGetGlobalSwitchResponse(response: Response): response is GetGlobalSwitchResponse { - return response.event === "Customized" && response.data.type === "GetGlobalSwitch"; +export function isGetGlobalSwitchResponse( + response: Response, +): response is GetGlobalSwitchResponse { + return ( + response.event === 'Customized' && response.data.type === 'GetGlobalSwitch' + ); } -export function isSetGlobalSwitchResponse(response: Response): response is SetGlobalSwitchResponse { - return response.event === "Customized" && response.data.type === "SetGlobalSwitch"; +export function isSetGlobalSwitchResponse( + response: Response, +): response is SetGlobalSwitchResponse { + return ( + response.event === 'Customized' && response.data.type === 'SetGlobalSwitch' + ); } -export function isCustomizedResponseWithType( - response: Response, - type: T, -): response is CustomizedResponseMap[T] { - return response.event === "Customized" && response.data.type === type; +export function isCustomizedResponseWithType< + T extends keyof CustomizedResponseMap, +>(response: Response, type: T): response is CustomizedResponseMap[T] { + return response.event === 'Customized' && response.data.type === type; } diff --git a/packages/mcp-servers/devtool-connector/test/clientId.test.ts b/packages/mcp-servers/devtool-connector/test/clientId.test.ts index c4ec590..d6f1236 100644 --- a/packages/mcp-servers/devtool-connector/test/clientId.test.ts +++ b/packages/mcp-servers/devtool-connector/test/clientId.test.ts @@ -2,66 +2,69 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { describe, test } from "node:test"; -import type { TestContext } from "node:test"; -import { ClientId } from "../src/index.ts"; +import type { TestContext } from 'node:test'; +import { describe, test } from 'node:test'; +import { ClientId } from '../src/index.ts'; -describe("ClientId", () => { - describe("serialize", () => { - test("returns deviceId:port format", (t: TestContext) => { - const serialized = ClientId.serialize("device-001", 9000); +describe('ClientId', () => { + describe('serialize', () => { + test('returns deviceId:port format', (t: TestContext) => { + const serialized = ClientId.serialize('device-001', 9000); - t.assert.equal(serialized, "device-001:9000"); + t.assert.equal(serialized, 'device-001:9000'); }); - test("URL encodes deviceId", (t: TestContext) => { - const serialized = ClientId.serialize("设备 001/测试", 9100); + test('URL encodes deviceId', (t: TestContext) => { + const serialized = ClientId.serialize('设备 001/测试', 9100); - t.assert.equal(serialized, "%E8%AE%BE%E5%A4%87%20001%2F%E6%B5%8B%E8%AF%95:9100"); + t.assert.equal( + serialized, + '%E8%AE%BE%E5%A4%87%20001%2F%E6%B5%8B%E8%AF%95:9100', + ); }); - test("encodes colon inside deviceId", (t: TestContext) => { - const serialized = ClientId.serialize("foo:bar", 9200); + test('encodes colon inside deviceId', (t: TestContext) => { + const serialized = ClientId.serialize('foo:bar', 9200); - t.assert.equal(serialized, "foo%3Abar:9200"); + t.assert.equal(serialized, 'foo%3Abar:9200'); }); }); - describe("deserialize", () => { - test("parses previously serialized value", (t: TestContext) => { - const result = ClientId.deserialize("device-001:9000"); + describe('deserialize', () => { + test('parses previously serialized value', (t: TestContext) => { + const result = ClientId.deserialize('device-001:9000'); - t.assert.deepStrictEqual(result, { deviceId: "device-001", port: 9000 }); + t.assert.deepStrictEqual(result, { deviceId: 'device-001', port: 9000 }); }); - test("uses the last colon when multiple are present", (t: TestContext) => { - const result = ClientId.deserialize("foo:bar:1234"); + test('uses the last colon when multiple are present', (t: TestContext) => { + const result = ClientId.deserialize('foo:bar:1234'); - t.assert.deepStrictEqual(result, { deviceId: "foo:bar", port: 1234 }); + t.assert.deepStrictEqual(result, { deviceId: 'foo:bar', port: 1234 }); }); - test("returns null when no colon exists", (t: TestContext) => { - const result = ClientId.deserialize("foobar"); + test('returns null when no colon exists', (t: TestContext) => { + const result = ClientId.deserialize('foobar'); t.assert.equal(result, null); }); - test("returns null when port cannot be parsed", (t: TestContext) => { - const result = ClientId.deserialize("foo:port"); + test('returns null when port cannot be parsed', (t: TestContext) => { + const result = ClientId.deserialize('foo:port'); t.assert.equal(result, null); }); - test("returns null when decodeURIComponent throws", (t: TestContext) => { - const result = ClientId.deserialize("%E0%A4%:1234"); + test('returns null when decodeURIComponent throws', (t: TestContext) => { + const result = ClientId.deserialize('%E0%A4%:1234'); t.assert.equal(result, null); }); - test("decodes colon inside deviceId", (t: TestContext) => { - const result = ClientId.deserialize("foo%3Abar:9300"); + test('decodes colon inside deviceId', (t: TestContext) => { + const result = ClientId.deserialize('foo%3Abar:9300'); - t.assert.deepStrictEqual(result, { deviceId: "foo:bar", port: 9300 }); + t.assert.deepStrictEqual(result, { deviceId: 'foo:bar', port: 9300 }); }); }); }); diff --git a/packages/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts b/packages/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts index b20d685..e440d95 100644 --- a/packages/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts +++ b/packages/mcp-servers/devtool-connector/test/connector-lifecycle.test.ts @@ -2,14 +2,20 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert/strict"; -import { ReadableStream, WritableStream } from "node:stream/web"; -import { test } from "node:test"; -import { setTimeout as sleep } from "node:timers/promises"; -import { ClientId, Connector } from "../src/index.ts"; -import type { App, Client, Connection, Device, Transport } from "../src/transport/transport.ts"; - -test("Connector.sendMessage waits for input write before disposing connection", async () => { +import assert from 'node:assert/strict'; +import { ReadableStream, WritableStream } from 'node:stream/web'; +import { test } from 'node:test'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { ClientId, Connector } from '../src/index.ts'; +import type { + App, + Client, + Connection, + Device, + Transport, +} from '../src/transport/transport.ts'; + +test('Connector.sendMessage waits for input write before disposing connection', async () => { const writeStarted = deferred(); const writeFinished = deferred(); const responseQueued = deferred(); @@ -23,7 +29,7 @@ test("Connector.sendMessage waits for input write before disposing connection", } listDevices(): Promise { - return Promise.resolve([{ id: "device-1", os: "Android" }]); + return Promise.resolve([{ id: 'device-1', os: 'Android' }]); } listAvailableApps(): Promise { @@ -38,7 +44,7 @@ test("Connector.sendMessage waits for input write before disposing connection", const readable = new ReadableStream({ async start(controller) { await writeStarted.promise; - controller.enqueue("response" as TOutput); + controller.enqueue('response' as TOutput); responseQueued.resolve(); }, }); @@ -67,24 +73,27 @@ test("Connector.sendMessage waits for input write before disposing connection", const connector = new Connector([new DelayedWriteTransport()]); const resultPromise = connector.sendMessage( - ClientId.serialize("device-1", 8901), - "request", + ClientId.serialize('device-1', 8901), + 'request', ); await responseQueued.promise; let settledBeforeWriteFinished = false; - resultPromise.then(() => { - settledBeforeWriteFinished = true; - }, () => { - settledBeforeWriteFinished = true; - }); + resultPromise.then( + () => { + settledBeforeWriteFinished = true; + }, + () => { + settledBeforeWriteFinished = true; + }, + ); await sleep(10); assert.equal(settledBeforeWriteFinished, false); writeFinished.resolve(); - assert.equal(await resultPromise, "response"); + assert.equal(await resultPromise, 'response'); assert.equal(disposedBeforeWriteSettled, false); }); diff --git a/packages/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts index bea7398..3448b2d 100644 --- a/packages/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts +++ b/packages/mcp-servers/devtool-connector/test/daemon-connect-timeout.test.ts @@ -2,23 +2,29 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { describe, test } from "node:test"; -import { DevtoolDaemon } from "../src/daemon/server.ts"; -import { DaemonTransport } from "../src/transport/daemon.ts"; -import type { Connection, Transport, TransportConnectOptions } from "../src/transport/transport.ts"; +import { describe, test } from 'node:test'; +import { DevtoolDaemon } from '../src/daemon/server.ts'; +import { DaemonTransport } from '../src/transport/daemon.ts'; +import type { + Connection, + Transport, + TransportConnectOptions, +} from '../src/transport/transport.ts'; -describe("DaemonTransport connection setup timeout", () => { - test("listClients returns when a daemon device port connect never settles", async (t) => { +describe('DaemonTransport connection setup timeout', () => { + test('listClients returns when a daemon device port connect never settles', async (t) => { const hangingTransport: Transport = { async close() {}, async listDevices() { - return [{ id: "test-device", os: "Android" as const }]; + return [{ id: 'test-device', os: 'Android' as const }]; }, async listAvailableApps() { return []; }, async openApp() {}, - async connect(options: TransportConnectOptions): Promise> { + async connect( + options: TransportConnectOptions, + ): Promise> { if (options.port === 8901) { return await new Promise>(() => {}); } diff --git a/packages/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts index d9a95b6..1420d19 100644 --- a/packages/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts +++ b/packages/mcp-servers/devtool-connector/test/daemon-device-connection.test.ts @@ -2,12 +2,15 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -/* eslint-disable n/no-unsupported-features/node-builtins */ -import assert from "node:assert/strict"; -import { ReadableStream, TransformStream, WritableStream } from "node:stream/web"; -import { describe, test } from "node:test"; -import { DeviceConnection } from "../src/daemon/device-connection.ts"; -import type { Connection, Transport } from "../src/transport/transport.ts"; +import assert from 'node:assert/strict'; +import { + type ReadableStream, + TransformStream, + WritableStream, +} from 'node:stream/web'; +import { describe, test } from 'node:test'; +import { DeviceConnection } from '../src/daemon/device-connection.ts'; +import type { Connection, Transport } from '../src/transport/transport.ts'; /** * Creates a fake transport where `connect()` returns an in-memory @@ -24,7 +27,8 @@ function createFakeTransport(): { const deviceMessages: unknown[] = []; // Device → Connector direction - const { readable: deviceReadable, writable: deviceSideWritable } = new TransformStream(); + const { readable: deviceReadable, writable: deviceSideWritable } = + new TransformStream(); const deviceWriter = deviceSideWritable.getWriter(); // Connector → Device direction @@ -40,7 +44,7 @@ function createFakeTransport(): { const transport: Transport = { async close() {}, async listDevices() { - return [{ id: "fake-device", os: "Android" }]; + return [{ id: 'fake-device', os: 'Android' }]; }, async listAvailableApps() { return []; @@ -60,116 +64,158 @@ function createFakeTransport(): { return { transport, deviceWriter, deviceMessages, closeConnection }; } -describe("DeviceConnection", () => { - test("connect() establishes the connection and exposes key/deviceId/port", async (t) => { +describe('DeviceConnection', () => { + test('connect() establishes the connection and exposes key/deviceId/port', async (t) => { const { transport, closeConnection } = createFakeTransport(); t.after(() => closeConnection()); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); await conn.connect(); t.after(() => conn.dispose()); - t.assert.equal(conn.key, "fake-device:8901"); - t.assert.equal(conn.deviceId, "fake-device"); + t.assert.equal(conn.key, 'fake-device:8901'); + t.assert.equal(conn.deviceId, 'fake-device'); t.assert.equal(conn.port, 8901); }); - test("send() forwards messages to the underlying transport", async (t) => { - const { transport, deviceMessages, closeConnection } = createFakeTransport(); + test('send() forwards messages to the underlying transport', async (t) => { + const { transport, deviceMessages, closeConnection } = + createFakeTransport(); t.after(() => closeConnection()); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); await conn.connect(); t.after(() => conn.dispose()); - await conn.send({ event: "Test", data: "hello" }); - await conn.send({ event: "Test", data: "world" }); + await conn.send({ event: 'Test', data: 'hello' }); + await conn.send({ event: 'Test', data: 'world' }); t.assert.equal(deviceMessages.length, 2); - assert.deepStrictEqual(deviceMessages[0], { event: "Test", data: "hello" }); - assert.deepStrictEqual(deviceMessages[1], { event: "Test", data: "world" }); + assert.deepStrictEqual(deviceMessages[0], { event: 'Test', data: 'hello' }); + assert.deepStrictEqual(deviceMessages[1], { event: 'Test', data: 'world' }); }); - test("broadcasts device messages to all subscribers", async (t) => { + test('broadcasts device messages to all subscribers', async (t) => { const { transport, deviceWriter, closeConnection } = createFakeTransport(); t.after(() => closeConnection()); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); await conn.connect(); t.after(() => conn.dispose()); const receivedA: unknown[] = []; const receivedB: unknown[] = []; - conn.addSubscriber({ id: 1, send: (msg) => receivedA.push(msg), close() {} }); - conn.addSubscriber({ id: 2, send: (msg) => receivedB.push(msg), close() {} }); + conn.addSubscriber({ + id: 1, + send: (msg) => receivedA.push(msg), + close() {}, + }); + conn.addSubscriber({ + id: 2, + send: (msg) => receivedB.push(msg), + close() {}, + }); t.assert.equal(conn.subscriberCount, 2); // Push a message from the device side - await deviceWriter.write({ event: "Customized", data: { type: "CDP" } }); + await deviceWriter.write({ event: 'Customized', data: { type: 'CDP' } }); // Give the read loop a tick to process await new Promise((resolve) => setTimeout(resolve, 50)); t.assert.equal(receivedA.length, 1); t.assert.equal(receivedB.length, 1); - assert.deepStrictEqual(receivedA[0], { event: "Customized", data: { type: "CDP" } }); - assert.deepStrictEqual(receivedB[0], { event: "Customized", data: { type: "CDP" } }); + assert.deepStrictEqual(receivedA[0], { + event: 'Customized', + data: { type: 'CDP' }, + }); + assert.deepStrictEqual(receivedB[0], { + event: 'Customized', + data: { type: 'CDP' }, + }); }); - test("removeSubscriber stops broadcasting to that subscriber", async (t) => { + test('removeSubscriber stops broadcasting to that subscriber', async (t) => { const { transport, deviceWriter, closeConnection } = createFakeTransport(); t.after(() => closeConnection()); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); await conn.connect(); t.after(() => conn.dispose()); const receivedA: unknown[] = []; const receivedB: unknown[] = []; - conn.addSubscriber({ id: 1, send: (msg) => receivedA.push(msg), close() {} }); - conn.addSubscriber({ id: 2, send: (msg) => receivedB.push(msg), close() {} }); + conn.addSubscriber({ + id: 1, + send: (msg) => receivedA.push(msg), + close() {}, + }); + conn.addSubscriber({ + id: 2, + send: (msg) => receivedB.push(msg), + close() {}, + }); // Remove subscriber A conn.removeSubscriber(1); t.assert.equal(conn.subscriberCount, 1); - await deviceWriter.write({ event: "Test" }); + await deviceWriter.write({ event: 'Test' }); await new Promise((resolve) => setTimeout(resolve, 50)); t.assert.equal(receivedA.length, 0); t.assert.equal(receivedB.length, 1); }); - test("captures appInfo from Register response and does not broadcast it", async (t) => { + test('captures appInfo from Register response and does not broadcast it', async (t) => { const { transport, deviceWriter, closeConnection } = createFakeTransport(); t.after(() => closeConnection()); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); await conn.connect(); t.after(() => conn.dispose()); const received: unknown[] = []; - conn.addSubscriber({ id: 1, send: (msg) => received.push(msg), close() {} }); + conn.addSubscriber({ + id: 1, + send: (msg) => received.push(msg), + close() {}, + }); t.assert.equal(conn.appInfo, null); // Simulate the device responding to Initialize with Register await deviceWriter.write({ - event: "Register", + event: 'Register', data: { id: 8901, info: { - App: "TestApp", - AppVersion: "1.0", - debugRouterId: "1", - debugRouterVersion: "2.0", - deviceModel: "Pixel", - network: "USB", - osVersion: "14", - sdkVersion: "3.0", + App: 'TestApp', + AppVersion: '1.0', + debugRouterId: '1', + debugRouterVersion: '2.0', + deviceModel: 'Pixel', + network: 'USB', + osVersion: '14', + sdkVersion: '3.0', }, }, }); @@ -177,23 +223,26 @@ describe("DeviceConnection", () => { // appInfo should be captured assert.ok(conn.appInfo !== null); - assert.equal(conn.appInfo?.App, "TestApp"); + assert.equal(conn.appInfo?.App, 'TestApp'); // Register message should NOT be broadcast to subscribers t.assert.equal(received.length, 0); // Subsequent messages SHOULD be broadcast - await deviceWriter.write({ event: "Customized", data: { type: "CDP" } }); + await deviceWriter.write({ event: 'Customized', data: { type: 'CDP' } }); await new Promise((resolve) => setTimeout(resolve, 50)); t.assert.equal(received.length, 1); }); - test("dispose() cleans up and clears subscribers", async (t) => { + test('dispose() cleans up and clears subscribers', async (t) => { const { transport, closeConnection } = createFakeTransport(); t.after(() => closeConnection()); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); await conn.connect(); conn.addSubscriber({ id: 1, send: () => {}, close() {} }); @@ -206,23 +255,26 @@ describe("DeviceConnection", () => { t.assert.equal(conn.subscriberCount, 0); }); - test("send() throws if not connected", async (t) => { + test('send() throws if not connected', async (t) => { const { transport } = createFakeTransport(); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); // Don't call connect() - await t.assert.rejects( - () => conn.send({ event: "Test" }), - /not connected/, - ); + await t.assert.rejects(() => conn.send({ event: 'Test' }), /not connected/); }); - test("dispose() is idempotent", async (t) => { + test('dispose() is idempotent', async (t) => { const { transport, closeConnection } = createFakeTransport(); t.after(() => closeConnection()); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); await conn.connect(); await conn.dispose(); @@ -231,10 +283,13 @@ describe("DeviceConnection", () => { t.assert.equal(conn.isDisposed, true); }); - test("closes all subscribers when device disconnects (remote close)", async (t) => { + test('closes all subscribers when device disconnects (remote close)', async (t) => { const { transport, closeConnection } = createFakeTransport(); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); await conn.connect(); t.after(() => conn.dispose()); @@ -248,11 +303,14 @@ describe("DeviceConnection", () => { assert.deepStrictEqual(closed.sort(), [1, 2]); }); - test("does not close subscribers when dispose() is called explicitly", async (t) => { + test('does not close subscribers when dispose() is called explicitly', async (t) => { const { transport, closeConnection } = createFakeTransport(); t.after(() => closeConnection()); - const conn = new DeviceConnection(transport, { deviceId: "fake-device", port: 8901 }); + const conn = new DeviceConnection(transport, { + deviceId: 'fake-device', + port: 8901, + }); await conn.connect(); const closed: number[] = []; diff --git a/packages/mcp-servers/devtool-connector/test/daemon-manager.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-manager.test.ts index 94a44a3..afb4737 100644 --- a/packages/mcp-servers/devtool-connector/test/daemon-manager.test.ts +++ b/packages/mcp-servers/devtool-connector/test/daemon-manager.test.ts @@ -2,39 +2,49 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { test } from "node:test"; -import { pathToFileURL } from "node:url"; -import { resolveDaemonEntryPath } from "../src/daemon/manager.ts"; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { resolveDaemonEntryPath } from '../src/daemon/manager.ts'; -test("resolveDaemonEntryPath resolves the source daemon entry from this package", () => { - assert.equal(resolveDaemonEntryPath(), path.resolve("src/daemon/entry.ts")); +test('resolveDaemonEntryPath resolves the source daemon entry from this package', () => { + assert.equal(resolveDaemonEntryPath(), path.resolve('src/daemon/entry.ts')); }); -test("resolveDaemonEntryPath respects package imports in a built package", async (t) => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "daemon-manager-")); +test('resolveDaemonEntryPath respects package imports in a built package', async (t) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'daemon-manager-')); t.after(async () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - await fs.mkdir(path.join(tempDir, "dist", "daemon"), { recursive: true }); - await fs.mkdir(path.join(tempDir, "src", "daemon"), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'dist', 'daemon'), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'src', 'daemon'), { recursive: true }); await fs.writeFile( - path.join(tempDir, "package.json"), + path.join(tempDir, 'package.json'), JSON.stringify({ - type: "module", - imports: { "#daemon-entry": "./dist/daemon/entry.js" }, + type: 'module', + imports: { '#daemon-entry': './dist/daemon/entry.js' }, }), ); - await fs.writeFile(path.join(tempDir, "dist", "daemon", "entry.js"), "export {};\n"); - await fs.writeFile(path.join(tempDir, "src", "daemon", "manager.js"), "export {};\n"); - const expectedEntryPath = await fs.realpath(path.join(tempDir, "dist", "daemon", "entry.js")); + await fs.writeFile( + path.join(tempDir, 'dist', 'daemon', 'entry.js'), + 'export {};\n', + ); + await fs.writeFile( + path.join(tempDir, 'src', 'daemon', 'manager.js'), + 'export {};\n', + ); + const expectedEntryPath = await fs.realpath( + path.join(tempDir, 'dist', 'daemon', 'entry.js'), + ); assert.equal( - resolveDaemonEntryPath(pathToFileURL(path.join(tempDir, "src", "daemon", "manager.js")).href), + resolveDaemonEntryPath( + pathToFileURL(path.join(tempDir, 'src', 'daemon', 'manager.js')).href, + ), expectedEntryPath, ); }); diff --git a/packages/mcp-servers/devtool-connector/test/daemon-protocol.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-protocol.test.ts index 5a6ca4d..0cf1a5d 100644 --- a/packages/mcp-servers/devtool-connector/test/daemon-protocol.test.ts +++ b/packages/mcp-servers/devtool-connector/test/daemon-protocol.test.ts @@ -2,85 +2,91 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { describe, test } from "node:test"; -import type { TestContext } from "node:test"; +import type { TestContext } from 'node:test'; +import { describe, test } from 'node:test'; import { isControlRequest, isCustomizedMessage, isListClientsRequest, isPingEvent, isRegisterEvent, -} from "../src/daemon/protocol.ts"; +} from '../src/daemon/protocol.ts'; -describe("daemon protocol type guards", () => { - describe("isRegisterEvent", () => { - test("returns true for a valid Register message", (t: TestContext) => { - t.assert.ok(isRegisterEvent({ event: "Register", data: { id: 1, type: "Driver" } })); +describe('daemon protocol type guards', () => { + describe('isRegisterEvent', () => { + test('returns true for a valid Register message', (t: TestContext) => { + t.assert.ok( + isRegisterEvent({ event: 'Register', data: { id: 1, type: 'Driver' } }), + ); }); - test("returns false for Initialize", (t: TestContext) => { - t.assert.ok(!isRegisterEvent({ event: "Initialize", data: 1 })); + test('returns false for Initialize', (t: TestContext) => { + t.assert.ok(!isRegisterEvent({ event: 'Initialize', data: 1 })); }); - test("returns false for null", (t: TestContext) => { + test('returns false for null', (t: TestContext) => { t.assert.ok(!isRegisterEvent(null)); }); - test("returns false for a string", (t: TestContext) => { - t.assert.ok(!isRegisterEvent("Register")); + test('returns false for a string', (t: TestContext) => { + t.assert.ok(!isRegisterEvent('Register')); }); }); - describe("isCustomizedMessage", () => { - test("returns true for a Customized message", (t: TestContext) => { - t.assert.ok(isCustomizedMessage({ - event: "Customized", - data: { type: "CDP", data: { client_id: 1 } }, - })); + describe('isCustomizedMessage', () => { + test('returns true for a Customized message', (t: TestContext) => { + t.assert.ok( + isCustomizedMessage({ + event: 'Customized', + data: { type: 'CDP', data: { client_id: 1 } }, + }), + ); }); - test("returns false for a non-Customized message", (t: TestContext) => { - t.assert.ok(!isCustomizedMessage({ event: "Register", data: {} })); + test('returns false for a non-Customized message', (t: TestContext) => { + t.assert.ok(!isCustomizedMessage({ event: 'Register', data: {} })); }); - test("returns false for undefined", (t: TestContext) => { + test('returns false for undefined', (t: TestContext) => { t.assert.ok(!isCustomizedMessage(undefined)); }); }); - describe("isControlRequest", () => { - test("returns true for a Control message", (t: TestContext) => { - t.assert.ok(isControlRequest({ - event: "Control", - data: { id: 1, method: "listDevices" }, - })); + describe('isControlRequest', () => { + test('returns true for a Control message', (t: TestContext) => { + t.assert.ok( + isControlRequest({ + event: 'Control', + data: { id: 1, method: 'listDevices' }, + }), + ); }); - test("returns false for Customized", (t: TestContext) => { - t.assert.ok(!isControlRequest({ event: "Customized", data: {} })); + test('returns false for Customized', (t: TestContext) => { + t.assert.ok(!isControlRequest({ event: 'Customized', data: {} })); }); }); - describe("isListClientsRequest", () => { - test("returns true for ListClients", (t: TestContext) => { - t.assert.ok(isListClientsRequest({ event: "ListClients" })); + describe('isListClientsRequest', () => { + test('returns true for ListClients', (t: TestContext) => { + t.assert.ok(isListClientsRequest({ event: 'ListClients' })); }); - test("returns false for Ping", (t: TestContext) => { - t.assert.ok(!isListClientsRequest({ event: "Ping" })); + test('returns false for Ping', (t: TestContext) => { + t.assert.ok(!isListClientsRequest({ event: 'Ping' })); }); }); - describe("isPingEvent", () => { - test("returns true for Ping", (t: TestContext) => { - t.assert.ok(isPingEvent({ event: "Ping" })); + describe('isPingEvent', () => { + test('returns true for Ping', (t: TestContext) => { + t.assert.ok(isPingEvent({ event: 'Ping' })); }); - test("returns false for Pong", (t: TestContext) => { - t.assert.ok(!isPingEvent({ event: "Pong" })); + test('returns false for Pong', (t: TestContext) => { + t.assert.ok(!isPingEvent({ event: 'Pong' })); }); - test("returns false for an empty object", (t: TestContext) => { + test('returns false for an empty object', (t: TestContext) => { t.assert.ok(!isPingEvent({})); }); }); diff --git a/packages/mcp-servers/devtool-connector/test/daemon-server.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-server.test.ts index d568806..8b40e62 100644 --- a/packages/mcp-servers/devtool-connector/test/daemon-server.test.ts +++ b/packages/mcp-servers/devtool-connector/test/daemon-server.test.ts @@ -2,17 +2,23 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert/strict"; -import http from "node:http"; -import { createRequire } from "node:module"; -import { TransformStream } from "node:stream/web"; -import { describe, test } from "node:test"; -import type { TestContext } from "node:test"; -import { WebSocket } from "ws"; -import { DevtoolDaemon } from "../src/daemon/server.ts"; -import type { Connection, Transport, TransportConnectOptions } from "../src/transport/transport.ts"; - -const packageJson = createRequire(import.meta.url)("../package.json") as { version: string }; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { createRequire } from 'node:module'; +import { TransformStream } from 'node:stream/web'; +import type { TestContext } from 'node:test'; +import { describe, test } from 'node:test'; +import { WebSocket } from 'ws'; +import { DevtoolDaemon } from '../src/daemon/server.ts'; +import type { + Connection, + Transport, + TransportConnectOptions, +} from '../src/transport/transport.ts'; + +const packageJson = createRequire(import.meta.url)('../package.json') as { + version: string; +}; // --------------------------------------------------------------------------- // Fake transport @@ -48,17 +54,19 @@ function createFakeTransport(opts: { return { async close() {}, async listDevices() { - return [{ id: deviceId, os: "Android" as const }]; + return [{ id: deviceId, os: 'Android' as const }]; }, async listAvailableApps() { - return [{ packageName: "com.test.app", name: "Test App" }]; + return [{ packageName: 'com.test.app', name: 'Test App' }]; }, async openApp() {}, async connect( options: TransportConnectOptions, ): Promise> { if (connectDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, connectDelayMs)); + await new Promise((resolve) => + setTimeout(resolve, connectDelayMs), + ); } if (!activePorts.includes(options.port)) { @@ -66,8 +74,10 @@ function createFakeTransport(opts: { } // In-memory pipe: what the connector writes → device reads → device writes → connector reads - const { readable: toDevice, writable: toDeviceWritable } = new TransformStream(); - const { readable: fromDevice, writable: fromDeviceWritable } = new TransformStream(); + const { readable: toDevice, writable: toDeviceWritable } = + new TransformStream(); + const { readable: fromDevice, writable: fromDeviceWritable } = + new TransformStream(); const fromDeviceWriter = fromDeviceWritable.getWriter(); // Process incoming messages from the connector @@ -76,31 +86,34 @@ function createFakeTransport(opts: { for await (const msg of toDevice) { onMessage?.(msg, options); const parsed = msg as Record; - if (parsed["event"] === "Initialize") { + if (parsed['event'] === 'Initialize') { if (silentPorts.includes(options.port)) { continue; } - const delayMs = registerDelayMsByPort[options.port] ?? registerDelayMs; + const delayMs = + registerDelayMsByPort[options.port] ?? registerDelayMs; if (delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); + await new Promise((resolve) => + setTimeout(resolve, delayMs), + ); } // Respond with Register await fromDeviceWriter.write({ - event: "Register", + event: 'Register', data: { id: options.port, info: { - App: "FakeApp", - AppVersion: "1.0", - AppProcessName: "com.test.app", - debugRouterId: "1", - debugRouterVersion: "1.0", - deviceModel: "FakeDevice", - network: "USB", - osVersion: "14", - sdkVersion: "1.0", + App: 'FakeApp', + AppVersion: '1.0', + AppProcessName: 'com.test.app', + debugRouterId: '1', + debugRouterVersion: '1.0', + deviceModel: 'FakeDevice', + network: 'USB', + osVersion: '14', + sdkVersion: '1.0', }, }, } as TOutput); @@ -114,7 +127,9 @@ function createFakeTransport(opts: { } finally { try { await fromDeviceWriter.close(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } })(); @@ -124,7 +139,9 @@ function createFakeTransport(opts: { async [Symbol.asyncDispose]() { try { await fromDeviceWriter.close(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }, }; }, @@ -146,14 +163,22 @@ function createCountingFakeTransport(opts: { return { transport: { ...baseTransport, - async connect(options: TransportConnectOptions): Promise> { - connectCounts.set(options.port, (connectCounts.get(options.port) ?? 0) + 1); + async connect( + options: TransportConnectOptions, + ): Promise> { + connectCounts.set( + options.port, + (connectCounts.get(options.port) ?? 0) + 1, + ); return baseTransport.connect(options); }, }, getConnectCount: (port?: number) => port === undefined - ? Array.from(connectCounts.values()).reduce((sum, count) => sum + count, 0) + ? Array.from(connectCounts.values()).reduce( + (sum, count) => sum + count, + 0, + ) : (connectCounts.get(port) ?? 0), }; } @@ -173,15 +198,17 @@ function connectWs(port: number): Promise { const ws = new WebSocket(`ws://127.0.0.1:${port}/devtool/connector`); const inbox: unknown[] = []; // Start collecting messages immediately, before "open" fires - ws.on("message", (raw) => { + ws.on('message', (raw) => { inbox.push(JSON.parse(String(raw))); }); - ws.on("open", () => resolve(Object.assign(ws, { inbox }))); - ws.on("error", reject); + ws.on('open', () => resolve(Object.assign(ws, { inbox }))); + ws.on('error', reject); }); } -async function readMessage(ws: WebSocket & { inbox: unknown[] }): Promise { +async function readMessage( + ws: WebSocket & { inbox: unknown[] }, +): Promise { const deadline = Date.now() + 5_000; while (Date.now() < deadline) { if (ws.inbox.length > 0) { @@ -189,13 +216,13 @@ async function readMessage(ws: WebSocket & { inbox: unknown[] }): Promise((r) => setTimeout(r, 10)); } - throw new Error("timeout waiting for WS message"); + throw new Error('timeout waiting for WS message'); } async function readMessageWithin( ws: WebSocket & { inbox: unknown[] }, timeoutMs: number, -): Promise { +): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (ws.inbox.length > 0) { @@ -203,20 +230,28 @@ async function readMessageWithin( } await new Promise((r) => setTimeout(r, 10)); } - return "timeout"; + return 'timeout'; } -async function assertNoMessage(ws: WebSocket & { inbox: unknown[] }, timeoutMs = 100): Promise { +async function assertNoMessage( + ws: WebSocket & { inbox: unknown[] }, + timeoutMs = 100, +): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (ws.inbox.length > 0) { - assert.fail(`expected no WS message, got ${JSON.stringify(ws.inbox.shift())}`); + assert.fail( + `expected no WS message, got ${JSON.stringify(ws.inbox.shift())}`, + ); } await new Promise((r) => setTimeout(r, 10)); } } -function sendAndRead(ws: WebSocket & { inbox: unknown[] }, msg: unknown): Promise { +function sendAndRead( + ws: WebSocket & { inbox: unknown[] }, + msg: unknown, +): Promise { ws.send(JSON.stringify(msg)); return readMessage(ws); } @@ -225,12 +260,14 @@ async function requestClientList( ws: WebSocket & { inbox: unknown[] }, id: number, ): Promise> { - ws.send(JSON.stringify({ - event: "Control", - data: { id, method: "listClients" }, - })); - - const response = await readMessage(ws) as { + ws.send( + JSON.stringify({ + event: 'Control', + data: { id, method: 'listClients' }, + }), + ); + + const response = (await readMessage(ws)) as { event: string; data: { id: number; @@ -238,38 +275,45 @@ async function requestClientList( error?: string; }; }; - assert.equal(response.event, "ControlResponse"); + assert.equal(response.event, 'ControlResponse'); assert.equal(response.data.id, id); assert.equal(response.data.error, undefined); return response.data.result ?? []; } -function requestJson(port: number, path: string, method: string = "GET"): Promise<{ +function requestJson( + port: number, + path: string, + method: string = 'GET', +): Promise<{ body: T; headers: http.IncomingHttpHeaders; statusCode: number; }> { return new Promise((resolve, reject) => { - const request = http.request({ host: "127.0.0.1", method, path, port }, (response) => { - let rawBody = ""; - response.setEncoding("utf8"); - response.on("data", (chunk: string) => { - rawBody += chunk; - }); - response.on("end", () => { - try { - resolve({ - body: JSON.parse(rawBody) as T, - headers: response.headers, - statusCode: response.statusCode ?? 0, - }); - } catch (err) { - reject(err); - } - }); - }); + const request = http.request( + { host: '127.0.0.1', method, path, port }, + (response) => { + let rawBody = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + rawBody += chunk; + }); + response.on('end', () => { + try { + resolve({ + body: JSON.parse(rawBody) as T, + headers: response.headers, + statusCode: response.statusCode ?? 0, + }); + } catch (err) { + reject(err); + } + }); + }, + ); - request.on("error", reject); + request.on('error', reject); request.end(); }); } @@ -278,13 +322,22 @@ async function subscribeToPort( ws: WebSocket & { inbox: unknown[] }, params: { id: number; deviceId: string; port: number }, ): Promise { - ws.send(JSON.stringify({ - event: "Control", - data: { id: params.id, method: "subscribe", params: { deviceId: params.deviceId, port: params.port } }, - })); + ws.send( + JSON.stringify({ + event: 'Control', + data: { + id: params.id, + method: 'subscribe', + params: { deviceId: params.deviceId, port: params.port }, + }, + }), + ); - const response = await readMessage(ws) as { event: string; data: { id: number; error?: string } }; - assert.equal(response.event, "ControlResponse"); + const response = (await readMessage(ws)) as { + event: string; + data: { id: number; error?: string }; + }; + assert.equal(response.event, 'ControlResponse'); assert.equal(response.data.id, params.id); assert.equal(response.data.error, undefined); } @@ -292,11 +345,13 @@ async function subscribeToPort( /** * Performs the Initialize/Register handshake and returns the assigned client ID. */ -async function performHandshake(ws: WebSocket & { inbox: unknown[] }): Promise { - const init = await readMessage(ws) as { event: string; data: number }; - assert.equal(init.event, "Initialize"); +async function performHandshake( + ws: WebSocket & { inbox: unknown[] }, +): Promise { + const init = (await readMessage(ws)) as { event: string; data: number }; + assert.equal(init.event, 'Initialize'); const id = init.data; - ws.send(JSON.stringify({ event: "Register", data: { id, type: "Driver" } })); + ws.send(JSON.stringify({ event: 'Register', data: { id, type: 'Driver' } })); return id; } @@ -304,47 +359,64 @@ async function performHandshake(ws: WebSocket & { inbox: unknown[] }): Promise { - test("starts and stops cleanly", async (t: TestContext) => { +describe('DevtoolDaemon', () => { + test('starts and stops cleanly', async (t: TestContext) => { const daemon = new DevtoolDaemon([]); await daemon.start(TEST_PORT); t.after(() => daemon.close()); // If we get here without throwing, the server started successfully }); - test("serves connector version over HTTP", async (t: TestContext) => { + test('serves connector version over HTTP', async (t: TestContext) => { const daemon = new DevtoolDaemon([]); await daemon.start(TEST_PORT + 14); t.after(() => daemon.close()); - const response = await requestJson<{ version?: string }>(TEST_PORT + 14, "/devtool/connector/version"); - const contentType = response.headers["content-type"]; + const response = await requestJson<{ version?: string }>( + TEST_PORT + 14, + '/devtool/connector/version', + ); + const contentType = response.headers['content-type']; t.assert.equal(response.statusCode, 200); - t.assert.match(Array.isArray(contentType) ? contentType.join(",") : contentType ?? "", /application\/json/); + t.assert.match( + Array.isArray(contentType) ? contentType.join(',') : (contentType ?? ''), + /application\/json/, + ); assert.deepStrictEqual(response.body, { version: packageJson.version }); }); - test("accepts HTTP shutdown requests", async (t: TestContext) => { + test('accepts HTTP shutdown requests', async (t: TestContext) => { let resolveShutdown: (() => void) | undefined; const shutdown = new Promise((resolve) => { resolveShutdown = resolve; }); - const daemon = new DevtoolDaemon([], { onShutdown: () => resolveShutdown?.() }); + const daemon = new DevtoolDaemon([], { + onShutdown: () => resolveShutdown?.(), + }); await daemon.start(TEST_PORT + 15); t.after(() => daemon.close()); - const response = await requestJson<{ ok?: boolean }>(TEST_PORT + 15, "/devtool/connector/shutdown", "POST"); - const contentType = response.headers["content-type"]; + const response = await requestJson<{ ok?: boolean }>( + TEST_PORT + 15, + '/devtool/connector/shutdown', + 'POST', + ); + const contentType = response.headers['content-type']; t.assert.equal(response.statusCode, 202); - t.assert.match(Array.isArray(contentType) ? contentType.join(",") : contentType ?? "", /application\/json/); + t.assert.match( + Array.isArray(contentType) ? contentType.join(',') : (contentType ?? ''), + /application\/json/, + ); assert.deepStrictEqual(response.body, { ok: true }); await shutdown; - await assert.rejects(() => requestJson(TEST_PORT + 15, "/devtool/connector/shutdown", "POST")); + await assert.rejects(() => + requestJson(TEST_PORT + 15, '/devtool/connector/shutdown', 'POST'), + ); }); - test("performs Initialize/Register handshake with connecting client", async (t: TestContext) => { + test('performs Initialize/Register handshake with connecting client', async (t: TestContext) => { const daemon = new DevtoolDaemon([]); await daemon.start(TEST_PORT + 1); t.after(() => daemon.close()); @@ -353,17 +425,22 @@ describe("DevtoolDaemon", () => { t.after(() => ws.close()); // Should receive Initialize - const init = await readMessage(ws) as { event: string; data: number }; - t.assert.equal(init.event, "Initialize"); - t.assert.equal(typeof init.data, "number"); + const init = (await readMessage(ws)) as { event: string; data: number }; + t.assert.equal(init.event, 'Initialize'); + t.assert.equal(typeof init.data, 'number'); // Send Register - ws.send(JSON.stringify({ event: "Register", data: { id: init.data, type: "Driver" } })); + ws.send( + JSON.stringify({ + event: 'Register', + data: { id: init.data, type: 'Driver' }, + }), + ); await assertNoMessage(ws); }); - test("responds to Ping with Pong", async (t: TestContext) => { + test('responds to Ping with Pong', async (t: TestContext) => { const daemon = new DevtoolDaemon([]); await daemon.start(TEST_PORT + 2); t.after(() => daemon.close()); @@ -373,12 +450,15 @@ describe("DevtoolDaemon", () => { await performHandshake(ws); - const pong = await sendAndRead(ws, { event: "Ping" }); - assert.deepStrictEqual(pong, { event: "Pong" }); + const pong = await sendAndRead(ws, { event: 'Ping' }); + assert.deepStrictEqual(pong, { event: 'Pong' }); }); - test("handles Control listDevices request", async (t: TestContext) => { - const fakeTransport = createFakeTransport({ deviceId: "emulator-5554", activePorts: [8901] }); + test('handles Control listDevices request', async (t: TestContext) => { + const fakeTransport = createFakeTransport({ + deviceId: 'emulator-5554', + activePorts: [8901], + }); const daemon = new DevtoolDaemon([fakeTransport]); await daemon.start(TEST_PORT + 3); t.after(() => daemon.close()); @@ -388,19 +468,29 @@ describe("DevtoolDaemon", () => { await performHandshake(ws); - ws.send(JSON.stringify({ - event: "Control", - data: { id: 42, method: "listDevices" }, - })); + ws.send( + JSON.stringify({ + event: 'Control', + data: { id: 42, method: 'listDevices' }, + }), + ); - const resp = await readMessage(ws) as { event: string; data: { id: number; result: unknown } }; - t.assert.equal(resp.event, "ControlResponse"); + const resp = (await readMessage(ws)) as { + event: string; + data: { id: number; result: unknown }; + }; + t.assert.equal(resp.event, 'ControlResponse'); t.assert.equal(resp.data.id, 42); - assert.deepStrictEqual(resp.data.result, [{ id: "emulator-5554", os: "Android" }]); + assert.deepStrictEqual(resp.data.result, [ + { id: 'emulator-5554', os: 'Android' }, + ]); }); - test("handles Control listAvailableApps request", async (t: TestContext) => { - const fakeTransport = createFakeTransport({ deviceId: "emulator-5554", activePorts: [8901] }); + test('handles Control listAvailableApps request', async (t: TestContext) => { + const fakeTransport = createFakeTransport({ + deviceId: 'emulator-5554', + activePorts: [8901], + }); const daemon = new DevtoolDaemon([fakeTransport]); await daemon.start(TEST_PORT + 4); t.after(() => daemon.close()); @@ -410,18 +500,29 @@ describe("DevtoolDaemon", () => { await performHandshake(ws); - ws.send(JSON.stringify({ - event: "Control", - data: { id: 99, method: "listAvailableApps", params: { deviceId: "emulator-5554" } }, - })); + ws.send( + JSON.stringify({ + event: 'Control', + data: { + id: 99, + method: 'listAvailableApps', + params: { deviceId: 'emulator-5554' }, + }, + }), + ); - const resp = await readMessage(ws) as { event: string; data: { id: number; result: unknown } }; - t.assert.equal(resp.event, "ControlResponse"); + const resp = (await readMessage(ws)) as { + event: string; + data: { id: number; result: unknown }; + }; + t.assert.equal(resp.event, 'ControlResponse'); t.assert.equal(resp.data.id, 99); - assert.deepStrictEqual(resp.data.result, [{ packageName: "com.test.app", name: "Test App" }]); + assert.deepStrictEqual(resp.data.result, [ + { packageName: 'com.test.app', name: 'Test App' }, + ]); }); - test("Control request returns error for unknown device", async (t: TestContext) => { + test('Control request returns error for unknown device', async (t: TestContext) => { const daemon = new DevtoolDaemon([]); await daemon.start(TEST_PORT + 5); t.after(() => daemon.close()); @@ -431,20 +532,32 @@ describe("DevtoolDaemon", () => { await performHandshake(ws); - ws.send(JSON.stringify({ - event: "Control", - data: { id: 77, method: "listAvailableApps", params: { deviceId: "nonexistent" } }, - })); + ws.send( + JSON.stringify({ + event: 'Control', + data: { + id: 77, + method: 'listAvailableApps', + params: { deviceId: 'nonexistent' }, + }, + }), + ); - const resp = await readMessage(ws) as { event: string; data: { id: number; error?: string } }; - t.assert.equal(resp.event, "ControlResponse"); + const resp = (await readMessage(ws)) as { + event: string; + data: { id: number; error?: string }; + }; + t.assert.equal(resp.event, 'ControlResponse'); t.assert.equal(resp.data.id, 77); - t.assert.ok(typeof resp.data.error === "string"); - t.assert.ok(resp.data.error.includes("not found")); + t.assert.ok(typeof resp.data.error === 'string'); + t.assert.ok(resp.data.error.includes('not found')); }); - test("subscribe + Customized message forwarding round-trip", async (t: TestContext) => { - const fakeTransport = createFakeTransport({ deviceId: "emulator-5554", activePorts: [8901] }); + test('subscribe + Customized message forwarding round-trip', async (t: TestContext) => { + const fakeTransport = createFakeTransport({ + deviceId: 'emulator-5554', + activePorts: [8901], + }); const daemon = new DevtoolDaemon([fakeTransport]); await daemon.start(TEST_PORT + 6); t.after(() => daemon.close()); @@ -455,33 +568,51 @@ describe("DevtoolDaemon", () => { await performHandshake(ws); // Subscribe to device:port - ws.send(JSON.stringify({ - event: "Control", - data: { id: 1, method: "subscribe", params: { deviceId: "emulator-5554", port: 8901 } }, - })); + ws.send( + JSON.stringify({ + event: 'Control', + data: { + id: 1, + method: 'subscribe', + params: { deviceId: 'emulator-5554', port: 8901 }, + }, + }), + ); - const subResp = await readMessage(ws) as { event: string; data: { id: number; error?: string } }; - t.assert.equal(subResp.event, "ControlResponse"); + const subResp = (await readMessage(ws)) as { + event: string; + data: { id: number; error?: string }; + }; + t.assert.equal(subResp.event, 'ControlResponse'); t.assert.equal(subResp.data.id, 1); t.assert.equal(subResp.data.error, undefined); // Send a Customized message — the fake transport echoes it back - ws.send(JSON.stringify({ - event: "Customized", - data: { - type: "CDP", - data: { client_id: 8901, session_id: 1, message: { id: 100, method: "DOM.getDocument" } }, - sender: 1, - }, - to: 8901, - })); + ws.send( + JSON.stringify({ + event: 'Customized', + data: { + type: 'CDP', + data: { + client_id: 8901, + session_id: 1, + message: { id: 100, method: 'DOM.getDocument' }, + }, + sender: 1, + }, + to: 8901, + }), + ); - const echo = await readMessage(ws) as { event: string; data: { type: string } }; - t.assert.equal(echo.event, "Customized"); - t.assert.equal(echo.data.type, "CDP"); + const echo = (await readMessage(ws)) as { + event: string; + data: { type: string }; + }; + t.assert.equal(echo.event, 'Customized'); + t.assert.equal(echo.data.type, 'CDP'); }); - test("rejects WebSocket connections to wrong path", async (t: TestContext) => { + test('rejects WebSocket connections to wrong path', async (t: TestContext) => { const daemon = new DevtoolDaemon([]); await daemon.start(TEST_PORT + 7); t.after(() => daemon.close()); @@ -489,19 +620,24 @@ describe("DevtoolDaemon", () => { await t.assert.rejects( () => new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT + 7}/wrong/path`); - ws.on("open", () => { + const ws = new WebSocket( + `ws://127.0.0.1:${TEST_PORT + 7}/wrong/path`, + ); + ws.on('open', () => { ws.close(); resolve(undefined); }); - ws.on("error", reject); - setTimeout(() => reject(new Error("timeout")), 2_000); + ws.on('error', reject); + setTimeout(() => reject(new Error('timeout')), 2_000); }), ); }); - test("multiple clients both receive broadcasts from same device", async (t: TestContext) => { - const fakeTransport = createFakeTransport({ deviceId: "emulator-5554", activePorts: [8901] }); + test('multiple clients both receive broadcasts from same device', async (t: TestContext) => { + const fakeTransport = createFakeTransport({ + deviceId: 'emulator-5554', + activePorts: [8901], + }); const daemon = new DevtoolDaemon([fakeTransport]); await daemon.start(TEST_PORT + 8); t.after(() => daemon.close()); @@ -512,10 +648,16 @@ describe("DevtoolDaemon", () => { await performHandshake(wsA); // Subscribe A - wsA.send(JSON.stringify({ - event: "Control", - data: { id: 1, method: "subscribe", params: { deviceId: "emulator-5554", port: 8901 } }, - })); + wsA.send( + JSON.stringify({ + event: 'Control', + data: { + id: 1, + method: 'subscribe', + params: { deviceId: 'emulator-5554', port: 8901 }, + }, + }), + ); await readMessage(wsA); // consume ControlResponse // Connect client B @@ -524,36 +666,48 @@ describe("DevtoolDaemon", () => { await performHandshake(wsB); // Subscribe B to same device:port - wsB.send(JSON.stringify({ - event: "Control", - data: { id: 2, method: "subscribe", params: { deviceId: "emulator-5554", port: 8901 } }, - })); + wsB.send( + JSON.stringify({ + event: 'Control', + data: { + id: 2, + method: 'subscribe', + params: { deviceId: 'emulator-5554', port: 8901 }, + }, + }), + ); await readMessage(wsB); // consume ControlResponse // Client A sends a message — device echoes it — both A and B should get it - wsA.send(JSON.stringify({ - event: "Customized", - data: { - type: "CDP", - data: { client_id: 8901, session_id: 1, message: { id: 200, method: "test" } }, - sender: 1, - }, - to: 8901, - })); + wsA.send( + JSON.stringify({ + event: 'Customized', + data: { + type: 'CDP', + data: { + client_id: 8901, + session_id: 1, + message: { id: 200, method: 'test' }, + }, + sender: 1, + }, + to: 8901, + }), + ); - const echoA = await readMessage(wsA) as { event: string }; - const echoB = await readMessage(wsB) as { event: string }; + const echoA = (await readMessage(wsA)) as { event: string }; + const echoB = (await readMessage(wsB)) as { event: string }; - t.assert.equal(echoA.event, "Customized"); - t.assert.equal(echoB.event, "Customized"); + t.assert.equal(echoA.event, 'Customized'); + t.assert.equal(echoB.event, 'Customized'); }); - test("forwards subscribed client messages with one stable app-side sender", async (t: TestContext) => { + test('forwards subscribed client messages with one stable app-side sender', async (t: TestContext) => { const forwardedMessages: unknown[] = []; const fakeTransport = createFakeTransport({ - deviceId: "emulator-5554", + deviceId: 'emulator-5554', activePorts: [8901], - onMessage: message => forwardedMessages.push(message), + onMessage: (message) => forwardedMessages.push(message), }); const daemon = new DevtoolDaemon([fakeTransport]); await daemon.start(TEST_PORT + 13); @@ -562,38 +716,60 @@ describe("DevtoolDaemon", () => { const wsA = await connectWs(TEST_PORT + 13); t.after(() => wsA.close()); await performHandshake(wsA); - await subscribeToPort(wsA, { id: 1, deviceId: "emulator-5554", port: 8901 }); + await subscribeToPort(wsA, { + id: 1, + deviceId: 'emulator-5554', + port: 8901, + }); const wsB = await connectWs(TEST_PORT + 13); t.after(() => wsB.close()); await performHandshake(wsB); - await subscribeToPort(wsB, { id: 2, deviceId: "emulator-5554", port: 8901 }); + await subscribeToPort(wsB, { + id: 2, + deviceId: 'emulator-5554', + port: 8901, + }); - wsA.send(JSON.stringify({ - event: "Customized", - data: { - type: "CDP", - data: { client_id: 8901, session_id: 1, message: { id: 201, method: "DOM.getDocument" } }, - sender: 101, - }, - to: 8901, - })); - wsB.send(JSON.stringify({ - event: "Customized", - data: { - type: "CDP", - data: { client_id: 8901, session_id: 1, message: { id: 202, method: "Runtime.evaluate" } }, - sender: 102, - }, - to: 8901, - })); + wsA.send( + JSON.stringify({ + event: 'Customized', + data: { + type: 'CDP', + data: { + client_id: 8901, + session_id: 1, + message: { id: 201, method: 'DOM.getDocument' }, + }, + sender: 101, + }, + to: 8901, + }), + ); + wsB.send( + JSON.stringify({ + event: 'Customized', + data: { + type: 'CDP', + data: { + client_id: 8901, + session_id: 1, + message: { id: 202, method: 'Runtime.evaluate' }, + }, + sender: 102, + }, + to: 8901, + }), + ); const deadline = Date.now() + 1_000; let forwardedCustomized: Array<{ data: { sender?: number } }> = []; while (Date.now() < deadline) { - forwardedCustomized = forwardedMessages.filter((message): message is { data: { sender?: number } } => - typeof message === "object" && message !== null - && (message as { event?: string }).event === "Customized" + forwardedCustomized = forwardedMessages.filter( + (message): message is { data: { sender?: number } } => + typeof message === 'object' && + message !== null && + (message as { event?: string }).event === 'Customized', ); if (forwardedCustomized.length >= 2) break; await new Promise((resolve) => setTimeout(resolve, 10)); @@ -601,14 +777,14 @@ describe("DevtoolDaemon", () => { t.assert.equal(forwardedCustomized.length, 2); t.assert.deepEqual( - forwardedCustomized.map(message => message.data.sender), + forwardedCustomized.map((message) => message.data.sender), [8901, 8901], ); }); - test("Control listClients waits for delayed device Register", async (t: TestContext) => { + test('Control listClients waits for delayed device Register', async (t: TestContext) => { const fakeTransport = createFakeTransport({ - deviceId: "emulator-5554", + deviceId: 'emulator-5554', activePorts: [8901], registerDelayMs: 800, }); @@ -624,13 +800,13 @@ describe("DevtoolDaemon", () => { const clientList = await requestClientList(ws, 42); t.assert.equal(clientList.length, 1); - t.assert.equal(clientList[0]?.id, "emulator-5554:8901"); - t.assert.equal(clientList[0]?.info.App, "FakeApp"); + t.assert.equal(clientList[0]?.id, 'emulator-5554:8901'); + t.assert.equal(clientList[0]?.info.App, 'FakeApp'); }); - test("caps discovery wait while returning all responsive clients", async (t: TestContext) => { + test('caps discovery wait while returning all responsive clients', async (t: TestContext) => { const fakeTransport = createFakeTransport({ - deviceId: "emulator-5554", + deviceId: 'emulator-5554', activePorts: [8901, 8902, 8903], silentPorts: [8901], registerDelayMsByPort: { @@ -648,23 +824,25 @@ describe("DevtoolDaemon", () => { const clientList = await requestClientList(ws, 43); - t.assert.deepEqual( - clientList.map((client) => client.id).sort(), - ["emulator-5554:8902", "emulator-5554:8903"], - ); + t.assert.deepEqual(clientList.map((client) => client.id).sort(), [ + 'emulator-5554:8902', + 'emulator-5554:8903', + ]); }); - test("Control listClients skips device ports whose connect never settles", async (t: TestContext) => { + test('Control listClients skips device ports whose connect never settles', async (t: TestContext) => { const hangingTransport: Transport = { async close() {}, async listDevices() { - return [{ id: "emulator-5554", os: "Android" as const }]; + return [{ id: 'emulator-5554', os: 'Android' as const }]; }, async listAvailableApps() { return []; }, async openApp() {}, - async connect(options: TransportConnectOptions): Promise> { + async connect( + options: TransportConnectOptions, + ): Promise> { if (options.port === 8901) { return await new Promise>(() => {}); } @@ -679,25 +857,30 @@ describe("DevtoolDaemon", () => { t.after(() => ws.close()); await performHandshake(ws); - ws.send(JSON.stringify({ - event: "Control", - data: { id: 45, method: "listClients" }, - })); + ws.send( + JSON.stringify({ + event: 'Control', + data: { id: 45, method: 'listClients' }, + }), + ); - const response = await readMessageWithin(ws, 6_000) as - | "timeout" - | { event: string; data: { id: number; result?: unknown[]; error?: string } }; + const response = (await readMessageWithin(ws, 6_000)) as + | 'timeout' + | { + event: string; + data: { id: number; result?: unknown[]; error?: string }; + }; - t.assert.notEqual(response, "timeout"); - t.assert.equal(response.event, "ControlResponse"); + t.assert.notEqual(response, 'timeout'); + t.assert.equal(response.event, 'ControlResponse'); t.assert.equal(response.data.id, 45); t.assert.equal(response.data.error, undefined); t.assert.deepEqual(response.data.result, []); }); - test("reusing a device connection resets the idle cleanup grace period", async (t: TestContext) => { + test('reusing a device connection resets the idle cleanup grace period', async (t: TestContext) => { const { transport, getConnectCount } = createCountingFakeTransport({ - deviceId: "emulator-5554", + deviceId: 'emulator-5554', activePorts: [8901], }); const daemon = new DevtoolDaemon([transport]); @@ -706,14 +889,22 @@ describe("DevtoolDaemon", () => { const wsA = await connectWs(TEST_PORT + 10); await performHandshake(wsA); - await subscribeToPort(wsA, { id: 1, deviceId: "emulator-5554", port: 8901 }); + await subscribeToPort(wsA, { + id: 1, + deviceId: 'emulator-5554', + port: 8901, + }); wsA.close(); await new Promise((resolve) => setTimeout(resolve, 9_000)); const wsB = await connectWs(TEST_PORT + 10); await performHandshake(wsB); - await subscribeToPort(wsB, { id: 2, deviceId: "emulator-5554", port: 8901 }); + await subscribeToPort(wsB, { + id: 2, + deviceId: 'emulator-5554', + port: 8901, + }); wsB.close(); await new Promise((resolve) => setTimeout(resolve, 1_500)); @@ -727,9 +918,9 @@ describe("DevtoolDaemon", () => { t.assert.equal(getConnectCount(8901), 1); }); - test("concurrent ClientList discovery reuses one in-flight connection per port", async (t: TestContext) => { + test('concurrent ClientList discovery reuses one in-flight connection per port', async (t: TestContext) => { const { transport, getConnectCount } = createCountingFakeTransport({ - deviceId: "emulator-5554", + deviceId: 'emulator-5554', activePorts: [8901], connectDelayMs: 100, registerDelayMs: 100, @@ -747,7 +938,7 @@ describe("DevtoolDaemon", () => { t.after(() => socket.close()); } - await Promise.all(sockets.map(socket => performHandshake(socket))); + await Promise.all(sockets.map((socket) => performHandshake(socket))); const clientLists = await Promise.all( sockets.map((socket, index) => requestClientList(socket, index + 1)), diff --git a/packages/mcp-servers/devtool-connector/test/daemon-transport.test.ts b/packages/mcp-servers/devtool-connector/test/daemon-transport.test.ts index b43d25e..227d88c 100644 --- a/packages/mcp-servers/devtool-connector/test/daemon-transport.test.ts +++ b/packages/mcp-servers/devtool-connector/test/daemon-transport.test.ts @@ -2,17 +2,17 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert/strict"; -import { ReadableStream, WritableStream } from "node:stream/web"; -import { test } from "node:test"; -import { setTimeout as sleep } from "node:timers/promises"; -import { WebSocketServer } from "ws"; -import { DaemonManager } from "../src/daemon/manager.ts"; -import { DaemonTransport } from "../src/transport/daemon.ts"; -import { wsStreams } from "../src/transport/ws-stream.ts"; - -test("DaemonTransport listClients sends explicit Control listClients request", async (t) => { - const server = new WebSocketServer({ port: 0, path: "/devtool/connector" }); +import assert from 'node:assert/strict'; +import { ReadableStream, WritableStream } from 'node:stream/web'; +import { test } from 'node:test'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { WebSocketServer } from 'ws'; +import { DaemonManager } from '../src/daemon/manager.ts'; +import { DaemonTransport } from '../src/transport/daemon.ts'; +import { wsStreams } from '../src/transport/ws-stream.ts'; + +test('DaemonTransport listClients sends explicit Control listClients request', async (t) => { + const server = new WebSocketServer({ port: 0, path: '/devtool/connector' }); t.after(() => { for (const client of server.clients) { client.terminate(); @@ -20,41 +20,43 @@ test("DaemonTransport listClients sends explicit Control listClients request", a server.close(); }); - server.on("connection", (ws) => { - ws.send(JSON.stringify({ event: "Initialize", data: 1 })); - ws.on("message", (raw) => { + server.on('connection', (ws) => { + ws.send(JSON.stringify({ event: 'Initialize', data: 1 })); + ws.on('message', (raw) => { const msg = JSON.parse(String(raw)) as { event?: string; data?: { id?: number; method?: string }; }; - if (msg.event !== "Control" || msg.data?.method !== "listClients") { + if (msg.event !== 'Control' || msg.data?.method !== 'listClients') { return; } setTimeout(() => { - ws.send(JSON.stringify({ - event: "ControlResponse", - data: { - id: msg.data.id, - result: [{ id: "device:8901", info: { App: "FakeApp" } }], - }, - })); + ws.send( + JSON.stringify({ + event: 'ControlResponse', + data: { + id: msg.data.id, + result: [{ id: 'device:8901', info: { App: 'FakeApp' } }], + }, + }), + ); }, 1_200); }); }); const address = server.address(); - assert(address && typeof address !== "string"); + assert(address && typeof address !== 'string'); const transport = new DaemonTransport(address.port); const clients = await transport.listClients(); assert.equal(clients.length, 1); - assert.equal(clients[0]?.id, "device:8901"); + assert.equal(clients[0]?.id, 'device:8901'); }); -test("DaemonTransport closes daemon connection when subscribe fails", async (t) => { - const server = new WebSocketServer({ port: 0, path: "/devtool/connector" }); +test('DaemonTransport closes daemon connection when subscribe fails', async (t) => { + const server = new WebSocketServer({ port: 0, path: '/devtool/connector' }); t.after(() => { for (const client of server.clients) { client.terminate(); @@ -67,44 +69,52 @@ test("DaemonTransport closes daemon connection when subscribe fails", async (t) resolveClosed = resolve; }); - server.on("connection", (ws) => { - ws.send(JSON.stringify({ event: "Initialize", data: 1 })); - ws.on("close", () => { + server.on('connection', (ws) => { + ws.send(JSON.stringify({ event: 'Initialize', data: 1 })); + ws.on('close', () => { resolveClosed?.(); }); - ws.on("message", (raw) => { + ws.on('message', (raw) => { const msg = JSON.parse(String(raw)) as { event?: string; data?: { id?: number; method?: string }; }; - if (msg.event !== "Control" || msg.data?.method !== "subscribe") { + if (msg.event !== 'Control' || msg.data?.method !== 'subscribe') { return; } - ws.send(JSON.stringify({ - event: "ControlResponse", - data: { id: msg.data.id, error: "device unavailable" }, - })); + ws.send( + JSON.stringify({ + event: 'ControlResponse', + data: { id: msg.data.id, error: 'device unavailable' }, + }), + ); }); }); const address = server.address(); - assert(address && typeof address !== "string"); + assert(address && typeof address !== 'string'); const transport = new DaemonTransport(address.port); - await assert.rejects(() => transport.connect({ deviceId: "device", port: 8901 })); + await assert.rejects(() => + transport.connect({ deviceId: 'device', port: 8901 }), + ); await Promise.race([ closed, sleep(500).then(() => { - throw new Error("Timed out waiting for daemon connection to close"); + throw new Error('Timed out waiting for daemon connection to close'); }), ]); }); -test("DaemonTransport aborts stalled daemon Register writes", async (t) => { - t.mock.method(DaemonManager, "ensureRunning", async () => "ws://daemon.test/devtool/connector"); +test('DaemonTransport aborts stalled daemon Register writes', async (t) => { + t.mock.method( + DaemonManager, + 'ensureRunning', + async () => 'ws://daemon.test/devtool/connector', + ); const originalCreate = wsStreams.create; t.after(() => { @@ -119,7 +129,7 @@ test("DaemonTransport aborts stalled daemon Register writes", async (t) => { opened = Promise.resolve({ readable: new ReadableStream({ start(controller) { - controller.enqueue(JSON.stringify({ event: "Initialize", data: 1 })); + controller.enqueue(JSON.stringify({ event: 'Initialize', data: 1 })); controller.close(); }, }), @@ -158,7 +168,9 @@ test("DaemonTransport aborts stalled daemon Register writes", async (t) => { const transport = new DaemonTransport(21783); const controller = new AbortController(); const timeout = setTimeout(() => { - controller.abort(new DOMException("register write timed out", "TimeoutError")); + controller.abort( + new DOMException('register write timed out', 'TimeoutError'), + ); }, 20); t.after(() => { clearTimeout(timeout); @@ -167,21 +179,31 @@ test("DaemonTransport aborts stalled daemon Register writes", async (t) => { await assert.rejects( () => Promise.race([ - transport.connect({ deviceId: "device", port: 8901, signal: controller.signal }), + transport.connect({ + deviceId: 'device', + port: 8901, + signal: controller.signal, + }), sleep(500).then(() => { - throw new Error("Timed out waiting for stalled Register write to abort"); + throw new Error( + 'Timed out waiting for stalled Register write to abort', + ); }), ]), (error: unknown) => - error instanceof DOMException - && error.name === "TimeoutError" - && error.message === "register write timed out", + error instanceof DOMException && + error.name === 'TimeoutError' && + error.message === 'register write timed out', ); assert.equal(abortCalled, true); }); -test("DaemonTransport writable waits for the websocket write to finish", async (t) => { - t.mock.method(DaemonManager, "ensureRunning", async () => "ws://daemon.test/devtool/connector"); +test('DaemonTransport writable waits for the websocket write to finish', async (t) => { + t.mock.method( + DaemonManager, + 'ensureRunning', + async () => 'ws://daemon.test/devtool/connector', + ); const originalCreate = wsStreams.create; t.after(() => { @@ -196,25 +218,30 @@ test("DaemonTransport writable waits for the websocket write to finish", async ( const readable = new ReadableStream({ start(controller) { - enqueueReadable = chunk => controller.enqueue(chunk); - controller.enqueue(JSON.stringify({ event: "Initialize", data: 123 })); + enqueueReadable = (chunk) => controller.enqueue(chunk); + controller.enqueue(JSON.stringify({ event: 'Initialize', data: 123 })); }, }); const writable = new WritableStream({ write(chunk) { - const message = JSON.parse(chunk) as { event?: string; data?: { id?: number; method?: string } }; - if (message.event === "Control" && message.data?.method === "subscribe") { + const message = JSON.parse(chunk) as { + event?: string; + data?: { id?: number; method?: string }; + }; + if (message.event === 'Control' && message.data?.method === 'subscribe') { queueMicrotask(() => { - enqueueReadable?.(JSON.stringify({ - event: "ControlResponse", - data: { id: message.data?.id, result: null }, - })); + enqueueReadable?.( + JSON.stringify({ + event: 'ControlResponse', + data: { id: message.data?.id, result: null }, + }), + ); }); return Promise.resolve(); } - if (message.event === "Customized") { + if (message.event === 'Customized') { customizedWriteStarted.resolve(); return websocketWrite.promise; } @@ -235,22 +262,24 @@ test("DaemonTransport writable waits for the websocket write to finish", async ( const transport = new DaemonTransport(21783); await using conn = await transport.connect({ - deviceId: "device", + deviceId: 'device', port: 8901, signal: AbortSignal.timeout(1_000), }); const writer = conn.writable.getWriter(); try { - const writePromise = writer.write({ - event: "Customized", - data: { - type: "ListSession", - data: {}, - }, - }).then(() => { - daemonWriteFinished = true; - }); + const writePromise = writer + .write({ + event: 'Customized', + data: { + type: 'ListSession', + data: {}, + }, + }) + .then(() => { + daemonWriteFinished = true; + }); await customizedWriteStarted.promise; await sleep(20); diff --git a/packages/mcp-servers/devtool-connector/test/ios.test.ts b/packages/mcp-servers/devtool-connector/test/ios.test.ts index 5314e7e..026ee55 100644 --- a/packages/mcp-servers/devtool-connector/test/ios.test.ts +++ b/packages/mcp-servers/devtool-connector/test/ios.test.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert/strict"; -import { once } from "node:events"; -import net from "node:net"; -import { test } from "node:test"; -import { build, parse, type PlistValue } from "plist"; -import { iOSTransport } from "../src/transport/ios.ts"; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import net from 'node:net'; +import { test } from 'node:test'; +import { build, type PlistValue, parse } from 'plist'; +import { iOSTransport } from '../src/transport/ios.ts'; const HEADER_SIZE = 16; const USBMUXD_VERSION = 1; @@ -15,7 +15,7 @@ const USBMUXD_PACKET_TYPE_PLIST = 8; const TAG = 1; function encodePacket(payload: PlistValue): Buffer { - const body = Buffer.from(build(payload), "utf8"); + const body = Buffer.from(build(payload), 'utf8'); const header = Buffer.alloc(HEADER_SIZE); header.writeUInt32LE(HEADER_SIZE + body.length, 0); header.writeUInt32LE(USBMUXD_VERSION, 4); @@ -27,14 +27,14 @@ function encodePacket(payload: PlistValue): Buffer { function decodePacket(buffer: Buffer): PlistValue { const length = buffer.readUInt32LE(0); assert.ok(buffer.length >= length); - return parse(buffer.subarray(HEADER_SIZE, length).toString("utf8")); + return parse(buffer.subarray(HEADER_SIZE, length).toString('utf8')); } function toNetworkByteOrderPort(port: number): number { - return ((port >> 8) & 0xFF) | ((port << 8) & 0xFF00); + return ((port >> 8) & 0xff) | ((port << 8) & 0xff00); } -test("iOSTransport listDevices uses usbmux serial number as device id", async () => { +test('iOSTransport listDevices uses usbmux serial number as device id', async () => { let resolveRequest!: (value: PlistValue) => void; let rejectRequest!: (reason?: unknown) => void; const request = new Promise((resolve, reject) => { @@ -43,102 +43,118 @@ test("iOSTransport listDevices uses usbmux serial number as device id", async () }); const server = net.createServer((socket) => { - socket.once("data", (chunk: Buffer) => { + socket.once('data', (chunk: Buffer) => { try { resolveRequest(decodePacket(chunk)); - socket.write(encodePacket({ - DeviceList: [ - { - DeviceID: 42, - MessageType: "Attached", - Properties: { - ConnectionSpeed: 480000000, - ConnectionType: "USB", + socket.write( + encodePacket({ + DeviceList: [ + { DeviceID: 42, - LocationID: 123, - ProductID: 456, - SerialNumber: "00008130-0008545E3608001C", - USBSerialNumber: "usb-serial", + MessageType: 'Attached', + Properties: { + ConnectionSpeed: 480000000, + ConnectionType: 'USB', + DeviceID: 42, + LocationID: 123, + ProductID: 456, + SerialNumber: '00008130-0008545E3608001C', + USBSerialNumber: 'usb-serial', + }, }, - }, - ], - })); + ], + }), + ); } catch (error) { rejectRequest(error); } }); }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); try { const address = server.address(); - assert.ok(address && typeof address === "object"); + assert.ok(address && typeof address === 'object'); - const transport = new iOSTransport({ host: "127.0.0.1", port: address.port }); + const transport = new iOSTransport({ + host: '127.0.0.1', + port: address.port, + }); const devices = await transport.listDevices(); assert.deepStrictEqual(await request, { - MessageType: "ListDevices", - ClientVersionString: "usbmux-driver", - ProgName: "usbmux-driver", + MessageType: 'ListDevices', + ClientVersionString: 'usbmux-driver', + ProgName: 'usbmux-driver', }); assert.deepStrictEqual(devices, [ - { id: "00008130-0008545E3608001C", os: "iOS" }, + { id: '00008130-0008545E3608001C', os: 'iOS' }, ]); } finally { server.close(); - await once(server, "close"); + await once(server, 'close'); } }); -test("iOSTransport connect resolves usbmux serial number to device id", async () => { +test('iOSTransport connect resolves usbmux serial number to device id', async () => { const requests: PlistValue[] = []; const server = net.createServer((socket) => { - socket.once("data", (chunk: Buffer) => { + socket.once('data', (chunk: Buffer) => { const request = decodePacket(chunk); requests.push(request); - if (typeof request === "object" && request !== null && request["MessageType"] === "ListDevices") { - socket.write(encodePacket({ - DeviceList: [ - { - DeviceID: 42, - MessageType: "Attached", - Properties: { - ConnectionSpeed: 480000000, - ConnectionType: "USB", + if ( + typeof request === 'object' && + request !== null && + request['MessageType'] === 'ListDevices' + ) { + socket.write( + encodePacket({ + DeviceList: [ + { DeviceID: 42, - LocationID: 123, - ProductID: 456, - SerialNumber: "00008130-0008545E3608001C", - USBSerialNumber: "usb-serial", + MessageType: 'Attached', + Properties: { + ConnectionSpeed: 480000000, + ConnectionType: 'USB', + DeviceID: 42, + LocationID: 123, + ProductID: 456, + SerialNumber: '00008130-0008545E3608001C', + USBSerialNumber: 'usb-serial', + }, }, - }, - ], - })); + ], + }), + ); return; } - socket.write(encodePacket({ - MessageType: "Result", - Number: 0, - })); + socket.write( + encodePacket({ + MessageType: 'Result', + Number: 0, + }), + ); }); }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); try { const address = server.address(); - assert.ok(address && typeof address === "object"); + assert.ok(address && typeof address === 'object'); - const transport = new iOSTransport({ host: "127.0.0.1", port: address.port }); + const transport = new iOSTransport({ + host: '127.0.0.1', + port: address.port, + }); await using conn = await transport.connect({ - deviceId: "00008130-0008545E3608001C", + deviceId: '00008130-0008545E3608001C', port: 8901, signal: AbortSignal.timeout(1_000), }); @@ -146,20 +162,20 @@ test("iOSTransport connect resolves usbmux serial number to device id", async () assert.deepStrictEqual(requests, [ { - MessageType: "ListDevices", - ClientVersionString: "usbmux-driver", - ProgName: "usbmux-driver", + MessageType: 'ListDevices', + ClientVersionString: 'usbmux-driver', + ProgName: 'usbmux-driver', }, { - MessageType: "Connect", - ClientVersionString: "usbmux-driver", - ProgName: "usbmux-driver", + MessageType: 'Connect', + ClientVersionString: 'usbmux-driver', + ProgName: 'usbmux-driver', DeviceID: 42, PortNumber: toNetworkByteOrderPort(8901), }, ]); } finally { server.close(); - await once(server, "close"); + await once(server, 'close'); } }); diff --git a/packages/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts b/packages/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts index 4a20584..2b747ce 100644 --- a/packages/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts +++ b/packages/mcp-servers/devtool-connector/test/list-clients-fallback.test.ts @@ -2,10 +2,14 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { describe, test } from "node:test"; -import { Connector } from "../src/index.ts"; -import { DaemonTransport } from "../src/transport/daemon.ts"; -import type { Client, Connection, Transport } from "../src/transport/transport.ts"; +import { describe, test } from 'node:test'; +import { Connector } from '../src/index.ts'; +import { DaemonTransport } from '../src/transport/daemon.ts'; +import type { + Client, + Connection, + Transport, +} from '../src/transport/transport.ts'; class EmptyDaemonTransport extends DaemonTransport { listClientsCalls = 0; @@ -33,12 +37,12 @@ class DirectFallbackProbeTransport implements Transport { async openApp(): Promise {} async connect(): Promise> { - throw new Error("Direct fallback probe should not connect"); + throw new Error('Direct fallback probe should not connect'); } } -describe("Connector listClients fallback", () => { - test("uses a fulfilled daemon result even when it is empty", async (t) => { +describe('Connector listClients fallback', () => { + test('uses a fulfilled daemon result even when it is empty', async (t) => { const daemonTransport = new EmptyDaemonTransport(); const directTransport = new DirectFallbackProbeTransport(); const connector = new Connector([daemonTransport, directTransport]); diff --git a/packages/mcp-servers/devtool-connector/test/list-clients-setup.test.ts b/packages/mcp-servers/devtool-connector/test/list-clients-setup.test.ts index 9c39d6c..c677e17 100644 --- a/packages/mcp-servers/devtool-connector/test/list-clients-setup.test.ts +++ b/packages/mcp-servers/devtool-connector/test/list-clients-setup.test.ts @@ -2,21 +2,25 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { ReadableStream, WritableStream } from "node:stream/web"; -import { describe, test } from "node:test"; -import type { TestContext } from "node:test"; -import { ClientId, Connector } from "../src/index.ts"; -import type { Connection, Transport, TransportConnectOptions } from "../src/transport/transport.ts"; +import { ReadableStream, WritableStream } from 'node:stream/web'; +import type { TestContext } from 'node:test'; +import { describe, test } from 'node:test'; +import { ClientId, Connector } from '../src/index.ts'; +import type { + Connection, + Transport, + TransportConnectOptions, +} from '../src/transport/transport.ts'; class SetupAwareTransport implements Transport { - readonly #deviceId = "device under:test"; + readonly #deviceId = 'device under:test'; readonly #ports = [8901, 8902]; readonly setupRequests: { key: string; port: number }[] = []; async close(): Promise {} async listDevices() { - return [{ id: this.#deviceId, os: "Android" as const }]; + return [{ id: this.#deviceId, os: 'Android' as const }]; } async listAvailableApps() { @@ -25,7 +29,9 @@ class SetupAwareTransport implements Transport { async openApp(): Promise {} - async connect(options: TransportConnectOptions): Promise> { + async connect( + options: TransportConnectOptions, + ): Promise> { if (options.deviceId !== this.#deviceId) { throw new Error(`Unexpected deviceId: ${options.deviceId}`); } @@ -52,13 +58,13 @@ class SetupAwareTransport implements Transport { if (setupKey) { this.setupRequests.push({ key: setupKey, port: options.port }); enqueueResponse?.({ - event: "Customized", + event: 'Customized', data: { - type: "SetGlobalSwitch", + type: 'SetGlobalSwitch', data: { client_id: options.port, session_id: -1, - message: "ok", + message: 'ok', }, }, }); @@ -82,53 +88,55 @@ class SetupAwareTransport implements Transport { #createRegisterResponse(port: number) { return { - event: "Register", + event: 'Register', data: { id: port, info: { App: `app-${port}`, - AppVersion: "1.0.0", + AppVersion: '1.0.0', AppProcessName: `app-${port}`, debugRouterId: `router-${port}`, - debugRouterVersion: "1.0.0", - deviceModel: "fake-device", - network: "wifi", - osVersion: "1", - sdkVersion: "1", + debugRouterVersion: '1.0.0', + deviceModel: 'fake-device', + network: 'wifi', + osVersion: '1', + sdkVersion: '1', }, }, }; } #isExpectedInitialize(message: unknown, port: number): boolean { - return this.#ports.includes(port) - && typeof message === "object" - && message !== null - && "event" in message - && message.event === "Initialize" - && "data" in message - && message.data === port; + return ( + this.#ports.includes(port) && + typeof message === 'object' && + message !== null && + 'event' in message && + message.event === 'Initialize' && + 'data' in message && + message.data === port + ); } #getSetGlobalSwitchKey(message: unknown): string | null { if ( - typeof message !== "object" - || message === null - || !("event" in message) - || message.event !== "Customized" - || !("data" in message) - || typeof message.data !== "object" - || message.data === null - || !("type" in message.data) - || message.data.type !== "SetGlobalSwitch" - || !("data" in message.data) - || typeof message.data.data !== "object" - || message.data.data === null - || !("message" in message.data.data) - || typeof message.data.data.message !== "object" - || message.data.data.message === null - || !("global_key" in message.data.data.message) - || typeof message.data.data.message.global_key !== "string" + typeof message !== 'object' || + message === null || + !('event' in message) || + message.event !== 'Customized' || + !('data' in message) || + typeof message.data !== 'object' || + message.data === null || + !('type' in message.data) || + message.data.type !== 'SetGlobalSwitch' || + !('data' in message.data) || + typeof message.data.data !== 'object' || + message.data.data === null || + !('message' in message.data.data) || + typeof message.data.data.message !== 'object' || + message.data.data.message === null || + !('global_key' in message.data.data.message) || + typeof message.data.data.message.global_key !== 'string' ) { return null; } @@ -137,25 +145,22 @@ class SetupAwareTransport implements Transport { } } -describe("Connector listClients setup", () => { - test("sets up every discovered client each time clients are listed", async (t: TestContext) => { +describe('Connector listClients setup', () => { + test('sets up every discovered client each time clients are listed', async (t: TestContext) => { const transport = new SetupAwareTransport(); const connector = new Connector([transport]); const clients = await connector.listClients(); - t.assert.deepStrictEqual( - clients.map(({ id }) => id).sort(), - [ - ClientId.serialize("device under:test", 8901), - ClientId.serialize("device under:test", 8902), - ], - ); + t.assert.deepStrictEqual(clients.map(({ id }) => id).sort(), [ + ClientId.serialize('device under:test', 8901), + ClientId.serialize('device under:test', 8902), + ]); t.assert.deepStrictEqual(transport.setupRequests, [ - { key: "enable_devtool", port: 8901 }, - { key: "enable_devtool", port: 8902 }, - { key: "enable_quickjs_debug", port: 8901 }, - { key: "enable_quickjs_debug", port: 8902 }, + { key: 'enable_devtool', port: 8901 }, + { key: 'enable_devtool', port: 8902 }, + { key: 'enable_quickjs_debug', port: 8901 }, + { key: 'enable_quickjs_debug', port: 8902 }, ]); await connector.listClients(); diff --git a/packages/mcp-servers/devtool-connector/test/open-app-daemon.test.ts b/packages/mcp-servers/devtool-connector/test/open-app-daemon.test.ts index 6f7fd17..c01218f 100644 --- a/packages/mcp-servers/devtool-connector/test/open-app-daemon.test.ts +++ b/packages/mcp-servers/devtool-connector/test/open-app-daemon.test.ts @@ -2,20 +2,25 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { Connector } from "../src/index.ts"; -import type { App, Client, Connection, Device, Transport } from "../src/transport/transport.ts"; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Connector } from '../src/index.ts'; +import type { + App, + Client, + Connection, + Device, + Transport, +} from '../src/transport/transport.ts'; class ClientListTransport implements Transport { #openedPackageName: string | null = null; listClientsCalls = 0; - async close(): Promise { - } + async close(): Promise {} async listDevices(): Promise { - return [{ id: "device", os: "Android" }]; + return [{ id: 'device', os: 'Android' }]; } async listAvailableApps(): Promise { @@ -32,32 +37,38 @@ class ClientListTransport implements Transport { return []; } - return [{ - id: "device:8902", - info: { - App: "LynxPlayground", - AppProcessName: this.#openedPackageName, - AppVersion: "1.0.0", - debugRouterId: "debug-router-id", - debugRouterVersion: "0.0.20", - deviceModel: "device", - network: "USB", - osVersion: "10", - sdkVersion: "0.0.1", + return [ + { + id: 'device:8902', + info: { + App: 'LynxPlayground', + AppProcessName: this.#openedPackageName, + AppVersion: '1.0.0', + debugRouterId: 'debug-router-id', + debugRouterVersion: '0.0.20', + deviceModel: 'device', + network: 'USB', + osVersion: '10', + sdkVersion: '0.0.1', + }, }, - }]; + ]; } - async connect(): Promise> { - throw new Error("openApp should use transport.listClients instead of probing ports"); + async connect(): Promise< + Connection + > { + throw new Error( + 'openApp should use transport.listClients instead of probing ports', + ); } } -test("Connector.openApp waits for clients through transport.listClients when available", async () => { +test('Connector.openApp waits for clients through transport.listClients when available', async () => { const transport = new ClientListTransport(); const connector = new Connector([transport]); - await connector.openApp("device", "com.lynx.uiapp", { + await connector.openApp('device', 'com.lynx.uiapp', { signal: AbortSignal.timeout(50), }); diff --git a/packages/mcp-servers/devtool-connector/test/testWithClient.test.ts b/packages/mcp-servers/devtool-connector/test/testWithClient.test.ts index 185446c..4fde44c 100644 --- a/packages/mcp-servers/devtool-connector/test/testWithClient.test.ts +++ b/packages/mcp-servers/devtool-connector/test/testWithClient.test.ts @@ -2,50 +2,55 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { test } from "node:test"; -import type { Client } from "../src/transport/transport.ts"; -import { getTestingSession, selectTestingClient, TEST_PAGE_URL, type TestingTarget } from "./testWithClient.ts"; +import { test } from 'node:test'; +import type { Client } from '../src/transport/transport.ts'; +import { + getTestingSession, + selectTestingClient, + TEST_PAGE_URL, + type TestingTarget, +} from './testWithClient.ts'; -test("selectTestingClient picks the target package", (t) => { +test('selectTestingClient picks the target package', (t) => { const clients: Client[] = [ { - id: "device:8901", + id: 'device:8901', info: { - App: "TikTok-M", - AppProcessName: "com.zhiliaoapp.musically", + App: 'TikTok-M', + AppProcessName: 'com.zhiliaoapp.musically', }, }, { - id: "device:8902", + id: 'device:8902', info: { - App: "LynxPlayground", - AppProcessName: "com.lynx.uiapp", + App: 'LynxPlayground', + AppProcessName: 'com.lynx.uiapp', }, }, ]; const target: TestingTarget = { - appPackageName: "com.lynx.uiapp", + appPackageName: 'com.lynx.uiapp', pageUrl: TEST_PAGE_URL, openUrl: TEST_PAGE_URL, }; const client = selectTestingClient(clients, target); - t.assert.equal(client?.id, "device:8902"); + t.assert.equal(client?.id, 'device:8902'); }); -test("selectTestingClient returns undefined when no match", (t) => { +test('selectTestingClient returns undefined when no match', (t) => { const clients: Client[] = [ { - id: "device:8901", + id: 'device:8901', info: { - App: "TikTok-M", - AppProcessName: "com.zhiliaoapp.musically", + App: 'TikTok-M', + AppProcessName: 'com.zhiliaoapp.musically', }, }, ]; const target: TestingTarget = { - appPackageName: "com.lynx.uiapp", + appPackageName: 'com.lynx.uiapp', pageUrl: TEST_PAGE_URL, openUrl: TEST_PAGE_URL, }; @@ -55,30 +60,29 @@ test("selectTestingClient returns undefined when no match", (t) => { t.assert.equal(client, undefined); }); -test("getTestingSession returns the latest session", async (t) => { +test('getTestingSession returns the latest session', async (t) => { const connector = { async sendListSessionMessage() { return [ - { session_id: 1, url: "https://example.com/a" }, + { session_id: 1, url: 'https://example.com/a' }, { session_id: 2, url: TEST_PAGE_URL }, ]; }, }; - const session = await getTestingSession(connector, "device:8901"); + const session = await getTestingSession(connector, 'device:8901'); t.assert.equal(session.session_id, 2); }); -test("getTestingSession throws when no sessions exist", async (t) => { +test('getTestingSession throws when no sessions exist', async (t) => { const connector = { async sendListSessionMessage() { return []; }, }; - await t.assert.rejects( - () => getTestingSession(connector, "device:8901"), - { message: /No sessions found/ }, - ); + await t.assert.rejects(() => getTestingSession(connector, 'device:8901'), { + message: /No sessions found/, + }); }); diff --git a/packages/mcp-servers/devtool-connector/test/testWithClient.ts b/packages/mcp-servers/devtool-connector/test/testWithClient.ts index 57d0030..974a10e 100644 --- a/packages/mcp-servers/devtool-connector/test/testWithClient.ts +++ b/packages/mcp-servers/devtool-connector/test/testWithClient.ts @@ -2,23 +2,22 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { describe, test, type TestContext } from "node:test"; -import { Connector } from "../src/index.ts"; -import { AndroidTransport } from "../src/transport/android.ts"; -import { DaemonTransport } from "../src/transport/daemon.ts"; -import { DesktopTransport } from "../src/transport/desktop.ts"; -import { iOSTransport } from "../src/transport/ios.ts"; -import type { Client, Transport } from "../src/transport/transport.ts"; - -export const TEST_APP_PACKAGE_NAME = "com.lynx.uiapp"; -export const TEST_PAGE_URL = - "https://example.com/template.js"; -const TEST_APP_PACKAGE_ENV = "LYNX_DEVTOOL_MCP_TESTING_APP_PACKAGE"; -const TEST_PAGE_URL_ENV = "LYNX_DEVTOOL_MCP_TESTING_PAGE_URL"; -const TEST_OPEN_URL_ENV = "LYNX_DEVTOOL_MCP_TESTING_OPEN_URL"; - -const transportsFromEnv = process.env["LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS"] - ? process.env["LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS"].split(",") +import { describe, type TestContext, test } from 'node:test'; +import { Connector } from '../src/index.ts'; +import { AndroidTransport } from '../src/transport/android.ts'; +import { DaemonTransport } from '../src/transport/daemon.ts'; +import { DesktopTransport } from '../src/transport/desktop.ts'; +import { iOSTransport } from '../src/transport/ios.ts'; +import type { Client, Transport } from '../src/transport/transport.ts'; + +export const TEST_APP_PACKAGE_NAME = 'com.lynx.uiapp'; +export const TEST_PAGE_URL = 'https://example.com/template.js'; +const TEST_APP_PACKAGE_ENV = 'LYNX_DEVTOOL_MCP_TESTING_APP_PACKAGE'; +const TEST_PAGE_URL_ENV = 'LYNX_DEVTOOL_MCP_TESTING_PAGE_URL'; +const TEST_OPEN_URL_ENV = 'LYNX_DEVTOOL_MCP_TESTING_OPEN_URL'; + +const transportsFromEnv = process.env['LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS'] + ? process.env['LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS'].split(',') : null; export interface TestingTarget { @@ -32,7 +31,9 @@ function readEnv(env: NodeJS.ProcessEnv, name: string): string | undefined { return value ? value : undefined; } -function resolveTestingTarget(env: NodeJS.ProcessEnv = process.env): TestingTarget { +function resolveTestingTarget( + env: NodeJS.ProcessEnv = process.env, +): TestingTarget { const pageUrl = readEnv(env, TEST_PAGE_URL_ENV) ?? TEST_PAGE_URL; return { @@ -43,17 +44,19 @@ function resolveTestingTarget(env: NodeJS.ProcessEnv = process.env): TestingTarg } function isClientForTarget(client: Client, target: TestingTarget): boolean { - return client.info.AppProcessName === target.appPackageName - || client.info.bundleId === target.appPackageName - || client.info.bundleName === target.appPackageName - || client.info.App === target.appPackageName; + return ( + client.info.AppProcessName === target.appPackageName || + client.info.bundleId === target.appPackageName || + client.info.bundleName === target.appPackageName || + client.info.App === target.appPackageName + ); } export function selectTestingClient( clients: Client[], target: TestingTarget = resolveTestingTarget(), ): Client | undefined { - return clients.find(client => isClientForTarget(client, target)); + return clients.find((client) => isClientForTarget(client, target)); } function formatClient(client: Client): string { @@ -66,7 +69,9 @@ function formatClient(client: Client): string { info.bundleName ? `bundleName=${info.bundleName}` : undefined, info.osType ? `osType=${info.osType}` : undefined, info.deviceModel ? `deviceModel=${info.deviceModel}` : undefined, - ].filter(Boolean).join(", "); + ] + .filter(Boolean) + .join(', '); } export function formatNoTestingClientMessage( @@ -78,9 +83,9 @@ export function formatNoTestingClientMessage( return `No ${name} clients found for target package ${target.appPackageName}`; } - return `No ${name} clients matched target package ${target.appPackageName}. Available clients: ${ - clients.map(formatClient).join("; ") - }`; + return `No ${name} clients matched target package ${target.appPackageName}. Available clients: ${clients + .map(formatClient) + .join('; ')}`; } export type TestingSession = { @@ -90,7 +95,9 @@ export type TestingSession = { }; export async function getTestingSession( - connector: { sendListSessionMessage(clientId: string): Promise }, + connector: { + sendListSessionMessage(clientId: string): Promise; + }, clientId: string, ): Promise { const sessions = await connector.sendListSessionMessage(clientId); @@ -103,24 +110,31 @@ export async function getTestingSession( return session; } -const Transports: { name: string; createTransports: () => Promise | Transport[] }[] = [ - { name: "iOS", createTransports: () => [new iOSTransport()] }, - { name: "Android", createTransports: () => [new AndroidTransport()] }, - { name: "Daemon", createTransports: () => [new DaemonTransport()] }, +const Transports: { + name: string; + createTransports: () => Promise | Transport[]; +}[] = [ + { name: 'iOS', createTransports: () => [new iOSTransport()] }, + { name: 'Android', createTransports: () => [new AndroidTransport()] }, + { name: 'Daemon', createTransports: () => [new DaemonTransport()] }, { - name: "EmbeddedLynx", + name: 'EmbeddedLynx', createTransports: () => [new DesktopTransport()], }, -] - .filter(i => !transportsFromEnv || transportsFromEnv.includes(i.name)); +].filter((i) => !transportsFromEnv || transportsFromEnv.includes(i.name)); if (transportsFromEnv && Transports.length === 0) { throw new Error( - `No transports matched LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS=${process.env["LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS"]}`, + `No transports matched LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS=${process.env['LYNX_DEVTOOL_MCP_TESTING_TRANSPORTS']}`, ); } -function createRunner(testFn: (name: string, fn: (t: TestContext) => Promise) => Promise) { +function createRunner( + testFn: ( + name: string, + fn: (t: TestContext) => Promise, + ) => Promise, +) { return ( testName: string, callback: ( @@ -158,11 +172,8 @@ function createRunner(testFn: (name: string, fn: (t: TestContext) => Promise {} async listDevices() { await sleep(10); - return [{ id: this.#deviceId, os: "Android" as const }]; + return [{ id: this.#deviceId, os: 'Android' as const }]; } async listAvailableApps() { @@ -48,14 +48,14 @@ class SlowDaemonSessionTransport extends DaemonTransport { } enqueueResponse?.({ - event: "Customized", + event: 'Customized', data: { - type: "SessionList", + type: 'SessionList', data: [ { session_id: 101, - type: "lynx", - url: "https://example.test/session/101", + type: 'lynx', + url: 'https://example.test/session/101', }, ], }, @@ -75,26 +75,28 @@ class SlowDaemonSessionTransport extends DaemonTransport { } #isListSessionRequest(message: unknown): boolean { - return typeof message === "object" - && message !== null - && "event" in message - && message.event === "Customized" - && "data" in message - && typeof message.data === "object" - && message.data !== null - && "type" in message.data - && message.data.type === "ListSession"; + return ( + typeof message === 'object' && + message !== null && + 'event' in message && + message.event === 'Customized' && + 'data' in message && + typeof message.data === 'object' && + message.data !== null && + 'type' in message.data && + message.data.type === 'ListSession' + ); } } class FastDirectNoResponseTransport implements Transport { - readonly #deviceId = "device under:test"; + readonly #deviceId = 'device under:test'; connectCalls = 0; async close(): Promise {} async listDevices() { - return [{ id: this.#deviceId, os: "Android" as const }]; + return [{ id: this.#deviceId, os: 'Android' as const }]; } async listAvailableApps() { @@ -131,19 +133,23 @@ class FastDirectNoResponseTransport implements Transport { } } -describe("Connector transport selection", () => { - test("prefers daemon transports over other transports for the same device", async (t: TestContext) => { +describe('Connector transport selection', () => { + test('prefers daemon transports over other transports for the same device', async (t: TestContext) => { const daemonTransport = new SlowDaemonSessionTransport(); const directTransport = new FastDirectNoResponseTransport(); const connector = new Connector([directTransport, daemonTransport]); - const sessions = await connector.sendListSessionMessage(ClientId.serialize("device under:test", 8901)); + const sessions = await connector.sendListSessionMessage( + ClientId.serialize('device under:test', 8901), + ); - t.assert.deepStrictEqual(sessions, [{ - session_id: 101, - type: "lynx", - url: "https://example.test/session/101", - }]); + t.assert.deepStrictEqual(sessions, [ + { + session_id: 101, + type: 'lynx', + url: 'https://example.test/session/101', + }, + ]); t.assert.equal(daemonTransport.connectCalls, 1); t.assert.equal(directTransport.connectCalls, 0); }); diff --git a/packages/mcp-servers/devtool-connector/test/usbmux.test.ts b/packages/mcp-servers/devtool-connector/test/usbmux.test.ts index 148fdc9..4735cdb 100644 --- a/packages/mcp-servers/devtool-connector/test/usbmux.test.ts +++ b/packages/mcp-servers/devtool-connector/test/usbmux.test.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert/strict"; -import { once } from "node:events"; -import net from "node:net"; -import { test } from "node:test"; -import { build, parse, type PlistValue } from "plist"; -import { Usbmux } from "../src/transport/usbmux.ts"; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import net from 'node:net'; +import { test } from 'node:test'; +import { build, type PlistValue, parse } from 'plist'; +import { Usbmux } from '../src/transport/usbmux.ts'; const HEADER_SIZE = 16; const USBMUXD_VERSION = 1; @@ -15,7 +15,7 @@ const USBMUXD_PACKET_TYPE_PLIST = 8; const TAG = 1; function encodePacket(payload: PlistValue): Buffer { - const body = Buffer.from(build(payload), "utf8"); + const body = Buffer.from(build(payload), 'utf8'); const header = Buffer.alloc(HEADER_SIZE); header.writeUInt32LE(HEADER_SIZE + body.length, 0); header.writeUInt32LE(USBMUXD_VERSION, 4); @@ -27,10 +27,10 @@ function encodePacket(payload: PlistValue): Buffer { function decodePacket(buffer: Buffer): PlistValue { const length = buffer.readUInt32LE(0); assert.ok(buffer.length >= length); - return parse(buffer.subarray(HEADER_SIZE, length).toString("utf8")); + return parse(buffer.subarray(HEADER_SIZE, length).toString('utf8')); } -test("listDevices exchanges plist packets with usbmuxd", async () => { +test('listDevices exchanges plist packets with usbmuxd', async () => { let resolveRequest!: (value: PlistValue) => void; let rejectRequest!: (reason?: unknown) => void; const request = new Promise((resolve, reject) => { @@ -39,64 +39,66 @@ test("listDevices exchanges plist packets with usbmuxd", async () => { }); const server = net.createServer((socket) => { - socket.once("data", (chunk: Buffer) => { + socket.once('data', (chunk: Buffer) => { try { resolveRequest(decodePacket(chunk)); - socket.write(encodePacket({ - DeviceList: [ - { - DeviceID: 42, - MessageType: "Attached", - Properties: { - ConnectionSpeed: 480000000, - ConnectionType: "USB", + socket.write( + encodePacket({ + DeviceList: [ + { DeviceID: 42, - LocationID: 123, - ProductID: 456, - SerialNumber: "device-serial", - USBSerialNumber: "usb-serial", + MessageType: 'Attached', + Properties: { + ConnectionSpeed: 480000000, + ConnectionType: 'USB', + DeviceID: 42, + LocationID: 123, + ProductID: 456, + SerialNumber: 'device-serial', + USBSerialNumber: 'usb-serial', + }, }, - }, - ], - })); + ], + }), + ); } catch (error) { rejectRequest(error); } }); }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); try { const address = server.address(); - assert.ok(address && typeof address === "object"); + assert.ok(address && typeof address === 'object'); - const usbmux = new Usbmux({ host: "127.0.0.1", port: address.port }); + const usbmux = new Usbmux({ host: '127.0.0.1', port: address.port }); const devices = await usbmux.listDevices(AbortSignal.timeout(1000)); assert.deepStrictEqual(await request, { - MessageType: "ListDevices", - ClientVersionString: "usbmux-driver", - ProgName: "usbmux-driver", + MessageType: 'ListDevices', + ClientVersionString: 'usbmux-driver', + ProgName: 'usbmux-driver', }); assert.deepStrictEqual(devices, [ { DeviceID: 42, - MessageType: "Attached", + MessageType: 'Attached', Properties: { ConnectionSpeed: 480000000, - ConnectionType: "USB", + ConnectionType: 'USB', DeviceID: 42, LocationID: 123, ProductID: 456, - SerialNumber: "device-serial", - USBSerialNumber: "usb-serial", + SerialNumber: 'device-serial', + USBSerialNumber: 'usb-serial', }, }, ]); } finally { server.close(); - await once(server, "close"); + await once(server, 'close'); } }); diff --git a/packages/mcp-servers/devtool-connector/tsconfig.json b/packages/mcp-servers/devtool-connector/tsconfig.json index d842fdd..3cc2867 100644 --- a/packages/mcp-servers/devtool-connector/tsconfig.json +++ b/packages/mcp-servers/devtool-connector/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "rootDir": "./src", "noEmit": true, - "types": ["ws"], + "types": ["ws"] }, - "include": ["src"], + "include": ["src"] } diff --git a/packages/mcp-servers/devtool-mcp-server/e2e/tools.test.ts b/packages/mcp-servers/devtool-mcp-server/e2e/tools.test.ts index 83be9d6..26060c3 100644 --- a/packages/mcp-servers/devtool-mcp-server/e2e/tools.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/e2e/tools.test.ts @@ -2,36 +2,36 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { testWithClient } from "@lynx-js/devtool-connector/test-with-client"; -import fs from "node:fs/promises"; -import type { TestContext } from "node:test"; -import { setTimeout } from "node:timers/promises"; -import { DescribeNode } from "../src/tools/DOM/DescribeNode.ts"; -import { GetAttributes } from "../src/tools/DOM/GetAttributes.ts"; -import { GetBoxModel } from "../src/tools/DOM/GetBoxModel.ts"; -import { GetDocument } from "../src/tools/DOM/GetDocument.ts"; -import { GetDocumentWithBoxModel } from "../src/tools/DOM/GetDocumentWithBoxModel.ts"; -import { GetNodeForLocation } from "../src/tools/DOM/GetNodeForLocation.ts"; -import { GetOriginalNodeIndex } from "../src/tools/DOM/GetOriginalNodeIndex.ts"; -import { GetSearchResults } from "../src/tools/DOM/GetSearchResults.ts"; -import { InnerText } from "../src/tools/DOM/InnerText.ts"; -import { PerformSearch } from "../src/tools/DOM/PerformSearch.ts"; -import { PushNodesByBackendIdsToFrontend } from "../src/tools/DOM/PushNodesByBackendIdsToFrontend.ts"; -import { QuerySelector } from "../src/tools/DOM/QuerySelector.ts"; -import { QuerySelectorAll } from "../src/tools/DOM/QuerySelectorAll.ts"; -import { RequestChildNodes } from "../src/tools/DOM/RequestChildNodes.ts"; -import { ScrollIntoViewIfNeeded } from "../src/tools/DOM/ScrollIntoViewIfNeeded.ts"; -import { SetAttributesAsText } from "../src/tools/DOM/SetAttributesAsText.ts"; -import { TakeHeapSnapshot } from "../src/tools/HeapProfiler/TakeHeapSnapshot.ts"; -import { GetVersion } from "../src/tools/Lynx/GetVersion.ts"; -import { GetResourceContent } from "../src/tools/Page/GetResourceContent.ts"; -import { GetResourceTree } from "../src/tools/Page/GetResourceTree.ts"; -import { GetAllPerformanceEntries } from "../src/tools/Performance/GetAllPerformanceEntries.ts"; -import { GetAllTimingInfo } from "../src/tools/Performance/GetAllTimingInfo.ts"; -import { Evaluate } from "../src/tools/Runtime/Evaluate.ts"; -import { GetHeapUsage } from "../src/tools/Runtime/GetHeapUsage.ts"; -import { GetProperties } from "../src/tools/Runtime/GetProperties.ts"; -import { GetLynxUITree } from "../src/tools/UITree/GetLynxUITree.ts"; +import fs from 'node:fs/promises'; +import type { TestContext } from 'node:test'; +import { setTimeout } from 'node:timers/promises'; +import { testWithClient } from '@lynx-js/devtool-connector/test-with-client'; +import { DescribeNode } from '../src/tools/DOM/DescribeNode.ts'; +import { GetAttributes } from '../src/tools/DOM/GetAttributes.ts'; +import { GetBoxModel } from '../src/tools/DOM/GetBoxModel.ts'; +import { GetDocument } from '../src/tools/DOM/GetDocument.ts'; +import { GetDocumentWithBoxModel } from '../src/tools/DOM/GetDocumentWithBoxModel.ts'; +import { GetNodeForLocation } from '../src/tools/DOM/GetNodeForLocation.ts'; +import { GetOriginalNodeIndex } from '../src/tools/DOM/GetOriginalNodeIndex.ts'; +import { GetSearchResults } from '../src/tools/DOM/GetSearchResults.ts'; +import { InnerText } from '../src/tools/DOM/InnerText.ts'; +import { PerformSearch } from '../src/tools/DOM/PerformSearch.ts'; +import { PushNodesByBackendIdsToFrontend } from '../src/tools/DOM/PushNodesByBackendIdsToFrontend.ts'; +import { QuerySelector } from '../src/tools/DOM/QuerySelector.ts'; +import { QuerySelectorAll } from '../src/tools/DOM/QuerySelectorAll.ts'; +import { RequestChildNodes } from '../src/tools/DOM/RequestChildNodes.ts'; +import { ScrollIntoViewIfNeeded } from '../src/tools/DOM/ScrollIntoViewIfNeeded.ts'; +import { SetAttributesAsText } from '../src/tools/DOM/SetAttributesAsText.ts'; +import { TakeHeapSnapshot } from '../src/tools/HeapProfiler/TakeHeapSnapshot.ts'; +import { GetVersion } from '../src/tools/Lynx/GetVersion.ts'; +import { GetResourceContent } from '../src/tools/Page/GetResourceContent.ts'; +import { GetResourceTree } from '../src/tools/Page/GetResourceTree.ts'; +import { GetAllPerformanceEntries } from '../src/tools/Performance/GetAllPerformanceEntries.ts'; +import { GetAllTimingInfo } from '../src/tools/Performance/GetAllTimingInfo.ts'; +import { Evaluate } from '../src/tools/Runtime/Evaluate.ts'; +import { GetHeapUsage } from '../src/tools/Runtime/GetHeapUsage.ts'; +import { GetProperties } from '../src/tools/Runtime/GetProperties.ts'; +import { GetLynxUITree } from '../src/tools/UITree/GetLynxUITree.ts'; import type { DescribeNodeResponse, GetAllPerformanceEntriesResponse, @@ -48,8 +48,8 @@ import type { QuerySelectorAllResponse, QuerySelectorResponse, UITreeNode, -} from "../test/utils/cdp-types.ts"; -import { createToolContext } from "../test/utils/testTool.ts"; +} from '../test/utils/cdp-types.ts'; +import { createToolContext } from '../test/utils/testTool.ts'; function flattenUITree(node: UITreeNode): UITreeNode[] { return [node, ...(node.children ?? []).flatMap(flattenUITree)]; @@ -74,15 +74,17 @@ function hasLynx4Metadata(node: UITreeNode): node is UITreeNode & { props: Record; label: string; } { - return typeof node.tagName === "string" - && typeof node.nodeIndex === "number" - && typeof node.props === "object" - && node.props !== null - && !Array.isArray(node.props) - && typeof node.label === "string"; + return ( + typeof node.tagName === 'string' && + typeof node.nodeIndex === 'number' && + typeof node.props === 'object' && + node.props !== null && + !Array.isArray(node.props) && + typeof node.label === 'string' + ); } -testWithClient("Tools", async (suite, connector, client, target) => { +testWithClient('Tools', async (suite, connector, client, target) => { await setTimeout(1000); const clientId = client.id; @@ -91,50 +93,59 @@ testWithClient("Tools", async (suite, connector, client, target) => { return sessions[sessions.length - 1]?.session_id; }; - await suite.test("DOM.getDocument", async (t: TestContext) => { + await suite.test('DOM.getDocument', async (t: TestContext) => { const { call } = createToolContext(GetDocument, connector, clientId); const tree = await call({}); - if (typeof tree === "object" && tree !== null) { + if (typeof tree === 'object' && tree !== null) { t.assert.ok(tree.root); - t.assert.equal(tree.root.nodeName, "#document"); + t.assert.equal(tree.root.nodeName, '#document'); } else { - t.assert.fail("Response should be a DOM tree object"); + t.assert.fail('Response should be a DOM tree object'); } const countNodes = (node: Node): number => - 1 + (node.children ?? []).reduce((sum, child) => sum + countNodes(child), 0); + 1 + + (node.children ?? []).reduce((sum, child) => sum + countNodes(child), 0); const depthZeroTree = await call({ depth: 0 }); const fullTree = await call({ depth: -1 }); - t.assert.ok(depthZeroTree.root, "Depth 0 should return a root node"); - t.assert.equal(depthZeroTree.root.nodeName, "#document", "Depth 0 should return the document root"); - t.assert.ok(fullTree.root, "Depth -1 should return a root node"); - t.assert.equal(fullTree.root.nodeName, "#document", "Depth -1 should return the document root"); + t.assert.ok(depthZeroTree.root, 'Depth 0 should return a root node'); + t.assert.equal( + depthZeroTree.root.nodeName, + '#document', + 'Depth 0 should return the document root', + ); + t.assert.ok(fullTree.root, 'Depth -1 should return a root node'); + t.assert.equal( + fullTree.root.nodeName, + '#document', + 'Depth -1 should return the document root', + ); t.assert.ok( countNodes(fullTree.root) > countNodes(depthZeroTree.root), - "Depth -1 should include more descendants than depth 0", + 'Depth -1 should include more descendants than depth 0', ); }); - await suite.test("DOM.describeNode", async (t: TestContext) => { + await suite.test('DOM.describeNode', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage( - clientId, - sessionId!, - "DOM.getDocument", - { - depth: -1, - }, - ); - t.assert.ok(root, "Should have a root node"); + const { root } = await connector.sendCDPMessage< + GetDocumentResponse, + { depth: number } + >(clientId, sessionId!, 'DOM.getDocument', { + depth: -1, + }); + t.assert.ok(root, 'Should have a root node'); const findElementWithChildren = (node: Node): Node | undefined => { if ( - node.nodeType === 1 && node.nodeName !== "PAGE" && (node.childNodeCount ?? 0) > 0 - && node.children?.length === node.childNodeCount + node.nodeType === 1 && + node.nodeName !== 'PAGE' && + (node.childNodeCount ?? 0) > 0 && + node.children?.length === node.childNodeCount ) { return node; } @@ -146,7 +157,7 @@ testWithClient("Tools", async (suite, connector, client, target) => { }; const targetNode = findElementWithChildren(root); - t.assert.ok(targetNode, "Should find an element with children"); + t.assert.ok(targetNode, 'Should find an element with children'); const { call } = createToolContext(DescribeNode, connector, clientId); const depthZeroResult = await call({ @@ -158,69 +169,103 @@ testWithClient("Tools", async (suite, connector, client, target) => { depth: 1, }); - t.assert.equal(depthZeroResult.compress, false, "Should disable compression for tool output"); - t.assert.equal(depthZeroResult.node?.nodeId, targetNode.nodeId, "Should describe the requested node"); + t.assert.equal( + depthZeroResult.compress, + false, + 'Should disable compression for tool output', + ); + t.assert.equal( + depthZeroResult.node?.nodeId, + targetNode.nodeId, + 'Should describe the requested node', + ); t.assert.equal( depthZeroResult.node?.childNodeCount, targetNode.childNodeCount, - "Should keep child count at depth 0", + 'Should keep child count at depth 0', + ); + t.assert.equal( + depthZeroResult.node?.children, + undefined, + 'Depth 0 should not include children', ); - t.assert.equal(depthZeroResult.node?.children, undefined, "Depth 0 should not include children"); - t.assert.equal(depthOneResult.node?.nodeId, targetNode.nodeId, "Depth 1 should describe the same node"); - t.assert.ok(Array.isArray(depthOneResult.node?.children), "Depth 1 should include direct children"); + t.assert.equal( + depthOneResult.node?.nodeId, + targetNode.nodeId, + 'Depth 1 should describe the same node', + ); + t.assert.ok( + Array.isArray(depthOneResult.node?.children), + 'Depth 1 should include direct children', + ); t.assert.equal( depthOneResult.node?.children?.length, targetNode.childNodeCount, - "Depth 1 should include exactly the direct children", + 'Depth 1 should include exactly the direct children', ); t.assert.equal( depthOneResult.node?.children?.[0]?.children, undefined, - "Depth 1 should not include grandchildren", + 'Depth 1 should not include grandchildren', ); }); - await suite.test("DOM.querySelector", async (t: TestContext) => { + await suite.test('DOM.querySelector', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); - t.assert.ok(root, "Should have a root node"); + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + 'DOM.getDocument', + ); + t.assert.ok(root, 'Should have a root node'); const rootNodeId = root.nodeId; const { call } = createToolContext(QuerySelector, connector, clientId); const result = await call({ nodeId: rootNodeId, - selector: "*", + selector: '*', }); - t.assert.ok(result.nodeId, "Should return a nodeId"); + t.assert.ok(result.nodeId, 'Should return a nodeId'); }); - await suite.test("DOM.querySelectorAll", async (t: TestContext) => { + await suite.test('DOM.querySelectorAll', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); - t.assert.ok(root, "Should have a root node"); + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + 'DOM.getDocument', + ); + t.assert.ok(root, 'Should have a root node'); const rootNodeId = root.nodeId; const { call } = createToolContext(QuerySelectorAll, connector, clientId); const result = await call({ nodeId: rootNodeId, - selector: "*", + selector: '*', }); - t.assert.ok(Array.isArray(result.nodeIds), "Should return an array of nodeIds"); + t.assert.ok( + Array.isArray(result.nodeIds), + 'Should return an array of nodeIds', + ); }); - await suite.test("DOM.getAttributes", async (t: TestContext) => { + await suite.test('DOM.getAttributes', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); - t.assert.ok(root, "Should have a root node"); + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + 'DOM.getDocument', + ); + t.assert.ok(root, 'Should have a root node'); const nodeId = root.children?.[0]?.nodeId ?? root.nodeId; @@ -229,54 +274,70 @@ testWithClient("Tools", async (suite, connector, client, target) => { nodeId, }); - t.assert.ok(Array.isArray(result.attributes), "Should return an array of attributes"); - }); - - await suite.test("DOM.setAttributesAsText updates node attributes", async (t: TestContext) => { - const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); - - const { root } = await connector.sendCDPMessage( - clientId, - sessionId!, - "DOM.getDocument", - { depth: -1 }, + t.assert.ok( + Array.isArray(result.attributes), + 'Should return an array of attributes', ); - const targetNode = findFirstElementNode(root); - t.assert.ok(targetNode, "Should find an element node"); - - const { call: setAttributes } = createToolContext(SetAttributesAsText, connector, clientId); - await setAttributes>({ - nodeId: targetNode.nodeId, - text: "style=\"opacity: 0.99;\"", - name: "style", - }); + }); - const { call: getAttributes } = createToolContext(GetAttributes, connector, clientId); - const result = await getAttributes({ - nodeId: targetNode.nodeId, - }); + await suite.test( + 'DOM.setAttributesAsText updates node attributes', + async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, 'Should have a sessionId'); + + const { root } = await connector.sendCDPMessage< + GetDocumentResponse, + { depth: number } + >(clientId, sessionId!, 'DOM.getDocument', { depth: -1 }); + const targetNode = findFirstElementNode(root); + t.assert.ok(targetNode, 'Should find an element node'); + + const { call: setAttributes } = createToolContext( + SetAttributesAsText, + connector, + clientId, + ); + await setAttributes>({ + nodeId: targetNode.nodeId, + text: 'style="opacity: 0.99;"', + name: 'style', + }); + + const { call: getAttributes } = createToolContext( + GetAttributes, + connector, + clientId, + ); + const result = await getAttributes({ + nodeId: targetNode.nodeId, + }); - t.assert.ok(result.attributes.includes("style"), "Updated node should include the style attribute"); - }); + t.assert.ok( + result.attributes.includes('style'), + 'Updated node should include the style attribute', + ); + }, + ); - await suite.test("DOM.getBoxModel", async (t: TestContext) => { + await suite.test('DOM.getBoxModel', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage( - clientId, - sessionId!, - "DOM.getDocument", - { - depth: -1, - }, - ); - t.assert.ok(root, "Should have a root node"); + const { root } = await connector.sendCDPMessage< + GetDocumentResponse, + { depth: number } + >(clientId, sessionId!, 'DOM.getDocument', { + depth: -1, + }); + t.assert.ok(root, 'Should have a root node'); const findLayoutNode = (node: Node): number | undefined => { if ( - node.nodeType === 1 && node.nodeName !== "HTML" && node.nodeName !== "BODY" && node.nodeName !== "#document" + node.nodeType === 1 && + node.nodeName !== 'HTML' && + node.nodeName !== 'BODY' && + node.nodeName !== '#document' ) { return node.nodeId; } @@ -290,22 +351,26 @@ testWithClient("Tools", async (suite, connector, client, target) => { }; const nodeId = findLayoutNode(root); - t.assert.ok(nodeId, "Should find a node with layout"); + t.assert.ok(nodeId, 'Should find a node with layout'); const { call } = createToolContext(GetBoxModel, connector, clientId); const result = await call({ nodeId, }); - t.assert.ok(result.model, "Should return a box model"); - t.assert.ok(result.model.content, "Should have content box"); + t.assert.ok(result.model, 'Should return a box model'); + t.assert.ok(result.model.content, 'Should have content box'); }); - await suite.test("DOM.getDocumentWithBoxModel", async (t: TestContext) => { - const { call } = createToolContext(GetDocumentWithBoxModel, connector, clientId); + await suite.test('DOM.getDocumentWithBoxModel', async (t: TestContext) => { + const { call } = createToolContext( + GetDocumentWithBoxModel, + connector, + clientId, + ); const result = await call({}); - t.assert.ok(result.root, "Should return a root node"); + t.assert.ok(result.root, 'Should return a root node'); const hasBoxModel = (node: Node): boolean => { if (node.box_model) return true; @@ -315,25 +380,32 @@ testWithClient("Tools", async (suite, connector, client, target) => { return false; }; - t.assert.ok(hasBoxModel(result.root), "Some node in the tree should have box_model"); + t.assert.ok( + hasBoxModel(result.root), + 'Some node in the tree should have box_model', + ); }); - await suite.test("DOM.getNodeForLocation", async (t: TestContext) => { + await suite.test('DOM.getNodeForLocation', async (t: TestContext) => { const { call } = createToolContext(GetNodeForLocation, connector, clientId); const result = await call({ x: 100, y: 100, }); - t.assert.ok(result.nodeId, "Should return a nodeId"); + t.assert.ok(result.nodeId, 'Should return a nodeId'); }); - await suite.test("DOM.innerText", async (t: TestContext) => { + await suite.test('DOM.innerText', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); - t.assert.ok(root, "Should have a root node"); + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + 'DOM.getDocument', + ); + t.assert.ok(root, 'Should have a root node'); const nodeId = root.nodeId; @@ -342,50 +414,62 @@ testWithClient("Tools", async (suite, connector, client, target) => { nodeId, }); - t.assert.ok(result.nodeId, "Should return nodeId"); - t.assert.ok(Array.isArray(result.rawTextValues), "Should return rawTextValues array"); + t.assert.ok(result.nodeId, 'Should return nodeId'); + t.assert.ok( + Array.isArray(result.rawTextValues), + 'Should return rawTextValues array', + ); }); - await suite.test("DOM.getOriginalNodeIndex", async (t: TestContext) => { + await suite.test('DOM.getOriginalNodeIndex', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); - t.assert.ok(root, "Should have a root node"); + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + 'DOM.getDocument', + ); + t.assert.ok(root, 'Should have a root node'); const nodeId = root.children?.[0]?.nodeId ?? root.nodeId; - const { call } = createToolContext(GetOriginalNodeIndex, connector, clientId); + const { call } = createToolContext( + GetOriginalNodeIndex, + connector, + clientId, + ); const result = await call({ nodeId, }); - t.assert.ok(result.nodeIndex !== undefined, "Should return a nodeIndex"); + t.assert.ok(result.nodeIndex !== undefined, 'Should return a nodeIndex'); }); - await suite.test("DOM.performSearch", async (t: TestContext) => { + await suite.test('DOM.performSearch', async (t: TestContext) => { const { call } = createToolContext(PerformSearch, connector, clientId); const result = await call({ - query: "*", + query: '*', }); - t.assert.ok(result.searchId, "Should return a searchId"); - t.assert.ok(result.resultCount !== undefined, "Should return a resultCount"); + t.assert.ok(result.searchId, 'Should return a searchId'); + t.assert.ok( + result.resultCount !== undefined, + 'Should return a resultCount', + ); }); - await suite.test("DOM.getSearchResults", async (t: TestContext) => { + await suite.test('DOM.getSearchResults', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { searchId, resultCount } = await connector.sendCDPMessage( - clientId, - sessionId!, - "DOM.performSearch", - { - query: "*", - }, - ); - t.assert.ok(searchId, "Should have a searchId"); + const { searchId, resultCount } = await connector.sendCDPMessage< + PerformSearchResponse, + { query: string } + >(clientId, sessionId!, 'DOM.performSearch', { + query: '*', + }); + t.assert.ok(searchId, 'Should have a searchId'); const { call } = createToolContext(GetSearchResults, connector, clientId); const result = await call({ @@ -394,31 +478,52 @@ testWithClient("Tools", async (suite, connector, client, target) => { toIndex: Math.min(resultCount, 1), }); - t.assert.ok(Array.isArray(result.nodeIds), "Should return an array of nodeIds"); + t.assert.ok( + Array.isArray(result.nodeIds), + 'Should return an array of nodeIds', + ); }); - await suite.test("DOM.pushNodesByBackendIdsToFrontend", async (t: TestContext) => { - const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + await suite.test( + 'DOM.pushNodesByBackendIdsToFrontend', + async (t: TestContext) => { + const sessionId = await latestSessionId(); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); - t.assert.ok(root, "Should have a root node"); - t.assert.ok(root.backendNodeId, "Root should have backendNodeId"); + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + 'DOM.getDocument', + ); + t.assert.ok(root, 'Should have a root node'); + t.assert.ok(root.backendNodeId, 'Root should have backendNodeId'); - const { call } = createToolContext(PushNodesByBackendIdsToFrontend, connector, clientId); - const result = await call({ - backendNodeIds: [root.backendNodeId], - }); + const { call } = createToolContext( + PushNodesByBackendIdsToFrontend, + connector, + clientId, + ); + const result = await call({ + backendNodeIds: [root.backendNodeId], + }); - t.assert.ok(Array.isArray(result.nodeIds), "Should return an array of nodeIds"); - }); + t.assert.ok( + Array.isArray(result.nodeIds), + 'Should return an array of nodeIds', + ); + }, + ); - await suite.test("DOM.requestChildNodes", async (t: TestContext) => { + await suite.test('DOM.requestChildNodes', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); - t.assert.ok(root, "Should have a root node"); + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + 'DOM.getDocument', + ); + t.assert.ok(root, 'Should have a root node'); const { call } = createToolContext(RequestChildNodes, connector, clientId); const result = await call>({ @@ -426,280 +531,459 @@ testWithClient("Tools", async (suite, connector, client, target) => { depth: 1, }); - t.assert.ok(result, "Should return a result (likely empty object)"); + t.assert.ok(result, 'Should return a result (likely empty object)'); }); - await suite.test("DOM.scrollIntoViewIfNeeded", async (t: TestContext) => { + await suite.test('DOM.scrollIntoViewIfNeeded', async (t: TestContext) => { const sessionId = await latestSessionId(); - t.assert.ok(sessionId, "Should have a sessionId"); + t.assert.ok(sessionId, 'Should have a sessionId'); - const { root } = await connector.sendCDPMessage(clientId, sessionId!, "DOM.getDocument"); - t.assert.ok(root, "Should have a root node"); + const { root } = await connector.sendCDPMessage( + clientId, + sessionId!, + 'DOM.getDocument', + ); + t.assert.ok(root, 'Should have a root node'); const nodeId = root.children?.[0]?.nodeId ?? root.nodeId; - const { call } = createToolContext(ScrollIntoViewIfNeeded, connector, clientId); + const { call } = createToolContext( + ScrollIntoViewIfNeeded, + connector, + clientId, + ); const result = await call>({ nodeId, }); - t.assert.ok(result, "Should return a result"); - }); - - await suite.test("Page.getResourceTree and Page.getResourceContent", async (t: TestContext) => { - const { call: getTree } = createToolContext(GetResourceTree, connector, clientId); - const tree = await getTree<{ frameTree?: { frame?: { id?: string; url?: string }; resources?: unknown[] } }>({}); - - t.assert.ok(typeof tree === "object" && tree !== null, "Should return a resource tree object"); - t.assert.ok(tree.frameTree, "Should include frameTree"); - - const { call: getContent } = createToolContext(GetResourceContent, connector, clientId); - const content = await getContent<{ content: string; base64Encoded: boolean }>({ - url: tree.frameTree?.frame?.url ?? target.pageUrl, - frameId: tree.frameTree?.frame?.id, - }); - - t.assert.equal(typeof content.content, "string", "Should return resource content"); - t.assert.equal(typeof content.base64Encoded, "boolean", "Should report whether the content is base64 encoded"); + t.assert.ok(result, 'Should return a result'); }); - await suite.test("Lynx.getVersion", { - skip: target.appPackageName === "EmbeddedLynx" - ? "EmbeddedLynx does not support Lynx.getVersion (for now)" - : false, - }, async (t: TestContext) => { - const { call } = createToolContext(GetVersion, connector, clientId); - const version = await call({}); - - t.assert.equal(typeof version, "string", "Should return a version string"); - t.assert.ok(version.length > 0, "Version string should not be empty"); - }); - - await suite.test("Runtime.evaluate and Runtime.getProperties inspect an object", async (t: TestContext) => { - const { call: evaluate } = createToolContext(Evaluate, connector, clientId); - const evaluated = await evaluate<{ - result?: { objectId?: string; type?: string; description?: string }; - }>({ - expression: "({ answer: 42, label: 'lynx-use' })", - objectGroup: "mcp-tools-test", - generatePreview: true, - }); + await suite.test( + 'Page.getResourceTree and Page.getResourceContent', + async (t: TestContext) => { + const { call: getTree } = createToolContext( + GetResourceTree, + connector, + clientId, + ); + const tree = await getTree<{ + frameTree?: { + frame?: { id?: string; url?: string }; + resources?: unknown[]; + }; + }>({}); - const objectId = evaluated.result?.objectId; - t.assert.equal(typeof objectId, "string", "Evaluation result should include an objectId"); + t.assert.ok( + typeof tree === 'object' && tree !== null, + 'Should return a resource tree object', + ); + t.assert.ok(tree.frameTree, 'Should include frameTree'); - const { call: getProperties } = createToolContext(GetProperties, connector, clientId); - const properties = await getProperties<{ - result?: Array<{ name: string; value?: { value?: unknown; type?: string } }>; - }>({ - objectId: objectId!, - ownProperties: true, - }); + const { call: getContent } = createToolContext( + GetResourceContent, + connector, + clientId, + ); + const content = await getContent<{ + content: string; + base64Encoded: boolean; + }>({ + url: tree.frameTree?.frame?.url ?? target.pageUrl, + frameId: tree.frameTree?.frame?.id, + }); + + t.assert.equal( + typeof content.content, + 'string', + 'Should return resource content', + ); + t.assert.equal( + typeof content.base64Encoded, + 'boolean', + 'Should report whether the content is base64 encoded', + ); + }, + ); + + await suite.test( + 'Lynx.getVersion', + { + skip: + target.appPackageName === 'EmbeddedLynx' + ? 'EmbeddedLynx does not support Lynx.getVersion (for now)' + : false, + }, + async (t: TestContext) => { + const { call } = createToolContext(GetVersion, connector, clientId); + const version = await call({}); + + t.assert.equal( + typeof version, + 'string', + 'Should return a version string', + ); + t.assert.ok(version.length > 0, 'Version string should not be empty'); + }, + ); + + await suite.test( + 'Runtime.evaluate and Runtime.getProperties inspect an object', + async (t: TestContext) => { + const { call: evaluate } = createToolContext( + Evaluate, + connector, + clientId, + ); + const evaluated = await evaluate<{ + result?: { objectId?: string; type?: string; description?: string }; + }>({ + expression: "({ answer: 42, label: 'lynx-use' })", + objectGroup: 'mcp-tools-test', + generatePreview: true, + }); + + const objectId = evaluated.result?.objectId; + t.assert.equal( + typeof objectId, + 'string', + 'Evaluation result should include an objectId', + ); - const answer = properties.result?.find(({ name }) => name === "answer"); - t.assert.ok(answer, "Should include the evaluated object's answer property"); - if (answer?.value && "value" in answer.value) { - t.assert.equal(answer.value.value, 42, "answer should preserve its numeric value when returned by value"); - } - }); + const { call: getProperties } = createToolContext( + GetProperties, + connector, + clientId, + ); + const properties = await getProperties<{ + result?: Array<{ + name: string; + value?: { value?: unknown; type?: string }; + }>; + }>({ + objectId: objectId!, + ownProperties: true, + }); + + const answer = properties.result?.find(({ name }) => name === 'answer'); + t.assert.ok( + answer, + "Should include the evaluated object's answer property", + ); + if (answer?.value && 'value' in answer.value) { + t.assert.equal( + answer.value.value, + 42, + 'answer should preserve its numeric value when returned by value', + ); + } + }, + ); - await suite.test("Runtime.getHeapUsage", async (t: TestContext) => { + await suite.test('Runtime.getHeapUsage', async (t: TestContext) => { const { call } = createToolContext(GetHeapUsage, connector, clientId); const result = await call<{ usedSize: number; totalSize: number }>({}); - t.assert.ok(typeof result.usedSize === "number", "Should return usedSize"); - t.assert.ok(typeof result.totalSize === "number", "Should return totalSize"); - }); - - await suite.test("Runtime.getHeapUsage supports the main thread", async (t: TestContext) => { - const { call } = createToolContext(GetHeapUsage, connector, clientId); - const result = await call<{ usedSize: number; totalSize: number }>({ - thread: "main", - }); - - t.assert.ok(typeof result.usedSize === "number", "Should return usedSize"); - t.assert.ok(typeof result.totalSize === "number", "Should return totalSize"); + t.assert.ok(typeof result.usedSize === 'number', 'Should return usedSize'); + t.assert.ok( + typeof result.totalSize === 'number', + 'Should return totalSize', + ); }); - await suite.test("UITree.getLynxUITree returns an uncompressed native UI tree", async (t: TestContext) => { - if (target.appPackageName === "EmbeddedLynx") { - t.skip("EmbeddedLynx does not support UITree.getLynxUITree yet"); - return; - } + await suite.test( + 'Runtime.getHeapUsage supports the main thread', + async (t: TestContext) => { + const { call } = createToolContext(GetHeapUsage, connector, clientId); + const result = await call<{ usedSize: number; totalSize: number }>({ + thread: 'main', + }); - const { call } = createToolContext(GetLynxUITree, connector, clientId); - const result = await call({}); + t.assert.ok( + typeof result.usedSize === 'number', + 'Should return usedSize', + ); + t.assert.ok( + typeof result.totalSize === 'number', + 'Should return totalSize', + ); + }, + ); + + await suite.test( + 'UITree.getLynxUITree returns an uncompressed native UI tree', + async (t: TestContext) => { + if (target.appPackageName === 'EmbeddedLynx') { + t.skip('EmbeddedLynx does not support UITree.getLynxUITree yet'); + return; + } - t.assert.equal(result.compress, false, "Should request an uncompressed UITree response"); - t.assert.ok(result.root, "Should return a UITree root"); - t.assert.ok(typeof result.root.name === "string", "Root should include the native class name"); - t.assert.ok(typeof result.root.id === "number", "Root should include the native UI id"); - t.assert.ok(Array.isArray(result.root.children), "Root should include child UI nodes"); + const { call } = createToolContext(GetLynxUITree, connector, clientId); + const result = await call({}); - const nodes = flattenUITree(result.root); - t.assert.ok(nodes.length > 0, "Should include at least one UI node"); + t.assert.equal( + result.compress, + false, + 'Should request an uncompressed UITree response', + ); + t.assert.ok(result.root, 'Should return a UITree root'); + t.assert.ok( + typeof result.root.name === 'string', + 'Root should include the native class name', + ); + t.assert.ok( + typeof result.root.id === 'number', + 'Root should include the native UI id', + ); + t.assert.ok( + Array.isArray(result.root.children), + 'Root should include child UI nodes', + ); - const nodeWithFrame = nodes.find(node => Array.isArray(node.frame)); - if (!nodeWithFrame?.frame) { - t.assert.fail("Should include frame data for at least one UI node"); - return; - } - t.assert.equal(nodeWithFrame.frame.length, 4, "Frame should be [x, y, width, height]"); - for (const value of nodeWithFrame.frame) { - t.assert.ok(typeof value === "number", "Frame values should be numbers"); - } - }); + const nodes = flattenUITree(result.root); + t.assert.ok(nodes.length > 0, 'Should include at least one UI node'); - await suite.test("UITree.getLynxUITree exposes Lynx 4 metadata fields", async (t: TestContext) => { - if (target.appPackageName === "EmbeddedLynx") { - t.skip("EmbeddedLynx does not support UITree.getLynxUITree yet"); - return; - } + const nodeWithFrame = nodes.find((node) => Array.isArray(node.frame)); + if (!nodeWithFrame?.frame) { + t.assert.fail('Should include frame data for at least one UI node'); + return; + } + t.assert.equal( + nodeWithFrame.frame.length, + 4, + 'Frame should be [x, y, width, height]', + ); + for (const value of nodeWithFrame.frame) { + t.assert.ok( + typeof value === 'number', + 'Frame values should be numbers', + ); + } + }, + ); + + await suite.test( + 'UITree.getLynxUITree exposes Lynx 4 metadata fields', + async (t: TestContext) => { + if (target.appPackageName === 'EmbeddedLynx') { + t.skip('EmbeddedLynx does not support UITree.getLynxUITree yet'); + return; + } - const { call } = createToolContext(GetLynxUITree, connector, clientId); - const result = await call({}); - const nodes = flattenUITree(result.root); + const { call } = createToolContext(GetLynxUITree, connector, clientId); + const result = await call({}); + const nodes = flattenUITree(result.root); - const metadataNode = nodes.find(hasLynx4Metadata); + const metadataNode = nodes.find(hasLynx4Metadata); - if (!metadataNode) { - t.assert.fail("Should include Lynx 4 UI metadata on at least one node"); - return; - } - t.assert.ok(metadataNode.nodeIndex >= 0, "nodeIndex should map to a non-negative DOM node index"); - t.assert.ok(metadataNode.tagName.length > 0, "tagName should be readable"); - }); + if (!metadataNode) { + t.assert.fail('Should include Lynx 4 UI metadata on at least one node'); + return; + } + t.assert.ok( + metadataNode.nodeIndex >= 0, + 'nodeIndex should map to a non-negative DOM node index', + ); + t.assert.ok( + metadataNode.tagName.length > 0, + 'tagName should be readable', + ); + }, + ); - await suite.test("Performance.getAllTimingInfo", async (t: TestContext) => { - if (target.appPackageName === "EmbeddedLynx") { - t.skip("EmbeddedLynx does not support Performance.getAllTimingInfo yet"); + await suite.test('Performance.getAllTimingInfo', async (t: TestContext) => { + if (target.appPackageName === 'EmbeddedLynx') { + t.skip('EmbeddedLynx does not support Performance.getAllTimingInfo yet'); return; } const { call } = createToolContext(GetAllTimingInfo, connector, clientId); const result = await call>({}); - t.assert.ok(typeof result === "object" && result !== null, "Should return a timing info object"); + t.assert.ok( + typeof result === 'object' && result !== null, + 'Should return a timing info object', + ); const timing = result as Record; - t.assert.ok(typeof timing.url === "string", "Should include url"); - t.assert.ok(typeof timing.has_reload === "number", "Should include has_reload"); - t.assert.ok(typeof timing.thread_strategy === "number", "Should include thread_strategy"); + t.assert.ok(typeof timing.url === 'string', 'Should include url'); + t.assert.ok( + typeof timing.has_reload === 'number', + 'Should include has_reload', + ); + t.assert.ok( + typeof timing.thread_strategy === 'number', + 'Should include thread_strategy', + ); const metrics = timing.metrics; - t.assert.ok(typeof metrics === "object" && metrics !== null, "Should include metrics object"); + t.assert.ok( + typeof metrics === 'object' && metrics !== null, + 'Should include metrics object', + ); // Some engine timing metrics are flaky across transport CI runs. t.assert.ok( - typeof (metrics as Record).lynx_tti === "number", - "metrics.lynx_tti should be a number", + typeof (metrics as Record).lynx_tti === 'number', + 'metrics.lynx_tti should be a number', ); // Container-level metrics depend on extra timing from the host app. They are useful when present, // but not guaranteed by the engine timing contract. - for (const key of ["fcp", "tti", "total_fcp", "total_tti"]) { + for (const key of ['fcp', 'tti', 'total_fcp', 'total_tti']) { if (Object.hasOwn(metrics as Record, key)) { t.assert.ok( - typeof (metrics as Record)[key] === "number", + typeof (metrics as Record)[key] === 'number', `metrics.${key} should be a number when present`, ); } } const setupTiming = timing.setup_timing; - t.assert.ok(typeof setupTiming === "object" && setupTiming !== null, "Should include setup_timing object"); - for (const key of ["pipeline_start", "load_template_start", "load_template_end", "draw_end"]) { + t.assert.ok( + typeof setupTiming === 'object' && setupTiming !== null, + 'Should include setup_timing object', + ); + for (const key of [ + 'pipeline_start', + 'load_template_start', + 'load_template_end', + 'draw_end', + ]) { t.assert.ok( - typeof (setupTiming as Record)[key] === "number", + typeof (setupTiming as Record)[key] === 'number', `setup_timing.${key} should be a number`, ); } const extraTiming = timing.extra_timing; - t.assert.ok(typeof extraTiming === "object" && extraTiming !== null, "Should include extra_timing object"); - for (const key of ["open_time", "container_init_start", "container_init_end"]) { + t.assert.ok( + typeof extraTiming === 'object' && extraTiming !== null, + 'Should include extra_timing object', + ); + for (const key of [ + 'open_time', + 'container_init_start', + 'container_init_end', + ]) { t.assert.ok( - typeof (extraTiming as Record)[key] === "number", + typeof (extraTiming as Record)[key] === 'number', `extra_timing.${key} should be a number`, ); } - t.assert.ok(typeof timing.update_timings === "object", "Should include update_timings object"); - }); - - await suite.test("Performance.getAllPerformanceEntries", async (t: TestContext) => { - const { call } = createToolContext(GetAllPerformanceEntries, connector, clientId); - const result = await call({}); - - t.assert.ok(typeof result === "object" && result !== null, "Should return a performance entries object"); - t.assert.ok(Array.isArray(result.entries), "Should include entries array"); - - for (const entry of result.entries) { - t.assert.ok(typeof entry === "object" && entry !== null, "Each entry should be an object"); - - if (Object.hasOwn(entry, "entryType")) { - t.assert.ok(typeof entry.entryType === "string", "entry.entryType should be a string when present"); - } - if (Object.hasOwn(entry, "name")) { - t.assert.ok(typeof entry.name === "string", "entry.name should be a string when present"); - } - if (Object.hasOwn(entry, "instanceId")) { - t.assert.ok(typeof entry.instanceId === "number", "entry.instanceId should be a number when present"); - } - } - }); - - await suite.test("HeapProfiler.takeHeapSnapshot saves a background snapshot filename by default", async (t: TestContext) => { - if (target.appPackageName === "EmbeddedLynx") { - t.skip("EmbeddedLynx does not support background-thread heap snapshots yet"); - return; - } - const { call } = createToolContext(TakeHeapSnapshot, connector, clientId); - const result = await call({}); - - t.assert.ok(typeof result === "string", "Should return a string"); - t.assert.match( - result, - /Heap snapshot saved to .*heap-background-\d+\.heapsnapshot$/, - "Should save a background snapshot by default", + t.assert.ok( + typeof timing.update_timings === 'object', + 'Should include update_timings object', ); }); - await suite.test("HeapProfiler.takeHeapSnapshot saves a main-thread snapshot filename when requested", async (t: TestContext) => { - const { call } = createToolContext(TakeHeapSnapshot, connector, clientId); - const result = await call({ - thread: "main", - }); - - t.assert.ok(typeof result === "string", "Should return a string"); - t.assert.match( - result, - /Heap snapshot saved to .*heap-main-\d+\.heapsnapshot$/, - "Should save a main-thread snapshot when requested", - ); - - const filePath = result.replace("Heap snapshot saved to ", ""); - - try { - const content = await fs.readFile(filePath, "utf8"); - const snapshot = JSON.parse(content) as { strings?: string[] }; + await suite.test( + 'Performance.getAllPerformanceEntries', + async (t: TestContext) => { + const { call } = createToolContext( + GetAllPerformanceEntries, + connector, + clientId, + ); + const result = await call({}); t.assert.ok( - Array.isArray(snapshot.strings), - "Main-thread heap snapshot should include strings array", + typeof result === 'object' && result !== null, + 'Should return a performance entries object', ); t.assert.ok( - snapshot.strings.includes("renderPage"), - "Main-thread heap snapshot should include renderPage", + Array.isArray(result.entries), + 'Should include entries array', ); - t.assert.ok( - snapshot.strings.includes("updatePage"), - "Main-thread heap snapshot should include updatePage", + + for (const entry of result.entries) { + t.assert.ok( + typeof entry === 'object' && entry !== null, + 'Each entry should be an object', + ); + + if (Object.hasOwn(entry, 'entryType')) { + t.assert.ok( + typeof entry.entryType === 'string', + 'entry.entryType should be a string when present', + ); + } + if (Object.hasOwn(entry, 'name')) { + t.assert.ok( + typeof entry.name === 'string', + 'entry.name should be a string when present', + ); + } + if (Object.hasOwn(entry, 'instanceId')) { + t.assert.ok( + typeof entry.instanceId === 'number', + 'entry.instanceId should be a number when present', + ); + } + } + }, + ); + + await suite.test( + 'HeapProfiler.takeHeapSnapshot saves a background snapshot filename by default', + async (t: TestContext) => { + if (target.appPackageName === 'EmbeddedLynx') { + t.skip( + 'EmbeddedLynx does not support background-thread heap snapshots yet', + ); + return; + } + const { call } = createToolContext(TakeHeapSnapshot, connector, clientId); + const result = await call({}); + + t.assert.ok(typeof result === 'string', 'Should return a string'); + t.assert.match( + result, + /Heap snapshot saved to .*heap-background-\d+\.heapsnapshot$/, + 'Should save a background snapshot by default', ); - t.assert.ok( - snapshot.strings.includes("updateGlobalProps"), - "Main-thread heap snapshot should include updateGlobalProps", + }, + ); + + await suite.test( + 'HeapProfiler.takeHeapSnapshot saves a main-thread snapshot filename when requested', + async (t: TestContext) => { + const { call } = createToolContext(TakeHeapSnapshot, connector, clientId); + const result = await call({ + thread: 'main', + }); + + t.assert.ok(typeof result === 'string', 'Should return a string'); + t.assert.match( + result, + /Heap snapshot saved to .*heap-main-\d+\.heapsnapshot$/, + 'Should save a main-thread snapshot when requested', ); - } finally { - await fs.unlink(filePath).catch(() => {}); - } - }); + + const filePath = result.replace('Heap snapshot saved to ', ''); + + try { + const content = await fs.readFile(filePath, 'utf8'); + const snapshot = JSON.parse(content) as { strings?: string[] }; + + t.assert.ok( + Array.isArray(snapshot.strings), + 'Main-thread heap snapshot should include strings array', + ); + t.assert.ok( + snapshot.strings.includes('renderPage'), + 'Main-thread heap snapshot should include renderPage', + ); + t.assert.ok( + snapshot.strings.includes('updatePage'), + 'Main-thread heap snapshot should include updatePage', + ); + t.assert.ok( + snapshot.strings.includes('updateGlobalProps'), + 'Main-thread heap snapshot should include updateGlobalProps', + ); + } finally { + await fs.unlink(filePath).catch(() => {}); + } + }, + ); }); diff --git a/packages/mcp-servers/devtool-mcp-server/rslib.config.ts b/packages/mcp-servers/devtool-mcp-server/rslib.config.ts index ce77f12..9e54822 100644 --- a/packages/mcp-servers/devtool-mcp-server/rslib.config.ts +++ b/packages/mcp-servers/devtool-mcp-server/rslib.config.ts @@ -1,18 +1,19 @@ -import { defineConfig } from "@rslib/core"; -import { pluginPublint } from "rsbuild-plugin-publint"; +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { defineConfig } from '@rslib/core'; +import { pluginPublint } from 'rsbuild-plugin-publint'; export default defineConfig({ - plugins: [ - pluginPublint({ throwOn: "suggestion" }), - ], + plugins: [pluginPublint({ throwOn: 'suggestion' })], lib: [ { - format: "esm", - syntax: "es2022", + format: 'esm', + syntax: 'es2022', source: { entry: { - connector: "./src/connector.ts", - index: "./src/index.ts", + connector: './src/connector.ts', + index: './src/index.ts', }, }, dts: { @@ -22,12 +23,12 @@ export default defineConfig({ }, }, { - format: "esm", - syntax: "es2022", + format: 'esm', + syntax: 'es2022', dts: false, source: { entry: { - main: "./src/main.ts", + main: './src/main.ts', }, }, autoExternal: { diff --git a/packages/mcp-servers/devtool-mcp-server/src/McpContext.ts b/packages/mcp-servers/devtool-mcp-server/src/McpContext.ts index f5a21ea..04ebc02 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/McpContext.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/McpContext.ts @@ -2,8 +2,8 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from "@lynx-js/devtool-connector"; -import type { Context } from "./tools/defineTool.ts"; +import type { Connector } from '@lynx-js/devtool-connector'; +import type { Context } from './tools/defineTool.ts'; export class McpContext implements Context { #connector: Connector; diff --git a/packages/mcp-servers/devtool-mcp-server/src/McpResponse.ts b/packages/mcp-servers/devtool-mcp-server/src/McpResponse.ts index 6547ddf..c691bea 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/McpResponse.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/McpResponse.ts @@ -2,9 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { ImageContent, TextContent } from "@modelcontextprotocol/sdk/types.js"; -import type { McpContext } from "./McpContext.ts"; -import type { ImageContentData, Response } from "./tools/defineTool.ts"; +import type { + ImageContent, + TextContent, +} from '@modelcontextprotocol/sdk/types.js'; +import type { McpContext } from './McpContext.ts'; +import type { ImageContentData, Response } from './tools/defineTool.ts'; export class McpResponse implements Response { #additionalLines: string[] = []; @@ -18,16 +21,18 @@ export class McpResponse implements Response { } async handle( + // eslint-disable-next-line @typescript-eslint/no-unused-vars _toolName: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars _context: McpContext, ): Promise> { return [ { - type: "text", - text: this.#additionalLines.join("\n"), + type: 'text', + text: this.#additionalLines.join('\n'), }, - ...this.#images.map(img => ({ - type: "image" as const, + ...this.#images.map((img) => ({ + type: 'image' as const, ...img, })), ]; diff --git a/packages/mcp-servers/devtool-mcp-server/src/connector.ts b/packages/mcp-servers/devtool-mcp-server/src/connector.ts index 4e84d54..43e78d5 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/connector.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/connector.ts @@ -2,16 +2,16 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -export { DevtoolDaemon } from "@lynx-js/devtool-connector/daemon"; +export { DevtoolDaemon } from '@lynx-js/devtool-connector/daemon'; export { CustomizedRequestTransformStream, CustomizedResponseTransformStream, FilterTransformStream, -} from "@lynx-js/devtool-connector/streams"; +} from '@lynx-js/devtool-connector/streams'; +export type { Transport } from '@lynx-js/devtool-connector/transport'; export { AndroidTransport, DaemonTransport, DesktopTransport, iOSTransport, -} from "@lynx-js/devtool-connector/transport"; -export type { Transport } from "@lynx-js/devtool-connector/transport"; +} from '@lynx-js/devtool-connector/transport'; diff --git a/packages/mcp-servers/devtool-mcp-server/src/index.ts b/packages/mcp-servers/devtool-mcp-server/src/index.ts index 9228814..9eb6685 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/index.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/index.ts @@ -2,66 +2,66 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import "core-js/modules/es.promise.with-resolvers.js"; -import { Connector } from "@lynx-js/devtool-connector"; +import 'core-js/modules/es.promise.with-resolvers.js'; +import { Connector } from '@lynx-js/devtool-connector'; +import type { Transport } from '@lynx-js/devtool-connector/transport'; import { AndroidTransport, DaemonTransport, DesktopTransport, iOSTransport, -} from "@lynx-js/devtool-connector/transport"; -import type { Transport } from "@lynx-js/devtool-connector/transport"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { McpContext } from "./McpContext.ts"; -import { McpResponse } from "./McpResponse.ts"; -import { GetGlobalSwitch } from "./tools/App/GetGlobalSwitch.ts"; -import { ListGlobalSwitch } from "./tools/App/ListGlobalSwitch.ts"; -import { SetGlobalSwitch } from "./tools/App/SetGlobalSwitch.ts"; -import { GetBackgroundColors } from "./tools/CSS/GetBackgroundColors.ts"; -import { GetComputedStyleForNode } from "./tools/CSS/GetComputedStyleForNode.ts"; -import { GetInlineStylesForNode } from "./tools/CSS/GetInlineStylesForNode.ts"; -import { GetMatchedStylesForNode } from "./tools/CSS/GetMatchedStylesForNode.ts"; -import { GetStyleSheetText } from "./tools/CSS/GetStyleSheetText.ts"; -import { GetScriptSource } from "./tools/Debugger/GetScriptSource.ts"; -import { ListScripts } from "./tools/Debugger/ListScripts.ts"; -import type { ToolDefinition } from "./tools/defineTool.ts"; -import { ClosePage } from "./tools/Device/ClosePage.ts"; -import { ListClients } from "./tools/Device/ListClients.ts"; -import { ListDevices } from "./tools/Device/ListDevices.ts"; -import { ListSessions } from "./tools/Device/ListSessions.ts"; -import { OpenPage } from "./tools/Device/OpenPage.ts"; -import { DescribeNode } from "./tools/DOM/DescribeNode.ts"; -import { GetAttributes } from "./tools/DOM/GetAttributes.ts"; -import { GetBoxModel } from "./tools/DOM/GetBoxModel.ts"; -import { GetDocument } from "./tools/DOM/GetDocument.ts"; -import { GetDocumentWithBoxModel } from "./tools/DOM/GetDocumentWithBoxModel.ts"; -import { GetNodeForLocation } from "./tools/DOM/GetNodeForLocation.ts"; -import { GetOriginalNodeIndex } from "./tools/DOM/GetOriginalNodeIndex.ts"; -import { GetSearchResults } from "./tools/DOM/GetSearchResults.ts"; -import { InnerText } from "./tools/DOM/InnerText.ts"; -import { PerformSearch } from "./tools/DOM/PerformSearch.ts"; -import { PushNodesByBackendIdsToFrontend } from "./tools/DOM/PushNodesByBackendIdsToFrontend.ts"; -import { QuerySelector } from "./tools/DOM/QuerySelector.ts"; -import { QuerySelectorAll } from "./tools/DOM/QuerySelectorAll.ts"; -import { RequestChildNodes } from "./tools/DOM/RequestChildNodes.ts"; -import { ScrollIntoViewIfNeeded } from "./tools/DOM/ScrollIntoViewIfNeeded.ts"; -import { SetAttributesAsText } from "./tools/DOM/SetAttributesAsText.ts"; -import { TakeHeapSnapshot } from "./tools/HeapProfiler/TakeHeapSnapshot.ts"; -import { EmulateTouchFromMouseEvent } from "./tools/Input/EmulateTouchFromMouseEvent.ts"; -import { GetVersion } from "./tools/Lynx/GetVersion.ts"; -import { GetAllMemoryUsage } from "./tools/Memory/GetAllMemoryUsage.ts"; -import { GetResourceContent } from "./tools/Page/GetResourceContent.ts"; -import { GetResourceTree } from "./tools/Page/GetResourceTree.ts"; -import { Reload } from "./tools/Page/Reload.ts"; -import { TakeScreenshot } from "./tools/Page/TakeScreenshot.ts"; -import { GetAllPerformanceEntries } from "./tools/Performance/GetAllPerformanceEntries.ts"; -import { GetAllTimingInfo } from "./tools/Performance/GetAllTimingInfo.ts"; -import { Evaluate } from "./tools/Runtime/Evaluate.ts"; -import { GetHeapUsage } from "./tools/Runtime/GetHeapUsage.ts"; -import { GetProperties } from "./tools/Runtime/GetProperties.ts"; -import { ListConsole } from "./tools/Runtime/ListConsole.ts"; -import { GetLynxUITree } from "./tools/UITree/GetLynxUITree.ts"; +} from '@lynx-js/devtool-connector/transport'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { McpContext } from './McpContext.ts'; +import { McpResponse } from './McpResponse.ts'; +import { GetGlobalSwitch } from './tools/App/GetGlobalSwitch.ts'; +import { ListGlobalSwitch } from './tools/App/ListGlobalSwitch.ts'; +import { SetGlobalSwitch } from './tools/App/SetGlobalSwitch.ts'; +import { GetBackgroundColors } from './tools/CSS/GetBackgroundColors.ts'; +import { GetComputedStyleForNode } from './tools/CSS/GetComputedStyleForNode.ts'; +import { GetInlineStylesForNode } from './tools/CSS/GetInlineStylesForNode.ts'; +import { GetMatchedStylesForNode } from './tools/CSS/GetMatchedStylesForNode.ts'; +import { GetStyleSheetText } from './tools/CSS/GetStyleSheetText.ts'; +import { GetScriptSource } from './tools/Debugger/GetScriptSource.ts'; +import { ListScripts } from './tools/Debugger/ListScripts.ts'; +import { ClosePage } from './tools/Device/ClosePage.ts'; +import { ListClients } from './tools/Device/ListClients.ts'; +import { ListDevices } from './tools/Device/ListDevices.ts'; +import { ListSessions } from './tools/Device/ListSessions.ts'; +import { OpenPage } from './tools/Device/OpenPage.ts'; +import { DescribeNode } from './tools/DOM/DescribeNode.ts'; +import { GetAttributes } from './tools/DOM/GetAttributes.ts'; +import { GetBoxModel } from './tools/DOM/GetBoxModel.ts'; +import { GetDocument } from './tools/DOM/GetDocument.ts'; +import { GetDocumentWithBoxModel } from './tools/DOM/GetDocumentWithBoxModel.ts'; +import { GetNodeForLocation } from './tools/DOM/GetNodeForLocation.ts'; +import { GetOriginalNodeIndex } from './tools/DOM/GetOriginalNodeIndex.ts'; +import { GetSearchResults } from './tools/DOM/GetSearchResults.ts'; +import { InnerText } from './tools/DOM/InnerText.ts'; +import { PerformSearch } from './tools/DOM/PerformSearch.ts'; +import { PushNodesByBackendIdsToFrontend } from './tools/DOM/PushNodesByBackendIdsToFrontend.ts'; +import { QuerySelector } from './tools/DOM/QuerySelector.ts'; +import { QuerySelectorAll } from './tools/DOM/QuerySelectorAll.ts'; +import { RequestChildNodes } from './tools/DOM/RequestChildNodes.ts'; +import { ScrollIntoViewIfNeeded } from './tools/DOM/ScrollIntoViewIfNeeded.ts'; +import { SetAttributesAsText } from './tools/DOM/SetAttributesAsText.ts'; +import type { ToolDefinition } from './tools/defineTool.ts'; +import { TakeHeapSnapshot } from './tools/HeapProfiler/TakeHeapSnapshot.ts'; +import { EmulateTouchFromMouseEvent } from './tools/Input/EmulateTouchFromMouseEvent.ts'; +import { GetVersion } from './tools/Lynx/GetVersion.ts'; +import { GetAllMemoryUsage } from './tools/Memory/GetAllMemoryUsage.ts'; +import { GetResourceContent } from './tools/Page/GetResourceContent.ts'; +import { GetResourceTree } from './tools/Page/GetResourceTree.ts'; +import { Reload } from './tools/Page/Reload.ts'; +import { TakeScreenshot } from './tools/Page/TakeScreenshot.ts'; +import { GetAllPerformanceEntries } from './tools/Performance/GetAllPerformanceEntries.ts'; +import { GetAllTimingInfo } from './tools/Performance/GetAllTimingInfo.ts'; +import { Evaluate } from './tools/Runtime/Evaluate.ts'; +import { GetHeapUsage } from './tools/Runtime/GetHeapUsage.ts'; +import { GetProperties } from './tools/Runtime/GetProperties.ts'; +import { ListConsole } from './tools/Runtime/ListConsole.ts'; +import { GetLynxUITree } from './tools/UITree/GetLynxUITree.ts'; const TOOLS = [ // App @@ -137,9 +137,13 @@ const TOOLS = [ GetLynxUITree, ] as unknown as ToolDefinition[]; -export function registerTool(mcpServer: McpServer, tool: ToolDefinition, transports: Transport[]): void { +export function registerTool( + mcpServer: McpServer, + tool: ToolDefinition, + transports: Transport[], +): void { if (!tool.schema) { - throw new Error("Tool schema is required"); + throw new Error('Tool schema is required'); } mcpServer.registerTool( tool.name, @@ -158,20 +162,22 @@ export function registerTool(mcpServer: McpServer, tool: ToolDefinition, transpo return { content }; } catch (error) { - const errorText = error instanceof Error ? error.message : String(error); + const errorText = + error instanceof Error ? error.message : String(error); return { isError: true, - content: [ - { type: "text", text: errorText }, - ], + content: [{ type: 'text', text: errorText }], }; } }, ); } -export function setupServer(mcpServer: McpServer, transports?: Transport[]): void { +export function setupServer( + mcpServer: McpServer, + transports?: Transport[], +): void { transports ??= createDefaultTransports(); for (const tool of TOOLS) { registerTool(mcpServer, tool, transports); @@ -185,13 +191,13 @@ function createDefaultTransports(): Transport[] { new DaemonTransport(), new iOSTransport(), new AndroidTransport({ - host: "127.0.0.1", + host: '127.0.0.1', port: 5037, }), new DesktopTransport(), ]; } -export type { Transport } from "./connector.ts"; -export * as Schema from "./schema/index.ts"; -export { defineTool, type ToolDefinition } from "./tools/defineTool.ts"; +export type { Transport } from './connector.ts'; +export * as Schema from './schema/index.ts'; +export { defineTool, type ToolDefinition } from './tools/defineTool.ts'; diff --git a/packages/mcp-servers/devtool-mcp-server/src/main.ts b/packages/mcp-servers/devtool-mcp-server/src/main.ts index 7cb382e..f0a152f 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/main.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/main.ts @@ -3,19 +3,20 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; - -import pkg from "../package.json" with { type: "json" }; +import pkg from '../package.json' with { type: 'json' }; async function main() { - const { setupServer } = await import("./index.ts"); - const mcpServer = new McpServer({ - name: "Lynx DevTool", - version: pkg.version, - }, { - instructions: `The Lynx DevTool MCP Server provides tools to interact with Lynx-based applications. + const { setupServer } = await import('./index.ts'); + const mcpServer = new McpServer( + { + name: 'Lynx DevTool', + version: pkg.version, + }, + { + instructions: `The Lynx DevTool MCP Server provides tools to interact with Lynx-based applications. Glossary: 1. Device: The DevTool MCP Server can connect to multiple devices (simulators or real devices). @@ -28,7 +29,8 @@ Tool selection guidance: Tool usage guidance: 1. Most of the tools would require a 'clientId' and a 'sessionId' parameter to identify the target client and session. You can get the list of connected devices, clients and sessions using the 'Device.listDevices', 'Device.listClients' and 'Device.listSessions' tools. `, - }); + }, + ); setupServer(mcpServer); diff --git a/packages/mcp-servers/devtool-mcp-server/src/schema/index.ts b/packages/mcp-servers/devtool-mcp-server/src/schema/index.ts index df2e635..652c935 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/schema/index.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/schema/index.ts @@ -2,92 +2,99 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; +import * as z from 'zod'; export const clientId = z .string() .describe( - "The clientId to list sessions. Use `Device_listClients` to get the ID. If somehow no clients are found, tool `Device_openApp` (sometimes unavailable) may help.", + 'The clientId to list sessions. Use `Device_listClients` to get the ID. If somehow no clients are found, tool `Device_openApp` (sometimes unavailable) may help.', ); -export const deviceId = z.string() +export const deviceId = z + .string() .describe( - "The deviceId. Use the `Device_listDevices` (if available, otherwise use `Device_acquireDevice`) to get ID for a devices.", + 'The deviceId. Use the `Device_listDevices` (if available, otherwise use `Device_acquireDevice`) to get ID for a devices.', ); // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#type-NodeId -export const nodeId = z.number() - .describe("Identifier of the node. Unique DOM node identifier."); +export const nodeId = z + .number() + .describe('Identifier of the node. Unique DOM node identifier.'); // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#type-BackendNodeId -export const backendNodeId = z.number() - .describe("Backend node identifier."); +export const backendNodeId = z.number().describe('Backend node identifier.'); export const sessionId = z .number() - .describe("The sessionId to list sessions. Use `Device_listSessions` to get the ID."); + .describe( + 'The sessionId to list sessions. Use `Device_listSessions` to get the ID.', + ); -export const selector = z.string() - .describe("CSS selector string"); +export const selector = z.string().describe('CSS selector string'); -export const query = z.string() - .describe("Search query string"); +export const query = z.string().describe('Search query string'); -export const searchId = z.union([z.number(), z.string()]) - .describe("Search identifier returned by `DOM.performSearch`. Pass it through unchanged."); +export const searchId = z + .union([z.number(), z.string()]) + .describe( + 'Search identifier returned by `DOM.performSearch`. Pass it through unchanged.', + ); -export const fromIndex = z.number() - .describe("Start index for search results"); +export const fromIndex = z.number().describe('Start index for search results'); -export const toIndex = z.number() - .describe("End index for search results"); +export const toIndex = z.number().describe('End index for search results'); -export const x = z.number() - .describe("X coordinate"); +export const x = z.number().describe('X coordinate'); -export const y = z.number() - .describe("Y coordinate"); +export const y = z.number().describe('Y coordinate'); -export const depth = z.number() +export const depth = z + .number() .optional() - .describe("Depth of child nodes to retrieve"); + .describe('Depth of child nodes to retrieve'); -export const pierce = z.boolean() +export const pierce = z + .boolean() .optional() - .describe("Whether to pierce through shadow DOM"); + .describe('Whether to pierce through shadow DOM'); -export const backendNodeIds = z.array(z.number()) - .describe("Array of backend node IDs"); +export const backendNodeIds = z + .array(z.number()) + .describe('Array of backend node IDs'); -export const includeUserAgentShadowDOM = z.boolean() +export const includeUserAgentShadowDOM = z + .boolean() .optional() - .describe("Whether to include user agent shadow DOM in search"); + .describe('Whether to include user agent shadow DOM in search'); // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#type-StyleSheetId -export const styleSheetId = z.string() - .describe("Style sheet identifier"); +export const styleSheetId = z.string().describe('Style sheet identifier'); // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#type-Rect export const rect = z.object({ x, y, - width: z.number().describe("Width of the rectangle"), - height: z.number().describe("Height of the rectangle"), + width: z.number().describe('Width of the rectangle'), + height: z.number().describe('Height of the rectangle'), }); -export const scriptId = z.string() - .describe("Identifier of the script. Use `Debugger_listScripts` to get the script IDs of a session."); +export const scriptId = z + .string() + .describe( + 'Identifier of the script. Use `Debugger_listScripts` to get the script IDs of a session.', + ); // Lynx DevTool native status codes (from devtool_status.cc) -export const screenshotMode = z.enum(["lynxview", "fullscreen"]) +export const screenshotMode = z + .enum(['lynxview', 'fullscreen']) .describe( - "Mode for screencast. `lynxview` captures only the viewable area of the page, while `fullscreen` captures the entire page.", + 'Mode for screencast. `lynxview` captures only the viewable area of the page, while `fullscreen` captures the entire page.', ); export const thread = z - .enum(["main", "background"]) + .enum(['main', 'background']) .optional() .describe( "Lynx has two Runtime/VM each on a separate thread. Some operations may need to specify the thread. Defaults to 'background'. 'main' for the main thread, 'background' for the background thread.", ) - .default("background"); + .default('background'); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts index 48a17e8..4e84fd9 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/App/GetGlobalSwitch.ts @@ -2,13 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; -import { globalSwitchKeySchema } from "./globalSwitch.ts"; +import { clientId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; +import { globalSwitchKeySchema } from './globalSwitch.ts'; export const GetGlobalSwitch = /*#__PURE__*/ defineTool({ - name: "App_getGlobalSwitch", - description: "Get global switch state for one key.", + name: 'App_getGlobalSwitch', + description: 'Get global switch state for one key.', schema: { clientId, key: globalSwitchKeySchema, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts index 405e097..354cfa7 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/App/ListGlobalSwitch.ts @@ -2,13 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; -import { GLOBAL_SWITCH_KEYS } from "./globalSwitch.ts"; +import { clientId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; +import { GLOBAL_SWITCH_KEYS } from './globalSwitch.ts'; export const ListGlobalSwitch = /*#__PURE__*/ defineTool({ - name: "App_listGlobalSwitch", - description: "List all global switch states by querying each supported key.", + name: 'App_listGlobalSwitch', + description: 'List all global switch states by querying each supported key.', schema: { clientId, }, @@ -17,7 +17,8 @@ export const ListGlobalSwitch = /*#__PURE__*/ defineTool({ }, async handler({ params }, response, context) { const connector = context.connector(); - const switches: Array<{ key: string; value?: boolean; error?: string }> = []; + const switches: Array<{ key: string; value?: boolean; error?: string }> = + []; for (const key of GLOBAL_SWITCH_KEYS) { try { diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts index 0cf08ad..914846c 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/App/SetGlobalSwitch.ts @@ -2,18 +2,18 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; -import { clientId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; -import { globalSwitchKeySchema } from "./globalSwitch.ts"; +import * as z from 'zod'; +import { clientId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; +import { globalSwitchKeySchema } from './globalSwitch.ts'; export const SetGlobalSwitch = /*#__PURE__*/ defineTool({ - name: "App_setGlobalSwitch", - description: "Set global switch state for one key.", + name: 'App_setGlobalSwitch', + description: 'Set global switch state for one key.', schema: { clientId, key: globalSwitchKeySchema, - switch: z.boolean().describe("Switch value (true/false)."), + switch: z.boolean().describe('Switch value (true/false).'), }, annotations: { readOnlyHint: false, @@ -22,6 +22,8 @@ export const SetGlobalSwitch = /*#__PURE__*/ defineTool({ const connector = context.connector(); await connector.setGlobalSwitch(params.clientId, params.key, params.switch); - response.appendLines(JSON.stringify({ key: params.key, value: params.switch })); + response.appendLines( + JSON.stringify({ key: params.key, value: params.switch }), + ); }, }); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts index e8a4efd..d81ba9f 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/App/globalSwitch.ts @@ -2,26 +2,28 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; +import * as z from 'zod'; export const GLOBAL_SWITCH_KEYS = [ - "enable_devtool", - "enable_logbox", - "enable_debug_mode", - "enable_dom_tree", - "enable_quickjs_debug", - "enable_quickjs_cache", - "enable_v8", - "enable_cdp_domain_dom", - "enable_cdp_domain_css", - "enable_cdp_domain_page", - "enable_long_press_menu", - "enable_highlight_touch", - "enable_preview_screen_shot", - "enable_pixel_copy", - "enable_fsp_screenshot", + 'enable_devtool', + 'enable_logbox', + 'enable_debug_mode', + 'enable_dom_tree', + 'enable_quickjs_debug', + 'enable_quickjs_cache', + 'enable_v8', + 'enable_cdp_domain_dom', + 'enable_cdp_domain_css', + 'enable_cdp_domain_page', + 'enable_long_press_menu', + 'enable_highlight_touch', + 'enable_preview_screen_shot', + 'enable_pixel_copy', + 'enable_fsp_screenshot', ] as const; export const globalSwitchKeySchema = z .enum(GLOBAL_SWITCH_KEYS) - .describe("Global switch key. Use `App_listGlobalSwitch` to inspect all keys."); + .describe( + 'Global switch key. Use `App_listGlobalSwitch` to inspect all keys.', + ); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts index d8d612e..2b569ab 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetBackgroundColors.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetBackgroundColors = /*#__PURE__*/ defineTool({ - name: "CSS_getBackgroundColors", - description: "Returns background color information for the node.", + name: 'CSS_getBackgroundColors', + description: 'Returns background color information for the node.', schema: { clientId, sessionId, @@ -16,13 +16,22 @@ export const GetBackgroundColors = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId } }, + response, + context, + ) { const connector = context.connector(); // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getBackgroundColors - const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getBackgroundColors", { - nodeId, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'CSS.getBackgroundColors', + { + nodeId, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts index dd73332..ce2da93 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetComputedStyleForNode.ts @@ -2,12 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetComputedStyleForNode = /*#__PURE__*/ defineTool({ - name: "CSS_getComputedStyleForNode", - description: "Returns the computed style for a DOM node identified by nodeId.", + name: 'CSS_getComputedStyleForNode', + description: + 'Returns the computed style for a DOM node identified by nodeId.', schema: { clientId, sessionId, @@ -17,13 +18,22 @@ export const GetComputedStyleForNode = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId } }, + response, + context, + ) { const connector = context.connector(); // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getComputedStyleForNode - const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getComputedStyleForNode", { - nodeId, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'CSS.getComputedStyleForNode', + { + nodeId, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts index 2bc03fc..20fd205 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetInlineStylesForNode.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetInlineStylesForNode = /*#__PURE__*/ defineTool({ - name: "CSS_getInlineStylesForNode", - description: "Returns inline style and style attribute for the node.", + name: 'CSS_getInlineStylesForNode', + description: 'Returns inline style and style attribute for the node.', schema: { clientId, sessionId, @@ -16,13 +16,22 @@ export const GetInlineStylesForNode = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId } }, + response, + context, + ) { const connector = context.connector(); // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getInlineStylesForNode - const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getInlineStylesForNode", { - nodeId, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'CSS.getInlineStylesForNode', + { + nodeId, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts index 01e2811..3fa8454 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetMatchedStylesForNode.ts @@ -2,13 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetMatchedStylesForNode = /*#__PURE__*/ defineTool({ - name: "CSS_getMatchedStylesForNode", + name: 'CSS_getMatchedStylesForNode', description: - "Returns CSS rules matching the specified node. The matchedCSSRules in the result are ordered by priority from high to low. When selectors have the same specificity, rules that appear later (further down) have higher priority.", + 'Returns CSS rules matching the specified node. The matchedCSSRules in the result are ordered by priority from high to low. When selectors have the same specificity, rules that appear later (further down) have higher priority.', schema: { clientId, sessionId, @@ -17,13 +17,22 @@ export const GetMatchedStylesForNode = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId } }, + response, + context, + ) { const connector = context.connector(); // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getMatchedStylesForNode - const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getMatchedStylesForNode", { - nodeId, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'CSS.getMatchedStylesForNode', + { + nodeId, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts index 408be31..25b39ee 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/CSS/GetStyleSheetText.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, sessionId, styleSheetId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, sessionId, styleSheetId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetStyleSheetText = /*#__PURE__*/ defineTool({ - name: "CSS_getStyleSheetText", - description: "Returns the text content of the stylesheet.", + name: 'CSS_getStyleSheetText', + description: 'Returns the text content of the stylesheet.', schema: { clientId, sessionId, @@ -16,13 +16,22 @@ export const GetStyleSheetText = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, styleSheetId } }, response, context) { + async handler( + { params: { clientId, sessionId, styleSheetId } }, + response, + context, + ) { const connector = context.connector(); // https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getStyleSheetText - const result = await connector.sendCDPMessage(clientId, sessionId, "CSS.getStyleSheetText", { - styleSheetId, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'CSS.getStyleSheetText', + { + styleSheetId, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts index 441b42f..b280ced 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/DescribeNode.ts @@ -2,12 +2,19 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { backendNodeId, clientId, depth, nodeId, pierce, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { + backendNodeId, + clientId, + depth, + nodeId, + pierce, + sessionId, +} from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const DescribeNode = /*#__PURE__*/ defineTool({ - name: "DOM_describeNode", - description: "Describe a DOM node, optionally including descendants.", + name: 'DOM_describeNode', + description: 'Describe a DOM node, optionally including descendants.', schema: { clientId, sessionId, @@ -19,19 +26,28 @@ export const DescribeNode = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId, backendNodeId, depth, pierce } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId, backendNodeId, depth, pierce } }, + response, + context, + ) { const connector = context.connector(); - await connector.sendCDPMessage(clientId, sessionId, "DOM.enable", { + await connector.sendCDPMessage(clientId, sessionId, 'DOM.enable', { useCompression: false, }); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.describeNode", { - nodeId, - backendNodeId, - depth, - pierce, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.describeNode', + { + nodeId, + backendNodeId, + depth, + pierce, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts index cfc51fa..a4fe94a 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetAttributes.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetAttributes = /*#__PURE__*/ defineTool({ - name: "DOM_getAttributes", - description: "Get all attributes of the specified node.", + name: 'DOM_getAttributes', + description: 'Get all attributes of the specified node.', schema: { clientId, sessionId, @@ -16,12 +16,21 @@ export const GetAttributes = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getAttributes", { - nodeId, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.getAttributes', + { + nodeId, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts index 2bae6ca..2b7fae0 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetBoxModel.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetBoxModel = /*#__PURE__*/ defineTool({ - name: "DOM_getBoxModel", - description: "Get the box model of an element.", + name: 'DOM_getBoxModel', + description: 'Get the box model of an element.', schema: { clientId, sessionId, @@ -17,13 +17,22 @@ export const GetBoxModel = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId } }, + response, + context, + ) { const connector = context.connector(); // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getBoxModel - const box = await connector.sendCDPMessage(clientId, sessionId, "DOM.getBoxModel", { - nodeId, - }); + const box = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.getBoxModel', + { + nodeId, + }, + ); response.appendLines(JSON.stringify(box)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts index f1bb9bb..d173cba 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocument.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, depth, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, depth, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetDocument = /*#__PURE__*/ defineTool({ - name: "DOM_getDocument", - description: "Get the document tree of the page.", + name: 'DOM_getDocument', + description: 'Get the document tree of the page.', schema: { clientId, sessionId, @@ -19,14 +19,14 @@ export const GetDocument = /*#__PURE__*/ defineTool({ async handler({ params: { clientId, sessionId, depth } }, response, context) { const connector = context.connector(); - await connector.sendCDPMessage(clientId, sessionId, "DOM.enable", { + await connector.sendCDPMessage(clientId, sessionId, 'DOM.enable', { useCompression: false, }); const tree = await connector.sendCDPMessage( clientId, sessionId, - "DOM.getDocument", + 'DOM.getDocument', depth === undefined ? undefined : { depth }, ); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts index 85e90cd..08ae6f6 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetDocumentWithBoxModel.ts @@ -2,12 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetDocumentWithBoxModel = /*#__PURE__*/ defineTool({ - name: "DOM_getDocumentWithBoxModel", - description: "Get the document tree of the Lynx page with box model information.", + name: 'DOM_getDocumentWithBoxModel', + description: + 'Get the document tree of the Lynx page with box model information.', schema: { clientId, sessionId, @@ -18,11 +19,15 @@ export const GetDocumentWithBoxModel = /*#__PURE__*/ defineTool({ async handler({ params: { clientId, sessionId } }, response, context) { const connector = context.connector(); - await connector.sendCDPMessage(clientId, sessionId, "DOM.enable", { + await connector.sendCDPMessage(clientId, sessionId, 'DOM.enable', { useCompression: false, }); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getDocumentWithBoxModel"); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.getDocumentWithBoxModel', + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts index d16d2d6..540f068 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetNodeForLocation.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, sessionId, x, y } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, sessionId, x, y } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetNodeForLocation = /*#__PURE__*/ defineTool({ - name: "DOM_getNodeForLocation", - description: "Get the node at the specified coordinates.", + name: 'DOM_getNodeForLocation', + description: 'Get the node at the specified coordinates.', schema: { clientId, sessionId, @@ -20,10 +20,15 @@ export const GetNodeForLocation = /*#__PURE__*/ defineTool({ async handler({ params: { clientId, sessionId, x, y } }, response, context) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getNodeForLocation", { - x, - y, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.getNodeForLocation', + { + x, + y, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts index 5141f1b..0e59016 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetOriginalNodeIndex.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetOriginalNodeIndex = /*#__PURE__*/ defineTool({ - name: "DOM_getOriginalNodeIndex", - description: "Get the original index of the node in its parent.", + name: 'DOM_getOriginalNodeIndex', + description: 'Get the original index of the node in its parent.', schema: { clientId, sessionId, @@ -16,12 +16,21 @@ export const GetOriginalNodeIndex = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getOriginalNodeIndex", { - nodeId, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.getOriginalNodeIndex', + { + nodeId, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts index 819e83b..3fa1d8b 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/GetSearchResults.ts @@ -2,12 +2,18 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, fromIndex, searchId, sessionId, toIndex } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { + clientId, + fromIndex, + searchId, + sessionId, + toIndex, +} from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetSearchResults = /*#__PURE__*/ defineTool({ - name: "DOM_getSearchResults", - description: "Get search results for the specified range.", + name: 'DOM_getSearchResults', + description: 'Get search results for the specified range.', schema: { clientId, sessionId, @@ -18,14 +24,23 @@ export const GetSearchResults = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, searchId, fromIndex, toIndex } }, response, context) { + async handler( + { params: { clientId, sessionId, searchId, fromIndex, toIndex } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.getSearchResults", { - searchId, - fromIndex, - toIndex, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.getSearchResults', + { + searchId, + fromIndex, + toIndex, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts index 6faee26..74c858d 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/InnerText.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const InnerText = /*#__PURE__*/ defineTool({ - name: "DOM_innerText", - description: "Get the visible text content of the node.", + name: 'DOM_innerText', + description: 'Get the visible text content of the node.', schema: { clientId, sessionId, @@ -16,12 +16,21 @@ export const InnerText = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.innerText", { - nodeId, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.innerText', + { + nodeId, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts index 928d4fd..90c5bd7 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PerformSearch.ts @@ -2,12 +2,17 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, includeUserAgentShadowDOM, query, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { + clientId, + includeUserAgentShadowDOM, + query, + sessionId, +} from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const PerformSearch = /*#__PURE__*/ defineTool({ - name: "DOM_performSearch", - description: "Search for nodes in the DOM tree.", + name: 'DOM_performSearch', + description: 'Search for nodes in the DOM tree.', schema: { clientId, sessionId, @@ -17,13 +22,22 @@ export const PerformSearch = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, query, includeUserAgentShadowDOM } }, response, context) { + async handler( + { params: { clientId, sessionId, query, includeUserAgentShadowDOM } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.performSearch", { - query, - includeUserAgentShadowDOM, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.performSearch', + { + query, + includeUserAgentShadowDOM, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts index 1c178d9..2ce5540 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/PushNodesByBackendIdsToFrontend.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { backendNodeIds, clientId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { backendNodeIds, clientId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const PushNodesByBackendIdsToFrontend = /*#__PURE__*/ defineTool({ - name: "DOM_pushNodesByBackendIdsToFrontend", - description: "Push backend node IDs to the frontend for inspection.", + name: 'DOM_pushNodesByBackendIdsToFrontend', + description: 'Push backend node IDs to the frontend for inspection.', schema: { clientId, sessionId, @@ -16,12 +16,21 @@ export const PushNodesByBackendIdsToFrontend = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, backendNodeIds } }, response, context) { + async handler( + { params: { clientId, sessionId, backendNodeIds } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.pushNodesByBackendIdsToFrontend", { - backendNodeIds, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.pushNodesByBackendIdsToFrontend', + { + backendNodeIds, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts index 4c043d5..ff4b651 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelector.ts @@ -2,30 +2,41 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, selector, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, selector, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const QuerySelector = /*#__PURE__*/ defineTool({ - name: "DOM_querySelector", - description: "Find the first element matching the CSS selector.", + name: 'DOM_querySelector', + description: 'Find the first element matching the CSS selector.', schema: { clientId, sessionId, - nodeId: nodeId.optional().describe( - "Identifier of the node. Unique DOM node identifier. Defaults to root node if not specified.", - ), + nodeId: nodeId + .optional() + .describe( + 'Identifier of the node. Unique DOM node identifier. Defaults to root node if not specified.', + ), selector, }, annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId, selector } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId, selector } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.querySelector", { - nodeId, - selector, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.querySelector', + { + nodeId, + selector, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts index 2924cbf..216fe11 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/QuerySelectorAll.ts @@ -2,30 +2,41 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, selector, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, selector, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const QuerySelectorAll = /*#__PURE__*/ defineTool({ - name: "DOM_querySelectorAll", - description: "Find all elements matching the CSS selector.", + name: 'DOM_querySelectorAll', + description: 'Find all elements matching the CSS selector.', schema: { clientId, sessionId, - nodeId: nodeId.optional().describe( - "Identifier of the node. Unique DOM node identifier. Defaults to root node if not specified.", - ), + nodeId: nodeId + .optional() + .describe( + 'Identifier of the node. Unique DOM node identifier. Defaults to root node if not specified.', + ), selector, }, annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId, selector } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId, selector } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.querySelectorAll", { - nodeId, - selector, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.querySelectorAll', + { + nodeId, + selector, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts index bdce24f..bc7a73f 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/RequestChildNodes.ts @@ -2,12 +2,18 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, depth, nodeId, pierce, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { + clientId, + depth, + nodeId, + pierce, + sessionId, +} from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const RequestChildNodes = /*#__PURE__*/ defineTool({ - name: "DOM_requestChildNodes", - description: "Request child nodes for a given parent node.", + name: 'DOM_requestChildNodes', + description: 'Request child nodes for a given parent node.', schema: { clientId, sessionId, @@ -18,14 +24,23 @@ export const RequestChildNodes = /*#__PURE__*/ defineTool({ annotations: { readOnlyHint: true, }, - async handler({ params: { clientId, sessionId, nodeId, depth, pierce } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId, depth, pierce } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.requestChildNodes", { - nodeId, - depth, - pierce, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.requestChildNodes', + { + nodeId, + depth, + pierce, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts index 75a2fb6..78d8bd9 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/ScrollIntoViewIfNeeded.ts @@ -2,30 +2,42 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, nodeId, rect, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, nodeId, rect, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const ScrollIntoViewIfNeeded = /*#__PURE__*/ defineTool({ - name: "DOM_scrollIntoViewIfNeeded", - description: "Scrolls the specified rect of the given node into view if not already visible.", + name: 'DOM_scrollIntoViewIfNeeded', + description: + 'Scrolls the specified rect of the given node into view if not already visible.', schema: { clientId, sessionId, nodeId, - rect: rect.describe( - "The rect to be scrolled into view, relative to the node's border box, in CSS pixels. When omitted, center of the node will be used.", - ).optional(), + rect: rect + .describe( + "The rect to be scrolled into view, relative to the node's border box, in CSS pixels. When omitted, center of the node will be used.", + ) + .optional(), }, annotations: { readOnlyHint: false, }, - async handler({ params: { clientId, sessionId, nodeId, rect } }, response, context) { + async handler( + { params: { clientId, sessionId, nodeId, rect } }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "DOM.scrollIntoViewIfNeeded", { - nodeId, - rect, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'DOM.scrollIntoViewIfNeeded', + { + nodeId, + rect, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts index 643451f..c17ff58 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/DOM/SetAttributesAsText.ts @@ -2,19 +2,21 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import * as z from 'zod'; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const SetAttributesAsText = /*#__PURE__*/ defineTool({ - name: "DOM_setAttributesAsText", - description: "Set node attributes from a text representation.", + name: 'DOM_setAttributesAsText', + description: 'Set node attributes from a text representation.', schema: { clientId, sessionId, nodeId, - text: z.string().describe("Attribute text, for example `style='color: pink;'`."), - name: z.string().optional().describe("Optional attribute name to replace."), + text: z + .string() + .describe("Attribute text, for example `style='color: pink;'`."), + name: z.string().optional().describe('Optional attribute name to replace.'), }, annotations: { readOnlyHint: false, @@ -23,16 +25,16 @@ export const SetAttributesAsText = /*#__PURE__*/ defineTool({ const connector = context.connector(); const cdpParams = Object.fromEntries( [ - ["nodeId", params.nodeId], - ["text", params.text], - ["name", params.name], + ['nodeId', params.nodeId], + ['text', params.text], + ['name', params.name], ].filter(([, value]) => value !== undefined), ); const result = await connector.sendCDPMessage( params.clientId, params.sessionId, - "DOM.setAttributesAsText", + 'DOM.setAttributesAsText', cdpParams, ); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts index 4d54e12..310ef36 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/GetScriptSource.ts @@ -2,25 +2,28 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import * as z from "zod"; -import { clientId, scriptId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import * as z from 'zod'; +import { clientId, scriptId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetScriptSource = /*#__PURE__*/ defineTool({ - name: "Debugger_getScriptSource", + name: 'Debugger_getScriptSource', description: - "Get the source code of a script identified by the given script identifier. Use `saveToTmp` if the response is too large.", + 'Get the source code of a script identified by the given script identifier. Use `saveToTmp` if the response is too large.', schema: { clientId, sessionId, scriptId, - saveToTmp: z.boolean() + saveToTmp: z + .boolean() .optional() - .describe("Whether to save the script source to a temporary file if it is large.") + .describe( + 'Whether to save the script source to a temporary file if it is large.', + ) .default(false), }, annotations: { @@ -29,24 +32,21 @@ export const GetScriptSource = /*#__PURE__*/ defineTool({ async handler({ params }, response, context) { const connector = context.connector(); - const { scriptSource } = await connector.sendCDPMessage<{ scriptSource: string }, { scriptId: string }>( - params.clientId, - params.sessionId, - "Debugger.getScriptSource", - { scriptId: params.scriptId }, - ); + const { scriptSource } = await connector.sendCDPMessage< + { scriptSource: string }, + { scriptId: string } + >(params.clientId, params.sessionId, 'Debugger.getScriptSource', { + scriptId: params.scriptId, + }); if (params.saveToTmp) { - const tmp = await fs.mkdtemp(path.join(tmpdir(), "lynx-devtool-mcp-")); - await fs.writeFile( - path.join(tmp, `${params.scriptId}.js`), - scriptSource, - ); + const tmp = await fs.mkdtemp(path.join(tmpdir(), 'lynx-devtool-mcp-')); + await fs.writeFile(path.join(tmp, `${params.scriptId}.js`), scriptSource); response.appendLines(`Script saved to ${tmp}/${params.scriptId}.js`); } else { - response.appendLines("```javascript"); + response.appendLines('```javascript'); response.appendLines(scriptSource); - response.appendLines("```"); + response.appendLines('```'); } }, }); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts index b97f9bf..ff3c914 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Debugger/ListScripts.ts @@ -2,15 +2,15 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { ReadableStream } from "node:stream/web"; -import { setTimeout } from "node:timers/promises"; -import { clientId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { ReadableStream } from 'node:stream/web'; +import { setTimeout } from 'node:timers/promises'; +import { clientId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const ListScripts = /*#__PURE__*/ defineTool({ - name: "Debugger_listScripts", + name: 'Debugger_listScripts', description: - "List all parsed scripts. If no scripts found, it means that the page is opened before the DevTool connected. Use `Page_reload` to reload the page and get the scripts again.", + 'List all parsed scripts. If no scripts found, it means that the page is opened before the DevTool connected. Use `Page_reload` to reload the page and get the scripts again.', schema: { clientId, sessionId, @@ -24,9 +24,11 @@ export const ListScripts = /*#__PURE__*/ defineTool({ await using stream = await connector.sendCDPStream( params.clientId, params.sessionId, - ReadableStream.from([{ - method: "Debugger.enable", - }]), + ReadableStream.from([ + { + method: 'Debugger.enable', + }, + ]), { signal: extra.signal }, ); @@ -41,9 +43,9 @@ export const ListScripts = /*#__PURE__*/ defineTool({ while (Date.now() - startTime < MAX_TOTAL_TIME) { const result = await Promise.race([ reader.read(), - setTimeout(IDLE_TIMEOUT, "timeout" as const), + setTimeout(IDLE_TIMEOUT, 'timeout' as const), ]); - if (result === "timeout") { + if (result === 'timeout') { await reader.cancel(); break; } @@ -51,7 +53,7 @@ export const ListScripts = /*#__PURE__*/ defineTool({ const { done, value } = result; if (done) break; - if (value.method === "Debugger.scriptParsed") { + if (value.method === 'Debugger.scriptParsed') { scripts.push(value.params as never); } } @@ -60,7 +62,9 @@ export const ListScripts = /*#__PURE__*/ defineTool({ } response.appendLines( - ...scripts.map(({ scriptId, url }) => `- scriptId: ${scriptId}, url: ${url}`), + ...scripts.map( + ({ scriptId, url }) => `- scriptId: ${scriptId}, url: ${url}`, + ), ); }, }); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts index 9d8af8f..8d43682 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ClosePage.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const ClosePage = /*#__PURE__*/ defineTool({ - name: "Device_closePage", - description: "Close the current page", + name: 'Device_closePage', + description: 'Close the current page', schema: { clientId, }, @@ -17,6 +17,6 @@ export const ClosePage = /*#__PURE__*/ defineTool({ async handler({ params }, _, context) { const connector = context.connector(); - await connector.sendAppMessage(params.clientId, "App.closePage"); + await connector.sendAppMessage(params.clientId, 'App.closePage'); }, }); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts index fd18dfa..34c7407 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListClients.ts @@ -2,11 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { defineTool } from "../defineTool.ts"; +import { defineTool } from '../defineTool.ts'; export const ListClients = /*#__PURE__*/ defineTool({ - name: "Device_listClients", - description: "List all connected clients. This tool may timeout if no clients are connected or just started.", + name: 'Device_listClients', + description: + 'List all connected clients. This tool may timeout if no clients are connected or just started.', schema: {}, annotations: { readOnlyHint: true, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts index aad410d..4b90153 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListDevices.ts @@ -2,11 +2,11 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { defineTool } from "../defineTool.ts"; +import { defineTool } from '../defineTool.ts'; export const ListDevices = /*#__PURE__*/ defineTool({ - name: "Device_listDevices", - description: "List all connected devices.", + name: 'Device_listDevices', + description: 'List all connected devices.', schema: {}, annotations: { readOnlyHint: true, @@ -16,10 +16,12 @@ export const ListDevices = /*#__PURE__*/ defineTool({ const devices = await connector.listDevices(); - response.appendLines(JSON.stringify( - devices.map(({ id }) => id), - null, - 2, - )); + response.appendLines( + JSON.stringify( + devices.map(({ id }) => id), + null, + 2, + ), + ); }, }); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts index 8c2ada8..68b0165 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/ListSessions.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const ListSessions = /*#__PURE__*/ defineTool({ - name: "Device_listSessions", - description: "List all opened sessions", + name: 'Device_listSessions', + description: 'List all opened sessions', schema: { clientId, }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts index 6f21d64..29cbe3b 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Device/OpenPage.ts @@ -2,17 +2,17 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { isListSessionResponse } from "@lynx-js/devtool-connector"; -import { FilterTransformStream } from "@lynx-js/devtool-connector/streams"; -import * as z from "zod"; -import { clientId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { isListSessionResponse } from '@lynx-js/devtool-connector'; +import { FilterTransformStream } from '@lynx-js/devtool-connector/streams'; +import * as z from 'zod'; +import { clientId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const OpenPage = /*#__PURE__*/ defineTool({ - name: "Device_openPage", - description: "Open a page", + name: 'Device_openPage', + description: 'Open a page', schema: { - url: z.string().describe("The URL of the page"), + url: z.string().describe('The URL of the page'), clientId, }, annotations: { @@ -24,32 +24,34 @@ export const OpenPage = /*#__PURE__*/ defineTool({ // The built-in headless runtime downloads its binary lazily on first use. // Wait for it here (tool layer) so the open request below is not cut off // while the binary is still downloading. - if (params.clientId.startsWith("headless:")) { + if (params.clientId.startsWith('headless:')) { await connector.waitForHeadlessReady(params.clientId); } try { - await connector.sendAppMessage(params.clientId, "App.openPage", { + await connector.sendAppMessage(params.clientId, 'App.openPage', { url: params.url, }); } catch { - await connector.sendMessage(params.clientId, { - event: "Customized", - data: { - type: "OpenCard", + await connector.sendMessage( + params.clientId, + { + event: 'Customized', data: { - type: "url", - url: params.url, + type: 'OpenCard', + data: { + type: 'url', + url: params.url, + }, + sender: -1, }, - sender: -1, + from: -1, }, - from: -1, - }, { - input: [], - output: [ - new FilterTransformStream(isListSessionResponse), - ], - }); + { + input: [], + output: [new FilterTransformStream(isListSessionResponse)], + }, + ); } }, }); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts index f04964d..d301445 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/HeapProfiler/TakeHeapSnapshot.ts @@ -2,21 +2,24 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { type CDPResponseMessage, CDPResponseTransformStream } from "@lynx-js/devtool-connector"; -import { randomInt } from "node:crypto"; -import { createWriteStream } from "node:fs"; -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { pipeline } from "node:stream/promises"; -import { ReadableStream } from "node:stream/web"; -import { setTimeout } from "node:timers/promises"; -import { clientId, sessionId, thread } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { randomInt } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { ReadableStream } from 'node:stream/web'; +import { setTimeout } from 'node:timers/promises'; +import { + type CDPResponseMessage, + CDPResponseTransformStream, +} from '@lynx-js/devtool-connector'; +import { clientId, sessionId, thread } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ - name: "HeapProfiler_takeHeapSnapshot", - description: "Take a heap snapshot and save it to a .heapsnapshot file.", + name: 'HeapProfiler_takeHeapSnapshot', + description: 'Take a heap snapshot and save it to a .heapsnapshot file.', schema: { clientId, sessionId, @@ -27,8 +30,10 @@ export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ }, async handler({ params, extra }, response, context) { const connector = context.connector(); - const expectedSessionId = params.thread === "main" ? "Main" : undefined; - const extraParams = expectedSessionId ? { sessionId: expectedSessionId } : {}; + const expectedSessionId = params.thread === 'main' ? 'Main' : undefined; + const extraParams = expectedSessionId + ? { sessionId: expectedSessionId } + : {}; const timeoutSignal = AbortSignal.timeout(60_000); // 60s timeout for heap snapshot const signal = extra.signal @@ -39,46 +44,47 @@ export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ await using stream = await connector.sendStream( params.clientId, - ReadableStream.from([{ - event: "Customized", - data: { - type: "CDP", + ReadableStream.from([ + { + event: 'Customized', data: { - session_id: params.sessionId, - message: { - id: requestId - 1, - method: "HeapProfiler.enable", - params: {}, - ...extraParams, + type: 'CDP', + data: { + session_id: params.sessionId, + message: { + id: requestId - 1, + method: 'HeapProfiler.enable', + params: {}, + ...extraParams, + }, }, }, }, - }, { - event: "Customized", - data: { - type: "CDP", + { + event: 'Customized', data: { - session_id: params.sessionId, - message: { - id: requestId, - method: "HeapProfiler.takeHeapSnapshot", - params: { - reportProgress: true, - treatGlobalObjectsAsRoots: true, - captureNumericValue: false, + type: 'CDP', + data: { + session_id: params.sessionId, + message: { + id: requestId, + method: 'HeapProfiler.takeHeapSnapshot', + params: { + reportProgress: true, + treatGlobalObjectsAsRoots: true, + captureNumericValue: false, + }, + ...extraParams, }, - ...extraParams, }, }, }, - }]), + ]), { signal, pipeline: { input: [], - output: [ - new CDPResponseTransformStream(), - ], + output: [new CDPResponseTransformStream()], }, }, ); @@ -86,7 +92,7 @@ export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ let didReceiveSnapshotResponse = false; const tmpFile = path.join( tmpdir(), - `heap-${params.thread === "main" ? "main" : "background"}-${Date.now()}.heapsnapshot`, + `heap-${params.thread === 'main' ? 'main' : 'background'}-${Date.now()}.heapsnapshot`, ); const reader = stream.getReader(); @@ -101,10 +107,10 @@ export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ while (Date.now() - startTime < MAX_TOTAL_TIME) { const result = await Promise.race([ reader.read(), - setTimeout(IDLE_TIMEOUT, "timeout" as const), + setTimeout(IDLE_TIMEOUT, 'timeout' as const), ]); - if (result === "timeout") { + if (result === 'timeout') { await reader.cancel(); break; } @@ -112,7 +118,12 @@ export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ const { done, value } = result; if (done) break; - const { method, params: eventParams, id, sessionId } = value as CDPResponseMessage & { + const { + method, + params: eventParams, + id, + sessionId, + } = value as CDPResponseMessage & { method?: string; params?: { chunk?: string; @@ -121,7 +132,7 @@ export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ sessionId?: string; }; - if (method === "HeapProfiler.addHeapSnapshotChunk") { + if (method === 'HeapProfiler.addHeapSnapshotChunk') { if (sessionId !== expectedSessionId) { continue; } @@ -136,9 +147,8 @@ export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ if (didReceiveSnapshotResponse) { break; } - } else if (method === "HeapProfiler.reportHeapSnapshotProgress") { + } else if (method === 'HeapProfiler.reportHeapSnapshotProgress') { if (sessionId !== expectedSessionId) { - continue; } } else if (id === requestId) { didReceiveSnapshotResponse = true; @@ -151,12 +161,14 @@ export const TakeHeapSnapshot = /*#__PURE__*/ defineTool({ await pipeline( snapshotChunks(), - createWriteStream(tmpFile, { encoding: "utf8" }), + createWriteStream(tmpFile, { encoding: 'utf8' }), { signal }, ); if (!didWriteSnapshotChunk) { - throw new Error("Failed to capture heap snapshot, no chunks received or timed out."); + throw new Error( + 'Failed to capture heap snapshot, no chunks received or timed out.', + ); } shouldKeepSnapshotFile = true; diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts index a67ccb9..8114090 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Input/EmulateTouchFromMouseEvent.ts @@ -2,19 +2,28 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; -import { clientId, sessionId, x, y } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import * as z from 'zod'; +import { clientId, sessionId, x, y } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; -const type = z.enum(["mousePressed", "mouseReleased", "mouseMoved"]).describe("Type of mouse event"); -const timestamp = z.number().describe("Timestamp of the mouse event"); -const button = z.enum(["left", "middle", "right"]).describe("Mouse button"); -const deltaX = z.number().optional().describe("Horizontal scroll delta (optional)"); -const deltaY = z.number().optional().describe("Vertical scroll delta (optional)"); +const type = z + .enum(['mousePressed', 'mouseReleased', 'mouseMoved']) + .describe('Type of mouse event'); +const timestamp = z.number().describe('Timestamp of the mouse event'); +const button = z.enum(['left', 'middle', 'right']).describe('Mouse button'); +const deltaX = z + .number() + .optional() + .describe('Horizontal scroll delta (optional)'); +const deltaY = z + .number() + .optional() + .describe('Vertical scroll delta (optional)'); export const EmulateTouchFromMouseEvent = defineTool({ - name: "Input_emulateTouchFromMouseEvent", - description: "Emulate touch from mouse event - converts mouse events to touch events for testing touch interactions", + name: 'Input_emulateTouchFromMouseEvent', + description: + 'Emulate touch from mouse event - converts mouse events to touch events for testing touch interactions', annotations: { readOnlyHint: false, }, @@ -29,18 +38,39 @@ export const EmulateTouchFromMouseEvent = defineTool({ deltaX, deltaY, }, - async handler({ params: { clientId, sessionId, type, x, y, timestamp, button, deltaX, deltaY } }, response, context) { + async handler( + { + params: { + clientId, + sessionId, + type, + x, + y, + timestamp, + button, + deltaX, + deltaY, + }, + }, + response, + context, + ) { const connector = context.connector(); - const result = await connector.sendCDPMessage(clientId, sessionId, "Input.emulateTouchFromMouseEvent", { - type, - x, - y, - timestamp, - button, - deltaX, - deltaY, - }); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'Input.emulateTouchFromMouseEvent', + { + type, + x, + y, + timestamp, + button, + deltaX, + deltaY, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts index 7cebeed..27fffdc 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Lynx/GetVersion.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetVersion = /*#__PURE__*/ defineTool({ - name: "Lynx_getVersion", - description: "Return the Lynx engine version for the selected session.", + name: 'Lynx_getVersion', + description: 'Return the Lynx engine version for the selected session.', schema: { clientId, sessionId, @@ -18,7 +18,12 @@ export const GetVersion = /*#__PURE__*/ defineTool({ async handler({ params }, response, context) { const connector = context.connector(); - const result = await connector.sendCDPMessage(params.clientId, params.sessionId, "Lynx.getVersion", {}); + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + 'Lynx.getVersion', + {}, + ); response.appendLines(JSON.stringify(result, null, 2)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts index c107d7c..ff0719d 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Memory/GetAllMemoryUsage.ts @@ -2,9 +2,9 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; -import { clientId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import * as z from 'zod'; +import { clientId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; const GLOBAL_CDP_SESSION_ID = -1; const MAX_MEMORY_USAGE_TIMEOUT_MS = 300_000; @@ -14,7 +14,7 @@ const globalSessionId = z .int() .optional() .describe( - "CDP session ID. Defaults to -1 for the global DevTool handler. Override only for platform-specific routing.", + 'CDP session ID. Defaults to -1 for the global DevTool handler. Override only for platform-specific routing.', ); const timeoutMs = z @@ -28,8 +28,9 @@ const timeoutMs = z ); export const GetAllMemoryUsage = /*#__PURE__*/ defineTool({ - name: "Memory_getAllMemoryUsage", - description: "Get global Lynx-attributed memory usage across live registered Lynx instances.", + name: 'Memory_getAllMemoryUsage', + description: + 'Get global Lynx-attributed memory usage across live registered Lynx instances.', schema: { clientId, sessionId: globalSessionId, @@ -40,12 +41,13 @@ export const GetAllMemoryUsage = /*#__PURE__*/ defineTool({ }, async handler({ params }, response, context) { const connector = context.connector(); - const requestParams = params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }; + const requestParams = + params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }; const result = await connector.sendCDPMessage( params.clientId, params.sessionId ?? GLOBAL_CDP_SESSION_ID, - "Memory.getAllMemoryUsage", + 'Memory.getAllMemoryUsage', requestParams, ); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts index 7a68006..90a0cef 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceContent.ts @@ -2,41 +2,51 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; -import { clientId, nodeId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import * as z from 'zod'; +import { clientId, nodeId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetResourceContent = /*#__PURE__*/ defineTool({ - name: "Page_getResourceContent", - description: "Return the content of a page resource by URL or Lynx node id.", + name: 'Page_getResourceContent', + description: 'Return the content of a page resource by URL or Lynx node id.', schema: { clientId, sessionId, - url: z.string().optional().describe("Resource URL returned by Page_getResourceTree."), - frameId: z.string().optional().describe("Frame id returned by Page_getResourceTree, when present."), - nodeId: nodeId.optional().describe("Lynx node id for engines that resolve resource content by node."), + url: z + .string() + .optional() + .describe('Resource URL returned by Page_getResourceTree.'), + frameId: z + .string() + .optional() + .describe('Frame id returned by Page_getResourceTree, when present.'), + nodeId: nodeId + .optional() + .describe( + 'Lynx node id for engines that resolve resource content by node.', + ), }, annotations: { readOnlyHint: true, }, async handler({ params }, response, context) { if (!params.url && params.nodeId === undefined) { - throw new Error("Either url or nodeId is required."); + throw new Error('Either url or nodeId is required.'); } const connector = context.connector(); const cdpParams = Object.fromEntries( [ - ["url", params.url], - ["frameId", params.frameId], - ["nodeId", params.nodeId], + ['url', params.url], + ['frameId', params.frameId], + ['nodeId', params.nodeId], ].filter(([, value]) => value !== undefined), ); const result = await connector.sendCDPMessage( params.clientId, params.sessionId, - "Page.getResourceContent", + 'Page.getResourceContent', cdpParams, ); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts index 76de0d1..0a251e5 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/GetResourceTree.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetResourceTree = /*#__PURE__*/ defineTool({ - name: "Page_getResourceTree", - description: "Return the page resource tree for the selected session.", + name: 'Page_getResourceTree', + description: 'Return the page resource tree for the selected session.', schema: { clientId, sessionId, @@ -18,7 +18,12 @@ export const GetResourceTree = /*#__PURE__*/ defineTool({ async handler({ params }, response, context) { const connector = context.connector(); - const result = await connector.sendCDPMessage(params.clientId, params.sessionId, "Page.getResourceTree", {}); + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + 'Page.getResourceTree', + {}, + ); response.appendLines(JSON.stringify(result, null, 2)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts index 7f0a833..1e557f0 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/Reload.ts @@ -2,21 +2,27 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; -import { clientId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import * as z from 'zod'; +import { clientId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const Reload = /*#__PURE__*/ defineTool({ - name: "Page_reload", - description: "Reload the current page.", + name: 'Page_reload', + description: 'Reload the current page.', schema: { clientId, sessionId, - url: z.string() - .describe("The URL to reload, if different from the current page. Optional.") + url: z + .string() + .describe( + 'The URL to reload, if different from the current page. Optional.', + ) .optional(), - ignoreCache: z.boolean() - .describe("Whether to ignore the cache when reloading the page. Defaults to `true`") + ignoreCache: z + .boolean() + .describe( + 'Whether to ignore the cache when reloading the page. Defaults to `true`', + ) .default(true), }, annotations: { @@ -25,10 +31,15 @@ export const Reload = /*#__PURE__*/ defineTool({ async handler({ params }, response, context) { const connector = context.connector(); - const result = await connector.sendCDPMessage(params.clientId, params.sessionId, "Page.reload", { - ignoreCache: params.ignoreCache, - url: params.url, - }); + const result = await connector.sendCDPMessage( + params.clientId, + params.sessionId, + 'Page.reload', + { + ignoreCache: params.ignoreCache, + url: params.url, + }, + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts index 078cd86..1ba66d6 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Page/TakeScreenshot.ts @@ -2,17 +2,17 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { ReadableStream } from "node:stream/web"; -import { setTimeout } from "node:timers/promises"; -import { clientId, screenshotMode, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { ReadableStream } from 'node:stream/web'; +import { setTimeout } from 'node:timers/promises'; +import { clientId, screenshotMode, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const TakeScreenshot = /*#__PURE__*/ defineTool({ - name: "Page_takeScreenshot", - description: "Take a screenshot of the current page.", + name: 'Page_takeScreenshot', + description: 'Take a screenshot of the current page.', schema: { clientId, sessionId, @@ -37,11 +37,11 @@ export const TakeScreenshot = /*#__PURE__*/ defineTool({ new ReadableStream({ async start(controller) { controller.enqueue({ - method: "Page.startScreencast", + method: 'Page.startScreencast', params: { - "format": "jpeg", - "quality": 80, - "mode": params.screenshotMode ?? "lynxview", + format: 'jpeg', + quality: 80, + mode: params.screenshotMode ?? 'lynxview', }, }); await Promise.race([ @@ -49,7 +49,7 @@ export const TakeScreenshot = /*#__PURE__*/ defineTool({ setTimeout(10_000, undefined, { signal }).catch(() => {}), ]); controller.enqueue({ - method: "Page.stopScreencast", + method: 'Page.stopScreencast', }); controller.close(); }, @@ -58,20 +58,22 @@ export const TakeScreenshot = /*#__PURE__*/ defineTool({ ); for await (const { method, params: eventParams } of stream) { - if (method === "Page.screencastFrame") { + if (method === 'Page.screencastFrame') { const { data } = eventParams as { data: string }; if (data) { resolve(); response.attachImage({ data, - mimeType: "image/jpeg", + mimeType: 'image/jpeg', }); - const tmp = await fs.mkdtemp(path.join(tmpdir(), "lynx-devtool-mcp-")); + const tmp = await fs.mkdtemp( + path.join(tmpdir(), 'lynx-devtool-mcp-'), + ); const fileName = `screenshot-Lynx_getScreenshot.jpeg`; await fs.writeFile( path.join(tmp, fileName), - Buffer.from(data, "base64"), + Buffer.from(data, 'base64'), ); response.appendLines(`Screenshot saved to ${tmp}/${fileName}`); return; @@ -79,6 +81,8 @@ export const TakeScreenshot = /*#__PURE__*/ defineTool({ } } - throw new Error("Failed to capture screenshot, no Page.screencastFrame event received within 10 seconds."); + throw new Error( + 'Failed to capture screenshot, no Page.screencastFrame event received within 10 seconds.', + ); }, }); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts index b611a03..755f191 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllPerformanceEntries.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetAllPerformanceEntries = /*#__PURE__*/ defineTool({ - name: "Performance_getAllPerformanceEntries", - description: "Get all cached PerformanceEntry objects from the current page.", + name: 'Performance_getAllPerformanceEntries', + description: 'Get all cached PerformanceEntry objects from the current page.', schema: { clientId, sessionId, @@ -21,14 +21,14 @@ export const GetAllPerformanceEntries = /*#__PURE__*/ defineTool({ await connector.sendCDPMessage( params.clientId, params.sessionId, - "Performance.enable", + 'Performance.enable', {}, ); const result = await connector.sendCDPMessage( params.clientId, params.sessionId, - "Performance.getAllPerformanceEntries", + 'Performance.getAllPerformanceEntries', {}, ); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts index e01ac2e..1f81353 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Performance/GetAllTimingInfo.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetAllTimingInfo = /*#__PURE__*/ defineTool({ - name: "Performance_getAllTimingInfo", - description: "Get all metric time durations(FR3 / PPE / FCP ..etc)", + name: 'Performance_getAllTimingInfo', + description: 'Get all metric time durations(FR3 / PPE / FCP ..etc)', schema: { clientId, sessionId, @@ -21,14 +21,14 @@ export const GetAllTimingInfo = /*#__PURE__*/ defineTool({ await connector.sendCDPMessage( params.clientId, params.sessionId, - "Performance.enable", + 'Performance.enable', {}, ); const result = await connector.sendCDPMessage( params.clientId, params.sessionId, - "Performance.getAllTimingInfo", + 'Performance.getAllTimingInfo', {}, ); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts index 71a5f8e..b48fdf9 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/Evaluate.ts @@ -2,60 +2,85 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; -import { clientId, sessionId, thread } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import * as z from 'zod'; +import { clientId, sessionId, thread } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const Evaluate = /*#__PURE__*/ defineTool({ - name: "Runtime_evaluate", - description: "Evaluate a JavaScript expression in the selected Lynx VM.", + name: 'Runtime_evaluate', + description: 'Evaluate a JavaScript expression in the selected Lynx VM.', schema: { clientId, sessionId, thread, - expression: z.string().describe("JavaScript expression to evaluate."), - silent: z.boolean().optional().describe("Do not report or pause on exceptions during evaluation."), - contextId: z.number().int().optional().describe("Execution context id to evaluate in."), - throwOnSideEffect: z.boolean().optional().describe("Throw if side effects cannot be ruled out."), - generatePreview: z.boolean().optional().describe("Whether to generate a preview for the result."), - objectGroup: z.string().optional().describe("Symbolic group name for released remote objects."), - returnByValue: z.boolean().optional().describe("Return the result by value when supported by the engine."), - awaitPromise: z.boolean().optional().describe("Await the resulting promise when supported by the engine."), - includeCommandLineAPI: z.boolean().optional().describe("Expose command line API during evaluation when supported."), + expression: z.string().describe('JavaScript expression to evaluate.'), + silent: z + .boolean() + .optional() + .describe('Do not report or pause on exceptions during evaluation.'), + contextId: z + .number() + .int() + .optional() + .describe('Execution context id to evaluate in.'), + throwOnSideEffect: z + .boolean() + .optional() + .describe('Throw if side effects cannot be ruled out.'), + generatePreview: z + .boolean() + .optional() + .describe('Whether to generate a preview for the result.'), + objectGroup: z + .string() + .optional() + .describe('Symbolic group name for released remote objects.'), + returnByValue: z + .boolean() + .optional() + .describe('Return the result by value when supported by the engine.'), + awaitPromise: z + .boolean() + .optional() + .describe('Await the resulting promise when supported by the engine.'), + includeCommandLineAPI: z + .boolean() + .optional() + .describe('Expose command line API during evaluation when supported.'), }, annotations: { readOnlyHint: false, }, async handler({ params }, response, context) { const connector = context.connector(); - const isMainThread = params.thread === "main"; + const isMainThread = params.thread === 'main'; await connector.sendCDPMessage( params.clientId, params.sessionId, - "Runtime.enable", + 'Runtime.enable', {}, isMainThread, ); const evaluateParams = Object.fromEntries( [ - ["expression", params.expression], - ["silent", params.silent], - ["contextId", params.contextId], - ["throwOnSideEffect", params.throwOnSideEffect], - ["generatePreview", params.generatePreview], - ["objectGroup", params.objectGroup], - ["returnByValue", params.returnByValue], - ["awaitPromise", params.awaitPromise], - ["includeCommandLineAPI", params.includeCommandLineAPI], + ['expression', params.expression], + ['silent', params.silent], + ['contextId', params.contextId], + ['throwOnSideEffect', params.throwOnSideEffect], + ['generatePreview', params.generatePreview], + ['objectGroup', params.objectGroup], + ['returnByValue', params.returnByValue], + ['awaitPromise', params.awaitPromise], + ['includeCommandLineAPI', params.includeCommandLineAPI], ].filter(([, value]) => value !== undefined), ); const result = await connector.sendCDPMessage( params.clientId, params.sessionId, - "Runtime.evaluate", + 'Runtime.evaluate', evaluateParams, isMainThread, ); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts index 50d9f82..d5a8b72 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetHeapUsage.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, sessionId, thread } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, sessionId, thread } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetHeapUsage = /*#__PURE__*/ defineTool({ - name: "Runtime_getHeapUsage", - description: "Returns the JavaScript heap usage for the given session.", + name: 'Runtime_getHeapUsage', + description: 'Returns the JavaScript heap usage for the given session.', schema: { clientId, sessionId, @@ -22,17 +22,17 @@ export const GetHeapUsage = /*#__PURE__*/ defineTool({ await connector.sendCDPMessage( params.clientId, params.sessionId, - "Runtime.enable", + 'Runtime.enable', {}, - params.thread === "main", + params.thread === 'main', ); const result = await connector.sendCDPMessage( params.clientId, params.sessionId, - "Runtime.getHeapUsage", + 'Runtime.getHeapUsage', {}, - params.thread === "main", + params.thread === 'main', ); response.appendLines(JSON.stringify(result, null, 2)); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts index e96f1c9..dd3e72b 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/GetProperties.ts @@ -2,52 +2,68 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as z from "zod"; -import { clientId, sessionId, thread } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import * as z from 'zod'; +import { clientId, sessionId, thread } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetProperties = /*#__PURE__*/ defineTool({ - name: "Runtime_getProperties", - description: "Return properties for a Runtime remote object.", + name: 'Runtime_getProperties', + description: 'Return properties for a Runtime remote object.', schema: { clientId, sessionId, thread, - objectId: z.string().describe("Remote object id returned by Runtime.evaluate or console output."), - ownProperties: z.boolean().optional().describe("Return only properties owned by the object."), - accessorPropertiesOnly: z.boolean().optional().describe("Return accessor properties only when supported."), - generatePreview: z.boolean().optional().describe("Whether to generate previews for property values."), - nonIndexedPropertiesOnly: z.boolean().optional().describe("Return non-indexed properties only when supported."), + objectId: z + .string() + .describe( + 'Remote object id returned by Runtime.evaluate or console output.', + ), + ownProperties: z + .boolean() + .optional() + .describe('Return only properties owned by the object.'), + accessorPropertiesOnly: z + .boolean() + .optional() + .describe('Return accessor properties only when supported.'), + generatePreview: z + .boolean() + .optional() + .describe('Whether to generate previews for property values.'), + nonIndexedPropertiesOnly: z + .boolean() + .optional() + .describe('Return non-indexed properties only when supported.'), }, annotations: { readOnlyHint: true, }, async handler({ params }, response, context) { const connector = context.connector(); - const isMainThread = params.thread === "main"; + const isMainThread = params.thread === 'main'; await connector.sendCDPMessage( params.clientId, params.sessionId, - "Runtime.enable", + 'Runtime.enable', {}, isMainThread, ); const getPropertiesParams = Object.fromEntries( [ - ["objectId", params.objectId], - ["ownProperties", params.ownProperties], - ["accessorPropertiesOnly", params.accessorPropertiesOnly], - ["generatePreview", params.generatePreview], - ["nonIndexedPropertiesOnly", params.nonIndexedPropertiesOnly], + ['objectId', params.objectId], + ['ownProperties', params.ownProperties], + ['accessorPropertiesOnly', params.accessorPropertiesOnly], + ['generatePreview', params.generatePreview], + ['nonIndexedPropertiesOnly', params.nonIndexedPropertiesOnly], ].filter(([, value]) => value !== undefined), ); const result = await connector.sendCDPMessage( params.clientId, params.sessionId, - "Runtime.getProperties", + 'Runtime.getProperties', getPropertiesParams, isMainThread, ); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts index ca497fe..e8b2dd0 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/Runtime/ListConsole.ts @@ -2,11 +2,11 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { ReadableStream } from "node:stream/web"; -import { setTimeout } from "node:timers/promises"; -import * as z from "zod"; -import { clientId, sessionId, thread } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { ReadableStream } from 'node:stream/web'; +import { setTimeout } from 'node:timers/promises'; +import * as z from 'zod'; +import { clientId, sessionId, thread } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; interface ConsoleCallFrame { url: string; @@ -33,21 +33,40 @@ interface ConsoleMessage { } export const ListConsole = /*#__PURE__*/ defineTool({ - name: "Runtime_listConsole", - description: "List all console messages.", + name: 'Runtime_listConsole', + description: 'List all console messages.', schema: { clientId, sessionId, - offset: z.number().optional().describe("The number of console messages to skip before returning results."), - limit: z.number().min(1).max(100).optional().describe("The maximum number of console messages to return."), - includeStackTraces: z.boolean().optional().describe( - "By default, only error messages would contain stack traces. Set this to true to include stack traces for all messages in the output.", - ), - level: z.array(z.enum(["log", "info", "warning", "error"])).optional().describe( - "The log level to filter messages. Defaults to ['info', 'log', 'warning', 'error']", - ), - thread: z.array(thread).optional().describe("VM thread to target: background or main. Defaults to both."), + offset: z + .number() + .optional() + .describe( + 'The number of console messages to skip before returning results.', + ), + limit: z + .number() + .min(1) + .max(100) + .optional() + .describe('The maximum number of console messages to return.'), + includeStackTraces: z + .boolean() + .optional() + .describe( + 'By default, only error messages would contain stack traces. Set this to true to include stack traces for all messages in the output.', + ), + level: z + .array(z.enum(['log', 'info', 'warning', 'error'])) + .optional() + .describe( + "The log level to filter messages. Defaults to ['info', 'log', 'warning', 'error']", + ), + thread: z + .array(thread) + .optional() + .describe('VM thread to target: background or main. Defaults to both.'), }, annotations: { readOnlyHint: true, @@ -59,8 +78,8 @@ export const ListConsole = /*#__PURE__*/ defineTool({ offset = 0, limit, includeStackTraces = false, - level = ["info", "log", "warning", "error"], - thread: threads = ["background", "main"], + level = ['info', 'log', 'warning', 'error'], + thread: threads = ['background', 'main'], } = params; await using stream = await connector.sendCDPStream( @@ -68,11 +87,11 @@ export const ListConsole = /*#__PURE__*/ defineTool({ params.sessionId, ReadableStream.from([ { - method: "Page.enable", + method: 'Page.enable', }, - ...threads.map((t: "background" | "main") => ({ - method: "Runtime.enable", - sessionId: t === "main" ? "Main" : undefined, + ...threads.map((t: 'background' | 'main') => ({ + method: 'Runtime.enable', + sessionId: t === 'main' ? 'Main' : undefined, })), ]), ); @@ -89,9 +108,9 @@ export const ListConsole = /*#__PURE__*/ defineTool({ while (Date.now() - startTime < MAX_TOTAL_TIME) { const result = await Promise.race([ reader.read(), - setTimeout(IDLE_TIMEOUT, "timeout" as const), + setTimeout(IDLE_TIMEOUT, 'timeout' as const), ]); - if (result === "timeout") { + if (result === 'timeout') { await reader.cancel(); break; } @@ -99,9 +118,13 @@ export const ListConsole = /*#__PURE__*/ defineTool({ const { done, value } = result; if (done) break; - if (value.method === "Runtime.consoleAPICalled") { + if (value.method === 'Runtime.consoleAPICalled') { const message = value.params as ConsoleMessage; - if (!level.includes(message.type as "log" | "info" | "warning" | "error")) { + if ( + !level.includes( + message.type as 'log' | 'info' | 'warning' | 'error', + ) + ) { continue; } @@ -110,7 +133,7 @@ export const ListConsole = /*#__PURE__*/ defineTool({ continue; } - if (!includeStackTraces && message.type !== "error") { + if (!includeStackTraces && message.type !== 'error') { delete message.stackTrace; } @@ -127,27 +150,26 @@ export const ListConsole = /*#__PURE__*/ defineTool({ } response.appendLines( - ...messages - .map(({ type, args, stackTrace, consoleTag }) => - `- [${type}/${consoleTag === "Lepus" ? "main-thread" : "background"}]: ${ - args - .map(arg => { - if (arg.objectId) { - return `<${arg.description || arg.className || "Object"} (objectId:${arg.objectId})>`; - } - return String(arg.value); - }) - .join(" ") - }${ + ...messages.map( + ({ type, args, stackTrace, consoleTag }) => + `- [${type}/${consoleTag === 'Lepus' ? 'main-thread' : 'background'}]: ${args + .map((arg) => { + if (arg.objectId) { + return `<${arg.description || arg.className || 'Object'} (objectId:${arg.objectId})>`; + } + return String(arg.value); + }) + .join(' ')}${ stackTrace - ? `\n${ - stackTrace.callFrames - .map(({ url, lineNumber, columnNumber }) => ` at ${url}:${lineNumber}:${columnNumber}`) - .join("\n") - }` - : "" - }` - ), + ? `\n${stackTrace.callFrames + .map( + ({ url, lineNumber, columnNumber }) => + ` at ${url}:${lineNumber}:${columnNumber}`, + ) + .join('\n')}` + : '' + }`, + ), ); }, }); diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts index 5e225ae..bf8c130 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/UITree/GetLynxUITree.ts @@ -2,13 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { clientId, sessionId } from "../../schema/index.ts"; -import { defineTool } from "../defineTool.ts"; +import { clientId, sessionId } from '../../schema/index.ts'; +import { defineTool } from '../defineTool.ts'; export const GetLynxUITree = /*#__PURE__*/ defineTool({ - name: "UITree_getLynxUITree", + name: 'UITree_getLynxUITree', description: - "Get the rendered Lynx UI tree with native UI metadata. The metadata fields tagName, nodeIndex, props, and label require Lynx 4.0 or newer.", + 'Get the rendered Lynx UI tree with native UI metadata. The metadata fields tagName, nodeIndex, props, and label require Lynx 4.0 or newer.', schema: { clientId, sessionId, @@ -19,11 +19,15 @@ export const GetLynxUITree = /*#__PURE__*/ defineTool({ async handler({ params: { clientId, sessionId } }, response, context) { const connector = context.connector(); - await connector.sendCDPMessage(clientId, sessionId, "UITree.enable", { + await connector.sendCDPMessage(clientId, sessionId, 'UITree.enable', { useCompression: false, }); - const result = await connector.sendCDPMessage(clientId, sessionId, "UITree.getLynxUITree"); + const result = await connector.sendCDPMessage( + clientId, + sessionId, + 'UITree.getLynxUITree', + ); response.appendLines(JSON.stringify(result)); }, diff --git a/packages/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts b/packages/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts index 6c92ae9..21b7994 100644 --- a/packages/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts +++ b/packages/mcp-servers/devtool-mcp-server/src/tools/defineTool.ts @@ -2,10 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from "@lynx-js/devtool-connector"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js"; -import type * as z from "zod"; +import type { Connector } from '@lynx-js/devtool-connector'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { + ServerNotification, + ServerRequest, +} from '@modelcontextprotocol/sdk/types.js'; +import type * as z from 'zod'; export interface Request { params: z.infer>; diff --git a/packages/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts b/packages/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts index 5fa3bef..b314da9 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/McpResponse.test.ts @@ -2,21 +2,21 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert/strict"; -import test from "node:test"; -import type { McpContext } from "../src/McpContext.ts"; -import { McpResponse } from "../src/McpResponse.ts"; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { McpContext } from '../src/McpContext.ts'; +import { McpResponse } from '../src/McpResponse.ts'; -test("McpResponse emits appended text without a generated title", async () => { +test('McpResponse emits appended text without a generated title', async () => { const response = new McpResponse(); response.appendLines(JSON.stringify({ ok: true })); - const content = await response.handle("Example_tool", {} as McpContext); + const content = await response.handle('Example_tool', {} as McpContext); assert.deepEqual(content, [ { - type: "text", - text: "{\"ok\":true}", + type: 'text', + text: '{"ok":true}', }, ]); }); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts index 20dc515..0c5144a 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/App_globalSwitch.test.ts @@ -2,24 +2,24 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { GetGlobalSwitch } from "../../src/tools/App/GetGlobalSwitch.ts"; -import { ListGlobalSwitch } from "../../src/tools/App/ListGlobalSwitch.ts"; -import { SetGlobalSwitch } from "../../src/tools/App/SetGlobalSwitch.ts"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { GetGlobalSwitch } from '../../src/tools/App/GetGlobalSwitch.ts'; +import { ListGlobalSwitch } from '../../src/tools/App/ListGlobalSwitch.ts'; +import { SetGlobalSwitch } from '../../src/tools/App/SetGlobalSwitch.ts'; +import { createToolContext } from '../utils/testTool.ts'; const createMockConnector = (overrides: Record = {}) => ({ getGlobalSwitch: async () => false, setGlobalSwitch: async () => {}, - sendListSessionMessage: async () => [{ session_id: "mock_session" }], + sendListSessionMessage: async () => [{ session_id: 'mock_session' }], ...overrides, }); -describe("App global switch tools", () => { - const testClientId = "test-client-id"; +describe('App global switch tools', () => { + const testClientId = 'test-client-id'; - test("App_getGlobalSwitch should read one key", async () => { + test('App_getGlobalSwitch should read one key', async () => { let called: { clientId: string; key: string } | null = null; const mockConnector = createMockConnector({ getGlobalSwitch: async (clientId: string, key: string) => { @@ -28,62 +28,99 @@ describe("App global switch tools", () => { }, }); - const { call } = createToolContext(GetGlobalSwitch, mockConnector as never, testClientId); - const result = await call<{ key: string; value: boolean }>({ key: "enable_devtool" }); + const { call } = createToolContext( + GetGlobalSwitch, + mockConnector as never, + testClientId, + ); + const result = await call<{ key: string; value: boolean }>({ + key: 'enable_devtool', + }); - assert.deepStrictEqual(called, { clientId: testClientId, key: "enable_devtool" }); - assert.deepStrictEqual(result, { key: "enable_devtool", value: true }); + assert.deepStrictEqual(called, { + clientId: testClientId, + key: 'enable_devtool', + }); + assert.deepStrictEqual(result, { key: 'enable_devtool', value: true }); }); - test("App_setGlobalSwitch should write one key", async () => { + test('App_setGlobalSwitch should write one key', async () => { let called: { clientId: string; key: string; value: boolean } | null = null; const mockConnector = createMockConnector({ - setGlobalSwitch: async (clientId: string, key: string, value: boolean) => { + setGlobalSwitch: async ( + clientId: string, + key: string, + value: boolean, + ) => { called = { clientId, key, value }; }, }); - const { call } = createToolContext(SetGlobalSwitch, mockConnector as never, testClientId); - const result = await call<{ key: string; value: boolean }>({ key: "enable_devtool", switch: false }); + const { call } = createToolContext( + SetGlobalSwitch, + mockConnector as never, + testClientId, + ); + const result = await call<{ key: string; value: boolean }>({ + key: 'enable_devtool', + switch: false, + }); - assert.deepStrictEqual(called, { clientId: testClientId, key: "enable_devtool", value: false }); - assert.deepStrictEqual(result, { key: "enable_devtool", value: false }); + assert.deepStrictEqual(called, { + clientId: testClientId, + key: 'enable_devtool', + value: false, + }); + assert.deepStrictEqual(result, { key: 'enable_devtool', value: false }); }); - test("App_listGlobalSwitch should return all key states", async () => { + test('App_listGlobalSwitch should return all key states', async () => { const mockConnector = createMockConnector({ - getGlobalSwitch: async (_clientId: string, key: string) => key !== "enable_logbox", + getGlobalSwitch: async (_clientId: string, key: string) => + key !== 'enable_logbox', }); - const { call } = createToolContext(ListGlobalSwitch, mockConnector as never, testClientId); - const result = await call<{ switches: Array<{ key: string; value?: boolean; error?: string }> }>({}); + const { call } = createToolContext( + ListGlobalSwitch, + mockConnector as never, + testClientId, + ); + const result = await call<{ + switches: Array<{ key: string; value?: boolean; error?: string }>; + }>({}); assert.equal(result.switches.length, 15); assert.deepStrictEqual( - result.switches.find(item => item.key === "enable_devtool"), - { key: "enable_devtool", value: true }, + result.switches.find((item) => item.key === 'enable_devtool'), + { key: 'enable_devtool', value: true }, ); assert.deepStrictEqual( - result.switches.find(item => item.key === "enable_logbox"), - { key: "enable_logbox", value: false }, + result.switches.find((item) => item.key === 'enable_logbox'), + { key: 'enable_logbox', value: false }, ); }); - test("App_listGlobalSwitch should keep per-key failure", async () => { + test('App_listGlobalSwitch should keep per-key failure', async () => { const mockConnector = createMockConnector({ getGlobalSwitch: async (_clientId: string, key: string) => { - if (key === "enable_v8") { - throw new Error("transport timeout"); + if (key === 'enable_v8') { + throw new Error('transport timeout'); } return true; }, }); - const { call } = createToolContext(ListGlobalSwitch, mockConnector as never, testClientId); - const result = await call<{ switches: Array<{ key: string; value?: boolean; error?: string }> }>({}); - const failed = result.switches.find(item => item.key === "enable_v8"); + const { call } = createToolContext( + ListGlobalSwitch, + mockConnector as never, + testClientId, + ); + const result = await call<{ + switches: Array<{ key: string; value?: boolean; error?: string }>; + }>({}); + const failed = result.switches.find((item) => item.key === 'enable_v8'); - assert.equal(typeof failed?.error, "string"); + assert.equal(typeof failed?.error, 'string'); }); }); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts index 5250431..e066b7c 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_describeNode.test.ts @@ -2,9 +2,9 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { createToolContext } from '../utils/testTool.ts'; type SentCDPMessage = { clientId: string; @@ -14,15 +14,20 @@ type SentCDPMessage = { }; async function loadDescribeNodeTool() { - const module = await import("../../src/tools/DOM/DescribeNode.ts").catch(() => undefined); + const module = await import('../../src/tools/DOM/DescribeNode.ts').catch( + () => undefined, + ); - assert.ok(module && "DescribeNode" in module, "Expected DOM DescribeNode tool module to exist"); + assert.ok( + module && 'DescribeNode' in module, + 'Expected DOM DescribeNode tool module to exist', + ); return module.DescribeNode; } -describe("DOM.describeNode", () => { - test("disables compression before describing a node by nodeId", async () => { +describe('DOM.describeNode', () => { + test('disables compression before describing a node by nodeId', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -33,40 +38,55 @@ describe("DOM.describeNode", () => { ) => { sentMessages.push({ clientId, sessionId, method, params }); - if (method === "DOM.enable") { + if (method === 'DOM.enable') { return {}; } - assert.strictEqual(method, "DOM.describeNode"); - return { node: { nodeId: 7, nodeName: "view" }, compress: false }; + assert.strictEqual(method, 'DOM.describeNode'); + return { node: { nodeId: 7, nodeName: 'view' }, compress: false }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; const DescribeNode = await loadDescribeNodeTool(); - const { call } = createToolContext(DescribeNode, connector as never, "test-client-id:9999"); - const result = await call<{ node: { nodeId: number; nodeName: string }; compress: boolean }>({ + const { call } = createToolContext( + DescribeNode, + connector as never, + 'test-client-id:9999', + ); + const result = await call<{ + node: { nodeId: number; nodeName: string }; + compress: boolean; + }>({ nodeId: 7, }); assert.deepStrictEqual(sentMessages, [ { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "DOM.enable", + method: 'DOM.enable', params: { useCompression: false }, }, { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "DOM.describeNode", - params: { nodeId: 7, backendNodeId: undefined, depth: undefined, pierce: undefined }, + method: 'DOM.describeNode', + params: { + nodeId: 7, + backendNodeId: undefined, + depth: undefined, + pierce: undefined, + }, }, ]); - assert.deepStrictEqual(result, { node: { nodeId: 7, nodeName: "view" }, compress: false }); + assert.deepStrictEqual(result, { + node: { nodeId: 7, nodeName: 'view' }, + compress: false, + }); }); - test("passes backendNodeId, depth, and pierce through to CDP", async () => { + test('passes backendNodeId, depth, and pierce through to CDP', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -77,34 +97,47 @@ describe("DOM.describeNode", () => { ) => { sentMessages.push({ clientId, sessionId, method, params }); - if (method === "DOM.enable") { + if (method === 'DOM.enable') { return {}; } - assert.strictEqual(method, "DOM.describeNode"); - return { node: { backendNodeId: 9, childNodeCount: 1 }, compress: false }; + assert.strictEqual(method, 'DOM.describeNode'); + return { + node: { backendNodeId: 9, childNodeCount: 1 }, + compress: false, + }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; const DescribeNode = await loadDescribeNodeTool(); - const { call } = createToolContext(DescribeNode, connector as never, "test-client-id:9999"); - const result = await call<{ node: { backendNodeId: number; childNodeCount: number }; compress: boolean }>({ + const { call } = createToolContext( + DescribeNode, + connector as never, + 'test-client-id:9999', + ); + const result = await call<{ + node: { backendNodeId: number; childNodeCount: number }; + compress: boolean; + }>({ backendNodeId: 9, depth: -1, pierce: true, }); assert.deepStrictEqual(sentMessages.at(-1), { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "DOM.describeNode", + method: 'DOM.describeNode', params: { nodeId: undefined, backendNodeId: 9, depth: -1, pierce: true }, }); - assert.deepStrictEqual(result, { node: { backendNodeId: 9, childNodeCount: 1 }, compress: false }); + assert.deepStrictEqual(result, { + node: { backendNodeId: 9, childNodeCount: 1 }, + compress: false, + }); }); - test("preserves Lynx depth semantics in returned nodes", async () => { + test('preserves Lynx depth semantics in returned nodes', async () => { const connector = { sendCDPMessage: async ( _clientId: string, @@ -112,11 +145,11 @@ describe("DOM.describeNode", () => { method: string, params?: Record, ) => { - if (method === "DOM.enable") { + if (method === 'DOM.enable') { return {}; } - assert.strictEqual(method, "DOM.describeNode"); + assert.strictEqual(method, 'DOM.describeNode'); if (params?.depth === 0) { return { node: { nodeId: 3, childNodeCount: 1 }, compress: false }; @@ -135,12 +168,20 @@ describe("DOM.describeNode", () => { }; const DescribeNode = await loadDescribeNodeTool(); - const { call } = createToolContext(DescribeNode, connector as never, "test-client-id:9999"); - const depthZero = await call<{ node: { nodeId: number; childNodeCount: number; children?: unknown[] } }>({ + const { call } = createToolContext( + DescribeNode, + connector as never, + 'test-client-id:9999', + ); + const depthZero = await call<{ + node: { nodeId: number; childNodeCount: number; children?: unknown[] }; + }>({ nodeId: 3, depth: 0, }); - const depthOne = await call<{ node: { nodeId: number; children?: Array<{ children?: unknown[] }> } }>({ + const depthOne = await call<{ + node: { nodeId: number; children?: Array<{ children?: unknown[] }> }; + }>({ nodeId: 3, depth: 1, }); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts index 6b99114..16672d3 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_getDocument.test.ts @@ -2,9 +2,9 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { createToolContext } from '../utils/testTool.ts'; type SentCDPMessage = { clientId: string; @@ -13,8 +13,8 @@ type SentCDPMessage = { params?: Record; }; -describe("DOM.getDocument", () => { - test("disables compression and passes depth through to CDP", async () => { +describe('DOM.getDocument', () => { + test('disables compression and passes depth through to CDP', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -25,36 +25,46 @@ describe("DOM.getDocument", () => { ) => { sentMessages.push({ clientId, sessionId, method, params }); - if (method === "DOM.enable") { + if (method === 'DOM.enable') { return {}; } - assert.strictEqual(method, "DOM.getDocument"); - return { root: { nodeId: 1, nodeName: "#document", childNodeCount: 1 } }; + assert.strictEqual(method, 'DOM.getDocument'); + return { + root: { nodeId: 1, nodeName: '#document', childNodeCount: 1 }, + }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { GetDocument } = await import("../../src/tools/DOM/GetDocument.ts"); + const { GetDocument } = await import('../../src/tools/DOM/GetDocument.ts'); - const { call } = createToolContext(GetDocument, connector as never, "test-client-id:9999"); - const result = await call<{ root: { nodeId: number; nodeName: string; childNodeCount: number } }>({ + const { call } = createToolContext( + GetDocument, + connector as never, + 'test-client-id:9999', + ); + const result = await call<{ + root: { nodeId: number; nodeName: string; childNodeCount: number }; + }>({ depth: -1, }); assert.deepStrictEqual(sentMessages, [ { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "DOM.enable", + method: 'DOM.enable', params: { useCompression: false }, }, { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "DOM.getDocument", + method: 'DOM.getDocument', params: { depth: -1 }, }, ]); - assert.deepStrictEqual(result, { root: { nodeId: 1, nodeName: "#document", childNodeCount: 1 } }); + assert.deepStrictEqual(result, { + root: { nodeId: 1, nodeName: '#document', childNodeCount: 1 }, + }); }); }); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts index f53a2ca..6e57450 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/DOM_setAttributesAsText.test.ts @@ -2,10 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { SetAttributesAsText } from "../../src/tools/DOM/SetAttributesAsText.ts"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { SetAttributesAsText } from '../../src/tools/DOM/SetAttributesAsText.ts'; +import { createToolContext } from '../utils/testTool.ts'; type SentCDPMessage = { clientId: string; @@ -14,8 +14,8 @@ type SentCDPMessage = { params: Record; }; -describe("DOM.setAttributesAsText", () => { - test("passes nodeId, text, and optional name through to CDP", async () => { +describe('DOM.setAttributesAsText', () => { + test('passes nodeId, text, and optional name through to CDP', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -25,25 +25,29 @@ describe("DOM.setAttributesAsText", () => { params: Record, ) => { sentMessages.push({ clientId, sessionId, method, params }); - assert.strictEqual(method, "DOM.setAttributesAsText"); + assert.strictEqual(method, 'DOM.setAttributesAsText'); return {}; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(SetAttributesAsText, connector as never, "test-client-id:9999"); + const { call } = createToolContext( + SetAttributesAsText, + connector as never, + 'test-client-id:9999', + ); const result = await call>({ nodeId: 13, text: "style='color: pink;'", - name: "style", + name: 'style', }); assert.deepStrictEqual(sentMessages, [ { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "DOM.setAttributesAsText", - params: { nodeId: 13, text: "style='color: pink;'", name: "style" }, + method: 'DOM.setAttributesAsText', + params: { nodeId: 13, text: "style='color: pink;'", name: 'style' }, }, ]); assert.deepStrictEqual(result, {}); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts index c287c1c..81a3cc8 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/Device_openPage.test.ts @@ -2,29 +2,30 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { ReadableStream, type TransformStream } from "node:stream/web"; -import { describe, test } from "node:test"; -import { OpenPage } from "../../src/tools/Device/OpenPage.ts"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { ReadableStream, type TransformStream } from 'node:stream/web'; +import { describe, test } from 'node:test'; +import type { Connector } from '@lynx-js/devtool-connector'; +import { OpenPage } from '../../src/tools/Device/OpenPage.ts'; +import { createToolContext } from '../utils/testTool.ts'; // Mock connector const createMockConnector = (overrides = {}) => ({ sendAppMessage: async () => {}, sendMessage: async () => {}, - sendListSessionMessage: async () => [{ session_id: "mock_session" }], + sendListSessionMessage: async () => [{ session_id: 'mock_session' }], ...overrides, }); -describe("Device.openPage", () => { - const testClientId = "test-client-id"; +describe('Device.openPage', () => { + const testClientId = 'test-client-id'; - test("should send App.openPage message on success", async () => { - let sentMethod = ""; - let sentParams = null; + test('should send App.openPage message on success', async () => { + let sentMethod = ''; + let sentParams: unknown = null; const mockConnector = createMockConnector({ - sendAppMessage: async (cid: string, method: string, params: any) => { + sendAppMessage: async (cid: string, method: string, params: unknown) => { if (cid === testClientId) { sentMethod = method; sentParams = params; @@ -32,52 +33,62 @@ describe("Device.openPage", () => { }, }); - const { call } = createToolContext(OpenPage, mockConnector as any, testClientId); + const { call } = createToolContext( + OpenPage, + mockConnector as unknown as Connector, + testClientId, + ); await call({ - url: "https://example.com", + url: 'https://example.com', }); - assert.strictEqual(sentMethod, "App.openPage"); - assert.deepStrictEqual(sentParams, { url: "https://example.com" }); + assert.strictEqual(sentMethod, 'App.openPage'); + assert.deepStrictEqual(sentParams, { url: 'https://example.com' }); }); - test("should fallback to Customized event if App.openPage fails", async () => { + test('should fallback to Customized event if App.openPage fails', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any let sentMessage: any = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let sentPipeline: any = null; const mockConnector = createMockConnector({ sendAppMessage: async () => { - throw new Error("Failed"); + throw new Error('Failed'); }, - sendMessage: async (cid: string, message: any, pipeline: any) => { + sendMessage: async (cid: string, message: unknown, pipeline: unknown) => { sentMessage = message; sentPipeline = pipeline; }, }); - const { call } = createToolContext(OpenPage, mockConnector as any, testClientId); + const { call } = createToolContext( + OpenPage, + mockConnector as unknown as Connector, + testClientId, + ); await call({ - url: "https://example.com", + url: 'https://example.com', }); assert.ok(sentMessage); - assert.strictEqual(sentMessage.event, "Customized"); - assert.strictEqual(sentMessage.data.type, "OpenCard"); - assert.strictEqual(sentMessage.data.data.type, "url"); - assert.strictEqual(sentMessage.data.data.url, "https://example.com"); + assert.strictEqual(sentMessage.event, 'Customized'); + assert.strictEqual(sentMessage.data.type, 'OpenCard'); + assert.strictEqual(sentMessage.data.data.type, 'url'); + assert.strictEqual(sentMessage.data.data.url, 'https://example.com'); - assert.strictEqual(sentMessage.data.sender, -1, "Sender should be -1"); - assert.strictEqual(sentMessage.from, -1, "From should be -1"); + assert.strictEqual(sentMessage.data.sender, -1, 'Sender should be -1'); + assert.strictEqual(sentMessage.from, -1, 'From should be -1'); const sessionList = { - event: "Customized", - data: { type: "SessionList", data: [{ session_id: "opened_session" }] }, + event: 'Customized', + data: { type: 'SessionList', data: [{ session_id: 'opened_session' }] }, }; const filtered = await Array.fromAsync( ReadableStream.from([ - { event: "Customized", data: { type: "OpenCard", data: {} } }, + { event: 'Customized', data: { type: 'OpenCard', data: {} } }, sessionList, ]).pipeThrough(sentPipeline.output[0] as TransformStream), ); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts index 1c3cf16..7ad29c9 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/HeapProfiler_takeHeapSnapshot.test.ts @@ -2,13 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { ClientId } from "@lynx-js/devtool-connector"; -import assert from "node:assert"; -import fs from "node:fs/promises"; -import { ReadableStream } from "node:stream/web"; -import { describe, test } from "node:test"; -import { TakeHeapSnapshot } from "../../src/tools/HeapProfiler/TakeHeapSnapshot.ts"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import fs from 'node:fs/promises'; +import { ReadableStream } from 'node:stream/web'; +import { describe, test } from 'node:test'; +import { ClientId } from '@lynx-js/devtool-connector'; +import { TakeHeapSnapshot } from '../../src/tools/HeapProfiler/TakeHeapSnapshot.ts'; +import { createToolContext } from '../utils/testTool.ts'; type ParsedCdpMessage = { method?: string; @@ -34,7 +34,9 @@ function createOutputStream(messages: ParsedCdpMessage[]) { }); } -async function collectMessages(stream: ReadableStream): Promise { +async function collectMessages( + stream: ReadableStream, +): Promise { const messages: unknown[] = []; for await (const message of stream) { @@ -44,12 +46,17 @@ async function collectMessages(stream: ReadableStream): Promise ParsedCdpMessage[]) => ({ +const createMockConnector = ( + buildMessages: (requestId: number) => ParsedCdpMessage[], +) => ({ sendCDPMessage: async () => ({}), - sendStream: async (_clientId: string, inputStream: ReadableStream) => { - const requests = await collectMessages(inputStream) as Array<{ + sendStream: async ( + _clientId: string, + inputStream: ReadableStream, + ) => { + const requests = (await collectMessages(inputStream)) as Array<{ data: { data: { message: { @@ -60,42 +67,53 @@ const createMockConnector = (buildMessages: (requestId: number) => ParsedCdpMess }; }>; - const request = requests.find((message) => message.data.data.message.method === "HeapProfiler.takeHeapSnapshot"); + const request = requests.find( + (message) => + message.data.data.message.method === 'HeapProfiler.takeHeapSnapshot', + ); - assert.ok(request, "Expected HeapProfiler.takeHeapSnapshot request in stream"); + assert.ok( + request, + 'Expected HeapProfiler.takeHeapSnapshot request in stream', + ); return createOutputStream(buildMessages(request.data.data.message.id)); }, sendListSessionMessage: async () => [{ session_id: 1 }], }); -describe("HeapProfiler.takeHeapSnapshot", () => { - test("preserves chunk order when writing a background snapshot", async () => { - const firstChunk = "{\"snapshot\":{\"meta\":{},\"node_count\":1,\"edge_count\":0,\"trace_function_count\":0},"; - const secondChunk = "\"nodes\":[],\"edges\":[],\"strings\":[]}"; +describe('HeapProfiler.takeHeapSnapshot', () => { + test('preserves chunk order when writing a background snapshot', async () => { + const firstChunk = + '{"snapshot":{"meta":{},"node_count":1,"edge_count":0,"trace_function_count":0},'; + const secondChunk = '"nodes":[],"edges":[],"strings":[]}'; const connector = createMockConnector((requestId) => [ { - method: "HeapProfiler.reportHeapSnapshotProgress", + method: 'HeapProfiler.reportHeapSnapshotProgress', params: { finished: true }, }, { - method: "HeapProfiler.addHeapSnapshotChunk", + method: 'HeapProfiler.addHeapSnapshotChunk', params: { chunk: firstChunk }, }, { - method: "HeapProfiler.addHeapSnapshotChunk", + method: 'HeapProfiler.addHeapSnapshotChunk', params: { chunk: secondChunk }, }, { id: requestId, result: {} }, ]); - const { call } = createToolContext(TakeHeapSnapshot, connector as never, testClientId); - const result = await call({ thread: "background" }); - const filePath = result.replace("Heap snapshot saved to ", ""); + const { call } = createToolContext( + TakeHeapSnapshot, + connector as never, + testClientId, + ); + const result = await call({ thread: 'background' }); + const filePath = result.replace('Heap snapshot saved to ', ''); try { - const content = await fs.readFile(filePath, "utf8"); + const content = await fs.readFile(filePath, 'utf8'); assert.deepStrictEqual(JSON.parse(content), { snapshot: { meta: {}, @@ -112,71 +130,82 @@ describe("HeapProfiler.takeHeapSnapshot", () => { } }); - test("streams snapshot chunks to disk without joining them in memory", async () => { - const firstChunk = "{\"snapshot\":{\"streamed\":true},"; - const secondChunk = "\"strings\":[\"large-snapshot\"]}"; + test('streams snapshot chunks to disk without joining them in memory', async () => { + const firstChunk = '{"snapshot":{"streamed":true},'; + const secondChunk = '"strings":["large-snapshot"]}'; const connector = createMockConnector((requestId) => [ { - method: "HeapProfiler.addHeapSnapshotChunk", + method: 'HeapProfiler.addHeapSnapshotChunk', params: { chunk: firstChunk }, }, { - method: "HeapProfiler.addHeapSnapshotChunk", + method: 'HeapProfiler.addHeapSnapshotChunk', params: { chunk: secondChunk }, }, { id: requestId, result: {} }, ]); - const { call } = createToolContext(TakeHeapSnapshot, connector as never, testClientId); + const { call } = createToolContext( + TakeHeapSnapshot, + connector as never, + testClientId, + ); const originalJoin = Array.prototype.join; let result: string; try { Array.prototype.join = function patchedJoin(separator?: string) { - if (Array.isArray(this) && this.some((value) => value === firstChunk || value === secondChunk)) { - throw new Error("Array.prototype.join should not be used to assemble heap snapshots"); + if ( + Array.isArray(this) && + this.some((value) => value === firstChunk || value === secondChunk) + ) { + throw new Error( + 'Array.prototype.join should not be used to assemble heap snapshots', + ); } return originalJoin.call(this, separator); } as typeof Array.prototype.join; - result = await call({ thread: "background" }); + result = await call({ thread: 'background' }); } finally { Array.prototype.join = originalJoin; } - const filePath = result.replace("Heap snapshot saved to ", ""); + const filePath = result.replace('Heap snapshot saved to ', ''); try { - const content = await fs.readFile(filePath, "utf8"); + const content = await fs.readFile(filePath, 'utf8'); assert.deepStrictEqual(JSON.parse(content), { snapshot: { streamed: true }, - strings: ["large-snapshot"], + strings: ['large-snapshot'], }); } finally { await fs.unlink(filePath).catch(() => {}); } }); - test("ignores chunks from another VM while capturing main-thread snapshot", async () => { - const backgroundSnapshot = JSON.stringify({ snapshot: { source: "background" } }); - const mainSnapshot = JSON.stringify({ snapshot: { source: "main" } }); + test('ignores chunks from another VM while capturing main-thread snapshot', async () => { + const backgroundSnapshot = JSON.stringify({ + snapshot: { source: 'background' }, + }); + const mainSnapshot = JSON.stringify({ snapshot: { source: 'main' } }); const connector = createMockConnector((requestId) => [ { - method: "HeapProfiler.addHeapSnapshotChunk", + method: 'HeapProfiler.addHeapSnapshotChunk', params: { chunk: backgroundSnapshot }, }, { - method: "HeapProfiler.reportHeapSnapshotProgress", + method: 'HeapProfiler.reportHeapSnapshotProgress', params: { finished: true }, - sessionId: "Main", + sessionId: 'Main', }, { - method: "HeapProfiler.addHeapSnapshotChunk", + method: 'HeapProfiler.addHeapSnapshotChunk', params: { chunk: mainSnapshot }, - sessionId: "Main", + sessionId: 'Main', }, { id: requestId, @@ -184,29 +213,33 @@ describe("HeapProfiler.takeHeapSnapshot", () => { }, ]); - const { call } = createToolContext(TakeHeapSnapshot, connector as never, testClientId); - const result = await call({ thread: "main" }); - const filePath = result.replace("Heap snapshot saved to ", ""); + const { call } = createToolContext( + TakeHeapSnapshot, + connector as never, + testClientId, + ); + const result = await call({ thread: 'main' }); + const filePath = result.replace('Heap snapshot saved to ', ''); try { - const content = await fs.readFile(filePath, "utf8"); + const content = await fs.readFile(filePath, 'utf8'); assert.deepStrictEqual(JSON.parse(content), JSON.parse(mainSnapshot)); } finally { await fs.unlink(filePath).catch(() => {}); } }); - test("waits for the matching snapshot response instead of stopping on unrelated ids", async () => { - const firstChunk = "{\"snapshot\":{\"phase\":\"first\"},"; - const secondChunk = "\"strings\":[\"renderPage\"]}"; + test('waits for the matching snapshot response instead of stopping on unrelated ids', async () => { + const firstChunk = '{"snapshot":{"phase":"first"},'; + const secondChunk = '"strings":["renderPage"]}'; const connector = createMockConnector((requestId) => [ { - method: "HeapProfiler.reportHeapSnapshotProgress", + method: 'HeapProfiler.reportHeapSnapshotProgress', params: { finished: true }, }, { - method: "HeapProfiler.addHeapSnapshotChunk", + method: 'HeapProfiler.addHeapSnapshotChunk', params: { chunk: firstChunk }, }, { @@ -214,7 +247,7 @@ describe("HeapProfiler.takeHeapSnapshot", () => { result: {}, }, { - method: "HeapProfiler.addHeapSnapshotChunk", + method: 'HeapProfiler.addHeapSnapshotChunk', params: { chunk: secondChunk }, }, { @@ -223,15 +256,19 @@ describe("HeapProfiler.takeHeapSnapshot", () => { }, ]); - const { call } = createToolContext(TakeHeapSnapshot, connector as never, testClientId); - const result = await call({ thread: "background" }); - const filePath = result.replace("Heap snapshot saved to ", ""); + const { call } = createToolContext( + TakeHeapSnapshot, + connector as never, + testClientId, + ); + const result = await call({ thread: 'background' }); + const filePath = result.replace('Heap snapshot saved to ', ''); try { - const content = await fs.readFile(filePath, "utf8"); + const content = await fs.readFile(filePath, 'utf8'); assert.deepStrictEqual(JSON.parse(content), { - snapshot: { phase: "first" }, - strings: ["renderPage"], + snapshot: { phase: 'first' }, + strings: ['renderPage'], }); } finally { await fs.unlink(filePath).catch(() => {}); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts index e4710ab..4b09ee7 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/Memory_getAllMemoryUsage.test.ts @@ -2,10 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { GetAllMemoryUsage } from "../../src/tools/Memory/GetAllMemoryUsage.ts"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { GetAllMemoryUsage } from '../../src/tools/Memory/GetAllMemoryUsage.ts'; +import { createToolContext } from '../utils/testTool.ts'; type SentCDPMessage = { clientId: string; @@ -14,8 +14,8 @@ type SentCDPMessage = { params: Record; }; -describe("Memory.getAllMemoryUsage", () => { - test("uses the global DevTool session by default", async () => { +describe('Memory.getAllMemoryUsage', () => { + test('uses the global DevTool session by default', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -26,39 +26,49 @@ describe("Memory.getAllMemoryUsage", () => { ) => { sentMessages.push({ clientId, sessionId, method, params }); - assert.strictEqual(method, "Memory.getAllMemoryUsage"); + assert.strictEqual(method, 'Memory.getAllMemoryUsage'); return { - collectionStatus: "completed", + collectionStatus: 'completed', totalBytes: 1024, instances: [], }; }, sendListSessionMessage: async () => { - throw new Error("Memory.getAllMemoryUsage should not require a LynxView session"); + throw new Error( + 'Memory.getAllMemoryUsage should not require a LynxView session', + ); }, }; - const { call } = createToolContext(GetAllMemoryUsage, connector as never, "test-client-id:9999"); - const result = await call<{ collectionStatus: string; totalBytes: number; instances: unknown[] }>({ + const { call } = createToolContext( + GetAllMemoryUsage, + connector as never, + 'test-client-id:9999', + ); + const result = await call<{ + collectionStatus: string; + totalBytes: number; + instances: unknown[]; + }>({ timeoutMs: 50_000, }); assert.deepStrictEqual(sentMessages, [ { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: -1, - method: "Memory.getAllMemoryUsage", + method: 'Memory.getAllMemoryUsage', params: { timeoutMs: 50_000 }, }, ]); assert.deepStrictEqual(result, { - collectionStatus: "completed", + collectionStatus: 'completed', totalBytes: 1024, instances: [], }); }); - test("supports explicit session override", async () => { + test('supports explicit session override', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -69,9 +79,9 @@ describe("Memory.getAllMemoryUsage", () => { ) => { sentMessages.push({ clientId, sessionId, method, params }); - assert.strictEqual(method, "Memory.getAllMemoryUsage"); + assert.strictEqual(method, 'Memory.getAllMemoryUsage'); return { - collectionStatus: "timeout", + collectionStatus: 'timeout', totalBytes: 2048, instances: [], }; @@ -79,21 +89,29 @@ describe("Memory.getAllMemoryUsage", () => { sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(GetAllMemoryUsage, connector as never, "test-client-id:9999"); - const result = await call<{ collectionStatus: string; totalBytes: number; instances: unknown[] }>({ + const { call } = createToolContext( + GetAllMemoryUsage, + connector as never, + 'test-client-id:9999', + ); + const result = await call<{ + collectionStatus: string; + totalBytes: number; + instances: unknown[]; + }>({ sessionId: 7, }); assert.deepStrictEqual(sentMessages, [ { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 7, - method: "Memory.getAllMemoryUsage", + method: 'Memory.getAllMemoryUsage', params: {}, }, ]); assert.deepStrictEqual(result, { - collectionStatus: "timeout", + collectionStatus: 'timeout', totalBytes: 2048, instances: [], }); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts index eae964d..e11c93c 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/Page_Lynx_resourceVersion.test.ts @@ -2,12 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { GetVersion } from "../../src/tools/Lynx/GetVersion.ts"; -import { GetResourceContent } from "../../src/tools/Page/GetResourceContent.ts"; -import { GetResourceTree } from "../../src/tools/Page/GetResourceTree.ts"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { GetVersion } from '../../src/tools/Lynx/GetVersion.ts'; +import { GetResourceContent } from '../../src/tools/Page/GetResourceContent.ts'; +import { GetResourceTree } from '../../src/tools/Page/GetResourceTree.ts'; +import { createToolContext } from '../utils/testTool.ts'; type SentCDPMessage = { clientId: string; @@ -16,8 +16,8 @@ type SentCDPMessage = { params?: Record; }; -describe("Page resource tools", () => { - test("gets the page resource tree", async () => { +describe('Page resource tools', () => { + test('gets the page resource tree', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -27,22 +27,36 @@ describe("Page resource tools", () => { params?: Record, ) => { sentMessages.push({ clientId, sessionId, method, params }); - assert.strictEqual(method, "Page.getResourceTree"); - return { frameTree: { frame: { id: "frame-1", url: "lynx://page" }, resources: [] } }; + assert.strictEqual(method, 'Page.getResourceTree'); + return { + frameTree: { + frame: { id: 'frame-1', url: 'lynx://page' }, + resources: [], + }, + }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(GetResourceTree, connector as never, "test-client-id:9999"); + const { call } = createToolContext( + GetResourceTree, + connector as never, + 'test-client-id:9999', + ); const result = await call<{ frameTree: { frame: { id: string } } }>({}); assert.deepStrictEqual(sentMessages, [ - { clientId: "test-client-id:9999", sessionId: 1, method: "Page.getResourceTree", params: {} }, + { + clientId: 'test-client-id:9999', + sessionId: 1, + method: 'Page.getResourceTree', + params: {}, + }, ]); - assert.equal(result.frameTree.frame.id, "frame-1"); + assert.equal(result.frameTree.frame.id, 'frame-1'); }); - test("gets resource content by url and optional frameId", async () => { + test('gets resource content by url and optional frameId', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -52,30 +66,37 @@ describe("Page resource tools", () => { params?: Record, ) => { sentMessages.push({ clientId, sessionId, method, params }); - assert.strictEqual(method, "Page.getResourceContent"); - return { content: "", base64Encoded: false }; + assert.strictEqual(method, 'Page.getResourceContent'); + return { content: '', base64Encoded: false }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(GetResourceContent, connector as never, "test-client-id:9999"); + const { call } = createToolContext( + GetResourceContent, + connector as never, + 'test-client-id:9999', + ); const result = await call<{ content: string; base64Encoded: boolean }>({ - url: "lynx://page/template.js", - frameId: "frame-1", + url: 'lynx://page/template.js', + frameId: 'frame-1', }); assert.deepStrictEqual(sentMessages, [ { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "Page.getResourceContent", - params: { url: "lynx://page/template.js", frameId: "frame-1" }, + method: 'Page.getResourceContent', + params: { url: 'lynx://page/template.js', frameId: 'frame-1' }, }, ]); - assert.deepStrictEqual(result, { content: "", base64Encoded: false }); + assert.deepStrictEqual(result, { + content: '', + base64Encoded: false, + }); }); - test("passes nodeId through for Lynx engines that resolve resource content by node", async () => { + test('passes nodeId through for Lynx engines that resolve resource content by node', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -85,26 +106,30 @@ describe("Page resource tools", () => { params?: Record, ) => { sentMessages.push({ clientId, sessionId, method, params }); - assert.strictEqual(method, "Page.getResourceContent"); - return { content: "", base64Encoded: false }; + assert.strictEqual(method, 'Page.getResourceContent'); + return { content: '', base64Encoded: false }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(GetResourceContent, connector as never, "test-client-id:9999"); + const { call } = createToolContext( + GetResourceContent, + connector as never, + 'test-client-id:9999', + ); await call({ nodeId: 10 }); assert.deepStrictEqual(sentMessages.at(-1), { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "Page.getResourceContent", + method: 'Page.getResourceContent', params: { nodeId: 10 }, }); }); }); -describe("Lynx.getVersion", () => { - test("gets the Lynx engine version", async () => { +describe('Lynx.getVersion', () => { + test('gets the Lynx engine version', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -114,18 +139,27 @@ describe("Lynx.getVersion", () => { params?: Record, ) => { sentMessages.push({ clientId, sessionId, method, params }); - assert.strictEqual(method, "Lynx.getVersion"); - return "3.5.0"; + assert.strictEqual(method, 'Lynx.getVersion'); + return '3.5.0'; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(GetVersion, connector as never, "test-client-id:9999"); + const { call } = createToolContext( + GetVersion, + connector as never, + 'test-client-id:9999', + ); const result = await call({}); assert.deepStrictEqual(sentMessages, [ - { clientId: "test-client-id:9999", sessionId: 1, method: "Lynx.getVersion", params: {} }, + { + clientId: 'test-client-id:9999', + sessionId: 1, + method: 'Lynx.getVersion', + params: {}, + }, ]); - assert.equal(result, "3.5.0"); + assert.equal(result, '3.5.0'); }); }); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts index 1b4de48..36f1700 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/Performance_getAllPerformanceEntries.test.ts @@ -2,9 +2,9 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { createToolContext } from '../utils/testTool.ts'; type SentCDPMessage = { clientId: string; @@ -14,18 +14,20 @@ type SentCDPMessage = { }; async function loadGetAllPerformanceEntriesTool() { - const module = await import("../../src/tools/Performance/GetAllPerformanceEntries.ts").catch(() => undefined); + const module = await import( + '../../src/tools/Performance/GetAllPerformanceEntries.ts' + ).catch(() => undefined); assert.ok( - module && "GetAllPerformanceEntries" in module, - "Expected Performance GetAllPerformanceEntries tool module to exist", + module && 'GetAllPerformanceEntries' in module, + 'Expected Performance GetAllPerformanceEntries tool module to exist', ); return module.GetAllPerformanceEntries; } -describe("Performance.getAllPerformanceEntries", () => { - test("enables Performance before reading all entries", async () => { +describe('Performance.getAllPerformanceEntries', () => { + test('enables Performance before reading all entries', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -36,16 +38,16 @@ describe("Performance.getAllPerformanceEntries", () => { ) => { sentMessages.push({ clientId, sessionId, method, params }); - if (method === "Performance.enable") { + if (method === 'Performance.enable') { return {}; } - assert.strictEqual(method, "Performance.getAllPerformanceEntries"); + assert.strictEqual(method, 'Performance.getAllPerformanceEntries'); return { entries: [ { - entryType: "metric", - name: "testMetric", + entryType: 'metric', + name: 'testMetric', startTime: 1.5, instanceId: 100, }, @@ -56,30 +58,39 @@ describe("Performance.getAllPerformanceEntries", () => { }; const GetAllPerformanceEntries = await loadGetAllPerformanceEntriesTool(); - const { call } = createToolContext(GetAllPerformanceEntries, connector as never, "test-client-id:9999"); + const { call } = createToolContext( + GetAllPerformanceEntries, + connector as never, + 'test-client-id:9999', + ); const result = await call<{ - entries: Array<{ entryType: string; name: string; startTime: number; instanceId: number }>; + entries: Array<{ + entryType: string; + name: string; + startTime: number; + instanceId: number; + }>; }>({}); assert.deepStrictEqual(sentMessages, [ { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "Performance.enable", + method: 'Performance.enable', params: {}, }, { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "Performance.getAllPerformanceEntries", + method: 'Performance.getAllPerformanceEntries', params: {}, }, ]); assert.deepStrictEqual(result, { entries: [ { - entryType: "metric", - name: "testMetric", + entryType: 'metric', + name: 'testMetric', startTime: 1.5, instanceId: 100, }, diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts index bf473b0..4bde77b 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_evaluate_getProperties.test.ts @@ -2,11 +2,11 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { Evaluate } from "../../src/tools/Runtime/Evaluate.ts"; -import { GetProperties } from "../../src/tools/Runtime/GetProperties.ts"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { Evaluate } from '../../src/tools/Runtime/Evaluate.ts'; +import { GetProperties } from '../../src/tools/Runtime/GetProperties.ts'; +import { createToolContext } from '../utils/testTool.ts'; type SentCDPMessage = { clientId: string; @@ -16,8 +16,8 @@ type SentCDPMessage = { isMainThread: boolean; }; -describe("Runtime.evaluate", () => { - test("enables runtime and evaluates on the background VM by default", async () => { +describe('Runtime.evaluate', () => { + test('enables runtime and evaluates on the background VM by default', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -27,41 +27,61 @@ describe("Runtime.evaluate", () => { params: Record, isMainThread = false, ) => { - sentMessages.push({ clientId, sessionId, method, params, isMainThread }); + sentMessages.push({ + clientId, + sessionId, + method, + params, + isMainThread, + }); - if (method === "Runtime.enable") { + if (method === 'Runtime.enable') { return {}; } - assert.strictEqual(method, "Runtime.evaluate"); - return { result: { type: "number", value: 4 } }; + assert.strictEqual(method, 'Runtime.evaluate'); + return { result: { type: 'number', value: 4 } }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(Evaluate, connector as never, "test-client-id:9999"); + const { call } = createToolContext( + Evaluate, + connector as never, + 'test-client-id:9999', + ); const result = await call<{ result: { type: string; value: number } }>({ - expression: "2 + 2", + expression: '2 + 2', generatePreview: true, - objectGroup: "mcp", + objectGroup: 'mcp', }); assert.deepStrictEqual(sentMessages, [ - { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.enable", params: {}, isMainThread: false }, { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "Runtime.evaluate", - params: { expression: "2 + 2", generatePreview: true, objectGroup: "mcp" }, + method: 'Runtime.enable', + params: {}, + isMainThread: false, + }, + { + clientId: 'test-client-id:9999', + sessionId: 1, + method: 'Runtime.evaluate', + params: { + expression: '2 + 2', + generatePreview: true, + objectGroup: 'mcp', + }, isMainThread: false, }, ]); - assert.deepStrictEqual(result, { result: { type: "number", value: 4 } }); + assert.deepStrictEqual(result, { result: { type: 'number', value: 4 } }); }); }); -describe("Runtime.getProperties", () => { - test("enables runtime and queries object properties on the requested VM thread", async () => { +describe('Runtime.getProperties', () => { + test('enables runtime and queries object properties on the requested VM thread', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -71,35 +91,56 @@ describe("Runtime.getProperties", () => { params: Record, isMainThread = false, ) => { - sentMessages.push({ clientId, sessionId, method, params, isMainThread }); + sentMessages.push({ + clientId, + sessionId, + method, + params, + isMainThread, + }); - if (method === "Runtime.enable") { + if (method === 'Runtime.enable') { return {}; } - assert.strictEqual(method, "Runtime.getProperties"); - return { result: [{ name: "answer", value: { type: "number", value: 42 } }] }; + assert.strictEqual(method, 'Runtime.getProperties'); + return { + result: [{ name: 'answer', value: { type: 'number', value: 42 } }], + }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(GetProperties, connector as never, "test-client-id:9999"); + const { call } = createToolContext( + GetProperties, + connector as never, + 'test-client-id:9999', + ); const result = await call<{ result: Array<{ name: string }> }>({ - objectId: "remote-object-id", + objectId: 'remote-object-id', ownProperties: true, - thread: "main", + thread: 'main', }); assert.deepStrictEqual(sentMessages, [ - { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.enable", params: {}, isMainThread: true }, { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', + sessionId: 1, + method: 'Runtime.enable', + params: {}, + isMainThread: true, + }, + { + clientId: 'test-client-id:9999', sessionId: 1, - method: "Runtime.getProperties", - params: { objectId: "remote-object-id", ownProperties: true }, + method: 'Runtime.getProperties', + params: { objectId: 'remote-object-id', ownProperties: true }, isMainThread: true, }, ]); - assert.deepStrictEqual(result.result.map(({ name }) => name), ["answer"]); + assert.deepStrictEqual( + result.result.map(({ name }) => name), + ['answer'], + ); }); }); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts index 796c855..5a6c233 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/Runtime_getHeapUsage.test.ts @@ -2,10 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { GetHeapUsage } from "../../src/tools/Runtime/GetHeapUsage.ts"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { GetHeapUsage } from '../../src/tools/Runtime/GetHeapUsage.ts'; +import { createToolContext } from '../utils/testTool.ts'; type SentCDPMessage = { clientId: string; @@ -15,8 +15,8 @@ type SentCDPMessage = { isMainThread: boolean; }; -describe("Runtime.getHeapUsage", () => { - test("uses the background VM by default", async () => { +describe('Runtime.getHeapUsage', () => { + test('uses the background VM by default', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { @@ -27,27 +27,45 @@ describe("Runtime.getHeapUsage", () => { params: Record, isMainThread = false, ) => { - sentMessages.push({ clientId, sessionId, method, params, isMainThread }); + sentMessages.push({ + clientId, + sessionId, + method, + params, + isMainThread, + }); - if (method === "Runtime.enable") { + if (method === 'Runtime.enable') { return {}; } - assert.strictEqual(method, "Runtime.getHeapUsage"); + assert.strictEqual(method, 'Runtime.getHeapUsage'); return { usedSize: 1, totalSize: 2 }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(GetHeapUsage, connector as never, "test-client-id:9999"); - const result = await call<{ usedSize: number; totalSize: number }>({ thread: "background" }); + const { call } = createToolContext( + GetHeapUsage, + connector as never, + 'test-client-id:9999', + ); + const result = await call<{ usedSize: number; totalSize: number }>({ + thread: 'background', + }); assert.deepStrictEqual(sentMessages, [ - { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.enable", params: {}, isMainThread: false }, { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "Runtime.getHeapUsage", + method: 'Runtime.enable', + params: {}, + isMainThread: false, + }, + { + clientId: 'test-client-id:9999', + sessionId: 1, + method: 'Runtime.getHeapUsage', params: {}, isMainThread: false, }, @@ -55,7 +73,7 @@ describe("Runtime.getHeapUsage", () => { assert.deepStrictEqual(result, { usedSize: 1, totalSize: 2 }); }); - test("passes main-thread requests through sendCDPMessage", async () => { + test('passes main-thread requests through sendCDPMessage', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { @@ -66,24 +84,48 @@ describe("Runtime.getHeapUsage", () => { params: Record, isMainThread = false, ) => { - sentMessages.push({ clientId, sessionId, method, params, isMainThread }); + sentMessages.push({ + clientId, + sessionId, + method, + params, + isMainThread, + }); - if (method === "Runtime.enable") { + if (method === 'Runtime.enable') { return {}; } - assert.strictEqual(method, "Runtime.getHeapUsage"); + assert.strictEqual(method, 'Runtime.getHeapUsage'); return { usedSize: 3, totalSize: 5 }; }, sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(GetHeapUsage, connector as never, "test-client-id:9999"); - const result = await call<{ usedSize: number; totalSize: number }>({ thread: "main" }); + const { call } = createToolContext( + GetHeapUsage, + connector as never, + 'test-client-id:9999', + ); + const result = await call<{ usedSize: number; totalSize: number }>({ + thread: 'main', + }); assert.deepStrictEqual(sentMessages, [ - { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.enable", params: {}, isMainThread: true }, - { clientId: "test-client-id:9999", sessionId: 1, method: "Runtime.getHeapUsage", params: {}, isMainThread: true }, + { + clientId: 'test-client-id:9999', + sessionId: 1, + method: 'Runtime.enable', + params: {}, + isMainThread: true, + }, + { + clientId: 'test-client-id:9999', + sessionId: 1, + method: 'Runtime.getHeapUsage', + params: {}, + isMainThread: true, + }, ]); assert.deepStrictEqual(result, { usedSize: 3, totalSize: 5 }); }); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts index c6d740d..edb4625 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/UITree_getLynxUITree.test.ts @@ -2,10 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { GetLynxUITree } from "../../src/tools/UITree/GetLynxUITree.ts"; -import { createToolContext } from "../utils/testTool.ts"; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { GetLynxUITree } from '../../src/tools/UITree/GetLynxUITree.ts'; +import { createToolContext } from '../utils/testTool.ts'; type SentCDPMessage = { clientId: string; @@ -14,8 +14,8 @@ type SentCDPMessage = { params?: Record; }; -describe("UITree.getLynxUITree", () => { - test("enables UITree without compression before reading native UI metadata", async () => { +describe('UITree.getLynxUITree', () => { + test('enables UITree without compression before reading native UI metadata', async () => { const sentMessages: SentCDPMessage[] = []; const connector = { sendCDPMessage: async ( @@ -26,19 +26,19 @@ describe("UITree.getLynxUITree", () => { ) => { sentMessages.push({ clientId, sessionId, method, params }); - if (method === "UITree.enable") { + if (method === 'UITree.enable') { return {}; } - assert.strictEqual(method, "UITree.getLynxUITree"); + assert.strictEqual(method, 'UITree.getLynxUITree'); return { root: { - name: "com.lynx.tasm.behavior.ui.LynxUI", + name: 'com.lynx.tasm.behavior.ui.LynxUI', id: 3, - tagName: "view", + tagName: 'view', nodeIndex: 2, - props: { id: "card" }, - label: "Card", + props: { id: 'card' }, + label: 'Card', frame: [0, 0, 100, 40], children: [], }, @@ -48,7 +48,11 @@ describe("UITree.getLynxUITree", () => { sendListSessionMessage: async () => [{ session_id: 1 }], }; - const { call } = createToolContext(GetLynxUITree, connector as never, "test-client-id:9999"); + const { call } = createToolContext( + GetLynxUITree, + connector as never, + 'test-client-id:9999', + ); const result = await call<{ root: { tagName: string; @@ -61,25 +65,25 @@ describe("UITree.getLynxUITree", () => { assert.deepStrictEqual(sentMessages, [ { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "UITree.enable", + method: 'UITree.enable', params: { useCompression: false }, }, { - clientId: "test-client-id:9999", + clientId: 'test-client-id:9999', sessionId: 1, - method: "UITree.getLynxUITree", + method: 'UITree.getLynxUITree', params: undefined, }, ]); assert.deepStrictEqual(result.root, { - name: "com.lynx.tasm.behavior.ui.LynxUI", + name: 'com.lynx.tasm.behavior.ui.LynxUI', id: 3, - tagName: "view", + tagName: 'view', nodeIndex: 2, - props: { id: "card" }, - label: "Card", + props: { id: 'card' }, + label: 'Card', frame: [0, 0, 100, 40], children: [], }); diff --git a/packages/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts b/packages/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts index 7850ec0..668220a 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/tools/proxyTestUtils.ts @@ -2,17 +2,21 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import assert from "node:assert"; +import assert from 'node:assert'; -export const testClientId = "test-client-id"; +export const testClientId = 'test-client-id'; -export const createMockConnector = (overrides: Record = {}) => ({ +export const createMockConnector = ( + overrides: Record = {}, +) => ({ sendMessage: async () => {}, - sendListSessionMessage: async () => [{ session_id: "mock_session" }], + sendListSessionMessage: async () => [{ session_id: 'mock_session' }], ...overrides, }); -export const readRequestMessage = (message: unknown): Record => { +export const readRequestMessage = ( + message: unknown, +): Record => { const envelope = message as { event?: unknown; data?: { @@ -24,11 +28,11 @@ export const readRequestMessage = (message: unknown): Record => }; }; - assert.equal(envelope.event, "Customized"); - assert.equal(envelope.data?.type, "xdb_msg"); + assert.equal(envelope.event, 'Customized'); + assert.equal(envelope.data?.type, 'xdb_msg'); assert.equal(envelope.data?.data?.session_id, -1); const rawMessage = envelope.data?.data?.message; - assert.equal(typeof rawMessage, "string"); + assert.equal(typeof rawMessage, 'string'); return JSON.parse(rawMessage as string) as Record; }; @@ -42,8 +46,8 @@ export const createRespondingConnector = ( const request = readRequestMessage(message); onRequest?.(request); return { - type: "xdb_msg_resp", - __id: request["__id"], + type: 'xdb_msg_resp', + __id: request['__id'], data, }; }, diff --git a/packages/mcp-servers/devtool-mcp-server/test/utils/testTool.ts b/packages/mcp-servers/devtool-mcp-server/test/utils/testTool.ts index 3cd9185..49418ab 100644 --- a/packages/mcp-servers/devtool-mcp-server/test/utils/testTool.ts +++ b/packages/mcp-servers/devtool-mcp-server/test/utils/testTool.ts @@ -2,30 +2,32 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { type Connector } from "@lynx-js/devtool-connector"; -import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; -import type { z } from "zod"; -import { McpContext } from "../../src/McpContext.ts"; -import { McpResponse } from "../../src/McpResponse.ts"; -import type { ToolDefinition } from "../../src/tools/defineTool.ts"; +import type { Connector } from '@lynx-js/devtool-connector'; +import type { TextContent } from '@modelcontextprotocol/sdk/types.js'; +import type { z } from 'zod'; +import { McpContext } from '../../src/McpContext.ts'; +import { McpResponse } from '../../src/McpResponse.ts'; +import type { ToolDefinition } from '../../src/tools/defineTool.ts'; export function createToolContext( tool: ToolDefinition, connector: Connector, clientId: string, ) { - const call = async (params: Partial>> = {}): Promise => { + const call = async ( + params: Partial>> = {}, + ): Promise => { const ctx = new McpContext(connector); const resp = new McpResponse(); // Auto-fill parameters - const fullParams = { ...params } as any; + const fullParams = { ...params } as Record; - if (tool.schema["clientId"] && !fullParams.clientId) { + if (tool.schema['clientId'] && !fullParams.clientId) { fullParams.clientId = clientId; } - if (tool.schema["sessionId"] && !fullParams.sessionId) { + if (tool.schema['sessionId'] && !fullParams.sessionId) { try { // Auto-fetch session const sessions = await connector.sendListSessionMessage(clientId); @@ -33,12 +35,13 @@ export function createToolContext( if (session) { fullParams.sessionId = session.session_id; } - } catch (e) { + } catch { // Ignore error if session listing fails, maybe tool doesn't strictly need it or will fail gracefully } } await tool.handler( + // eslint-disable-next-line @typescript-eslint/no-explicit-any { params: fullParams, extra: {} as any }, resp, ctx, @@ -47,7 +50,9 @@ export function createToolContext( // Extract result const contents = await resp.handle(tool.name, ctx); - const texts = contents.filter((c): c is TextContent => c.type === "text").map((c) => c.text); + const texts = contents + .filter((c): c is TextContent => c.type === 'text') + .map((c) => c.text); if (texts.length === 1 && texts[0]) { try { return JSON.parse(texts[0]) as T; diff --git a/packages/mcp-servers/devtool-mcp-server/tsconfig.json b/packages/mcp-servers/devtool-mcp-server/tsconfig.json index bbe2314..2b57a91 100644 --- a/packages/mcp-servers/devtool-mcp-server/tsconfig.json +++ b/packages/mcp-servers/devtool-mcp-server/tsconfig.json @@ -39,7 +39,7 @@ "rootDir": "./src", - "noEmit": true, + "noEmit": true }, - "include": ["src"], + "include": ["src"] } diff --git a/packages/skills/lynx-devtool/e2e/reactlynx.test.ts b/packages/skills/lynx-devtool/e2e/reactlynx.test.ts index dbbb328..bae2bb9 100644 --- a/packages/skills/lynx-devtool/e2e/reactlynx.test.ts +++ b/packages/skills/lynx-devtool/e2e/reactlynx.test.ts @@ -21,193 +21,248 @@ * Override per-environment with `LYNX_DEVTOOL_MCP_TESTING_PAGE_URL`. */ -import { getTestingSession, testWithClient } from "@lynx-js/devtool-connector/test-with-client"; -import type { TestContext } from "node:test"; -import { buildSubstringMatcher, findComponents } from "../src/commands/reactlynx/find.ts"; -import { formatTree } from "../src/commands/reactlynx/format.ts"; -import type { RendererState } from "../src/commands/reactlynx/protocol.ts"; -import { buildOutboundFrame, type PreactEnvelope, runReactLynxSession } from "../src/commands/reactlynx/transport.ts"; +import type { TestContext } from 'node:test'; +import { + getTestingSession, + testWithClient, +} from '@lynx-js/devtool-connector/test-with-client'; +import { + buildSubstringMatcher, + findComponents, +} from '../src/commands/reactlynx/find.ts'; +import { formatTree } from '../src/commands/reactlynx/format.ts'; +import type { RendererState } from '../src/commands/reactlynx/protocol.ts'; +import { + buildOutboundFrame, + type PreactEnvelope, + runReactLynxSession, +} from '../src/commands/reactlynx/transport.ts'; const run = testWithClient; -run("reactlynx tree", async (t, connector, client, target) => { +run('reactlynx tree', async (t, connector, client, target) => { let capturedTree: RendererState | undefined; const session = await getTestingSession(connector, client.id); - await t.test("init + refresh produces a non-empty tree", async (t: TestContext) => { - const collected = await runReactLynxSession({ - connector, - clientId: client.id, - sessionId: session.session_id, - outbound: [buildOutboundFrame("refresh")], - idleMs: 1_000, - maxMs: 15_000, - signal: t.signal, - }); - - t.assert.ok( - collected.framesSeen > 0, - `Expected at least one PreactDevtools frame from page ${target.pageUrl}; saw 0. ` - + `Verify the page bundle contains a recent @lynx-js/preact-devtools dev build.`, - ); - t.assert.ok( - collected.operationFrames > 0, - `Expected at least one operation_v2 frame after refresh; saw types=${[...collected.envelopeTypes].join(",")}. ` - + `If only root-order/root-order-page arrived, the App is running an outdated ` - + `@lynx-js/preact-devtools without the PR #2 / PR #5 fixes.`, - ); - t.assert.ok( - collected.rootOrderFrames > 0, - `Expected at least one root-order frame after refresh; saw types=${[...collected.envelopeTypes].join(",")}`, - ); - - t.assert.ok( - collected.state.tree.size > 0, - "Decoded tree must not be empty after a successful operation_v2 mount", - ); - t.assert.ok( - collected.state.roots.length > 0, - "Decoded tree must report at least one root", - ); - - const formatted = formatTree(collected.state, { hideShells: true }); - t.assert.ok(formatted.text.length > 0, "Formatted tree must be non-empty"); - t.assert.ok( - formatted.text.startsWith("@c1 ["), - `Formatted tree must start with @c1; got: ${formatted.text.split("\n")[0]}`, - ); - t.assert.ok( - formatted.labels.length > 0, - "Formatted tree must expose at least one @cN label", - ); - t.assert.equal( - formatted.labels.length, - formatted.text.split("\n").length, - "Every visible printed line must correspond to one @cN label", - ); - - t.diagnostic( - `frames=${collected.framesSeen} operation_v2=${collected.operationFrames} ` - + `root-order=${collected.rootOrderFrames} types=${ - [...collected.envelopeTypes].sort().join(",") - } treeSize=${collected.state.tree.size}`, - ); - t.diagnostic(`first line: ${formatted.text.split("\n")[0]}`); - - capturedTree = collected.state; - }); - - await t.test("findComponents finds at least one match in the live tree", async (t: TestContext) => { - if (!capturedTree) { - t.skip("tree snapshot not captured (preceding subtest failed)"); - return; - } - const matches = findComponents(capturedTree, buildSubstringMatcher("Provider"), { - hideShells: true, - limit: 50, - }); - t.assert.ok( - matches.length > 0, - "Expected at least one component containing 'Provider' in the bundle", - ); - for (const match of matches) { - t.assert.match(match.label, /^@c\d+$/, `match.label must be @cN form, got ${match.label}`); - t.assert.ok(match.name.toLowerCase().includes("provider")); - } - t.diagnostic(`find matches: ${matches.map((m) => `${m.label} ${m.name}`).join(", ")}`); - }); - - await t.test("inspect round-trip returns InspectData for first labelled component", async (t: TestContext) => { - if (!capturedTree) { - t.skip("tree snapshot not captured (preceding subtest failed)"); - return; - } - const labels = formatTree(capturedTree, { hideShells: true }).labels; - const targetId = labels[0]; - t.assert.ok(targetId !== undefined, "tree must expose at least one labelled root"); - if (targetId === undefined) return; - - let inspectData: { id: number; name: string; props: unknown } | undefined; - await runReactLynxSession({ - connector, - clientId: client.id, - sessionId: session.session_id, - outbound: [buildOutboundFrame("inspect", targetId)], - idleMs: 1_000, - maxMs: 5_000, - signal: t.signal, - onEnvelope: (envelope: PreactEnvelope) => { - if (envelope.type !== "inspect-result") return "continue"; - inspectData = envelope.data as typeof inspectData; - return "stop"; - }, - }); - - t.assert.ok( - inspectData !== undefined, - `Expected an inspect-result frame for id ${targetId} within 5s`, - ); - if (!inspectData) return; - t.assert.equal(inspectData.id, targetId, "inspect-result.id must match the requested id"); - t.assert.equal(typeof inspectData.name, "string"); - t.assert.ok(inspectData.name.length > 0, "inspect-result.name must be non-empty"); - t.diagnostic(`inspect-result: id=${inspectData.id} name=${inspectData.name}`); - }); - - await t.test("update-prop round-trip applies the new value and confirms via inspect-result", async (t: TestContext) => { - if (!capturedTree) { - t.skip("tree snapshot not captured (preceding subtest failed)"); - return; - } - const labels = formatTree(capturedTree, { hideShells: true }).labels; - const targetId = labels[0]; - t.assert.ok(targetId !== undefined, "tree must expose at least one labelled root"); - if (targetId === undefined) return; - - const TEST_KEY = "__lynxDevtoolUpdatePropTest"; - const TEST_VALUE = `marker-${Date.now()}`; - - let confirmed: { id: number; props: Record } | undefined; - await runReactLynxSession({ - connector, - clientId: client.id, - sessionId: session.session_id, - outbound: [ - buildOutboundFrame("update-prop", { - id: targetId, - path: `root.${TEST_KEY}`, - value: TEST_VALUE, - }), - ], - idleMs: 1_000, - maxMs: 5_000, - signal: t.signal, - onEnvelope: (envelope: PreactEnvelope) => { - if (envelope.type !== "inspect-result") return "continue"; - const candidate = envelope.data as { id?: number; props?: Record }; - if (candidate.id !== targetId) return "continue"; - confirmed = candidate as typeof confirmed; - return "stop"; - }, - }); - - t.assert.ok( - confirmed !== undefined, - `Expected an inspect-result for id ${targetId} after update-prop within 5s`, - ); - if (!confirmed) return; - t.assert.ok( - confirmed.props && typeof confirmed.props === "object", - "post-update inspect-result.props must be an object", - ); - t.assert.equal( - confirmed.props[TEST_KEY], - TEST_VALUE, - `post-update inspect-result.props.${TEST_KEY} must equal the value we sent (${TEST_VALUE})`, - ); - t.diagnostic( - `update-prop confirmed: id=${confirmed.id} props.${TEST_KEY}=${JSON.stringify(confirmed.props[TEST_KEY])}`, - ); - }); + await t.test( + 'init + refresh produces a non-empty tree', + async (t: TestContext) => { + const collected = await runReactLynxSession({ + connector, + clientId: client.id, + sessionId: session.session_id, + outbound: [buildOutboundFrame('refresh')], + idleMs: 1_000, + maxMs: 15_000, + signal: t.signal, + }); + + t.assert.ok( + collected.framesSeen > 0, + `Expected at least one PreactDevtools frame from page ${target.pageUrl}; saw 0. ` + + `Verify the page bundle contains a recent @lynx-js/preact-devtools dev build.`, + ); + t.assert.ok( + collected.operationFrames > 0, + `Expected at least one operation_v2 frame after refresh; saw types=${[...collected.envelopeTypes].join(',')}. ` + + `If only root-order/root-order-page arrived, the App is running an outdated ` + + `@lynx-js/preact-devtools without the PR #2 / PR #5 fixes.`, + ); + t.assert.ok( + collected.rootOrderFrames > 0, + `Expected at least one root-order frame after refresh; saw types=${[...collected.envelopeTypes].join(',')}`, + ); + + t.assert.ok( + collected.state.tree.size > 0, + 'Decoded tree must not be empty after a successful operation_v2 mount', + ); + t.assert.ok( + collected.state.roots.length > 0, + 'Decoded tree must report at least one root', + ); + + const formatted = formatTree(collected.state, { hideShells: true }); + t.assert.ok( + formatted.text.length > 0, + 'Formatted tree must be non-empty', + ); + t.assert.ok( + formatted.text.startsWith('@c1 ['), + `Formatted tree must start with @c1; got: ${formatted.text.split('\n')[0]}`, + ); + t.assert.ok( + formatted.labels.length > 0, + 'Formatted tree must expose at least one @cN label', + ); + t.assert.equal( + formatted.labels.length, + formatted.text.split('\n').length, + 'Every visible printed line must correspond to one @cN label', + ); + + t.diagnostic( + `frames=${collected.framesSeen} operation_v2=${collected.operationFrames} ` + + `root-order=${collected.rootOrderFrames} types=${[ + ...collected.envelopeTypes, + ] + .sort() + .join(',')} treeSize=${collected.state.tree.size}`, + ); + t.diagnostic(`first line: ${formatted.text.split('\n')[0]}`); + + capturedTree = collected.state; + }, + ); + + await t.test( + 'findComponents finds at least one match in the live tree', + async (t: TestContext) => { + if (!capturedTree) { + t.skip('tree snapshot not captured (preceding subtest failed)'); + return; + } + const matches = findComponents( + capturedTree, + buildSubstringMatcher('Provider'), + { + hideShells: true, + limit: 50, + }, + ); + t.assert.ok( + matches.length > 0, + "Expected at least one component containing 'Provider' in the bundle", + ); + for (const match of matches) { + t.assert.match( + match.label, + /^@c\d+$/, + `match.label must be @cN form, got ${match.label}`, + ); + t.assert.ok(match.name.toLowerCase().includes('provider')); + } + t.diagnostic( + `find matches: ${matches.map((m) => `${m.label} ${m.name}`).join(', ')}`, + ); + }, + ); + + await t.test( + 'inspect round-trip returns InspectData for first labelled component', + async (t: TestContext) => { + if (!capturedTree) { + t.skip('tree snapshot not captured (preceding subtest failed)'); + return; + } + const labels = formatTree(capturedTree, { hideShells: true }).labels; + const targetId = labels[0]; + t.assert.ok( + targetId !== undefined, + 'tree must expose at least one labelled root', + ); + if (targetId === undefined) return; + + let inspectData: { id: number; name: string; props: unknown } | undefined; + await runReactLynxSession({ + connector, + clientId: client.id, + sessionId: session.session_id, + outbound: [buildOutboundFrame('inspect', targetId)], + idleMs: 1_000, + maxMs: 5_000, + signal: t.signal, + onEnvelope: (envelope: PreactEnvelope) => { + if (envelope.type !== 'inspect-result') return 'continue'; + inspectData = envelope.data as typeof inspectData; + return 'stop'; + }, + }); + + t.assert.ok( + inspectData !== undefined, + `Expected an inspect-result frame for id ${targetId} within 5s`, + ); + if (!inspectData) return; + t.assert.equal( + inspectData.id, + targetId, + 'inspect-result.id must match the requested id', + ); + t.assert.equal(typeof inspectData.name, 'string'); + t.assert.ok( + inspectData.name.length > 0, + 'inspect-result.name must be non-empty', + ); + t.diagnostic( + `inspect-result: id=${inspectData.id} name=${inspectData.name}`, + ); + }, + ); + + await t.test( + 'update-prop round-trip applies the new value and confirms via inspect-result', + async (t: TestContext) => { + if (!capturedTree) { + t.skip('tree snapshot not captured (preceding subtest failed)'); + return; + } + const labels = formatTree(capturedTree, { hideShells: true }).labels; + const targetId = labels[0]; + t.assert.ok( + targetId !== undefined, + 'tree must expose at least one labelled root', + ); + if (targetId === undefined) return; + + const TEST_KEY = '__lynxDevtoolUpdatePropTest'; + const TEST_VALUE = `marker-${Date.now()}`; + + let confirmed: { id: number; props: Record } | undefined; + await runReactLynxSession({ + connector, + clientId: client.id, + sessionId: session.session_id, + outbound: [ + buildOutboundFrame('update-prop', { + id: targetId, + path: `root.${TEST_KEY}`, + value: TEST_VALUE, + }), + ], + idleMs: 1_000, + maxMs: 5_000, + signal: t.signal, + onEnvelope: (envelope: PreactEnvelope) => { + if (envelope.type !== 'inspect-result') return 'continue'; + const candidate = envelope.data as { + id?: number; + props?: Record; + }; + if (candidate.id !== targetId) return 'continue'; + confirmed = candidate as typeof confirmed; + return 'stop'; + }, + }); + + t.assert.ok( + confirmed !== undefined, + `Expected an inspect-result for id ${targetId} after update-prop within 5s`, + ); + if (!confirmed) return; + t.assert.ok( + confirmed.props && typeof confirmed.props === 'object', + 'post-update inspect-result.props must be an object', + ); + t.assert.equal( + confirmed.props[TEST_KEY], + TEST_VALUE, + `post-update inspect-result.props.${TEST_KEY} must equal the value we sent (${TEST_VALUE})`, + ); + t.diagnostic( + `update-prop confirmed: id=${confirmed.id} props.${TEST_KEY}=${JSON.stringify(confirmed.props[TEST_KEY])}`, + ); + }, + ); }); diff --git a/packages/skills/lynx-devtool/rslib.config.ts b/packages/skills/lynx-devtool/rslib.config.ts index 2cde3b6..c83dc33 100644 --- a/packages/skills/lynx-devtool/rslib.config.ts +++ b/packages/skills/lynx-devtool/rslib.config.ts @@ -1,35 +1,39 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { defineConfig } from "@rslib/core"; -import path from "node:path"; + +import path from 'node:path'; +import { defineConfig } from '@rslib/core'; const buildTime = new Date(); -const formattedBuildTime = `${buildTime.getFullYear()}-${String(buildTime.getMonth() + 1).padStart(2, "0")}-${ - String(buildTime.getDate()).padStart(2, "0") -} ${String(buildTime.getHours()).padStart(2, "0")}:${String(buildTime.getMinutes()).padStart(2, "0")}`; +const formattedBuildTime = `${buildTime.getFullYear()}-${String(buildTime.getMonth() + 1).padStart(2, '0')}-${String( + buildTime.getDate(), +).padStart( + 2, + '0', +)} ${String(buildTime.getHours()).padStart(2, '0')}:${String(buildTime.getMinutes()).padStart(2, '0')}`; export default defineConfig({ source: { entry: { - index: "./src/index.ts", - connector: "./src/connector.ts", + index: './src/index.ts', + connector: './src/connector.ts', }, define: { - "process.env.NODE_ENV": JSON.stringify("production"), - "process.env.BUILD_TIME": JSON.stringify(formattedBuildTime), + 'process.env.NODE_ENV': JSON.stringify('production'), + 'process.env.BUILD_TIME': JSON.stringify(formattedBuildTime), }, }, lib: [ { - format: "esm", - syntax: "es2022", + format: 'esm', + syntax: 'es2022', dts: false, output: { filename: { - js: "[name].mjs", + js: '[name].mjs', }, - distPath: "./scripts", + distPath: './scripts', }, autoExtension: false, }, @@ -38,9 +42,8 @@ export default defineConfig({ rspack: { output: { library: { - type: "modern-module", - // eslint-disable-next-line n/no-unsupported-features/node-builtins - preserveModules: path.resolve(import.meta.dirname, "src/commands"), + type: 'modern-module', + preserveModules: path.resolve(import.meta.dirname, 'src/commands'), }, }, }, diff --git a/packages/skills/lynx-devtool/src/commands/app.ts b/packages/skills/lynx-devtool/src/commands/app.ts index c2e078f..f7b9ef7 100644 --- a/packages/skills/lynx-devtool/src/commands/app.ts +++ b/packages/skills/lynx-devtool/src/commands/app.ts @@ -1,17 +1,22 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClient } from "./utils.ts"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClient, +} from './utils.ts'; export function registerAppCommand(program: Command, context: Context) { program - .command("app") - .description("Send an App request") - .requiredOption("-m, --method ", "App method (e.g., App.openPage)") + .command('app') + .description('Send an App request') + .requiredOption('-m, --method ', 'App method (e.g., App.openPage)') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) - .argument("[params]", "JSON string of parameters") + .argument('[params]', 'JSON string of parameters') .action(async (paramsStr, options) => { const { connector, clientId } = await resolveClient(context, options); const { method } = options; diff --git a/packages/skills/lynx-devtool/src/commands/cdp.ts b/packages/skills/lynx-devtool/src/commands/cdp.ts index 5d73049..1d1057e 100644 --- a/packages/skills/lynx-devtool/src/commands/cdp.ts +++ b/packages/skills/lynx-devtool/src/commands/cdp.ts @@ -1,27 +1,48 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "./utils.ts"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClientAndSession, + SESSION_OPTION, +} from './utils.ts'; export function registerCdpCommand(program: Command, context: Context) { program - .command("cdp") - .description("Send a CDP request") - .requiredOption("-m, --method ", "CDP method (e.g., DOM.getDocument)") + .command('cdp') + .description('Send a CDP request') + .requiredOption( + '-m, --method ', + 'CDP method (e.g., DOM.getDocument)', + ) .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) - .option("--thread ", "Thread to target (e.g., 'main' or 'background'). Defaults to 'background'") - .argument("[params]", "JSON string of parameters") + .option( + '--thread ', + "Thread to target (e.g., 'main' or 'background'). Defaults to 'background'", + ) + .argument('[params]', 'JSON string of parameters') .action(async (paramsStr, options) => { - const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); + const { connector, clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); const { method } = options; - const thread = options.thread ?? "background"; + const thread = options.thread ?? 'background'; const params = paramsStr ? JSON.parse(paramsStr) : {}; - const result = await connector.sendCDPMessage(clientId, Number(sessionId), method, params, thread === "main"); + const result = await connector.sendCDPMessage( + clientId, + Number(sessionId), + method, + params, + thread === 'main', + ); console.log(JSON.stringify(result, null, 2)); }); diff --git a/packages/skills/lynx-devtool/src/commands/get-console.ts b/packages/skills/lynx-devtool/src/commands/get-console.ts index 058b7a1..31e0e73 100644 --- a/packages/skills/lynx-devtool/src/commands/get-console.ts +++ b/packages/skills/lynx-devtool/src/commands/get-console.ts @@ -1,9 +1,9 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -/* eslint-disable */ -import { Command } from "commander"; -import { ReadableStream } from "node:stream/web"; + +import { ReadableStream } from 'node:stream/web'; +import type { Command } from 'commander'; import { CLIENT_NAME_OPTION, CLIENT_OPTION, @@ -11,7 +11,7 @@ import { readUntilIdle, resolveClientAndSession, SESSION_OPTION, -} from "./utils.ts"; +} from './utils.ts'; interface ConsoleCallFrame { url: string; @@ -40,89 +40,108 @@ interface ConsoleMessage { consoleTag?: string; } -function formatConsoleMessage({ type, args, stackTrace, consoleTag }: ConsoleMessage): string { - return `- [${type}/${consoleTag === "Lepus" ? "main-thread" : "background"}]: ${ - args.map((arg) => { +function formatConsoleMessage({ + type, + args, + stackTrace, + consoleTag, +}: ConsoleMessage): string { + return `- [${type}/${consoleTag === 'Lepus' ? 'main-thread' : 'background'}]: ${args + .map((arg) => { if (arg.objectId) { - return `<${arg.description || arg.className || "Object"} (objectId:${arg.objectId})>`; + return `<${arg.description || arg.className || 'Object'} (objectId:${arg.objectId})>`; } return arg.value; - }).join(" ") - }${ + }) + .join(' ')}${ stackTrace - ? "\n" - + stackTrace.callFrames.map(({ url, lineNumber, columnNumber }) => - ` at ${url}:${lineNumber}:${columnNumber}` - ).join("\n") - : "" + ? '\n' + + stackTrace.callFrames + .map( + ({ url, lineNumber, columnNumber }) => + ` at ${url}:${lineNumber}:${columnNumber}`, + ) + .join('\n') + : '' }`; } export function registerGetConsoleCommand(program: Command, context: Context) { program - .command("get-console") - .description("Capture console logs from the device") + .command('get-console') + .description('Capture console logs from the device') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) - .option("--offset ", "The number of console messages to skip before returning results.", parseInt) - .option("--limit ", "The maximum number of console messages to return.", parseInt) .option( - "--include-stack-traces", - "By default, only error messages would contain stack traces. Set this to true to include stack traces for all messages in the output.", + '--offset ', + 'The number of console messages to skip before returning results.', + parseInt, + ) + .option( + '--limit ', + 'The maximum number of console messages to return.', + parseInt, ) .option( - "--level ", + '--include-stack-traces', + 'By default, only error messages would contain stack traces. Set this to true to include stack traces for all messages in the output.', + ) + .option( + '--level ', "The log level to filter messages. Defaults to ['info', 'log', 'warning', 'error']", - (value) => value.split(",").map((s) => s.trim()), + (value) => value.split(',').map((s) => s.trim()), ) - .option("--thread ", "VM thread to target: background or main", ["background", "main"]) + .option('--thread ', 'VM thread to target: background or main', [ + 'background', + 'main', + ]) .option( - "-w, --watch", - "Stream console logs as they arrive, printing each message immediately, until interrupted (Ctrl+C) or --limit is reached", + '-w, --watch', + 'Stream console logs as they arrive, printing each message immediately, until interrupted (Ctrl+C) or --limit is reached', false, ) .action(async (options) => { - const { - offset = 0, - includeStackTraces, - level, - watch, - } = options; + const { offset = 0, includeStackTraces, level, watch } = options; let { limit, thread } = options; if (!Array.isArray(thread)) { thread = [thread]; } - if (!thread.every((t: string) => t === "background" || t === "main")) { - throw new Error(`Invalid thread: ${thread}. Expected 'background' or 'main'.`); + if (!thread.every((t: string) => t === 'background' || t === 'main')) { + throw new Error( + `Invalid thread: ${thread}. Expected 'background' or 'main'.`, + ); } if (limit) { limit = Math.max(1, Math.min(100, limit)); } - const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); + const { connector, clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); await using stream = await connector.sendCDPStream( clientId, Number(sessionId), ReadableStream.from([ - { method: "Page.enable" }, - { method: "Page.getResourceTree" }, + { method: 'Page.enable' }, + { method: 'Page.getResourceTree' }, ...thread.map((t: string) => ({ - method: "Debugger.enable", - sessionId: t === "main" ? "Main" : undefined, + method: 'Debugger.enable', + sessionId: t === 'main' ? 'Main' : undefined, })), ...thread.map((t: string) => ({ - method: "Runtime.enable", - sessionId: t === "main" ? "Main" : undefined, + method: 'Runtime.enable', + sessionId: t === 'main' ? 'Main' : undefined, })), ]), ); - const defaultLevels = ["info", "log", "warning", "error"]; + const defaultLevels = ['info', 'log', 'warning', 'error']; const allowedLevels = level || defaultLevels; let skipped = 0; let produced = 0; @@ -134,14 +153,14 @@ export function registerGetConsoleCommand(program: Command, context: Context) { aborted = true; reader.cancel().catch(() => {}); }; - process.once("SIGINT", onSigint); + process.once('SIGINT', onSigint); try { while (!aborted) { const { done, value } = await reader.read(); if (done) break; - if (value.method !== "Runtime.consoleAPICalled") continue; + if (value.method !== 'Runtime.consoleAPICalled') continue; const params = value.params as ConsoleMessage; if (!allowedLevels.includes(params.type)) continue; @@ -150,7 +169,7 @@ export function registerGetConsoleCommand(program: Command, context: Context) { continue; } - if (!includeStackTraces && params.type !== "error") { + if (!includeStackTraces && params.type !== 'error') { delete params.stackTrace; } @@ -163,7 +182,7 @@ export function registerGetConsoleCommand(program: Command, context: Context) { } } } finally { - process.off("SIGINT", onSigint); + process.off('SIGINT', onSigint); reader.releaseLock(); } @@ -172,8 +191,11 @@ export function registerGetConsoleCommand(program: Command, context: Context) { const messages: ConsoleMessage[] = []; - for await (const value of readUntilIdle(stream, { idleMs: 500, maxMs: 5000 })) { - if (value.method !== "Runtime.consoleAPICalled") continue; + for await (const value of readUntilIdle(stream, { + idleMs: 500, + maxMs: 5000, + })) { + if (value.method !== 'Runtime.consoleAPICalled') continue; const params = value.params as ConsoleMessage; if (!allowedLevels.includes(params.type)) continue; @@ -182,7 +204,7 @@ export function registerGetConsoleCommand(program: Command, context: Context) { continue; } - if (!includeStackTraces && params.type !== "error") { + if (!includeStackTraces && params.type !== 'error') { delete params.stackTrace; } @@ -193,6 +215,6 @@ export function registerGetConsoleCommand(program: Command, context: Context) { } } - console.log(messages.map(formatConsoleMessage).join("\n")); + console.log(messages.map(formatConsoleMessage).join('\n')); }); } diff --git a/packages/skills/lynx-devtool/src/commands/get-sources.ts b/packages/skills/lynx-devtool/src/commands/get-sources.ts index 1f25da9..739b2a8 100644 --- a/packages/skills/lynx-devtool/src/commands/get-sources.ts +++ b/packages/skills/lynx-devtool/src/commands/get-sources.ts @@ -1,9 +1,9 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -/* eslint-disable */ -import { Command } from "commander"; -import { ReadableStream } from "node:stream/web"; + +import { ReadableStream } from 'node:stream/web'; +import type { Command } from 'commander'; import { CLIENT_NAME_OPTION, CLIENT_OPTION, @@ -11,7 +11,7 @@ import { readUntilIdle, resolveClientAndSession, SESSION_OPTION, -} from "./utils.ts"; +} from './utils.ts'; interface ScriptParsedEvent { scriptId: string; @@ -21,19 +21,22 @@ interface ScriptParsedEvent { export function registerGetSourcesCommand(program: Command, context: Context) { program - .command("get-sources") - .description("List all parsed scripts.") + .command('get-sources') + .description('List all parsed scripts.') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) .action(async (options) => { - const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); + const { connector, clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); const numericSessionId = Number(sessionId); const messages: { method: string }[] = [ - { method: "Debugger.disable" }, - { method: "Debugger.enable" }, + { method: 'Debugger.disable' }, + { method: 'Debugger.enable' }, ]; await using stream = await connector.sendCDPStream( @@ -44,12 +47,21 @@ export function registerGetSourcesCommand(program: Command, context: Context) { const scripts: ScriptParsedEvent[] = []; - for await (const value of readUntilIdle(stream, { idleMs: 2000, maxMs: 5000 })) { - if (value.method === "Debugger.scriptParsed") { + for await (const value of readUntilIdle(stream, { + idleMs: 2000, + maxMs: 5000, + })) { + if (value.method === 'Debugger.scriptParsed') { scripts.push(value.params as ScriptParsedEvent); } } - console.log(JSON.stringify(scripts.map(({ scriptId, url }) => ({ scriptId, url })), null, 2)); + console.log( + JSON.stringify( + scripts.map(({ scriptId, url }) => ({ scriptId, url })), + null, + 2, + ), + ); }); } diff --git a/packages/skills/lynx-devtool/src/commands/global-switch.ts b/packages/skills/lynx-devtool/src/commands/global-switch.ts index b9521d0..812871b 100644 --- a/packages/skills/lynx-devtool/src/commands/global-switch.ts +++ b/packages/skills/lynx-devtool/src/commands/global-switch.ts @@ -1,53 +1,68 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, parseOnOff, resolveClient } from "./utils.ts"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + parseOnOff, + resolveClient, +} from './utils.ts'; const GLOBAL_SWITCH_KEYS = [ - "enable_devtool", - "enable_logbox", - "enable_debug_mode", - "enable_dom_tree", - "enable_quickjs_debug", - "enable_quickjs_cache", - "enable_v8", - "enable_cdp_domain_dom", - "enable_cdp_domain_css", - "enable_cdp_domain_page", - "enable_long_press_menu", - "enable_highlight_touch", - "enable_preview_screen_shot", - "enable_pixel_copy", - "enable_fsp_screenshot", + 'enable_devtool', + 'enable_logbox', + 'enable_debug_mode', + 'enable_dom_tree', + 'enable_quickjs_debug', + 'enable_quickjs_cache', + 'enable_v8', + 'enable_cdp_domain_dom', + 'enable_cdp_domain_css', + 'enable_cdp_domain_page', + 'enable_long_press_menu', + 'enable_highlight_touch', + 'enable_preview_screen_shot', + 'enable_pixel_copy', + 'enable_fsp_screenshot', ] as const; type GlobalSwitchKey = (typeof GLOBAL_SWITCH_KEYS)[number]; -const GLOBAL_SWITCH_KEYS_HELP = GLOBAL_SWITCH_KEYS.join(" | "); +const GLOBAL_SWITCH_KEYS_HELP = GLOBAL_SWITCH_KEYS.join(' | '); function parseKey(input: string): GlobalSwitchKey { if ((GLOBAL_SWITCH_KEYS as readonly string[]).includes(input)) { return input as GlobalSwitchKey; } - throw new Error(`Invalid --key value: ${input}. Use global-switch list to inspect supported keys.`); + throw new Error( + `Invalid --key value: ${input}. Use global-switch list to inspect supported keys.`, + ); } -export function registerGlobalSwitchCommand(program: Command, context: Context) { +export function registerGlobalSwitchCommand( + program: Command, + context: Context, +) { const globalSwitch = program - .command("global-switch") - .description("Manage DevTool global switches"); + .command('global-switch') + .description('Manage DevTool global switches'); globalSwitch - .command("list") - .description("List all global switch states") + .command('list') + .description('List all global switch states') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) - .option("--fail-fast", "Abort on first key-read failure") + .option('--fail-fast', 'Abort on first key-read failure') .action(async (options) => { const { connector, clientId } = await resolveClient(context, options); - const switches: Array<{ key: GlobalSwitchKey; value?: boolean; error?: string }> = []; + const switches: Array<{ + key: GlobalSwitchKey; + value?: boolean; + error?: string; + }> = []; for (const key of GLOBAL_SWITCH_KEYS) { try { const value = await connector.getGlobalSwitch(clientId, key); @@ -68,9 +83,12 @@ export function registerGlobalSwitchCommand(program: Command, context: Context) }); globalSwitch - .command("get") - .description("Get one global switch state") - .requiredOption("--key ", `Global switch key. Supported: ${GLOBAL_SWITCH_KEYS_HELP}`) + .command('get') + .description('Get one global switch state') + .requiredOption( + '--key ', + `Global switch key. Supported: ${GLOBAL_SWITCH_KEYS_HELP}`, + ) .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .action(async (options) => { @@ -84,10 +102,13 @@ export function registerGlobalSwitchCommand(program: Command, context: Context) }); globalSwitch - .command("set") - .description("Set one global switch state") - .requiredOption("--key ", `Global switch key. Supported: ${GLOBAL_SWITCH_KEYS_HELP}`) - .requiredOption("--status ", "Switch status: on/off") + .command('set') + .description('Set one global switch state') + .requiredOption( + '--key ', + `Global switch key. Supported: ${GLOBAL_SWITCH_KEYS_HELP}`, + ) + .requiredOption('--status ', 'Switch status: on/off') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .action(async (options) => { diff --git a/packages/skills/lynx-devtool/src/commands/inspect.ts b/packages/skills/lynx-devtool/src/commands/inspect.ts index 69d223e..1f3a1d3 100644 --- a/packages/skills/lynx-devtool/src/commands/inspect.ts +++ b/packages/skills/lynx-devtool/src/commands/inspect.ts @@ -1,19 +1,25 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "./utils.ts"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClientAndSession, + SESSION_OPTION, +} from './utils.ts'; const DEFAULT_DAEMON_PORT = 21783; export function registerInspectCommand(program: Command, context: Context) { program - .command("inspect") - .description("Output the inspector URL for a client/session") + .command('inspect') + .description('Output the inspector URL for a client/session') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) - .option("--port ", "Daemon port", String(DEFAULT_DAEMON_PORT)) + .option('--port ', 'Daemon port', String(DEFAULT_DAEMON_PORT)) .action(async (options) => { const { clientId, sessionId } = await resolveClientAndSession( context, @@ -21,9 +27,9 @@ export function registerInspectCommand(program: Command, context: Context) { ); const port = parseInt(options.port, 10) || DEFAULT_DAEMON_PORT; - const inspectorUrl = `http://127.0.0.1:${port}/devtool/connector/inspector?clientId=${ - encodeURIComponent(clientId) - }&sessionId=${encodeURIComponent(sessionId)}`; + const inspectorUrl = `http://127.0.0.1:${port}/devtool/connector/inspector?clientId=${encodeURIComponent( + clientId, + )}&sessionId=${encodeURIComponent(sessionId)}`; console.log(inspectorUrl); }); diff --git a/packages/skills/lynx-devtool/src/commands/list-clients.ts b/packages/skills/lynx-devtool/src/commands/list-clients.ts index 90516b2..29d6a4b 100644 --- a/packages/skills/lynx-devtool/src/commands/list-clients.ts +++ b/packages/skills/lynx-devtool/src/commands/list-clients.ts @@ -1,26 +1,24 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Connector } from "@lynx-js/devtool-connector"; -import { Command } from "commander"; -import type { Context } from "./utils.ts"; +import { Connector } from '@lynx-js/devtool-connector'; +import type { Command } from 'commander'; +import type { Context } from './utils.ts'; const NO_CLIENTS_FOUND_MESSAGE = [ - "No Lynx DevTool clients were found.", - "", - "Try these steps:", - "1. Make sure the target device/simulator and app are running.", - "2. If the app just launched, wait a moment and rerun `list-clients`.", + 'No Lynx DevTool clients were found.', + '', + 'Try these steps:', + '1. Make sure the target device/simulator and app are running.', + '2. If the app just launched, wait a moment and rerun `list-clients`.', "3. If this is unexpected, rerun with `DEBUG='devtool-mcp-server:connector*'` or try `--no-daemon`.", - "", - "See `skills/lynx-devtool/references/troubleshooting/symptoms.md#list-clients-returns-` for more details.", -].join("\n"); + '', + 'See `skills/lynx-devtool/references/troubleshooting/symptoms.md#list-clients-returns-` for more details.', +].join('\n'); export async function runListClientsCommand( - connector: Pick, - { - print = console.log, - }: { print?: (line: string) => void } = {}, + connector: Pick, + { print = console.log }: { print?: (line: string) => void } = {}, ): Promise { const clients = await connector.listClients(); @@ -31,10 +29,13 @@ export async function runListClientsCommand( print(JSON.stringify(clients, null, 2)); } -export function registerListClientsCommand(program: Command, { transports }: Context) { +export function registerListClientsCommand( + program: Command, + { transports }: Context, +) { program - .command("list-clients") - .description("List all available clients") + .command('list-clients') + .description('List all available clients') .action(async () => { const connector = new Connector(transports); await runListClientsCommand(connector); diff --git a/packages/skills/lynx-devtool/src/commands/list-sessions.ts b/packages/skills/lynx-devtool/src/commands/list-sessions.ts index 2af9a47..6257ff8 100644 --- a/packages/skills/lynx-devtool/src/commands/list-sessions.ts +++ b/packages/skills/lynx-devtool/src/commands/list-sessions.ts @@ -1,13 +1,21 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClient } from "./utils.ts"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClient, +} from './utils.ts'; -export function registerListSessionsCommand(program: Command, context: Context) { +export function registerListSessionsCommand( + program: Command, + context: Context, +) { program - .command("list-sessions") - .description("List all available sessions") + .command('list-sessions') + .description('List all available sessions') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .action(async (options) => { diff --git a/packages/skills/lynx-devtool/src/commands/open.ts b/packages/skills/lynx-devtool/src/commands/open.ts index e114db2..e20afdc 100644 --- a/packages/skills/lynx-devtool/src/commands/open.ts +++ b/packages/skills/lynx-devtool/src/commands/open.ts @@ -1,37 +1,44 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Command } from "commander"; -import { FilterTransformStream, isListSessionResponse } from "../connector.ts"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClient } from "./utils.ts"; +import type { Command } from 'commander'; +import { FilterTransformStream, isListSessionResponse } from '../connector.ts'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClient, +} from './utils.ts'; export function registerOpenCommand(program: Command, context: Context) { program - .command("open") - .description("Open page") + .command('open') + .description('Open page') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) - .argument("", "The url of the page") + .argument('', 'The url of the page') .action(async (url, options) => { const { connector, clientId } = await resolveClient(context, options); - const result = await connector.sendMessage(clientId, { - event: "Customized", - data: { - type: "OpenCard", + const result = await connector.sendMessage( + clientId, + { + event: 'Customized', data: { - type: "url", - url, + type: 'OpenCard', + data: { + type: 'url', + url, + }, + sender: -1, }, - sender: -1, + from: -1, }, - from: -1, - }, { - input: [], - output: [ - new FilterTransformStream(isListSessionResponse), - ], - }); + { + input: [], + output: [new FilterTransformStream(isListSessionResponse)], + }, + ); console.log(JSON.stringify(result, null, 2)); }); diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/find.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/find.ts index 55c6adb..7f3000a 100644 --- a/packages/skills/lynx-devtool/src/commands/reactlynx/find.ts +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/find.ts @@ -1,12 +1,22 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "../utils.ts"; -import { formatTree } from "./format.ts"; -import { type DevNodeType, typeTag } from "./protocol.ts"; -import type { ID, RendererState } from "./protocol.ts"; -import { buildOutboundFrame, emptyTreeDiagnostic, runReactLynxSession } from "./transport.ts"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClientAndSession, + SESSION_OPTION, +} from '../utils.ts'; +import { formatTree } from './format.ts'; +import type { ID, RendererState } from './protocol.ts'; +import { type DevNodeType, typeTag } from './protocol.ts'; +import { + buildOutboundFrame, + emptyTreeDiagnostic, + runReactLynxSession, +} from './transport.ts'; interface FindOptions { client?: string; @@ -54,7 +64,7 @@ export function findComponents( } matches.push({ - label: idToLabel.get(id) ?? "@c?", + label: idToLabel.get(id) ?? '@c?', id: node.id, name: node.name, type: node.type, @@ -67,7 +77,9 @@ export function findComponents( return matches; } -export function buildSubstringMatcher(pattern: string): (name: string) => boolean { +export function buildSubstringMatcher( + pattern: string, +): (name: string) => boolean { const needle = pattern.toLowerCase(); return (name) => name.toLowerCase().includes(needle); } @@ -85,25 +97,32 @@ export function buildRegexMatcher(pattern: string): (name: string) => boolean { return (name) => re.test(name); } -export function registerFindCommand(reactlynx: Command, context: Context): void { +export function registerFindCommand( + reactlynx: Command, + context: Context, +): void { reactlynx - .command("find ") + .command('find ') .description( - "Find components by display name. Default match is case-insensitive substring; " - + "use --regex for a JavaScript regular expression.", + 'Find components by display name. Default match is case-insensitive substring; ' + + 'use --regex for a JavaScript regular expression.', ) .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) - .option("--regex", "Treat as a JavaScript regular expression", false) .option( - "--show-shells", - "Include the synthetic Fragment/Root/Anonymous wrappers ReactLynx inserts", + '--regex', + 'Treat as a JavaScript regular expression', false, ) .option( - "--limit ", - "Maximum number of matches to print (default: 50)", + '--show-shells', + 'Include the synthetic Fragment/Root/Anonymous wrappers ReactLynx inserts', + false, + ) + .option( + '--limit ', + 'Maximum number of matches to print (default: 50)', (v) => { const n = Number.parseInt(v, 10); if (!Number.isFinite(n) || n < 1) { @@ -114,8 +133,8 @@ export function registerFindCommand(reactlynx: Command, context: Context): void 50, ) .option( - "--json", - "Emit a JSON array `[{ label, id, name, type, key, ancestors: [{label, name}] }]`", + '--json', + 'Emit a JSON array `[{ label, id, name, type, key, ancestors: [{label, name}] }]`', false, ) .action(async (pattern: string, options: FindOptions) => { @@ -132,11 +151,13 @@ export function registerFindCommand(reactlynx: Command, context: Context): void connector, clientId, sessionId: Number(sessionId), - outbound: [buildOutboundFrame("refresh")], + outbound: [buildOutboundFrame('refresh')], }); if (result.state.tree.size === 0) { - process.stderr.write(`[reactlynx find] ${emptyTreeDiagnostic(result)}\n`); + process.stderr.write( + `[reactlynx find] ${emptyTreeDiagnostic(result)}\n`, + ); process.exitCode = 1; return; } @@ -148,19 +169,19 @@ export function registerFindCommand(reactlynx: Command, context: Context): void if (matches.length === 0) { process.stderr.write( - `[reactlynx find] no components match ${options.regex ? "regex" : "substring"} ${JSON.stringify(pattern)} ` - + `(searched ${result.state.tree.size} components${options.showShells ? "" : ", shells hidden"})\n`, + `[reactlynx find] no components match ${options.regex ? 'regex' : 'substring'} ${JSON.stringify(pattern)} ` + + `(searched ${result.state.tree.size} components${options.showShells ? '' : ', shells hidden'})\n`, ); process.exitCode = 1; return; } if (options.json) { - process.stdout.write(JSON.stringify(matches, null, 2) + "\n"); + process.stdout.write(JSON.stringify(matches, null, 2) + '\n'); return; } - process.stdout.write(formatMatches(matches) + "\n"); + process.stdout.write(formatMatches(matches) + '\n'); }); } @@ -172,10 +193,10 @@ export function formatMatches(matches: FindMatch[]): string { lines.push(header); if (match.ancestors.length > 0) { lines.push( - " in " - + match.ancestors.map((a) => `${a.label} ${a.name}`).join(" > "), + ' in ' + + match.ancestors.map((a) => `${a.label} ${a.name}`).join(' > '), ); } } - return lines.join("\n"); + return lines.join('\n'); } diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/format.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/format.ts index 8fcd390..614b69f 100644 --- a/packages/skills/lynx-devtool/src/commands/reactlynx/format.ts +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/format.ts @@ -1,17 +1,22 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { type ID, type RendererState, typeTag, type VNode } from "./protocol.ts"; +import { + type ID, + type RendererState, + typeTag, + type VNode, +} from './protocol.ts'; export interface FormattedTree { text: string; labels: ID[]; } -const PIPE = "│ "; -const TEE = "├─ "; -const ELBOW = "└─ "; -const SPACE = " "; +const PIPE = '│ '; +const TEE = '├─ '; +const ELBOW = '└─ '; +const SPACE = ' '; interface FormatContext { state: RendererState; @@ -22,7 +27,7 @@ interface FormatContext { hideShells: boolean; } -const SHELL_NAMES = new Set(["Fragment", "Root", "Anonymous"]); +const SHELL_NAMES = new Set(['Fragment', 'Root', 'Anonymous']); function isShell(node: VNode): boolean { return SHELL_NAMES.has(node.name); @@ -43,7 +48,7 @@ function visibleChildren(ctx: FormatContext, node: VNode): VNode[] { } function formatRef(ctx: FormatContext, node: VNode): string { - const label = ctx.labelOf.get(node.id) ?? "@c?"; + const label = ctx.labelOf.get(node.id) ?? '@c?'; let out = `${label} [${typeTag(node.type)}] ${node.name}`; if (node.key) out += ` key=${node.key}`; return out; @@ -57,15 +62,22 @@ function walk( isRoot: boolean, depth: number, ): void { - const connector = isRoot ? "" : isLast ? ELBOW : TEE; + const connector = isRoot ? '' : isLast ? ELBOW : TEE; ctx.lines.push(`${prefix}${connector}${formatRef(ctx, node)}`); if (depth >= ctx.maxDepth) return; const children = visibleChildren(ctx, node); - const childPrefix = isRoot ? "" : prefix + (isLast ? SPACE : PIPE); + const childPrefix = isRoot ? '' : prefix + (isLast ? SPACE : PIPE); children.forEach((child, idx) => { - walk(ctx, child, childPrefix, idx === children.length - 1, false, depth + 1); + walk( + ctx, + child, + childPrefix, + idx === children.length - 1, + false, + depth + 1, + ); }); } @@ -102,8 +114,8 @@ export function formatTree( for (const r of visibleRoots) assign(r, 1); visibleRoots.forEach((root, idx) => { - walk(ctx, root, "", idx === visibleRoots.length - 1, true, 1); + walk(ctx, root, '', idx === visibleRoots.length - 1, true, 1); }); - return { text: ctx.lines.join("\n"), labels: ctx.labels }; + return { text: ctx.lines.join('\n'), labels: ctx.labels }; } diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/index.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/index.ts index ba81e0c..35bc96b 100644 --- a/packages/skills/lynx-devtool/src/commands/reactlynx/index.ts +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/index.ts @@ -1,17 +1,22 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Command } from "commander"; -import type { Context } from "../utils.ts"; -import { registerFindCommand } from "./find.ts"; -import { registerComponentCommand } from "./inspect.ts"; -import { registerTreeCommand } from "./tree.ts"; -import { registerUpdateCommands } from "./update.ts"; +import type { Command } from 'commander'; +import type { Context } from '../utils.ts'; +import { registerFindCommand } from './find.ts'; +import { registerComponentCommand } from './inspect.ts'; +import { registerTreeCommand } from './tree.ts'; +import { registerUpdateCommands } from './update.ts'; -export function registerReactLynxCommand(program: Command, context: Context): void { +export function registerReactLynxCommand( + program: Command, + context: Context, +): void { const reactlynx = program - .command("reactlynx") - .description("Inspect a running ReactLynx app via @lynx-js/preact-devtools"); + .command('reactlynx') + .description( + 'Inspect a running ReactLynx app via @lynx-js/preact-devtools', + ); registerTreeCommand(reactlynx, context); registerComponentCommand(reactlynx, context); diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/inspect.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/inspect.ts index cd3707a..1138f3f 100644 --- a/packages/skills/lynx-devtool/src/commands/reactlynx/inspect.ts +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/inspect.ts @@ -1,12 +1,23 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "../utils.ts"; -import { formatTree } from "./format.ts"; -import { type DevNodeType, typeTag } from "./protocol.ts"; -import type { ID } from "./protocol.ts"; -import { buildOutboundFrame, emptyTreeDiagnostic, type PreactEnvelope, runReactLynxSession } from "./transport.ts"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClientAndSession, + SESSION_OPTION, +} from '../utils.ts'; +import { formatTree } from './format.ts'; +import type { ID } from './protocol.ts'; +import { type DevNodeType, typeTag } from './protocol.ts'; +import { + buildOutboundFrame, + emptyTreeDiagnostic, + type PreactEnvelope, + runReactLynxSession, +} from './transport.ts'; interface ComponentOptions { client?: string; @@ -33,14 +44,14 @@ export interface InspectResult { export function parseComponentRef( ref: string, -): { kind: "label"; index: number } | { kind: "id"; id: ID } { +): { kind: 'label'; index: number } | { kind: 'id'; id: ID } { const labelMatch = /^@c(\d+)$/.exec(ref); if (labelMatch) { const index = Number.parseInt(labelMatch[1]!, 10); if (!Number.isFinite(index) || index < 1) { throw new Error(`Invalid label ${ref}; expected @c1, @c2, ...`); } - return { kind: "label", index }; + return { kind: 'label', index }; } const numeric = Number.parseInt(ref, 10); if (!Number.isFinite(numeric) || String(numeric) !== ref.trim()) { @@ -48,44 +59,47 @@ export function parseComponentRef( `Invalid ${JSON.stringify(ref)}; expected @cN or a numeric id.`, ); } - return { kind: "id", id: numeric }; + return { kind: 'id', id: numeric }; } export function formatInspectResult(data: InspectResult, ref: string): string { const lines: string[] = []; - const headerKey = data.key ? ` key=${data.key}` : ""; - lines.push(`${ref} (id=${data.id}) [${typeTag(data.type)}] ${data.name}${headerKey}`); + const headerKey = data.key ? ` key=${data.key}` : ''; + lines.push( + `${ref} (id=${data.id}) [${typeTag(data.type)}] ${data.name}${headerKey}`, + ); if (data.__source) { lines.push( ` source: ${data.__source.fileName}:${data.__source.lineNumber}:${data.__source.columnNumber}`, ); } - if (data.suspended !== undefined && data.suspended) lines.push(" suspended: true"); + if (data.suspended !== undefined && data.suspended) + lines.push(' suspended: true'); - appendSection(lines, "props", data.props); - appendSection(lines, "state", data.state); - appendSection(lines, "hooks", data.hooks); - appendSection(lines, "context", data.context); - appendSection(lines, "signals", data.signals); + appendSection(lines, 'props', data.props); + appendSection(lines, 'state', data.state); + appendSection(lines, 'hooks', data.hooks); + appendSection(lines, 'context', data.context); + appendSection(lines, 'signals', data.signals); - return lines.join("\n"); + return lines.join('\n'); } function appendSection(lines: string[], label: string, value: unknown): void { if (value === null || value === undefined) return; if (Array.isArray(value) && value.length === 0) return; if ( - typeof value === "object" - && !Array.isArray(value) - && Object.keys(value as object).length === 0 + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value as object).length === 0 ) { return; } lines.push(` ${label}:`); const rendered = JSON.stringify(value, null, 2) - .split("\n") + .split('\n') .map((l) => ` ${l}`) - .join("\n"); + .join('\n'); lines.push(rendered); } @@ -94,21 +108,21 @@ export function registerComponentCommand( context: Context, ): void { reactlynx - .command("component ") + .command('component ') .description( - "Inspect a single component (props/state/hooks/context). " - + " is either `@cN` (resolved against `reactlynx tree`) or a numeric id.", + 'Inspect a single component (props/state/hooks/context). ' + + ' is either `@cN` (resolved against `reactlynx tree`) or a numeric id.', ) .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) .option( - "--show-shells", - "When resolving `@cN`, count synthetic Fragment/Root/Anonymous wrappers " - + "the same way `reactlynx tree --show-shells` does. Has no effect on numeric ids.", + '--show-shells', + 'When resolving `@cN`, count synthetic Fragment/Root/Anonymous wrappers ' + + 'the same way `reactlynx tree --show-shells` does. Has no effect on numeric ids.', false, ) - .option("--json", "Print the raw `InspectData` payload as JSON", false) + .option('--json', 'Print the raw `InspectData` payload as JSON', false) .action(async (ref: string, options: ComponentOptions) => { const { connector, clientId, sessionId } = await resolveClientAndSession( context, @@ -118,12 +132,12 @@ export function registerComponentCommand( let targetId: ID; const parsed = parseComponentRef(ref); - if (parsed.kind === "label") { + if (parsed.kind === 'label') { const snapshot = await runReactLynxSession({ connector, clientId, sessionId: Number(sessionId), - outbound: [buildOutboundFrame("refresh")], + outbound: [buildOutboundFrame('refresh')], }); if (snapshot.state.tree.size === 0) { @@ -155,37 +169,42 @@ export function registerComponentCommand( connector, clientId, sessionId: Number(sessionId), - outbound: [buildOutboundFrame("inspect", targetId)], + outbound: [buildOutboundFrame('inspect', targetId)], idleMs: 1_000, maxMs: 5_000, onEnvelope: (env: PreactEnvelope) => { - if (env.type === "inspect-result" && env.data && typeof env.data === "object") { + if ( + env.type === 'inspect-result' && + env.data && + typeof env.data === 'object' + ) { inspectResult = env.data as InspectResult; - return "stop"; + return 'stop'; } - return "continue"; + return 'continue'; }, }); if (!inspectResult) { - const types = [...inspectSession.envelopeTypes].sort().join(",") || "(none)"; + const types = + [...inspectSession.envelopeTypes].sort().join(',') || '(none)'; process.stderr.write( - `[reactlynx component] no \`inspect-result\` for id ${targetId} after ${inspectSession.framesSeen} frame(s) ` - + `(types=${types}). Common causes:\n` - + ` - the id is stale (the App has unmounted that component since the snapshot was taken)\n` - + ` - the App is running an old @lynx-js/preact-devtools that doesn't honor \`inspect\`\n` - + ` - the targeted thread does not have a Preact renderer (e.g. you picked a non-ReactLynx session).\n` - + `Rerun with DEBUG=devtool-mcp-server:reactlynx to see every frame.\n`, + `[reactlynx component] no \`inspect-result\` for id ${targetId} after ${inspectSession.framesSeen} frame(s) ` + + `(types=${types}). Common causes:\n` + + ` - the id is stale (the App has unmounted that component since the snapshot was taken)\n` + + ` - the App is running an old @lynx-js/preact-devtools that doesn't honor \`inspect\`\n` + + ` - the targeted thread does not have a Preact renderer (e.g. you picked a non-ReactLynx session).\n` + + `Rerun with DEBUG=devtool-mcp-server:reactlynx to see every frame.\n`, ); process.exitCode = 1; return; } if (options.json) { - process.stdout.write(JSON.stringify(inspectResult, null, 2) + "\n"); + process.stdout.write(JSON.stringify(inspectResult, null, 2) + '\n'); return; } - process.stdout.write(formatInspectResult(inspectResult, ref) + "\n"); + process.stdout.write(formatInspectResult(inspectResult, ref) + '\n'); }); } diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/protocol.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/protocol.ts index b4193a2..ebd7811 100644 --- a/packages/skills/lynx-devtool/src/commands/reactlynx/protocol.ts +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/protocol.ts @@ -59,13 +59,13 @@ function parseStringTable(slice: number[]): string[] { const strLen = slice[i]!; let start = i + 1; const end = i + strLen + 1; - let str = ""; + let str = ''; for (; start < end; start++) { const code = slice[start]; - if (typeof code === "number" && code >= 0 && code <= 0x10FFFF) { + if (typeof code === 'number' && code >= 0 && code <= 0x10ffff) { str += String.fromCodePoint(code); } else { - str += "?"; + str += '?'; } } strings.push(str); @@ -106,8 +106,8 @@ export function applyOperationV2(state: RendererState, ops: number[]): void { tree.set(id, { id, type, - name: strings[nameId - 1] ?? "", - key: keyId > 0 ? (strings[keyId - 1] ?? "") : "", + name: strings[nameId - 1] ?? '', + key: keyId > 0 ? (strings[keyId - 1] ?? '') : '', parent: parentId, owner, children: [], @@ -174,7 +174,7 @@ export function applyOperationV2(state: RendererState, ops: number[]): void { } case MsgType.COMMIT_STATS: { throw new Error( - "operation_v2 commit-stats not implemented; enable stats parsing if needed", + 'operation_v2 commit-stats not implemented; enable stats parsing if needed', ); } case MsgType.HOC_NODES: { @@ -195,26 +195,26 @@ export function applyRootOrder(state: RendererState, rootOrder: ID[]): void { export function typeTag(type: DevNodeType): string { switch (type) { case DevNodeType.FunctionComponent: - return "fn"; + return 'fn'; case DevNodeType.ClassComponent: - return "cls"; + return 'cls'; case DevNodeType.ForwardRef: - return "fRef"; + return 'fRef'; case DevNodeType.Memo: - return "memo"; + return 'memo'; case DevNodeType.Suspense: - return "susp"; + return 'susp'; case DevNodeType.Context: - return "ctx"; + return 'ctx'; case DevNodeType.Consumer: - return "cons"; + return 'cons'; case DevNodeType.Portal: - return "portal"; + return 'portal'; case DevNodeType.Element: - return "host"; + return 'host'; case DevNodeType.Group: - return "group"; + return 'group'; default: - return "?"; + return '?'; } } diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/transport.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/transport.ts index 3f14b04..5d37f85 100644 --- a/packages/skills/lynx-devtool/src/commands/reactlynx/transport.ts +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/transport.ts @@ -1,17 +1,23 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from "@lynx-js/devtool-connector"; -import { ReadableStream } from "node:stream/web"; -import { createDebug } from "obug"; -import { readUntilIdle } from "../utils.ts"; -import { applyOperationV2, applyRootOrder, createRendererState, type RendererState } from "./protocol.ts"; -const debug = createDebug("devtool-mcp-server:reactlynx"); +import { ReadableStream } from 'node:stream/web'; +import type { Connector } from '@lynx-js/devtool-connector'; +import { createDebug } from 'obug'; +import { readUntilIdle } from '../utils.ts'; +import { + applyOperationV2, + applyRootOrder, + createRendererState, + type RendererState, +} from './protocol.ts'; -const PREACT_EVENT = "PreactDevtools"; -const SOURCE_PAGE_HOOK = "preact-page-hook"; -const SOURCE_DEVTOOLS_TO_CLIENT = "preact-devtools-to-client"; +const debug = createDebug('devtool-mcp-server:reactlynx'); + +const PREACT_EVENT = 'PreactDevtools'; +const SOURCE_PAGE_HOOK = 'preact-page-hook'; +const SOURCE_DEVTOOLS_TO_CLIENT = 'preact-devtools-to-client'; export const DEFAULT_IDLE_MS = 700; export const DEFAULT_MAX_MS = 5_000; @@ -38,17 +44,15 @@ export function buildOutboundFrame( data?: T, ): OutboundCDPFrame { return { - method: "Lynx.sendVMEvent", + method: 'Lynx.sendVMEvent', params: { - vmType: "JSContext", + vmType: 'JSContext', event: PREACT_EVENT, - data: JSON.stringify( - { - source: SOURCE_DEVTOOLS_TO_CLIENT, - type, - data: data ?? null, - } satisfies PreactEnvelope, - ), + data: JSON.stringify({ + source: SOURCE_DEVTOOLS_TO_CLIENT, + type, + data: data ?? null, + } satisfies PreactEnvelope), }, }; } @@ -61,7 +65,7 @@ export interface SessionResult { envelopeTypes: Set; } -export type EnvelopeAction = "continue" | "stop"; +export type EnvelopeAction = 'continue' | 'stop'; export interface RunSessionOptions { connector: Connector; @@ -84,7 +88,7 @@ export async function runReactLynxSession( sessionId, outbound, sendInit = true, - onEnvelope = () => "continue", + onEnvelope = () => 'continue', idleMs = DEFAULT_IDLE_MS, maxMs = DEFAULT_MAX_MS, signal, @@ -94,7 +98,7 @@ export async function runReactLynxSession( let cancelInput: () => void = () => {}; const input = new ReadableStream({ start(controller) { - if (sendInit) controller.enqueue(buildOutboundFrame("init")); + if (sendInit) controller.enqueue(buildOutboundFrame('init')); for (const frame of outbound) controller.enqueue(frame); cancelInput = () => { try { @@ -120,22 +124,20 @@ export async function runReactLynxSession( const envelopeTypes = new Set(); try { - for await ( - const value of readUntilIdle( - stream as unknown as ReadableStream, - { idleMs, maxMs }, - ) - ) { - if (typeof value !== "object" || value === null) continue; + for await (const value of readUntilIdle( + stream as unknown as ReadableStream, + { idleMs, maxMs }, + )) { + if (typeof value !== 'object' || value === null) continue; const method = (value as { method?: string }).method; - if (method !== "Lynx.onVMEvent") continue; + if (method !== 'Lynx.onVMEvent') continue; const params = (value as { params?: LynxOnVMEventParams }).params ?? {}; if (params.event !== PREACT_EVENT) continue; let envelope: PreactEnvelope; try { - envelope = JSON.parse(params.data ?? "null") as PreactEnvelope; + envelope = JSON.parse(params.data ?? 'null') as PreactEnvelope; } catch { continue; } @@ -144,7 +146,7 @@ export async function runReactLynxSession( framesSeen += 1; envelopeTypes.add(envelope.type); debug( - "frame %d: type=%s dataSize=%s", + 'frame %d: type=%s dataSize=%s', framesSeen, envelope.type, Array.isArray(envelope.data) @@ -153,13 +155,13 @@ export async function runReactLynxSession( ); switch (envelope.type) { - case "operation_v2": + case 'operation_v2': if (Array.isArray(envelope.data)) { operationFrames += 1; applyOperationV2(state, envelope.data as number[]); } break; - case "root-order": + case 'root-order': if (Array.isArray(envelope.data)) { rootOrderFrames += 1; applyRootOrder(state, envelope.data as number[]); @@ -167,7 +169,7 @@ export async function runReactLynxSession( break; } - if (onEnvelope(envelope) === "stop") { + if (onEnvelope(envelope) === 'stop') { stopRequested = true; break; } @@ -177,7 +179,7 @@ export async function runReactLynxSession( } debug( - "session done: stop=%s frames=%d op=%d root=%d types=%o", + 'session done: stop=%s frames=%d op=%d root=%d types=%o', stopRequested, framesSeen, operationFrames, @@ -191,22 +193,22 @@ export async function runReactLynxSession( export function emptyTreeDiagnostic(result: SessionResult): string { if (result.framesSeen === 0) { return ( - "saw 0 frames -- the App is silent on the PreactDevtools channel. " - + "Most likely `@lynx-js/preact-devtools` is not installed, the bundle is a production build " - + "(`setupReactLynx()` is stripped from `react-lynx/index.ts:3`), or `setupReactLynx()` has not run yet. " - + "Look for `[PREACT DEVTOOLS] Devtools initialized successfully` in the device console." + 'saw 0 frames -- the App is silent on the PreactDevtools channel. ' + + 'Most likely `@lynx-js/preact-devtools` is not installed, the bundle is a production build ' + + '(`setupReactLynx()` is stripped from `react-lynx/index.ts:3`), or `setupReactLynx()` has not run yet. ' + + 'Look for `[PREACT DEVTOOLS] Devtools initialized successfully` in the device console.' ); } if (result.operationFrames === 0) { return ( - `saw ${result.framesSeen} frame(s) but no \`operation_v2\` -- ` - + "`@lynx-js/preact-devtools` is loaded but does not honor `refresh`. " - + "Upgrade to a build that includes the PR #2 (`document.body`) and PR #5 (`preactDevtoolsCtx.Node`) fixes from `lynx-family/preact-devtools`." + `saw ${result.framesSeen} frame(s) but no \`operation_v2\` -- ` + + '`@lynx-js/preact-devtools` is loaded but does not honor `refresh`. ' + + 'Upgrade to a build that includes the PR #2 (`document.body`) and PR #5 (`preactDevtoolsCtx.Node`) fixes from `lynx-family/preact-devtools`.' ); } return ( - `saw ${result.framesSeen} frame(s) including ${result.operationFrames} \`operation_v2\` ` - + "but the resulting tree is empty (every node was unmounted). " - + "This is unusual -- rerun with `DEBUG=devtool-mcp-server:reactlynx` to inspect each frame's payload." + `saw ${result.framesSeen} frame(s) including ${result.operationFrames} \`operation_v2\` ` + + 'but the resulting tree is empty (every node was unmounted). ' + + "This is unusual -- rerun with `DEBUG=devtool-mcp-server:reactlynx` to inspect each frame's payload." ); } diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/tree.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/tree.ts index 4b8f96e..8e0a62c 100644 --- a/packages/skills/lynx-devtool/src/commands/reactlynx/tree.ts +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/tree.ts @@ -1,23 +1,36 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "../utils.ts"; -import { formatTree } from "./format.ts"; -import { buildOutboundFrame, emptyTreeDiagnostic, runReactLynxSession } from "./transport.ts"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClientAndSession, + SESSION_OPTION, +} from '../utils.ts'; +import { formatTree } from './format.ts'; +import { + buildOutboundFrame, + emptyTreeDiagnostic, + runReactLynxSession, +} from './transport.ts'; -export function registerTreeCommand(reactlynx: Command, context: Context): void { +export function registerTreeCommand( + reactlynx: Command, + context: Context, +): void { reactlynx - .command("tree") + .command('tree') .description( - "Print the ReactLynx component tree as an ASCII diagram with @cN labels.", + 'Print the ReactLynx component tree as an ASCII diagram with @cN labels.', ) .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) .option( - "--depth ", - "Maximum tree depth to print (default: unbounded)", + '--depth ', + 'Maximum tree depth to print (default: unbounded)', (v) => { const n = Number.parseInt(v, 10); if (!Number.isFinite(n) || n < 1) { @@ -27,13 +40,13 @@ export function registerTreeCommand(reactlynx: Command, context: Context): void }, ) .option( - "--show-shells", - "Include the synthetic Fragment/Root/Anonymous wrappers ReactLynx inserts", + '--show-shells', + 'Include the synthetic Fragment/Root/Anonymous wrappers ReactLynx inserts', false, ) .option( - "--json", - "Emit a JSON object { labels, roots, nodes } instead of ASCII", + '--json', + 'Emit a JSON object { labels, roots, nodes } instead of ASCII', false, ) .action(async (options) => { @@ -46,11 +59,13 @@ export function registerTreeCommand(reactlynx: Command, context: Context): void connector, clientId, sessionId: Number(sessionId), - outbound: [buildOutboundFrame("refresh")], + outbound: [buildOutboundFrame('refresh')], }); if (result.state.tree.size === 0) { - process.stderr.write(`[reactlynx tree] ${emptyTreeDiagnostic(result)}\n`); + process.stderr.write( + `[reactlynx tree] ${emptyTreeDiagnostic(result)}\n`, + ); process.exitCode = 1; return; } @@ -74,10 +89,10 @@ export function registerTreeCommand(reactlynx: Command, context: Context): void { labels: formatted.labels, roots: result.state.roots, nodes }, null, 2, - ) + "\n", + ) + '\n', ); } else { - process.stdout.write(formatted.text + "\n"); + process.stdout.write(formatted.text + '\n'); } }); } diff --git a/packages/skills/lynx-devtool/src/commands/reactlynx/update.ts b/packages/skills/lynx-devtool/src/commands/reactlynx/update.ts index 65e64b4..c617d9e 100644 --- a/packages/skills/lynx-devtool/src/commands/reactlynx/update.ts +++ b/packages/skills/lynx-devtool/src/commands/reactlynx/update.ts @@ -1,14 +1,29 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "../utils.ts"; -import { formatTree } from "./format.ts"; -import { formatInspectResult, type InspectResult, parseComponentRef } from "./inspect.ts"; -import type { ID } from "./protocol.ts"; -import { buildOutboundFrame, emptyTreeDiagnostic, type PreactEnvelope, runReactLynxSession } from "./transport.ts"; - -export type UpdateKind = "update-prop" | "update-state" | "update-context"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClientAndSession, + SESSION_OPTION, +} from '../utils.ts'; +import { formatTree } from './format.ts'; +import { + formatInspectResult, + type InspectResult, + parseComponentRef, +} from './inspect.ts'; +import type { ID } from './protocol.ts'; +import { + buildOutboundFrame, + emptyTreeDiagnostic, + type PreactEnvelope, + runReactLynxSession, +} from './transport.ts'; + +export type UpdateKind = 'update-prop' | 'update-state' | 'update-context'; interface UpdateOptions { client?: string; @@ -24,14 +39,17 @@ interface UpdatePayload { value: unknown; } -export function parseUpdateValue(input: string, options: { raw: boolean }): unknown { +export function parseUpdateValue( + input: string, + options: { raw: boolean }, +): unknown { if (options.raw) return input; try { return JSON.parse(input); } catch (err) { throw new Error( - ` must be valid JSON (e.g. \`"hello"\`, \`42\`, \`true\`, \`null\`, \`{"a":1}\`); ` - + `pass --raw to send the input verbatim as a string. Underlying error: ${ + ` must be valid JSON (e.g. \`"hello"\`, \`42\`, \`true\`, \`null\`, \`{"a":1}\`); ` + + `pass --raw to send the input verbatim as a string. Underlying error: ${ err instanceof Error ? err.message : String(err) }`, { cause: err }, @@ -42,45 +60,50 @@ export function parseUpdateValue(input: string, options: { raw: boolean }): unkn export function buildUpdatePath(userPath: string): string { if (userPath.length === 0) { throw new Error( - " must not be empty. Use dot notation, e.g. `count`, `user.name`, `items.0.title`.", + ' must not be empty. Use dot notation, e.g. `count`, `user.name`, `items.0.title`.', ); } - for (const prefix of ["root.", "props.", "state.", "context."] as const) { + for (const prefix of ['root.', 'props.', 'state.', 'context.'] as const) { if (userPath.startsWith(prefix)) { throw new Error( - ` ${JSON.stringify(userPath)} must not start with \`${prefix}\`. ` - + "The CLI prepends `root.` automatically; pass paths starting at the field name, e.g. `count`.", + ` ${JSON.stringify(userPath)} must not start with \`${prefix}\`. ` + + 'The CLI prepends `root.` automatically; pass paths starting at the field name, e.g. `count`.', ); } } - for (const segment of userPath.split(".")) { + for (const segment of userPath.split('.')) { if (segment.length === 0) { throw new Error( - ` ${JSON.stringify(userPath)} contains an empty segment. ` - + "Dot notation must look like `a.b.c`, not `a..b` or `.a`.", + ` ${JSON.stringify(userPath)} contains an empty segment. ` + + 'Dot notation must look like `a.b.c`, not `a..b` or `.a`.', ); } } return `root.${userPath}`; } -export function registerUpdateCommands(reactlynx: Command, context: Context): void { +export function registerUpdateCommands( + reactlynx: Command, + context: Context, +): void { registerOneUpdate(reactlynx, context, { - name: "update-prop", - description: "Set a prop on a single ReactLynx component (forceUpdate is called for you)", - kind: "update-prop", + name: 'update-prop', + description: + 'Set a prop on a single ReactLynx component (forceUpdate is called for you)', + kind: 'update-prop', }); registerOneUpdate(reactlynx, context, { - name: "update-state", - description: "Set a state field on a single class component (forceUpdate is called for you)", - kind: "update-state", + name: 'update-state', + description: + 'Set a state field on a single class component (forceUpdate is called for you)', + kind: 'update-state', }); registerOneUpdate(reactlynx, context, { - name: "update-context", + name: 'update-context', description: - "Set a context value on a single component. Best-effort; upstream may make this read-only in the future.", - kind: "update-context", + 'Set a context value on a single component. Best-effort; upstream may make this read-only in the future.', + kind: 'update-context', }); } @@ -96,19 +119,19 @@ function registerOneUpdate( .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) .option( - "--show-shells", - "When resolving `@cN`, count synthetic Fragment/Root/Anonymous wrappers " - + "the same way `reactlynx tree --show-shells` does. No effect for numeric ids.", + '--show-shells', + 'When resolving `@cN`, count synthetic Fragment/Root/Anonymous wrappers ' + + 'the same way `reactlynx tree --show-shells` does. No effect for numeric ids.', false, ) .option( - "--raw", - "Send verbatim as a string instead of parsing it as JSON", + '--raw', + 'Send verbatim as a string instead of parsing it as JSON', false, ) .option( - "--json", - "Print the post-update `InspectData` as JSON instead of an ASCII summary", + '--json', + 'Print the post-update `InspectData` as JSON instead of an ASCII summary', false, ) .action( @@ -121,19 +144,17 @@ function registerOneUpdate( const path = buildUpdatePath(userPath); const value = parseUpdateValue(rawValue, { raw: options.raw ?? false }); - const { connector, clientId, sessionId } = await resolveClientAndSession( - context, - options, - ); + const { connector, clientId, sessionId } = + await resolveClientAndSession(context, options); let targetId: ID; const parsed = parseComponentRef(ref); - if (parsed.kind === "label") { + if (parsed.kind === 'label') { const snapshot = await runReactLynxSession({ connector, clientId, sessionId: Number(sessionId), - outbound: [buildOutboundFrame("refresh")], + outbound: [buildOutboundFrame('refresh')], }); if (snapshot.state.tree.size === 0) { @@ -176,39 +197,39 @@ function registerOneUpdate( maxMs: 5_000, onEnvelope: (env: PreactEnvelope) => { if ( - env.type === "inspect-result" - && env.data - && typeof env.data === "object" - && (env.data as { id?: number }).id === targetId + env.type === 'inspect-result' && + env.data && + typeof env.data === 'object' && + (env.data as { id?: number }).id === targetId ) { confirmation = env.data as InspectResult; - return "stop"; + return 'stop'; } - return "continue"; + return 'continue'; }, }); if (!confirmation) { - const types = [...session.envelopeTypes].sort().join(",") || "(none)"; + const types = [...session.envelopeTypes].sort().join(',') || '(none)'; process.stderr.write( - `[reactlynx ${spec.name}] no confirmation \`inspect-result\` for id ${targetId} after ` - + `${session.framesSeen} frame(s) (types=${types}). Common causes:\n` - + ` - the path is wrong (the App's setInCopy walks objects/arrays; non-existent intermediate keys are created, but typos still produce a no-op forceUpdate)\n` - + ` - the id is stale (component unmounted between snapshot and update)\n` - + ` - the App is running an old @lynx-js/preact-devtools that doesn't honor \`${spec.kind}\`\n` - + ` - for update-state/update-context: the target is a function component (those have neither)\n` - + `Rerun with DEBUG=devtool-mcp-server:reactlynx to see every frame.\n`, + `[reactlynx ${spec.name}] no confirmation \`inspect-result\` for id ${targetId} after ` + + `${session.framesSeen} frame(s) (types=${types}). Common causes:\n` + + ` - the path is wrong (the App's setInCopy walks objects/arrays; non-existent intermediate keys are created, but typos still produce a no-op forceUpdate)\n` + + ` - the id is stale (component unmounted between snapshot and update)\n` + + ` - the App is running an old @lynx-js/preact-devtools that doesn't honor \`${spec.kind}\`\n` + + ` - for update-state/update-context: the target is a function component (those have neither)\n` + + `Rerun with DEBUG=devtool-mcp-server:reactlynx to see every frame.\n`, ); process.exitCode = 1; return; } if (options.json) { - process.stdout.write(JSON.stringify(confirmation, null, 2) + "\n"); + process.stdout.write(JSON.stringify(confirmation, null, 2) + '\n'); return; } - process.stdout.write(formatInspectResult(confirmation, ref) + "\n"); + process.stdout.write(formatInspectResult(confirmation, ref) + '\n'); }, ); } diff --git a/packages/skills/lynx-devtool/src/commands/recorder-analysis.ts b/packages/skills/lynx-devtool/src/commands/recorder-analysis.ts index d47e325..e7e8f5c 100644 --- a/packages/skills/lynx-devtool/src/commands/recorder-analysis.ts +++ b/packages/skills/lynx-devtool/src/commands/recorder-analysis.ts @@ -1,8 +1,8 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import path from "node:path"; -import zlib from "node:zlib"; +import path from 'node:path'; +import zlib from 'node:zlib'; export interface FileDiagnostic { file: string; @@ -15,26 +15,29 @@ export interface FileDiagnostic { } interface Action { - "Function Name": string; - "Record Time"?: string; + 'Function Name': string; + 'Record Time'?: string; Params?: Record; } -type RecordingData = Action[] | { "Action List"?: Action[] }; +type RecordingData = Action[] | { 'Action List'?: Action[] }; -export function analyzeRecordingBuffer(filePath: string, buffer: Buffer): FileDiagnostic { +export function analyzeRecordingBuffer( + filePath: string, + buffer: Buffer, +): FileDiagnostic { const fileSizeBytes = buffer.byteLength; let recording: RecordingData; let parseFailed = false; try { - const raw = buffer.toString("utf-8").trim(); - if (raw.startsWith("{") || raw.startsWith("[")) { + const raw = buffer.toString('utf-8').trim(); + if (raw.startsWith('{') || raw.startsWith('[')) { recording = JSON.parse(raw); } else { - const decoded = Buffer.from(raw, "base64"); + const decoded = Buffer.from(raw, 'base64'); const inflated = zlib.inflateSync(decoded); - recording = JSON.parse(inflated.toString("utf-8")); + recording = JSON.parse(inflated.toString('utf-8')); } } catch { parseFailed = true; @@ -49,21 +52,27 @@ export function analyzeRecordingBuffer(filePath: string, buffer: Buffer): FileDi actions: 0, hasTemplate: false, functionDistribution: {}, - verdict: "Cannot parse — file is not a valid TestBench recording", + verdict: 'Cannot parse — file is not a valid TestBench recording', }; } - const actions = Array.isArray(recording) ? recording : recording["Action List"] ?? []; + const actions = Array.isArray(recording) + ? recording + : (recording['Action List'] ?? []); const functionDistribution: Record = {}; for (const action of actions) { - const fn = action["Function Name"]; + const fn = action['Function Name']; functionDistribution[fn] = (functionDistribution[fn] ?? 0) + 1; } - const hasTemplate = actions.some(a => a["Function Name"] === "loadTemplate"); - const hasTouchEvents = actions.some(a => - a["Function Name"] === "SendTouchEvent" || a["Function Name"] === "sendEventDarwin" + const hasTemplate = actions.some( + (a) => a['Function Name'] === 'loadTemplate', + ); + const hasTouchEvents = actions.some( + (a) => + a['Function Name'] === 'SendTouchEvent' || + a['Function Name'] === 'sendEventDarwin', ); let healthy: boolean; @@ -71,18 +80,18 @@ export function analyzeRecordingBuffer(filePath: string, buffer: Buffer): FileDi if (actions.length === 0) { healthy = false; - verdict = "Empty recording — no actions captured"; + verdict = 'Empty recording — no actions captured'; } else if (hasTemplate) { healthy = true; - verdict = "Valid recording — includes template load and interaction data"; + verdict = 'Valid recording — includes template load and interaction data'; } else if (hasTouchEvents) { healthy = true; verdict = - "Recording captures touch events but no template load — still useful for analyzing interactions, but cannot be replayed in Lynx Explorer"; + 'Recording captures touch events but no template load — still useful for analyzing interactions, but cannot be replayed in Lynx Explorer'; } else { healthy = true; verdict = - "Recording has actions but no template load — useful for inspecting JSB calls or data updates, but cannot be replayed"; + 'Recording has actions but no template load — useful for inspecting JSB calls or data updates, but cannot be replayed'; } return { @@ -96,9 +105,17 @@ export function analyzeRecordingBuffer(filePath: string, buffer: Buffer): FileDi }; } -export function recordingOutputPath(basePath: string, sessionId: number, index: number): string { - const suffix = sessionId > 0 ? `-session${sessionId}` : index > 0 ? `-${index}` : ""; +export function recordingOutputPath( + basePath: string, + sessionId: number, + index: number, +): string { + const suffix = + sessionId > 0 ? `-session${sessionId}` : index > 0 ? `-${index}` : ''; if (!suffix) return basePath; const parsed = path.parse(basePath); - return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || ".json"}`); + return path.join( + parsed.dir, + `${parsed.name}${suffix}${parsed.ext || '.json'}`, + ); } diff --git a/packages/skills/lynx-devtool/src/commands/recorder-end.ts b/packages/skills/lynx-devtool/src/commands/recorder-end.ts index 42b8a64..7f30d7b 100644 --- a/packages/skills/lynx-devtool/src/commands/recorder-end.ts +++ b/packages/skills/lynx-devtool/src/commands/recorder-end.ts @@ -1,34 +1,50 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { type CDPResponseMessage, Connector } from "@lynx-js/devtool-connector"; -import { Command } from "commander"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { ReadableStream } from "node:stream/web"; -import { analyzeRecordingBuffer, type FileDiagnostic, recordingOutputPath } from "./recorder-analysis.ts"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, isAbortError, resolveClient } from "./utils.ts"; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { ReadableStream } from 'node:stream/web'; +import type { CDPResponseMessage, Connector } from '@lynx-js/devtool-connector'; +import type { Command } from 'commander'; +import { + analyzeRecordingBuffer, + type FileDiagnostic, + recordingOutputPath, +} from './recorder-analysis.ts'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + isAbortError, + resolveClient, +} from './utils.ts'; const IO_READ_CHUNK_SIZE = 1024 * 1024; const RECORDING_END_TIMEOUT_MS = 60_000; export function registerEndCommand(parent: Command, context: Context) { parent - .command("end") - .description("Stop TestBench recording and save the replay file") + .command('end') + .description('Stop TestBench recording and save the replay file') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option( - "-o, --output ", - "Output file or directory path (defaults to ~/.lynx-devtool/files/lynxrecorder/recording--.json)", + '-o, --output ', + 'Output file or directory path (defaults to ~/.lynx-devtool/files/lynxrecorder/recording--.json)', ) .action(async (options) => { const { connector, clientId } = await resolveClient(context, options); const { output } = options; const result = await runRecordingEnd(connector, clientId, output); - console.log(JSON.stringify({ success: true, message: "Recording ended successfully.", ...result })); + console.log( + JSON.stringify({ + success: true, + message: 'Recording ended successfully.', + ...result, + }), + ); }); } @@ -36,20 +52,27 @@ export async function runRecordingEnd( connector: Connector, clientId: string, output: string | undefined, -): Promise<{ savedFiles: string[]; recordingComplete: Record; diagnostics: FileDiagnostic[] }> { - const recordingComplete = await readRecordingCompleteEvent(connector, clientId); +): Promise<{ + savedFiles: string[]; + recordingComplete: Record; + diagnostics: FileDiagnostic[]; +}> { + const recordingComplete = await readRecordingCompleteEvent( + connector, + clientId, + ); const savedFiles: string[] = []; const diagnostics: FileDiagnostic[] = []; const baseOutputPath = await resolveRecordingBaseOutputPath(output, clientId); - const streams = recordingComplete["stream"] as number[] | undefined; - const sessionIDs = recordingComplete["sessionIDs"] as number[] | undefined; + const streams = recordingComplete['stream'] as number[] | undefined; + const sessionIDs = recordingComplete['sessionIDs'] as number[] | undefined; if (!Array.isArray(streams) || streams.length === 0) { throw new Error( - "Recording.recordingComplete did not include any streams. " - + "If recording was never started, run `recorder start` first.", + 'Recording.recordingComplete did not include any streams. ' + + 'If recording was never started, run `recorder start` first.', ); } @@ -57,15 +80,24 @@ export async function runRecordingEnd( const sessionId = sessionIDs?.[index]; if (sessionId === undefined) { throw new Error( - "Recording.recordingComplete returned mismatched `stream` and `sessionIDs` lengths. " - + "Reconnect and retry `recorder end`.", + 'Recording.recordingComplete returned mismatched `stream` and `sessionIDs` lengths. ' + + 'Reconnect and retry `recorder end`.', ); } if (sessionId === -1) continue; const signal = AbortSignal.timeout(RECORDING_END_TIMEOUT_MS); - const data = await readStreamFully(connector, clientId, streamHandle, signal); - const filePath = recordingOutputPath(baseOutputPath, sessionId, savedFiles.length); + const data = await readStreamFully( + connector, + clientId, + streamHandle, + signal, + ); + const filePath = recordingOutputPath( + baseOutputPath, + sessionId, + savedFiles.length, + ); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, data); savedFiles.push(filePath); @@ -74,24 +106,29 @@ export async function runRecordingEnd( if (savedFiles.length === 0) { throw new Error( - buildNoPageRecordingMessage(clientId, recordingComplete, streams, sessionIDs), + buildNoPageRecordingMessage( + clientId, + recordingComplete, + streams, + sessionIDs, + ), ); } - const unhealthy = diagnostics.filter(d => !d.healthy); + const unhealthy = diagnostics.filter((d) => !d.healthy); if (unhealthy.length > 0) { console.warn( - "Recording saved, but the following file(s) may be unusable:\n" - + unhealthy.map(d => ` - ${d.file}: ${d.verdict}`).join("\n"), + 'Recording saved, but the following file(s) may be unusable:\n' + + unhealthy.map((d) => ` - ${d.file}: ${d.verdict}`).join('\n'), ); } - const noTemplate = diagnostics.filter(d => d.healthy && !d.hasTemplate); + const noTemplate = diagnostics.filter((d) => d.healthy && !d.hasTemplate); if (noTemplate.length > 0) { console.warn( - "Note: the following file(s) have no `loadTemplate` action and cannot be replayed,\n" - + "but may still be useful for inspecting recorded behavior:\n" - + noTemplate.map(d => ` - ${d.file}: ${d.verdict}`).join("\n"), + 'Note: the following file(s) have no `loadTemplate` action and cannot be replayed,\n' + + 'but may still be useful for inspecting recorded behavior:\n' + + noTemplate.map((d) => ` - ${d.file}: ${d.verdict}`).join('\n'), ); } @@ -104,21 +141,22 @@ function buildNoPageRecordingMessage( streams: number[], sessionIDs: number[] | undefined, ): string { - const filenames = recordingComplete["filenames"]; - const nativeFiles = Array.isArray(filenames) && filenames.length > 0 - ? ` Native filenames: ${JSON.stringify(filenames)}.` - : ""; + const filenames = recordingComplete['filenames']; + const nativeFiles = + Array.isArray(filenames) && filenames.length > 0 + ? ` Native filenames: ${JSON.stringify(filenames)}.` + : ''; return [ - "Recording ended, but no page recording was produced.", + 'Recording ended, but no page recording was produced.', `Native returned sessionIDs=${JSON.stringify(sessionIDs ?? [])}, streams=${streams.length}.${nativeFiles}`, - "This usually means no Lynx page session was opened or reloaded after `recorder start`.", - "To produce a replayable file:", + 'This usually means no Lynx page session was opened or reloaded after `recorder start`.', + 'To produce a replayable file:', `1. Run \`list-sessions --client ${clientId}\` and confirm there is a Lynx session.`, - "2. After `recorder start`, open or reload the target page.", + '2. After `recorder start`, open or reload the target page.', ` Example: \`cdp --client ${clientId} --session -m Page.reload '{"ignoreCache":true}'\``, - "3. Interact with the page, then run `recorder end` again.", - ].join("\n"); + '3. Interact with the page, then run `recorder end` again.', + ].join('\n'); } async function readRecordingCompleteEvent( @@ -126,48 +164,46 @@ async function readRecordingCompleteEvent( clientId: string, ): Promise> { const timeoutSignal = AbortSignal.timeout(RECORDING_END_TIMEOUT_MS); - const isTimeoutError = (err: unknown) => isAbortError(err) || (err instanceof Error && err.name === "TimeoutError"); + const isTimeoutError = (err: unknown) => + isAbortError(err) || (err instanceof Error && err.name === 'TimeoutError'); try { await using stream = await connector.sendCDPStream( clientId, -1, - // eslint-disable-next-line n/no-unsupported-features/node-builtins - ReadableStream.from([{ method: "Recording.end", params: {} }]), + ReadableStream.from([{ method: 'Recording.end', params: {} }]), { signal: timeoutSignal }, ); - for await ( - const value of stream as unknown as AsyncIterable< - CDPResponseMessage & { - method?: string; - params?: Record; - error?: { message: string }; - } - > - ) { - if (value.method === "Recording.recordingComplete") { + for await (const value of stream as unknown as AsyncIterable< + CDPResponseMessage & { + method?: string; + params?: Record; + error?: { message: string }; + } + >) { + if (value.method === 'Recording.recordingComplete') { return value.params ?? {}; } if (value.error) { throw new Error( - `Recording.end failed: ${value.error.message}. ` - + "If recording was never started, run `recorder start` first.", + `Recording.end failed: ${value.error.message}. ` + + 'If recording was never started, run `recorder start` first.', ); } } } catch (err) { if (!isTimeoutError(err)) throw err; throw new Error( - "Recording.end timed out before receiving Recording.recordingComplete. " - + "Make sure recording was started with `recorder start` and the device is still connected.", + 'Recording.end timed out before receiving Recording.recordingComplete. ' + + 'Make sure recording was started with `recorder start` and the device is still connected.', { cause: err }, ); } throw new Error( - "Recording.end stream closed before Recording.recordingComplete was received. " - + "Make sure recording was started with `recorder start` and the device is still connected.", + 'Recording.end stream closed before Recording.recordingComplete was received. ' + + 'Make sure recording was started with `recorder start` and the device is still connected.', ); } @@ -184,54 +220,74 @@ async function readStreamFully( connector.sendCDPMessage< { data?: string; base64Encoded?: boolean; eof?: boolean }, Record - >(clientId, -1, "IO.read", { handle, size: IO_READ_CHUNK_SIZE }), + >(clientId, -1, 'IO.read', { handle, size: IO_READ_CHUNK_SIZE }), signal, - `IO.read timed out reading recording stream handle ${handle}. ` - + "The device may have stalled; reconnect and retry `recorder end`.", + `IO.read timed out reading recording stream handle ${handle}. ` + + 'The device may have stalled; reconnect and retry `recorder end`.', ); if (chunk.data) { - chunks.push(Buffer.from(chunk.data, chunk.base64Encoded ? "base64" : "utf-8")); + chunks.push( + Buffer.from(chunk.data, chunk.base64Encoded ? 'base64' : 'utf-8'), + ); } if (chunk.eof) break; } return Buffer.concat(chunks); } finally { await abortable( - connector.sendCDPMessage(clientId, -1, "IO.close", { handle }), + connector.sendCDPMessage(clientId, -1, 'IO.close', { handle }), AbortSignal.timeout(5_000), `IO.close timed out closing recording stream handle ${handle}. Proceeding with local cleanup.`, ).catch(() => {}); } } -function abortable(promise: Promise, signal: AbortSignal, message: string): Promise { +function abortable( + promise: Promise, + signal: AbortSignal, + message: string, +): Promise { if (signal.aborted) return Promise.reject(new Error(message)); return new Promise((resolve, reject) => { const onAbort = () => reject(new Error(message)); - signal.addEventListener("abort", onAbort, { once: true }); + signal.addEventListener('abort', onAbort, { once: true }); promise.then( (v) => { - signal.removeEventListener("abort", onAbort); + signal.removeEventListener('abort', onAbort); resolve(v); }, (e) => { - signal.removeEventListener("abort", onAbort); + signal.removeEventListener('abort', onAbort); reject(e); }, ); }); } -async function resolveRecordingBaseOutputPath(output: string | undefined, clientId: string): Promise { - const defaultFileName = `recording-${clientId.replace(/[<>:"/\\|?*()]/g, "_")}-${Date.now()}.json`; +async function resolveRecordingBaseOutputPath( + output: string | undefined, + clientId: string, +): Promise { + const defaultFileName = `recording-${clientId.replace(/[<>:"/\\|?*()]/g, '_')}-${Date.now()}.json`; if (!output) { - return path.resolve(os.homedir(), ".lynx-devtool", "files", "lynxrecorder", defaultFileName); + return path.resolve( + os.homedir(), + '.lynx-devtool', + 'files', + 'lynxrecorder', + defaultFileName, + ); } const resolvedOutput = path.resolve(output); - const outputLooksLikeDirectory = output.endsWith(path.sep) || output.endsWith("/") || output.endsWith("\\"); - const outputIsDirectory = outputLooksLikeDirectory - || await fs.stat(resolvedOutput).then(stats => stats.isDirectory()).catch(() => false); + const outputLooksLikeDirectory = + output.endsWith(path.sep) || output.endsWith('/') || output.endsWith('\\'); + const outputIsDirectory = + outputLooksLikeDirectory || + (await fs + .stat(resolvedOutput) + .then((stats) => stats.isDirectory()) + .catch(() => false)); if (outputIsDirectory) { await fs.mkdir(resolvedOutput, { recursive: true }); diff --git a/packages/skills/lynx-devtool/src/commands/recorder-start.ts b/packages/skills/lynx-devtool/src/commands/recorder-start.ts index 1fdb117..7977a5c 100644 --- a/packages/skills/lynx-devtool/src/commands/recorder-start.ts +++ b/packages/skills/lynx-devtool/src/commands/recorder-start.ts @@ -1,12 +1,17 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Command } from "commander"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClient } from "./utils.ts"; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClient, +} from './utils.ts'; -const DEBUG_MODE_KEY = "enable_debug_mode"; +const DEBUG_MODE_KEY = 'enable_debug_mode'; const DEBUG_MODE_RESTART_MESSAGE = - "`enable_debug_mode` has been enabled. Restart the app and run `recorder start` again."; + '`enable_debug_mode` has been enabled. Restart the app and run `recorder start` again.'; type RecordingStartResult = | { started: true; restartRequired: false; message: string } @@ -14,8 +19,8 @@ type RecordingStartResult = export function registerStartCommand(parent: Command, context: Context) { parent - .command("start") - .description("Start TestBench recording") + .command('start') + .description('Start TestBench recording') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .action(async (options) => { @@ -34,7 +39,10 @@ export async function runRecordingStart( }, clientId: string, ): Promise { - const debugModeEnabled = await connector.getGlobalSwitch(clientId, DEBUG_MODE_KEY); + const debugModeEnabled = await connector.getGlobalSwitch( + clientId, + DEBUG_MODE_KEY, + ); if (!debugModeEnabled) { await connector.setGlobalSwitch(clientId, DEBUG_MODE_KEY, true); return { @@ -45,13 +53,13 @@ export async function runRecordingStart( } try { - await connector.sendCDPMessage(clientId, -1, "Recording.start", {}); + await connector.sendCDPMessage(clientId, -1, 'Recording.start', {}); } catch (err) { if (!isRecordingStartNotImplementedError(err)) throw err; throw new Error( - "Recording.start is not implemented even after `enable_debug_mode` is enabled. " - + "The app or engine may not include `ENABLE_TESTBENCH_RECORDER`, " - + "or it may not be a dev/recorder build.", + 'Recording.start is not implemented even after `enable_debug_mode` is enabled. ' + + 'The app or engine may not include `ENABLE_TESTBENCH_RECORDER`, ' + + 'or it may not be a dev/recorder build.', { cause: err }, ); } @@ -59,16 +67,31 @@ export async function runRecordingStart( return { started: true, restartRequired: false, - message: "Recording started successfully. Open or reload a Lynx page before `recorder end`.", + message: + 'Recording started successfully. Open or reload a Lynx page before `recorder end`.', }; } -type ConnectorGlobalSwitch = (clientId: string, key: typeof DEBUG_MODE_KEY) => Promise; -type ConnectorSetGlobalSwitch = (clientId: string, key: typeof DEBUG_MODE_KEY, value: boolean) => Promise; -type ConnectorCDPMessage = (clientId: string, sessionId: number, method: string, params: object) => Promise; +type ConnectorGlobalSwitch = ( + clientId: string, + key: typeof DEBUG_MODE_KEY, +) => Promise; +type ConnectorSetGlobalSwitch = ( + clientId: string, + key: typeof DEBUG_MODE_KEY, + value: boolean, +) => Promise; +type ConnectorCDPMessage = ( + clientId: string, + sessionId: number, + method: string, + params: object, +) => Promise; function isRecordingStartNotImplementedError(err: unknown): boolean { - return err instanceof Error - && err.message.includes("Not implemented") - && err.message.includes("Recording.start"); + return ( + err instanceof Error && + err.message.includes('Not implemented') && + err.message.includes('Recording.start') + ); } diff --git a/packages/skills/lynx-devtool/src/commands/take-heap-snapshot.ts b/packages/skills/lynx-devtool/src/commands/take-heap-snapshot.ts index 469965c..b08f75f 100644 --- a/packages/skills/lynx-devtool/src/commands/take-heap-snapshot.ts +++ b/packages/skills/lynx-devtool/src/commands/take-heap-snapshot.ts @@ -1,14 +1,17 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -/* eslint-disable */ -import { type CDPResponseMessage, CDPResponseTransformStream } from "@lynx-js/devtool-connector"; -import { Command } from "commander"; -import { randomInt } from "node:crypto"; -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { ReadableStream } from "node:stream/web"; + +import { randomInt } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { ReadableStream } from 'node:stream/web'; +import { + type CDPResponseMessage, + CDPResponseTransformStream, +} from '@lynx-js/devtool-connector'; +import type { Command } from 'commander'; import { CLIENT_NAME_OPTION, CLIENT_OPTION, @@ -16,83 +19,111 @@ import { readUntilIdle, resolveClientAndSession, SESSION_OPTION, -} from "./utils.ts"; +} from './utils.ts'; -export function registerTakeHeapSnapshotCommand(program: Command, context: Context) { +export function registerTakeHeapSnapshotCommand( + program: Command, + context: Context, +) { program - .command("take-heap-snapshot") - .description("Take a heap snapshot and save it to a .heapsnapshot file") + .command('take-heap-snapshot') + .description('Take a heap snapshot and save it to a .heapsnapshot file') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) - .option("--thread ", "VM thread to target: background or main", "background") - .option("-o, --output ", "Output file path (default: /heap--.heapsnapshot)") + .option( + '--thread ', + 'VM thread to target: background or main', + 'background', + ) + .option( + '-o, --output ', + 'Output file path (default: /heap--.heapsnapshot)', + ) .action(async (options) => { - const { output, thread = "background" } = options; + const { output, thread = 'background' } = options; - if (thread !== "background" && thread !== "main") { - throw new Error(`Invalid thread: ${thread}. Expected 'background' or 'main'.`); + if (thread !== 'background' && thread !== 'main') { + throw new Error( + `Invalid thread: ${thread}. Expected 'background' or 'main'.`, + ); } - const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); + const { connector, clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); - const expectedSessionId = thread === "main" ? "Main" : undefined; - const extraParams = expectedSessionId ? { sessionId: expectedSessionId } : {}; + const expectedSessionId = thread === 'main' ? 'Main' : undefined; + const extraParams = expectedSessionId + ? { sessionId: expectedSessionId } + : {}; const timeoutSignal = AbortSignal.timeout(60_000); const requestId = randomInt(10_000, 50_000); await using stream = await connector.sendStream( clientId, - ReadableStream.from([{ - event: "Customized", - data: { - type: "CDP", + ReadableStream.from([ + { + event: 'Customized', data: { - session_id: Number(sessionId), - message: { - id: requestId - 1, - method: "HeapProfiler.enable", - params: {}, - ...extraParams, + type: 'CDP', + data: { + session_id: Number(sessionId), + message: { + id: requestId - 1, + method: 'HeapProfiler.enable', + params: {}, + ...extraParams, + }, }, }, }, - }, { - event: "Customized", - data: { - type: "CDP", + { + event: 'Customized', data: { - session_id: Number(sessionId), - message: { - id: requestId, - method: "HeapProfiler.takeHeapSnapshot", - params: { - reportProgress: true, - treatGlobalObjectsAsRoots: true, - captureNumericValue: false, + type: 'CDP', + data: { + session_id: Number(sessionId), + message: { + id: requestId, + method: 'HeapProfiler.takeHeapSnapshot', + params: { + reportProgress: true, + treatGlobalObjectsAsRoots: true, + captureNumericValue: false, + }, + ...extraParams, }, - ...extraParams, }, }, }, - }]), + ]), { signal: timeoutSignal, pipeline: { input: [], - output: [ - new CDPResponseTransformStream(), - ], + output: [new CDPResponseTransformStream()], }, }, ); - let chunks: string[] = []; + const chunks: string[] = []; let didReceiveSnapshotResponse = false; - const fileName = output ?? path.join(tmpdir(), `heap-${thread}-${Date.now()}.heapsnapshot`); + const fileName = + output ?? + path.join(tmpdir(), `heap-${thread}-${Date.now()}.heapsnapshot`); - for await (const value of readUntilIdle(stream, { idleMs: 15_000, maxMs: 60_000 })) { - const { method, params: eventParams, id, sessionId: responseSessionId } = value as CDPResponseMessage & { + for await (const value of readUntilIdle(stream, { + idleMs: 15_000, + maxMs: 60_000, + })) { + const { + method, + params: eventParams, + id, + sessionId: responseSessionId, + } = value as CDPResponseMessage & { method?: string; params?: { chunk?: string; @@ -101,7 +132,7 @@ export function registerTakeHeapSnapshotCommand(program: Command, context: Conte sessionId?: string; }; - if (method === "HeapProfiler.addHeapSnapshotChunk") { + if (method === 'HeapProfiler.addHeapSnapshotChunk') { if (responseSessionId !== expectedSessionId) { continue; } @@ -115,9 +146,8 @@ export function registerTakeHeapSnapshotCommand(program: Command, context: Conte if (didReceiveSnapshotResponse) { break; } - } else if (method === "HeapProfiler.reportHeapSnapshotProgress") { + } else if (method === 'HeapProfiler.reportHeapSnapshotProgress') { if (responseSessionId !== expectedSessionId) { - continue; } } else if (id === requestId) { didReceiveSnapshotResponse = true; @@ -128,10 +158,12 @@ export function registerTakeHeapSnapshotCommand(program: Command, context: Conte } if (chunks.length === 0) { - throw new Error("Failed to capture heap snapshot, no chunks received or timed out."); + throw new Error( + 'Failed to capture heap snapshot, no chunks received or timed out.', + ); } - await fs.writeFile(fileName, chunks.join("")); + await fs.writeFile(fileName, chunks.join('')); console.log(`Heap snapshot saved to ${fileName}`); }); diff --git a/packages/skills/lynx-devtool/src/commands/take-screenshot.ts b/packages/skills/lynx-devtool/src/commands/take-screenshot.ts index fccebe2..80f7218 100644 --- a/packages/skills/lynx-devtool/src/commands/take-screenshot.ts +++ b/packages/skills/lynx-devtool/src/commands/take-screenshot.ts @@ -1,30 +1,50 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -/* eslint-disable */ -import { Command } from "commander"; -import fs from "node:fs/promises"; -import { ReadableStream } from "node:stream/web"; -import { setTimeout } from "node:timers/promises"; -import { CLIENT_NAME_OPTION, CLIENT_OPTION, type Context, resolveClientAndSession, SESSION_OPTION } from "./utils.ts"; -export function registerTakeScreenshotCommand(program: Command, context: Context) { +import fs from 'node:fs/promises'; +import { ReadableStream } from 'node:stream/web'; +import { setTimeout } from 'node:timers/promises'; +import type { Command } from 'commander'; +import { + CLIENT_NAME_OPTION, + CLIENT_OPTION, + type Context, + resolveClientAndSession, + SESSION_OPTION, +} from './utils.ts'; + +export function registerTakeScreenshotCommand( + program: Command, + context: Context, +) { program - .command("take-screenshot") - .description("Take a screenshot of the current page") + .command('take-screenshot') + .description('Take a screenshot of the current page') .option(...CLIENT_OPTION) .option(...CLIENT_NAME_OPTION) .option(...SESSION_OPTION) - .option("--fullscreen", "Capture the fullscreen screenshot instead of the lynxview") - .option("-o, --output ", "Output file path (default: screenshot-.jpeg)") + .option( + '--fullscreen', + 'Capture the fullscreen screenshot instead of the lynxview', + ) + .option( + '-o, --output ', + 'Output file path (default: screenshot-.jpeg)', + ) .action(async (options) => { - const { connector, clientId, sessionId } = await resolveClientAndSession(context, options); + const { connector, clientId, sessionId } = await resolveClientAndSession( + context, + options, + ); const { output, fullscreen } = options; const numericSessionId = Number(sessionId); const signal = AbortSignal.timeout(10_000); - const { promise: framePromise, resolve: resolveFrame } = Promise.withResolvers(); - const { promise: ackPromise, resolve: resolveAck } = Promise.withResolvers(); + const { promise: framePromise, resolve: resolveFrame } = + Promise.withResolvers(); + const { promise: ackPromise, resolve: resolveAck } = + Promise.withResolvers(); await using stream = await connector.sendCDPStream( clientId, @@ -32,11 +52,11 @@ export function registerTakeScreenshotCommand(program: Command, context: Context new ReadableStream({ async start(controller) { controller.enqueue({ - method: "Page.startScreencast", + method: 'Page.startScreencast', params: { - "format": "jpeg", - "quality": 80, - "mode": fullscreen ? "fullscreen" : "lynxview", + format: 'jpeg', + quality: 80, + mode: fullscreen ? 'fullscreen' : 'lynxview', }, }); const hasFrame = await Promise.race([ @@ -45,7 +65,7 @@ export function registerTakeScreenshotCommand(program: Command, context: Context ]); if (hasFrame) { controller.enqueue({ - method: "Page.screencastFrameAck", + method: 'Page.screencastFrameAck', }); } controller.close(); @@ -56,14 +76,14 @@ export function registerTakeScreenshotCommand(program: Command, context: Context ); for await (const { method, params: eventParams } of stream) { - if (method === "Page.screencastFrame") { + if (method === 'Page.screencastFrame') { const { data } = eventParams as { data: string }; if (data) { resolveFrame(); await ackPromise; const fileName = output ?? `screenshot-${Date.now()}.jpeg`; - await fs.writeFile(fileName, Buffer.from(data, "base64")); + await fs.writeFile(fileName, Buffer.from(data, 'base64')); console.log(`Screenshot saved to ${fileName}`); return; @@ -71,6 +91,8 @@ export function registerTakeScreenshotCommand(program: Command, context: Context } } - throw new Error("Failed to capture screenshot, no Page.screencastFrame event received within 10 seconds."); + throw new Error( + 'Failed to capture screenshot, no Page.screencastFrame event received within 10 seconds.', + ); }); } diff --git a/packages/skills/lynx-devtool/src/commands/utils.ts b/packages/skills/lynx-devtool/src/commands/utils.ts index c8de165..5ea28e1 100644 --- a/packages/skills/lynx-devtool/src/commands/utils.ts +++ b/packages/skills/lynx-devtool/src/commands/utils.ts @@ -1,35 +1,39 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Connector } from "@lynx-js/devtool-connector"; -import type { Client, Transport } from "@lynx-js/devtool-connector/transport"; -import type { ReadableStream, ReadableStreamDefaultReader } from "node:stream/web"; -import { setTimeout as delay } from "node:timers/promises"; + +import type { + ReadableStream, + ReadableStreamDefaultReader, +} from 'node:stream/web'; +import { setTimeout as delay } from 'node:timers/promises'; +import { Connector } from '@lynx-js/devtool-connector'; +import type { Client, Transport } from '@lynx-js/devtool-connector/transport'; export interface Context { transports: Transport[]; } export const CLIENT_OPTION = [ - "-c, --client ", - "Client ID (optional, auto-discovered if omitted).", + '-c, --client ', + 'Client ID (optional, auto-discovered if omitted).', ] as const; export const CLIENT_NAME_OPTION = [ - "--client-name ", - "Client package/app name (optional, resolved from list-clients; e.g. com.example.app)", + '--client-name ', + 'Client package/app name (optional, resolved from list-clients; e.g. com.example.app)', ] as const; export const SESSION_OPTION = [ - "-s, --session ", - "Session ID (optional, will auto-discover if not provided)", + '-s, --session ', + 'Session ID (optional, will auto-discover if not provided)', ] as const; export async function getFirstClient(connector: Connector): Promise { const clients = await connector.listClients(); const firstClient = clients[0]; if (!firstClient) { - throw new Error("No available clients found."); + throw new Error('No available clients found.'); } return firstClient.id; } @@ -38,8 +42,8 @@ function uniqueNonEmptyStrings(values: Array): string[] { return Array.from( new Set( values - .filter((value): value is string => typeof value === "string") - .map(value => value.trim()) + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) .filter(Boolean), ), ); @@ -56,17 +60,17 @@ function getClientNames(client: Client): string[] { function formatClientForError(client: Client): string { const names = getClientNames(client); - const suffix = names.length > 0 ? ` (${names.join(", ")})` : ""; + const suffix = names.length > 0 ? ` (${names.join(', ')})` : ''; return ` ${client.id}${suffix}`; } export async function getClientByName( - connector: Pick, + connector: Pick, clientName: string, ): Promise { const clients = await connector.listClients(); - const matches = clients.filter(client => - getClientNames(client).includes(clientName) + const matches = clients.filter((client) => + getClientNames(client).includes(clientName), ); if (matches.length === 1) { @@ -76,27 +80,30 @@ export async function getClientByName( if (matches.length > 1) { throw new Error( - `Multiple clients found matching --client-name "${clientName}". Use --client with one of:\n` - + matches.map(formatClientForError).join("\n"), + `Multiple clients found matching --client-name "${clientName}". Use --client with one of:\n` + + matches.map(formatClientForError).join('\n'), ); } - const availableClients = clients - .map(formatClientForError) - .join("\n"); + const availableClients = clients.map(formatClientForError).join('\n'); throw new Error( - `No client found matching --client-name "${clientName}".` - + (availableClients ? `\nAvailable clients:\n${availableClients}` : "\nNo real-device clients are available."), + `No client found matching --client-name "${clientName}".` + + (availableClients + ? `\nAvailable clients:\n${availableClients}` + : '\nNo real-device clients are available.'), ); } -export async function getLatestSession(connector: Connector, clientId: string): Promise { +export async function getLatestSession( + connector: Connector, + clientId: string, +): Promise { const sessions = await connector.sendListSessionMessage(clientId); if (!sessions || sessions.length === 0) { throw new Error(`No available sessions found for client: ${clientId}`); } const latestSession = sessions.reduce((max, session) => - Number(session.session_id) > Number(max.session_id) ? session : max + Number(session.session_id) > Number(max.session_id) ? session : max, ); return String(latestSession.session_id); } @@ -107,10 +114,13 @@ export async function resolveClient( ): Promise<{ connector: Connector; clientId: string }> { const connector = new Connector(transports); if (options.client && options.clientName) { - throw new Error("Use either --client or --client-name, not both."); + throw new Error('Use either --client or --client-name, not both.'); } - const clientId = options.client - ?? (options.clientName ? await getClientByName(connector, options.clientName) : await getFirstClient(connector)); + const clientId = + options.client ?? + (options.clientName + ? await getClientByName(connector, options.clientName) + : await getFirstClient(connector)); return { connector, clientId }; } @@ -119,20 +129,21 @@ export async function resolveClientAndSession( options: { client?: string; clientName?: string; session?: string }, ): Promise<{ connector: Connector; clientId: string; sessionId: string }> { const { connector, clientId } = await resolveClient(context, options); - const sessionId = options.session ?? await getLatestSession(connector, clientId); + const sessionId = + options.session ?? (await getLatestSession(connector, clientId)); return { connector, clientId, sessionId }; } export function isAbortError(err: unknown): boolean { - return err instanceof Error && err.name === "AbortError"; + return err instanceof Error && err.name === 'AbortError'; } -export function parseOnOff(input: string, optionName = "--status"): boolean { +export function parseOnOff(input: string, optionName = '--status'): boolean { const normalized = input.trim().toLowerCase(); - if (normalized === "on" || normalized === "true" || normalized === "1") { + if (normalized === 'on' || normalized === 'true' || normalized === '1') { return true; } - if (normalized === "off" || normalized === "false" || normalized === "0") { + if (normalized === 'off' || normalized === 'false' || normalized === '0') { return false; } throw new Error(`Invalid ${optionName} value: ${input}. Use on/off.`); @@ -143,34 +154,36 @@ export function buildWatchSignal( fallbackTimeoutMs: number, ): { signal: AbortSignal; cleanup: () => void } { if (!watch) { - return { signal: AbortSignal.timeout(fallbackTimeoutMs), cleanup: () => {} }; + return { + signal: AbortSignal.timeout(fallbackTimeoutMs), + cleanup: () => {}, + }; } const controller = new AbortController(); const onSigint = () => { controller.abort(); }; - process.once("SIGINT", onSigint); + process.once('SIGINT', onSigint); return { signal: controller.signal, - cleanup: () => process.off("SIGINT", onSigint), + cleanup: () => process.off('SIGINT', onSigint), }; } -type ReadOrTimeoutResult = ReadableStreamReadResult | "timeout"; +type ReadOrTimeoutResult = ReadableStreamReadResult | 'timeout'; async function readOrTimeout( reader: ReadableStreamDefaultReader, idleMs: number, ): Promise> { const idleAbortController = new AbortController(); - const idle = delay(idleMs, "timeout" as const, { signal: idleAbortController.signal }); + const idle = delay(idleMs, 'timeout' as const, { + signal: idleAbortController.signal, + }); try { - return await Promise.race([ - reader.read(), - idle, - ]); + return await Promise.race([reader.read(), idle]); } finally { idleAbortController.abort(); await idle.catch(() => {}); @@ -187,7 +200,7 @@ export async function* readUntilIdle( try { while (Date.now() - startTime < opts.maxMs) { const result = await readOrTimeout(reader, opts.idleMs); - if (result === "timeout") { + if (result === 'timeout') { await reader.cancel(); terminated = true; return; diff --git a/packages/skills/lynx-devtool/src/connector.ts b/packages/skills/lynx-devtool/src/connector.ts index e1c4fd5..7a75d15 100644 --- a/packages/skills/lynx-devtool/src/connector.ts +++ b/packages/skills/lynx-devtool/src/connector.ts @@ -1,23 +1,23 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Connector } from "@lynx-js/devtool-connector"; +import { Connector } from '@lynx-js/devtool-connector'; import { AndroidTransport, DesktopTransport, iOSTransport, type Transport, -} from "@lynx-js/devtool-connector/transport"; +} from '@lynx-js/devtool-connector/transport'; -export * from "@lynx-js/devtool-connector"; -export * from "@lynx-js/devtool-connector/streams"; -export * from "@lynx-js/devtool-connector/transport"; +export * from '@lynx-js/devtool-connector'; +export * from '@lynx-js/devtool-connector/streams'; +export * from '@lynx-js/devtool-connector/transport'; function getAndroidTransportSpec(): { host: string; port: number } { - const port = Number.parseInt(process.env["ADB_SERVER_PORT"] ?? "5037", 10); + const port = Number.parseInt(process.env['ADB_SERVER_PORT'] ?? '5037', 10); return { - host: process.env["ADB_SERVER_HOST"] ?? "127.0.0.1", + host: process.env['ADB_SERVER_HOST'] ?? '127.0.0.1', port: Number.isInteger(port) && port > 0 ? port : 5037, }; } @@ -30,6 +30,8 @@ export function createDefaultTransports(): Transport[] { ]; } -export function createDefaultConnector(transports: Transport[] = createDefaultTransports()): Connector { +export function createDefaultConnector( + transports: Transport[] = createDefaultTransports(), +): Connector { return new Connector(transports); } diff --git a/packages/skills/lynx-devtool/src/devtool.ts b/packages/skills/lynx-devtool/src/devtool.ts index c672086..78ca987 100644 --- a/packages/skills/lynx-devtool/src/devtool.ts +++ b/packages/skills/lynx-devtool/src/devtool.ts @@ -6,35 +6,40 @@ import { DaemonTransport, DesktopTransport, iOSTransport, -} from "@lynx-js/devtool-connector/transport"; -import { Command } from "commander"; -import pkg from "../package.json" with { type: "json" }; -import { registerAppCommand } from "./commands/app.ts"; -import { registerCdpCommand } from "./commands/cdp.ts"; -import { registerGetConsoleCommand } from "./commands/get-console.ts"; -import { registerGetSourcesCommand } from "./commands/get-sources.ts"; -import { registerGlobalSwitchCommand } from "./commands/global-switch.ts"; -import { registerInspectCommand } from "./commands/inspect.ts"; -import { registerListClientsCommand } from "./commands/list-clients.ts"; -import { registerListSessionsCommand } from "./commands/list-sessions.ts"; -import { registerOpenCommand } from "./commands/open.ts"; -import { registerReactLynxCommand } from "./commands/reactlynx/index.ts"; -import { registerEndCommand } from "./commands/recorder-end.ts"; -import { registerStartCommand } from "./commands/recorder-start.ts"; -import { registerTakeHeapSnapshotCommand } from "./commands/take-heap-snapshot.ts"; -import { registerTakeScreenshotCommand } from "./commands/take-screenshot.ts"; -import type { Context } from "./commands/utils.ts"; +} from '@lynx-js/devtool-connector/transport'; +import { Command } from 'commander'; +import pkg from '../package.json' with { type: 'json' }; +import { registerAppCommand } from './commands/app.ts'; +import { registerCdpCommand } from './commands/cdp.ts'; +import { registerGetConsoleCommand } from './commands/get-console.ts'; +import { registerGetSourcesCommand } from './commands/get-sources.ts'; +import { registerGlobalSwitchCommand } from './commands/global-switch.ts'; +import { registerInspectCommand } from './commands/inspect.ts'; +import { registerListClientsCommand } from './commands/list-clients.ts'; +import { registerListSessionsCommand } from './commands/list-sessions.ts'; +import { registerOpenCommand } from './commands/open.ts'; +import { registerReactLynxCommand } from './commands/reactlynx/index.ts'; +import { registerEndCommand } from './commands/recorder-end.ts'; +import { registerStartCommand } from './commands/recorder-start.ts'; +import { registerTakeHeapSnapshotCommand } from './commands/take-heap-snapshot.ts'; +import { registerTakeScreenshotCommand } from './commands/take-screenshot.ts'; +import type { Context } from './commands/utils.ts'; -function getAndroidTransportSpec(env: NodeJS.ProcessEnv): { host: string; port: number } { - const port = Number.parseInt(env["ADB_SERVER_PORT"] ?? "5037", 10); +function getAndroidTransportSpec(env: NodeJS.ProcessEnv): { + host: string; + port: number; +} { + const port = Number.parseInt(env['ADB_SERVER_PORT'] ?? '5037', 10); return { - host: env["ADB_SERVER_HOST"] ?? "127.0.0.1", + host: env['ADB_SERVER_HOST'] ?? '127.0.0.1', port: Number.isInteger(port) && port > 0 ? port : 5037, }; } -export function createProgram(options: { env?: NodeJS.ProcessEnv } = {}): Command { +export function createProgram( + options: { env?: NodeJS.ProcessEnv } = {}, +): Command { const env = options.env ?? process.env; const program = new Command(); const context: Context = { @@ -46,21 +51,21 @@ export function createProgram(options: { env?: NodeJS.ProcessEnv } = {}): Comman }; program - .name("lynx-devtool") - .description("CLI to interact with Lynx DevTool Connector") + .name('lynx-devtool') + .description('CLI to interact with Lynx DevTool Connector') .version(pkg.version) .option( - "--no-daemon", - "Run in non-daemon mode, which will not start the background service", + '--no-daemon', + 'Run in non-daemon mode, which will not start the background service', ) - .hook("preAction", async (thisCommand) => { + .hook('preAction', async (thisCommand) => { const rootOptions = thisCommand.opts<{ daemon?: boolean }>(); if (rootOptions.daemon) { context.transports.push(new DaemonTransport()); } }) - .hook("postAction", async () => { - await Promise.allSettled(context.transports.map(t => t.close())); + .hook('postAction', async () => { + await Promise.allSettled(context.transports.map((t) => t.close())); }); registerListClientsCommand(program, context); @@ -75,7 +80,7 @@ export function createProgram(options: { env?: NodeJS.ProcessEnv } = {}): Comman registerTakeHeapSnapshotCommand(program, context); registerGlobalSwitchCommand(program, context); - const record = program.command("recorder"); + const record = program.command('recorder'); registerStartCommand(record, context); registerEndCommand(record, context); diff --git a/packages/skills/lynx-devtool/src/index.ts b/packages/skills/lynx-devtool/src/index.ts index ae5f439..b7a7ebe 100644 --- a/packages/skills/lynx-devtool/src/index.ts +++ b/packages/skills/lynx-devtool/src/index.ts @@ -1,11 +1,11 @@ // Copyright 2025 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import "core-js/modules/es.promise.with-resolvers.js"; -import { createProgram } from "./devtool.ts"; +import 'core-js/modules/es.promise.with-resolvers.js'; +import { createProgram } from './devtool.ts'; createProgram({ env: process.env }) .parseAsync(process.argv) - .catch(error => { + .catch((error) => { throw error; });