diff --git a/.changeset/sharp-dragons-search.md b/.changeset/sharp-dragons-search.md new file mode 100644 index 0000000000..c336f31cd7 --- /dev/null +++ b/.changeset/sharp-dragons-search.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/kitten-lynx-test-infra": patch +--- + +feat: initial commit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2090c18e6e..0751487014 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -242,6 +242,63 @@ jobs: with: runs-on: lynx-ubuntu-24.04-medium run: pnpm -r run test:type + kitten-lynx-android-emulator: + needs: build + uses: ./.github/workflows/workflow-test.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + name: Kitten Lynx Android Emulator Test + with: + runs-on: lynx-ubuntu-22.04-physical-medium + run: | + # 4. Start Emulator + echo "Starting emulator..." + ${ANDROID_HOME}/emulator/emulator -avd Nexus_5_API_28 -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect & + + # 5. Wait for boot + echo "Waiting for emulator to boot..." + adb wait-for-device + BOOT_TIMEOUT=60 + BOOT_ELAPSED=0 + while [ "$(adb shell getprop sys.boot_completed | tr -d '\r')" != "1" ]; do + if [ $BOOT_ELAPSED -ge $BOOT_TIMEOUT ]; then + echo "Error: Emulator failed to boot within ${BOOT_TIMEOUT} seconds!" + exit 1 + fi + sleep 2 + BOOT_ELAPSED=$((BOOT_ELAPSED+2)) + done + echo "Emulator is ready." + + # 6. Install Lynx Explorer + wget -q https://github.com/lynx-family/lynx/releases/download/3.6.0/LynxExplorer-noasan-release.apk -O LynxExplorer.apk + adb install -r LynxExplorer.apk + + # 7. Start Lynx Explorer and verify + echo "Starting Lynx Explorer..." + if ! adb shell pm list packages | grep -q com.lynx.explorer; then + echo "Error: com.lynx.explorer is not installed!" + exit 1 + fi + adb shell monkey -p com.lynx.explorer -c android.intent.category.LAUNCHER 1 + + echo "Waiting for Lynx Explorer to start..." + MAX_RETRIES=10 + RETRY_COUNT=0 + while ! adb shell pidof com.lynx.explorer > /dev/null; do + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "Error: com.lynx.explorer failed to start!" + exit 1 + fi + echo "Waiting... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 2 + RETRY_COUNT=$((RETRY_COUNT+1)) + done + echo "Lynx Explorer is running." + + # 8. Run the tests + pnpm --filter @lynx-js/kitten-lynx-test-infra run test --coverage --reporter=github-actions --reporter=dot --reporter=junit --outputFile=test-report.junit.xml --coverage.reporter='json' --coverage.reporter='text' --testTimeout=50000 --no-cache --logHeapUsage --silent + test-typos: runs-on: lynx-ubuntu-24.04-medium steps: diff --git a/packages/mcp-servers/devtool-connector/src/index.ts b/packages/mcp-servers/devtool-connector/src/index.ts index 2dc102903e..18efd11156 100644 --- a/packages/mcp-servers/devtool-connector/src/index.ts +++ b/packages/mcp-servers/devtool-connector/src/index.ts @@ -119,6 +119,12 @@ export class Connector { .flatMap(({ value }) => value); } + async close(): Promise { + await Promise.allSettled( + this.#transports.map(t => t.close()), + ); + } + async listAvailableApps(deviceId: string): Promise { const transport = await this.#findTransportWithDeviceId(deviceId); diff --git a/packages/mcp-servers/devtool-connector/src/transport/android.ts b/packages/mcp-servers/devtool-connector/src/transport/android.ts index 5a9fe76c3f..0b00f73d65 100644 --- a/packages/mcp-servers/devtool-connector/src/transport/android.ts +++ b/packages/mcp-servers/devtool-connector/src/transport/android.ts @@ -3,7 +3,8 @@ // LICENSE file in the root directory of this source tree. import type { SocketConnectOpts } from 'node:net'; -import { type Adb, AdbServerClient } from '@yume-chan/adb'; +import { AdbServerClient } from '@yume-chan/adb'; +import type { Adb } from '@yume-chan/adb'; import { AdbServerNodeTcpConnector } from '@yume-chan/adb-server-node-tcp'; import createDebug from 'debug'; @@ -14,7 +15,7 @@ import type { OpenAppOptions, Transport, TransportConnectOptions, -} from './transport.ts'; +} from './transport.js'; const debug = createDebug('devtool-mcp-server:connector:android'); @@ -159,6 +160,46 @@ export class AndroidTransport implements Transport { debug(`openApp clear data output ${output}`); } + // Attempt to use cmd package resolve-activity like appium-adb + try { + const resolveOutput = await adb.subprocess.noneProtocol.spawnWaitText([ + 'cmd', + 'package', + 'resolve-activity', + '--brief', + packageName, + ]); + const lines = resolveOutput.split('\n').map((line) => line.trim()); + const activityName = lines.find((line) => line.includes('/')); + + if ( + activityName + && activityName !== 'android/com.android.internal.app.ResolverActivity' + ) { + debug(`openApp am start: ${activityName}`); + const amOutput = await adb.subprocess.noneProtocol.spawnWaitText([ + 'am', + 'start', + '-a', + 'android.intent.action.MAIN', + '-c', + 'android.intent.category.LAUNCHER', + '-f', + '0x10200000', + '-n', + activityName, + ]); + debug(`openApp am start output: ${amOutput}`); + if (!(/^error:/im.exec(amOutput))) { + return; + } + } + } catch (e) { + debug(`openApp cmd package resolve-activity failed: %o`, e); + } + + // Fallback to monkey + debug('openApp trying fallback to monkey'); const output = await adb.subprocess.noneProtocol.spawnWaitText([ // adb shell monkey -p -c android.intent.category.LAUNCHER 1 'monkey', @@ -169,13 +210,11 @@ export class AndroidTransport implements Transport { '1', ]); debug(`openApp LAUNCHER output ${output}`); - if (output.includes('No activities found')) { + + if (!output.includes('Events injected:')) { throw new Error( - `No launchable activity found for package ${packageName}.`, + `Failed to open app ${packageName}: appium-adb style activation and monkey fallback both failed.`, ); } - if (output.includes('monkey aborted')) { - throw new Error(`Failed to open app ${packageName}.`); - } } } diff --git a/packages/testing-library/kitten-lynx/AGENTS.md b/packages/testing-library/kitten-lynx/AGENTS.md new file mode 100644 index 0000000000..f49e1bb4a2 --- /dev/null +++ b/packages/testing-library/kitten-lynx/AGENTS.md @@ -0,0 +1,58 @@ +# @lynx-js/kitten-lynx-test-infra + +This document provides context, architecture guidelines, and workflows for agents interacting with the `kitten-lynx` framework. + +## Overview + +`kitten-lynx` is a Puppeteer-like testing library designed for interacting with the Lynx browser engine and Lynx Explorer Android application. It utilizes the `@lynx-js/devtool-connector` (stateless, short-lived connection architecture) to communicate with Lynx apps running on Android devices via ADB. + +Through the Chrome DevTools Protocol (CDP), `kitten-lynx` enables: + +- Starting and tearing down `LynxView` instances. +- Navigating to Lynx bundle URLs and reading the DOM structure. +- Querying elements via `DOM.querySelector`. +- Reading styles, attributes, and precise boundary boxes of elements. +- Simulating native touches through `Input.emulateTouchFromMouseEvent`. + +## Architecture Details + +### Connections & Sessions + +1. **Lynx.ts**: The entry point. Initializes `Connector` with `AndroidTransport`, discovers ADB devices, restarts the target app, and polls `listClients()` to find the Lynx client. Accepts `ConnectOptions` to target a specific device and app package. +2. **LynxView.ts**: Manages individual pages. Attaches to a CDP session via `sendListSessionMessage()`, sends `Page.navigate` to load a Lynx bundle, then polls sessions by URL to find and re-attach to the correct session (apps may have multiple Lynx views). +3. **CDPChannel.ts**: A stateless wrapper that sends CDP commands via `connector.sendCDPMessage()`. Each call is a short-lived request/response — no persistent connection is maintained. +4. **ElementNode.ts**: A wrapper around `nodeId`s matching an element. Implements interactive methods like `getAttribute()`, `computedStyleMap()`, and `tap()`. + +### Key Design Patterns + +- **Stateless connector**: The `devtool-connector` does not maintain persistent WebSocket connections. Each `sendCDPMessage` / `sendListSessionMessage` call is a self-contained request through ADB/USB transport. +- **Retry-based initialization**: After restarting the app, polling loops handle the delay before the devtool server is ready. `onAttachedToTarget()` only assigns `_channel` after all CDP domain enables succeed, making the whole operation retryable. +- **Session URL matching**: After `Page.navigate`, the Lynx runtime creates a new session for the navigated URL. `goto()` polls `sendListSessionMessage()` and matches sessions by URL (full URL, filename, or suffix) to find the correct one. + +### Prerequisites + +For the library to interact successfully: + +- The host machine (or CI environment) must have an Android environment (emulator or real device) running with ADB enabled and authorized. +- The Lynx Explorer APK must be installed on the device (e.g., `adb install /path/to/LynxExplorer.apk`). The latest apk could be found here `https://github.com/lynx-family/lynx/releases` +- Typical commands use `pnpm run test` starting `vitest` logic inside the Node wrapper. + +### Known Gotchas + +- **`Page.navigate` does not work like Chrome**: In Lynx, `Page.navigate` tells the runtime to load a new bundle, which creates a **new session** rather than updating the current one in place. You must poll `sendListSessionMessage()` to find the new session by URL and re-attach to it. +- **`App.openPage` is not implemented** in Lynx Explorer 3.6.0. Do not rely on `sendAppMessage('App.openPage')` for navigation. +- **Network access & Local Serving**: If the Android environment lacks direct internet access to your host machine's local server (common in some CI setups): + 1. Serve your Lynx bundles from the host (e.g., `python3 -m http.server 8080`). + 2. Use `adb reverse tcp:8080 tcp:8080` to map the host port to the device. + 3. Navigate to `http://localhost:8080/your.bundle`. +- **Multiple ADB targets**: When multiple ADB devices are connected (e.g. physical phone + emulator), use `ConnectOptions.deviceId` to target a specific one (e.g. `192.168.240.112:5555`). Otherwise the first available client is used, which may be on the wrong device. +- **CDP timeouts**: The connector uses a 5-second `AbortSignal.timeout`. Keep test operations tolerant of emulator boot/warm-up times. + +## Adding Features + +When extending the `kitten-lynx` testing library, adhere to these rules: + +1. **Protocol Typings**: Only update `Protocol` types in `src/CDPChannel.ts` when implementing new standard CDP requests (e.g. `Page.reload`, `DOM.getOuterHTML`). +2. **Puppeteer Equivalency**: Maintain an API design similar to Puppeteer/Playwright. Add element-level logic inside the `ElementNode` class (e.g., `type()`, `boundingBox()`) and page-level logic inside `LynxView` (e.g., `evaluate()`, `screenshot()`). +3. **Session Reconnection**: Be mindful of device timeouts. CDP requests time out after 5000ms. Keep connection tests tolerant of emulator boot/warm-up times gracefully in test suites (`tests/lynx.spec.ts`). +4. **Vitest Verification**: Before pushing feature changes, verify functionality using `pnpm run build && pnpm run test` inside the package root folder. diff --git a/packages/testing-library/kitten-lynx/README.md b/packages/testing-library/kitten-lynx/README.md new file mode 100644 index 0000000000..a5f4694541 --- /dev/null +++ b/packages/testing-library/kitten-lynx/README.md @@ -0,0 +1,113 @@ +# Kitten-Lynx (🐾 testing-library) + +**Kitten-Lynx** is a Puppeteer-like / Playwright-like testing library. It is designed specifically for interacting with the **Lynx browser engine** and the **Lynx Explorer Android application**. + +If you are an AI Agent (or a developer) reading this, this document is optimized to be as clear and straightforward as possible to help you write tests and understand the architecture without guessing. + +--- + +## 🌟 What does it do? + +Using the Chrome DevTools Protocol (CDP) over USB/ADB, `kitten-lynx` gives you the power to: + +1. Automatically open the Lynx Explorer app on an Android emulator or physical device. +2. Navigate to `.lynx.bundle` URLs. +3. Access the Lynx DOM (Document Object Model) tree. +4. Find elements using CSS Selectors (e.g. `page.locator('#my-id')`). +5. Read element styles and attributes. +6. Simulate native touch gestures (like tapping on buttons). + +--- + +## 🏗️ Architecture Explained (For Agents) + +In standard Web Playwright/Puppeteer, you connect to a persistent browser WebSocket. **Lynx is different.** + +1. **Stateless Connector:** This library uses `@lynx-js/devtool-connector` which operates via Android Debug Bridge (ADB). It sends isolated Request/Response commands. There is no long-living socket. +2. **Session Hopping:** When you tell Lynx to navigate to a new URL, Lynx creates an entirely **new debugging session**. +3. **`Lynx.ts`**: Handles the physical device connection, force-stops the app, restarts it, and ensures the Master devtool switch is ON. +4. **`KittenLynxView.ts`**: Represents a single "Page". When you call `goto(url)`, it sends the navigate command, and then intensely **polls** the ADB session list until it finds the new session matching your URL, and re-attaches to it. +5. **`ElementNode.ts`**: Represents a physical tag (like `` or ``). Cached via `WeakRef` to save memory. Uses native coordinate math via `DOM.getBoxModel` to simulate real screen taps. + +--- + +## 🚀 Quick Start Guide + +### Prerequisites + +- You must have an Android Emulator or device running via `adb`. +- The Lynx Explorer APK must be installed (`adb install LynxExplorer.apk`). +- (In CI) Ensure your test runner can reach your local bundle dev server (you might need `adb reverse tcp:8080 tcp:8080`). + +### Example Test Script + +Here is the blueprint for a standard test written using `kitten-lynx` and `vitest`: + +```typescript +import { expect, test, beforeAll, afterAll } from 'vitest'; +import { Lynx } from '@lynx-js/kitten-lynx-test-infra'; +import type { KittenLynxView } from '@lynx-js/kitten-lynx-test-infra'; + +let lynx: Lynx; +let page: KittenLynxView; + +// Setup: Connect to device +beforeAll(async () => { + // Connects to the first available ADB device and opens com.lynx.explorer + lynx = await Lynx.connect(); + page = await lynx.newPage(); +}, 60000); // Give ADB enough time to boot! + +// Teardown: Clean up resources +afterAll(async () => { + await lynx.close(); +}); + +test('Basic Navigation and Interaction', async () => { + // 1. Navigate to the bundle (Will poll until the session is found) + await page.goto('http://10.0.2.2:8080/dist/main.lynx.bundle'); + + // 2. Locate an element by CSS Selector + const button = await page.locator('#submit-btn'); + expect(button).toBeDefined(); + + // 3. Read an attribute. + // (Note: 'id' maps internally to Lynx's 'idSelector') + const idValue = await button!.getAttribute('id'); + expect(idValue).toBe('submit-btn'); + + // 4. Read computed CSS styles + const styles = await button!.computedStyleMap(); + expect(styles.get('display')).toBe('flex'); + + // 5. Simulate a native tap + await button!.tap(); + + // 6. Assert DOM changes (re-query the new element) + const successText = await page.locator('.success-message'); + expect(successText).toBeDefined(); +}, 30000); +``` + +--- + +## ⚠️ Known Gotchas & Pitfalls + +If you are writing scripts and tests, memorize these rules: + +1. **`goto()` implies a Session Change:** After `page.goto()`, the old node IDs are dead. Always query elements _after_ the navigation finishes. +2. **Timeouts:** Android emulators take time to boot. The devtool ADB bridge takes time to synchronize. Always set high timeouts for setup hooks (`beforeAll(..., 60000)`). +3. **No `App.openPage` locally:** Lynx Explorer 3.6.0 does not support `App.openPage` properly in some fallback layers. `kitten-lynx` falls back to a Custom OpenCard event automatically. You do not need to worry about this, but do not be alarmed by terminal warnings. +4. **Id Selector:** Standard web writes ``. Lynx internally uses `idSelector="test"`. The `ElementNode.getAttribute('id')` handles this mapping automatically for you. Do not query `'idSelector'` directly. +5. **DOM Snapshots:** You can call `await page.content()` to get a massive HTML-like string of the current Lynx DOM. This is extremely helpful for debugging what is actually rendering! + +--- + +## 🛠️ Extending the Library + +If you need to add a newly supported CDP command: + +1. Open `src/CDPChannel.ts`. +2. Add the strictly-typed parameter and return shapes to the `Protocol` interface block at the top of the file. +3. Call `await this._channel.send('Domain.methodName', params)` in `KittenLynxView` or `ElementNode`. +4. Run `pnpm run build && pnpm run test` before committing. diff --git a/packages/testing-library/kitten-lynx/package.json b/packages/testing-library/kitten-lynx/package.json new file mode 100644 index 0000000000..c8cf291e81 --- /dev/null +++ b/packages/testing-library/kitten-lynx/package.json @@ -0,0 +1,37 @@ +{ + "name": "@lynx-js/kitten-lynx-test-infra", + "version": "0.1.0", + "description": "A testing framework executing the Lynx explorer Android application", + "keywords": [ + "Lynx", + "testing", + "android", + "emulator" + ], + "author": { + "name": "lynx-stack" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "dependencies": { + "@lynx-js/devtool-connector": "workspace:*", + "@yume-chan/adb": "catalog:adb", + "@yume-chan/adb-server-node-tcp": "catalog:adb" + } +} diff --git a/packages/testing-library/kitten-lynx/src/CDPChannel.ts b/packages/testing-library/kitten-lynx/src/CDPChannel.ts new file mode 100644 index 0000000000..23f491f370 --- /dev/null +++ b/packages/testing-library/kitten-lynx/src/CDPChannel.ts @@ -0,0 +1,165 @@ +import type { Connector } from '@lynx-js/devtool-connector'; + +const sessionIdToChannel: Record> = {}; +type Quad = [number, number, number, number, number, number, number, number]; +/** + * Represents a node in the DOM tree returned by `DOM.getDocument`. + */ +export interface NodeInfoInGetDocument { + /** Unique DOM node identifier. */ + nodeId: number; + /** Child nodes of this node. */ + children: NodeInfoInGetDocument[]; + /** Flat array of alternating attribute name/value pairs. */ + attributes: string[]; + /** The node's tag name (e.g. `'view'`, `'text'`). */ + nodeName: string; +} +interface Protocol { + 'DOM.getDocument': { + params: { + depth: number; + pierce?: boolean; + }; + return: { + root: NodeInfoInGetDocument; + }; + }; + 'DOM.querySelector': { + params: { + nodeId: number; + selector: string; + }; + return: { + nodeId: number; + }; + }; + 'DOM.getAttributes': { + params: { + nodeId: number; + }; + return: { + attributes: string[]; + }; + }; + 'DOM.getBoxModel': { + params: { + nodeId: number; + }; + return: { + model: { + content: Quad; + padding: Quad; + border: Quad; + margin: Quad; + width: number; + height: number; + }; + }; + }; + 'CSS.getComputedStyleForNode': { + params: { + nodeId: number; + }; + return: { + computedStyle: { name: string; value: string }[]; + }; + }; + 'Page.navigate': { + params: { + url: string; + }; + return: unknown; + }; + 'Input.emulateTouchFromMouseEvent': { + params: { + type: 'mousePressed' | 'mouseReleased' | 'mouseMoved'; + x: number; + y: number; + timestamp?: number; + button: 'left' | 'middle' | 'right'; + deltaX?: number; + deltaY?: number; + }; + return: unknown; + }; +} + +/** + * A stateless CDP (Chrome DevTools Protocol) channel for sending commands + * to a specific Lynx session. + * + * Each `send()` call is a short-lived request/response via the `Connector`. + * Channels are cached per session ID using `WeakRef` to allow reuse. + */ +export class CDPChannel { + /** + * Retrieves or instantiates a stateless `CDPChannel` for a specific Lynx session. + * + * **Design pattern (For Agents):** + * This testing library uses a **stateless** CDP architecture to avoid hanging Websocket connections + * over unstable ADB links. Channels are cached per `sessionId` via `WeakRef` to reduce allocations. + * + * @param sessionId - The numeric Lynx devtool session ID assigned by the Lynx runtime. + * @param clientId - The unique device-port identifier for the client app (e.g., `"emulator-5554:40121"`). + * @param connector - The active `Connector` instance powering the ADB transport. + * @returns A `CDPChannel` reference bound to the requested session. + */ + static from( + sessionId: number, + clientId: string, + connector: Connector, + ): CDPChannel { + const maybeChannel = sessionIdToChannel[sessionId]?.deref(); + if (maybeChannel) return maybeChannel; + else { + const channel = new CDPChannel(connector, clientId, sessionId); + sessionIdToChannel[sessionId] = new WeakRef(channel); + return channel; + } + } + /** + * Constructs a new CDP Channel strictly bound to a single Lynx session. + * + * **Note for Agents:** + * Favor using `CDPChannel.from()` over manually newing up a channel, as `from()` handles + * WeakRef caching to minimize memory footprint. + * + * @param _connector - Low-level ADB transport controller. + * @param _clientId - Bound devtool target identification string. + * @param _sessionId - Narrowly scoped Lynx view session ID. + */ + constructor( + private _connector: Connector, + private _clientId: string, + private _sessionId: number, + ) {} + + /** + * Dispatches a strongly-typed Chrome DevTools Protocol (CDP) method command and awaits its return block. + * + * **Agent Usage:** + * This is the beating heart of all interactions in `kitten-lynx`. Instead of maintaining a socket, + * every `send(...)` call constructs a self-contained ADB request and awaits the isolated response. + * + * **Typings:** + * Notice that the generics strictly bind to the `Protocol` interface defined at the top of this file. + * If you need to invoke a CDP command that TypeScript rejects, you must update the `Protocol` interface first! + * + * @typeParam T - The literal string name of the CDP method (e.g., `'DOM.getDocument'`, `'Page.navigate'`). + * @param method - The command to send to the Lynx devtool server. + * @param params - A strongly-typed payload object required by the CDP method. + * @returns A promise resolving to a strongly-typed response object matching the expected return structure of the `method`. + */ + async send( + method: T, + params: Protocol[T]['params'], + ): Promise { + return await this._connector.sendCDPMessage( + this._clientId, + this._sessionId, + method, + params as any, + ); + } +} diff --git a/packages/testing-library/kitten-lynx/src/ElementNode.ts b/packages/testing-library/kitten-lynx/src/ElementNode.ts new file mode 100644 index 0000000000..07556aec95 --- /dev/null +++ b/packages/testing-library/kitten-lynx/src/ElementNode.ts @@ -0,0 +1,161 @@ +import type { KittenLynxView } from './KittenLynxView.js'; +import { setTimeout } from 'node:timers/promises'; + +const idToElementNode = new WeakMap[]>(); + +/** + * Represents a DOM element in a Lynx page. + * + * Wraps a CDP `nodeId` and provides methods for inspecting attributes, + * computed styles, and simulating user interactions like taps. + */ +export class ElementNode { + /** + * Retrieves or creates an `ElementNode` instance corresponding to a specific Lynx DOM node ID. + * + * **Agent Caching Strategy:** + * Nodes are aggressively cached per `KittenLynxView` instance using `WeakRef`. + * This means if an Agent queries the same DOM node twice before garbage collection, + * it returns the same `ElementNode` reference, reducing memory overhead during extensive DOM crawling. + * + * @param id - The numeric CDP node ID to wrap. + * @param lynxView - The `KittenLynxView` instance this node belongs to. + * @returns An `ElementNode` bound to the provided node ID and view. + */ + static fromId(id: number, lynxView: KittenLynxView): ElementNode { + const currentViewMap = idToElementNode.get(lynxView); + if (currentViewMap) { + const couldBeElementNode = currentViewMap[id]?.deref(); + if (couldBeElementNode) return couldBeElementNode; + } + + const node = new ElementNode(id, lynxView); + const ref = new WeakRef(node); + + if (currentViewMap) { + currentViewMap[id] = ref; + } else { + const newMap: WeakRef[] = []; + newMap[id] = ref; + idToElementNode.set(lynxView, newMap); + } + return node; + } + + /** + * Initializes a new `ElementNode` instance to represent a Lynx Component or Tag. + * + * **Note for Agents:** + * Similar to `KittenLynxView`, you should rarely call this directly. + * Rely on `KittenLynxView.locator()` or `ElementNode.fromId()` for instantiations. + * + * @param nodeId - The unique CDP numeric ID denoting this element in the Lynx renderer. + * @param _lynxView - The parent `KittenLynxView` instance used to dispatch subsequent CDP queries. + */ + constructor( + public readonly nodeId: number, + private _lynxView: KittenLynxView, + ) {} + + /** + * Simulates a native user tap (touch press followed by touch release) directly on the center of this element. + * + * **Internal Mechanics (For Agents):** + * This is not a simulated DOM event (like `element.click()` in Web). It is a highly accurate native-layer + * gesture dispatch: + * 1. Fetches the exact boundary coordinates via `DOM.getBoxModel`. + * 2. Calculates the absolute center `(x, y)` of the content box. + * 3. Dispatches an `Input.emulateTouchFromMouseEvent` (`'mousePressed'`) over the ADB bridge. + * 4. Waits 50ms, then dispatches the corresponding `'mouseReleased'`. + * + * **Crucial:** Ensure the element is visible on the screen before calling this, or the coordinate calculation might fail or hit nothing. + * + * @throws Error if the layout box model cannot be computed natively. + */ + async tap(): Promise { + const { model } = await this._lynxView._channel.send('DOM.getBoxModel', { + nodeId: this.nodeId, + }); + + // Calculate center coordinates + let x = 0, y = 0; + // Content box is usually defined by 8 coordinates [x1,y1, x2,y2, x3,y3, x4,y4] + // Top-left and bottom-right average gives center + if (model.content && model.content.length === 8) { + x = (model.content[0]! + model.content[4]!) / 2; + y = (model.content[1]! + model.content[5]!) / 2; + } else { + throw new Error( + `Could not determine coordinates for node ${this.nodeId}`, + ); + } + + await this._lynxView._channel.send('Input.emulateTouchFromMouseEvent', { + type: 'mousePressed', + x, + y, + button: 'left', + }); + + await setTimeout(50); + + await this._lynxView._channel.send('Input.emulateTouchFromMouseEvent', { + type: 'mouseReleased', + x, + y, + button: 'left', + }); + } + + /** + * Retrieves the string value of a specified attribute on this element. + * + * **Agent Quirk / Gotcha:** + * In Lynx, standard web `id="foo"` attributes are actually stored as `idSelector="foo"` internally. + * This library provides a seamless shim: if you query `getAttribute('id')`, it automatically + * queries `idSelector` instead. No manual handling of `idSelector` is required on your part. + * + * @param name - The name of the attribute to fetch. + * @returns A promise resolving to the string value of the attribute, or `null` if the attribute does not exist. + */ + async getAttribute(name: string): Promise { + if (name === 'id') { + name = 'idSelector'; + } + const ret = await this._lynxView._channel.send('DOM.getAttributes', { + nodeId: this.nodeId, + }); + const attributes: Record = {}; + for (let ii = 0; ii < ret.attributes.length - 1; ii += 2) { + const attrName = ret.attributes[ii]!; + const value = ret.attributes[ii + 1]!; + attributes[attrName] = value; + } + return attributes[name] ?? null; + } + + /** + * Fetches all actively computed CSS properties for this specific element. + * + * **Agent Usage:** + * This relies on `CSS.getComputedStyleForNode`. It returns the fully resolved style values + * post-layout calculation (e.g., resolving `100%` into absolute `px` values). + * It is highly recommended to use this for visual assertions (e.g., verifying a box is indeed `display: none` + * or has a specific `background-color`). + * + * @returns A promise resolving to a native JS `Map`, where keys are CSS property names and values are their computed string expressions. + */ + async computedStyleMap(): Promise> { + const map = new Map(); + const ret = await this._lynxView._channel.send( + 'CSS.getComputedStyleForNode', + { + nodeId: this.nodeId, + }, + ); + for (const style of ret.computedStyle) { + map.set(style.name, style.value); + } + return map; + } +} diff --git a/packages/testing-library/kitten-lynx/src/KittenLynxView.ts b/packages/testing-library/kitten-lynx/src/KittenLynxView.ts new file mode 100644 index 0000000000..b0d16367c9 --- /dev/null +++ b/packages/testing-library/kitten-lynx/src/KittenLynxView.ts @@ -0,0 +1,330 @@ +import type { Connector } from '@lynx-js/devtool-connector'; +import { CDPChannel } from './CDPChannel.js'; +import type { NodeInfoInGetDocument } from './CDPChannel.js'; +import { ElementNode } from './ElementNode.js'; +import { setTimeout } from 'node:timers/promises'; + +const idToKittenLynxView: Record> = {}; + +/** + * Represents a Lynx page instance, similar to Puppeteer's `Page`. + * + * Provides methods for navigating to Lynx bundle URLs, querying the DOM, + * and reading page content. Created via {@link Lynx.newPage}. + */ +export class KittenLynxView { + private static incId = 1; + private _root?: ElementNode; + _channel!: CDPChannel; + readonly id: number; + + /** + * Retrieves a previously created `KittenLynxView` instance using its stringified numeric ID. + * + * **Why this is useful for Agents:** + * This is primarily used for cross-referencing or finding an existing view without passing the object reference around. + * + * @param id - The string representation of the LynxView's numeric ID (e.g., `'1'`, `'2'`). + * @returns The `KittenLynxView` instance if it is still alive in memory, or `undefined` if it has been garbage-collected. + */ + static getKittenLynxViewById(id: string): KittenLynxView | undefined { + return idToKittenLynxView[id]?.deref(); + } + + /** + * Initializes a new `KittenLynxView` instance. + * + * **Note for Agents:** + * You generally should avoid calling this constructor directly. Instead, use `Lynx.newPage()` to properly + * initialize a `KittenLynxView` instance bound to the active ADB connection and client. + * + * @param _connector - The low-level `Connector` instance used to dispatch CDP messages over ADB/USB. + * @param _clientId - The unique client identifier (typically `:`). + * @param _client - Optional client metadata object containing internal app states. + */ + constructor( + private _connector: Connector, + private _clientId: string, + private _client?: any, + ) { + this.id = KittenLynxView.incId++; + idToKittenLynxView[this.id.toString()] = new WeakRef(this); + } + + /** + * Navigates the Lynx App to a specific Lynx bundle URL and attaches to the corresponding CDP session. + * + * **How it works (Crucial for Agents to understand):** + * Unlike standard web browsers, calling `Page.navigate` in Lynx creates a **new** debugging session + * instead of reusing the current one. This method abstracts away that complexity by: + * 1. Waiting for the devtool server to boot. + * 2. Sending an `App.openPage` (or a fallback message) to trigger the navigation. + * 3. **Polling the session list** over ADB to find a new session whose URL matches the target bundle URL. + * 4. Automatically re-attaching to the matched session and fetching the initial DOM tree (`DOM.getDocument`). + * + * @param url - The absolute URL of the Lynx bundle to navigate to (e.g., `'http://localhost:8080/dist/main.lynx.bundle'`). + * @param _options - Currently unused. Reserved for future navigation options. + * @throws An error if it times out waiting for the devtool server to boot (60s limit). + * @throws An error if the specific session for the URL cannot be found (30s limit) or cannot be attached. + */ + async goto(url: string, _options?: unknown): Promise { + const urlPath = url.split('/').pop() || url; + + // Wait until the Lynx app has booted and registered its devtool server. + // We confirm this by waiting until at least one session is reported. + if (!this._channel) { + console.log(`[goto] Waiting for devtool to boot...`); + const startTime = Date.now(); + let ready = false; + let bootLoops = 0; + while (Date.now() - startTime < 60000) { + bootLoops++; + try { + const sessions = await this._connector.sendListSessionMessage( + this._clientId, + ); + if (bootLoops % 10 === 0) { + console.log( + `[goto] list sessions returned ${sessions.length} sessions (loop ${bootLoops})`, + ); + } + if (Array.isArray(sessions)) { + console.log( + `[goto] Devtool booted in ${Date.now() - startTime}ms. Sessions:`, + JSON.stringify(sessions), + ); + ready = true; + break; + } + } catch (error: any) { + if (bootLoops % 10 === 0) { + console.error( + `[goto] list sessions error (loop ${bootLoops}):`, + error.message || error, + ); + } + } + await setTimeout(500); + } + if (!ready) { + throw new Error( + 'Timeout waiting for Lynx App devtool to boot completely before navigation', + ); + } + } + + let existingSessionIds = new Set(); + try { + const existing = await this._connector.sendListSessionMessage( + this._clientId, + ); + if (Array.isArray(existing)) { + existing.forEach(s => existingSessionIds.add(s.session_id)); + } + } catch (e) { + // ignore + } + + console.log(`[goto] Sending App.openPage to URL: ${url}`); + try { + const msg = await this._connector.sendAppMessage( + this._clientId, + 'App.openPage', + { + url, + }, + ); + console.log(`[goto] App.openPage succeeded:`, msg); + } catch (e: any) { + console.log( + `[goto] App.openPage failed, falling back to Customized OpenCard. Error:`, + e.message, + ); + try { + const msg = await this._connector.sendMessage(this._clientId, { + event: 'Customized', + data: { + type: 'OpenCard', + data: { + type: 'url', + url, + }, + sender: -1, + }, + from: -1, + }); + console.log(`[goto] Customized OpenCard succeeded:`, msg); + } catch (fallbackErr: any) { + console.error( + `[goto] Customized OpenCard failed:`, + fallbackErr.message, + ); + } + } + + // Poll for the session whose URL matches the navigated bundle + console.log(`[goto] Polling for session matching URL: ${url}`); + let matchedSessionId: number | undefined; + const navStartTime = Date.now(); + let pollLoops = 0; + while (Date.now() - navStartTime < 30000) { + pollLoops++; + await setTimeout(500); + try { + const sessions = await this._connector.sendListSessionMessage( + this._clientId, + ); + + if (pollLoops % 10 === 0) { + console.log( + `[goto] (Loop ${pollLoops}) Available sessions:`, + JSON.stringify(sessions.map(s => s.url)), + ); + } + + const newSessionMatches = sessions.filter( + s => + !existingSessionIds.has(s.session_id) + && (s.url === url || s.url === urlPath || url.endsWith(s.url) + || s.url.endsWith(urlPath)), + ); + let matched = newSessionMatches[0]; + + if (!matched) { + const suffixMatches = sessions.filter( + s => + s.url === url || s.url === urlPath || url.endsWith(s.url) + || s.url.endsWith(urlPath), + ); + if (suffixMatches.length === 1) { + matched = suffixMatches[0]; + } + } + + if (matched) { + console.log( + `[goto] Found matched session after ${ + Date.now() - navStartTime + }ms: id=${matched.session_id}, url=${matched.url}`, + ); + matchedSessionId = matched.session_id; + break; + } + } catch (error: any) { + if (pollLoops % 10 === 0) { + console.error( + `[goto] list sessions error in polling (loop ${pollLoops}):`, + error.message || error, + ); + } + } + } + + if (matchedSessionId === undefined) { + console.error( + `[goto] Failed to find session for URL after 30000ms: ${url}`, + ); + } else { + console.log('matchedSessionId', matchedSessionId); + } + if (matchedSessionId === undefined) { + throw new Error('cannot find session for URL: ' + url); + } + await this.onAttachedToTarget(matchedSessionId); + + if (!this._channel) { + throw new Error('Failed to attach to session for URL: ' + url); + } + } + + /** + * Locates the first DOM element matching the provided CSS selector in the current page. + * + * **Agent Usage:** + * This operates identically to `document.querySelector` or Playwright's `page.locator()`. + * It relies on the `DOM` CDP domain. Always ensure that `goto()` has completed successfully before calling this, + * otherwise the DOM tree will not exist. + * + * @param selector - A valid CSS selector string targeting the desired element (e.g., `'view'`, `'#submit-btn'`, `'.container > text'`). + * @returns A promise resolving to an `ElementNode` containing methods to interact with the element, or `undefined` if no node matched. + * @throws Error if the method is called before a page is loaded via `goto()`. + */ + async locator(selector: string): Promise { + if (!this._root) { + throw new Error('Not connected to a document yet. Call goto() first.'); + } + const { nodeId } = await this._channel.send('DOM.querySelector', { + nodeId: this._root.nodeId, + selector, + }); + if (nodeId !== -1) { + return ElementNode.fromId(nodeId, this); + } + return; + } + + /** + * Attaches the LynxView to a specific Lynx devtool session and initializes the CDP channel. + * + * **Internal Mechanics:** + * This method creates a `CDPChannel` for the specific `sessionId` and immediately fires + * a `DOM.getDocument` request to cache the root document node. This must succeed for the page to be interactable. + * + * **Note for Agents:** + * This is generally marked as internal, but it is not `private`. You normally do not need to call this manually, + * as `goto()` handles session attachment automatically. + * + * @param sessionId - The numeric Lynx devtool session ID to attach to, discovered via `sendListSessionMessage`. + */ + async onAttachedToTarget(sessionId: number) { + const channel = CDPChannel.from( + sessionId, + this._clientId, + this._connector, + ); + + const response = await channel.send('DOM.getDocument', { + depth: -1, + }); + const root = response.root.children[0]!; + this._channel = channel; + this._root = ElementNode.fromId(root.nodeId, this); + } + + #contentToStringImpl(buffer: string[], node: NodeInfoInGetDocument) { + const tagName = node.nodeName.toLowerCase(); + buffer.push('<', tagName); + for (let ii = 0; ii < node.attributes.length; ii += 2) { + let key = node.attributes[ii]!.toLowerCase(); + const value = node.attributes[ii + 1]!; + if (key === 'idselector') { + key = 'id'; + } + buffer.push(' ', key, '="', value, '"'); + } + buffer.push('>'); + for (const child of node.children) { + this.#contentToStringImpl(buffer, child); + } + buffer.push(''); + } + + /** + * Serializes the current page's entire DOM tree into an HTML-like string format. + * + * **Agent Usage:** + * This is highly useful for debugging and logging the current state of the Lynx App DOM. + * It forces a fresh `DOM.getDocument` snapshot from the CDP server and recursively walks the tree + * to build a string containing tags and attributes (e.g., `...`). + * + * @returns A promise resolving to a string representing the serialized DOM content of the page. + */ + async content(): Promise { + const document = await this._channel.send('DOM.getDocument', { + depth: -1, + }); + const buffer: string[] = []; + this.#contentToStringImpl(buffer, document.root); + return buffer.join(''); + } +} diff --git a/packages/testing-library/kitten-lynx/src/Lynx.ts b/packages/testing-library/kitten-lynx/src/Lynx.ts new file mode 100644 index 0000000000..d2279e31ea --- /dev/null +++ b/packages/testing-library/kitten-lynx/src/Lynx.ts @@ -0,0 +1,211 @@ +import { Connector } from '@lynx-js/devtool-connector'; +import { AndroidTransport } from '@lynx-js/devtool-connector/transport'; +import { AdbServerClient } from '@yume-chan/adb'; +import { AdbServerNodeTcpConnector } from '@yume-chan/adb-server-node-tcp'; +import { KittenLynxView } from './KittenLynxView.js'; + +/** + * Options for configuring the connection to a Lynx device. + */ +export interface ConnectOptions { + /** + * ADB device serial to target (e.g. `"localhost:5555"`, `"emulator-5554"`). + * When multiple ADB devices are connected, use this to select the correct one. + * If omitted, uses the first available device. + */ + deviceId?: string; + /** + * App package name to launch on the device. + * @default "com.lynx.explorer" + */ + appPackage?: string; +} + +const DEFAULT_APP_PACKAGE = 'com.lynx.explorer'; + +/** + * Main entry point for the kitten-lynx testing framework. + * + * Provides Puppeteer-like APIs for connecting to a Lynx app running on an + * Android device (physical or emulator) via ADB and the Chrome DevTools Protocol. + * + * @example + * ```typescript + * const lynx = await Lynx.connect({ deviceId: 'localhost:5555' }); + * const page = await lynx.newPage(); + * await page.goto('http://example.com/bundle.lynx.bundle'); + * const content = await page.content(); + * await lynx.close(); + * ``` + */ +export class Lynx { + private _connector: Connector | null = null; + private _currentClient: any | null = null; + private _currentClientId: string = ''; + + /** + * Main setup method. Connects to the Lynx app devtool server over ADB. + * + * **Agent Guide on the Connection Flow:** + * 1. Discovers the target ADB device (physical Android or emulator). + * 2. Force-stops the target app to ensure a clean state (`adb shell am force-stop`). + * 3. Launches the application (usually Lynx Explorer) on the device. + * 4. Queries the `Connector` for available clients and matches by device ID and package name. + * 5. Enables the master devtool switch (`enable_devtool`). + * + * **When to use:** + * This should be the first method invoked in any test script or interaction flow. + * + * @param options - Configure connection variables, such as `appPackage` and `deviceId` (useful if multiple devices are attached). + * @returns A Promise resolving to a connected `Lynx` instance ready to spawn new pages. + * @throws Errors if devices aren't found, the app is missing, or the target client fails to initialize. + */ + static async connect(options?: ConnectOptions): Promise { + const targetDevice = options?.deviceId; + const appPackage = options?.appPackage ?? DEFAULT_APP_PACKAGE; + + const lynx = new Lynx(); + lynx._connector = new Connector([new AndroidTransport()]); + + const devices = await lynx._connector.listDevices(); + if (devices.length === 0) { + throw new Error('Failed to connect to Lynx: no devices found.'); + } + + let devicesToSearch = devices; + if (targetDevice) { + devicesToSearch = devices.filter(d => d.id === targetDevice); + if (devicesToSearch.length === 0) { + throw new Error( + `Failed to connect to Lynx: device ${targetDevice} not found.`, + ); + } + } + + let deviceIdToUse: string | undefined; + for (const device of devicesToSearch) { + try { + const apps = await lynx._connector.listAvailableApps(device.id); + if (apps.some(app => app.packageName === appPackage)) { + deviceIdToUse = device.id; + break; + } + } catch (e) { + console.error(e); + } + } + + if (!deviceIdToUse) { + throw new Error( + `Failed to connect to Lynx: app ${appPackage} not found on any available device.`, + ); + } + + console.log( + `[Lynx] Restarting ${appPackage} on device ${deviceIdToUse}...`, + ); + try { + const client = new AdbServerClient( + new AdbServerNodeTcpConnector({ port: 5037 }), + ); + const adb = await client.createAdb({ serial: deviceIdToUse }); + try { + await adb.subprocess.noneProtocol.spawnWaitText([ + 'am', + 'force-stop', + appPackage, + ]); + } finally { + await adb.close(); + } + } catch (e) { + console.error( + `[Lynx] Failed to force-stop app on device ${deviceIdToUse}:`, + e, + ); + } + + try { + await lynx._connector.openApp(deviceIdToUse, appPackage); + } catch (e) { + console.error(`[Lynx] Failed to open app on device ${deviceIdToUse}:`, e); + throw e; + } + + console.log( + `[Lynx] Waiting 2 seconds for app to initialize before listing clients...`, + ); + await new Promise(resolve => setTimeout(resolve, 2000)); + + const clients = await lynx._connector.listClients(); + console.log(`[Lynx] Found ${clients.length} clients in total.`); + for (const c of clients) { + console.log(`[Lynx] Client ID: ${c.id}, App: ${c.info?.AppProcessName}`); + } + + // Filter clients by deviceId and target package + const encodedDeviceId = encodeURIComponent(deviceIdToUse); + const matchedClients = clients.filter( + c => + c.id.startsWith(encodedDeviceId + ':') + && c.info.AppProcessName === appPackage, + ); + + if (matchedClients.length === 0) { + throw new Error( + `Failed to connect to Lynx: no client found for ${appPackage} on device "${deviceIdToUse}".`, + ); + } + + lynx._currentClient = matchedClients[0]; + lynx._currentClientId = matchedClients[0]!.id; + + await lynx._connector.setGlobalSwitch( + lynx._currentClientId, + 'enable_devtool', + true, + ); + + return lynx; + } + + /** + * Spawns a new page representation for the connected Lynx environment. + * + * **Agent Usage:** + * Similar to Puppeteer's `browser.newPage()`. Once you have a `Lynx` connection instance safely created + * via `Lynx.connect()`, call this method to obtain a `KittenLynxView`. You can then call `post.goto(url)` + * on the view to navigate to a Lynx Bundle. + * + * @returns A Promise resolving to a new `KittenLynxView` instance bound to the active ADB client. + * @throws Error if the Lynx connection isn't properly initialized first. + */ + async newPage(): Promise { + if (!this._connector || this._currentClientId === '') { + throw new Error('Not connected. Call Lynx.connect() first.'); + } + return new KittenLynxView( + this._connector, + this._currentClientId, + this._currentClient, + ); + } + + /** + * Closes the active ADB/CDP connection and releases associated resources. + * + * **Agent Usage:** + * Ensure this is called in your test's teardown block (e.g., in `afterAll()`) to prevent + * floating Node.js processes or hanging ADB connections that can ruin subsequent test runs. + * + * @returns A Promise resolving when the cleanup operation is fully processed. + */ + async close(): Promise { + if (this._connector) { + await this._connector.close(); + } + this._connector = null; + this._currentClient = null; + this._currentClientId = ''; + } +} diff --git a/packages/testing-library/kitten-lynx/src/index.ts b/packages/testing-library/kitten-lynx/src/index.ts new file mode 100644 index 0000000000..67785bf2f0 --- /dev/null +++ b/packages/testing-library/kitten-lynx/src/index.ts @@ -0,0 +1,4 @@ +export { Lynx } from './Lynx.js'; +export { KittenLynxView } from './KittenLynxView.js'; +export { ElementNode } from './ElementNode.js'; +export { CDPChannel } from './CDPChannel.js'; diff --git a/packages/testing-library/kitten-lynx/tests/lynx.spec.ts b/packages/testing-library/kitten-lynx/tests/lynx.spec.ts new file mode 100644 index 0000000000..556faceb7a --- /dev/null +++ b/packages/testing-library/kitten-lynx/tests/lynx.spec.ts @@ -0,0 +1,46 @@ +import { Lynx } from '../src/Lynx.js'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +describe('kitten-lynx testing framework', () => { + let lynx: Lynx; + + beforeAll(async () => { + lynx = await Lynx.connect(); + }); + + afterAll(async () => { + await lynx?.close(); + }); + + it('can navigate to a page and read the DOM', async () => { + console.log('[test] creating new page...'); + const page = await lynx.newPage(); + console.log('[test] page created.'); + + console.log('[test] navigating to hello-world bundle...'); + await page.goto( + 'https://lynxjs.org/next/lynx-examples/hello-world/dist/main.lynx.bundle', + ); + console.log('[test] navigation complete.'); + + console.log('[test] getting page content...'); + const content = await page.content(); + console.log(`[test] page content received, length: ${content.length}`); + expect(content).toContain('have fun'); + + console.log('[test] locator view...'); + const rootElement = await page.locator('view'); + expect(rootElement).toBeDefined(); + + if (rootElement) { + console.log('[test] getting computed styles...'); + const styles = await rootElement.computedStyleMap(); + expect(styles.size).toBeGreaterThan(0); + + // Perform a tap action to verify the method executes successfully + console.log('[test] tapping root element...'); + await expect(rootElement.tap()).resolves.toBeUndefined(); + } + console.log('[test] finished successfully'); + }, 90000); // Increase timeout to 90s as connecting/launching emulator app can be slow +}); diff --git a/packages/testing-library/kitten-lynx/tsconfig.json b/packages/testing-library/kitten-lynx/tsconfig.json new file mode 100644 index 0000000000..44d7dc5c8d --- /dev/null +++ b/packages/testing-library/kitten-lynx/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@lynx-js/devtool-connector": ["../../mcp-servers/devtool-connector/dist/index.d.ts"], + "@lynx-js/devtool-connector/transport": ["../../mcp-servers/devtool-connector/dist/transport/index.d.ts"], + }, + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], +} diff --git a/packages/testing-library/kitten-lynx/vitest.config.ts b/packages/testing-library/kitten-lynx/vitest.config.ts new file mode 100644 index 0000000000..bae04ba618 --- /dev/null +++ b/packages/testing-library/kitten-lynx/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 60000, + hookTimeout: 60000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16aed1b658..4cdb1ccf39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1222,6 +1222,18 @@ importers: specifier: ^1.5.1 version: 1.5.1(@babel/core@7.29.0)(vite@5.4.2(@types/node@24.10.13)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)) + packages/testing-library/kitten-lynx: + dependencies: + '@lynx-js/devtool-connector': + specifier: workspace:* + version: link:../../mcp-servers/devtool-connector + '@yume-chan/adb': + specifier: catalog:adb + version: 2.5.1 + '@yume-chan/adb-server-node-tcp': + specifier: catalog:adb + version: 2.5.2 + packages/testing-library/testing-environment: devDependencies: '@testing-library/jest-dom':