Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
db0962b
test(kitten-lynx): impl vitest test suite, tap interaction, and AGENT…
PupilTong Feb 25, 2026
e327783
feat(kitten-lynx): impl core testing API and finish test runner
PupilTong Feb 26, 2026
5912ee3
feat: Automatically restart the Lynx app on all connected ADB devices…
PupilTong Feb 26, 2026
d1e79b3
feat: Introduce configurable container images for reusable workflows …
PupilTong Feb 27, 2026
013788f
refactor: switch Lynx connection to devtool-connector and add configu…
PupilTong Mar 6, 2026
161619f
feat: Migrate Android testing documentation to Waydroid and enhance a…
PupilTong Mar 6, 2026
9d6ef89
ci: Replace Docker-based Android emulator with Waydroid for CI tests.
PupilTong Mar 6, 2026
c542281
feat: Install Waydroid and initialize it with a vanilla image.
PupilTong Mar 9, 2026
6a5c0d5
chore: Add kmod package to Waydroid installation in the test workflow.
PupilTong Mar 9, 2026
d17cc1b
ci: Install Waydroid kernel modules and their dependencies in the tes…
PupilTong Mar 9, 2026
5030f5c
+ fix
PupilTong Mar 9, 2026
5bf624f
fix: Explicitly set ANDROID_HOME and use `--sdk_root` with `sdkmanage…
PupilTong Mar 10, 2026
634592f
+ fix
PupilTong Mar 10, 2026
2ea97fb
+ remove install stage
PupilTong Mar 10, 2026
8958529
+ fix url
PupilTong Mar 10, 2026
69e6d66
+ waiting time
PupilTong Mar 10, 2026
fdce55f
+ fix
PupilTong Mar 10, 2026
e1ca7fe
ci: Start and verify Lynx Explorer application before running tests i…
PupilTong Mar 10, 2026
b3290b9
+ log
PupilTong Mar 11, 2026
30c8fda
+ fix
PupilTong Mar 11, 2026
57aca0c
+ essential infos
PupilTong Mar 11, 2026
51a1019
docs: Correct typo "chould" to "could" in AGENTS.md.
PupilTong Mar 11, 2026
cea23be
+ fix dedupe
PupilTong Mar 12, 2026
53fadb5
docs: Add comprehensive JSDoc comments and a new README to clarify Ki…
PupilTong Mar 12, 2026
da748a5
Remove `container-image` parameter from workflows and adjust test exc…
PupilTong Mar 12, 2026
f264485
refactor: rename `@lynx-test/kitten-lynx` to `@lynx-js/kitten-lynx-te…
PupilTong Mar 12, 2026
b92aa16
refactor: Migrate ADB interactions to `@yume-chan/adb` and adjust tou…
PupilTong Mar 12, 2026
32ebdfe
chore: update pnpm lockfile
PupilTong Mar 12, 2026
1d38cc8
+ report coverage
PupilTong Mar 16, 2026
c3b1fd7
feat: Improve `kitten-lynx` test stability by implementing local serv…
PupilTong Mar 17, 2026
1f6a24b
feat: Implement `cmd package resolve-activity` for Android app launch…
PupilTong Mar 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sharp-dragons-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lynx-js/kitten-lynx-test-infra": patch
---

feat: initial commit
Comment thread
PupilTong marked this conversation as resolved.
57 changes: 57 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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:
Expand Down
6 changes: 6 additions & 0 deletions packages/mcp-servers/devtool-connector/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ export class Connector {
.flatMap(({ value }) => value);
}

async close(): Promise<void> {
await Promise.allSettled(
this.#transports.map(t => t.close()),
);
}

async listAvailableApps(deviceId: string): Promise<App[]> {
const transport = await this.#findTransportWithDeviceId(deviceId);

Expand Down
53 changes: 46 additions & 7 deletions packages/mcp-servers/devtool-connector/src/transport/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -14,7 +15,7 @@ import type {
OpenAppOptions,
Transport,
TransportConnectOptions,
} from './transport.ts';
} from './transport.js';

const debug = createDebug('devtool-mcp-server:connector:android');

Expand Down Expand Up @@ -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 <package_name> -c android.intent.category.LAUNCHER 1
'monkey',
Expand All @@ -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:')) {
Comment thread
colinaaa marked this conversation as resolved.
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}.`);
}
}
}
58 changes: 58 additions & 0 deletions packages/testing-library/kitten-lynx/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.

Comment thread
PupilTong marked this conversation as resolved.
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.
113 changes: 113 additions & 0 deletions packages/testing-library/kitten-lynx/README.md
Original file line number Diff line number Diff line change
@@ -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 `<view>` or `<text>`). 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 `<view id="test">`. 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.
37 changes: 37 additions & 0 deletions packages/testing-library/kitten-lynx/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading