diff --git a/packages/connect-examples/electron-example/package.json b/packages/connect-examples/electron-example/package.json index b48aad01b..f8271b4f8 100644 --- a/packages/connect-examples/electron-example/package.json +++ b/packages/connect-examples/electron-example/package.json @@ -36,7 +36,7 @@ "@types/webpack-node-externals": "^3.0.4", "clean-webpack-plugin": "^4.0.0", "cross-env": "^7.0.3", - "electron": "^28.0.0", + "electron": "^40.1.0", "electron-builder": "^24.9.1", "webpack": "^5.90.2", "webpack-node-externals": "^3.0.0" diff --git a/packages/connect-examples/expo-example/App.tsx b/packages/connect-examples/expo-example/App.tsx index be56ff696..af1aeb4c1 100644 --- a/packages/connect-examples/expo-example/App.tsx +++ b/packages/connect-examples/expo-example/App.tsx @@ -25,6 +25,7 @@ const FunctionalTestingScreen = lazy(() => import('./src/views/FunctionalTesting const AttachToPinTestingScreen = lazy(() => import('./src/views/AttachToPinTestingScreen')); const SLIP39TestScreen = lazy(() => import('./src/views/SLIP39TestScreen')); const ChainMethodTestScreen = lazy(() => import('./src/views/ChainMethodTestScreen')); +const AutomationTestScreen = lazy(() => import('./src/views/AutomationTestScreen')); // React Navigation v6 linking 配置 const linking: LinkingOptions = { @@ -46,6 +47,7 @@ const linking: LinkingOptions = { [Routes.FunctionalTesting]: 'expo-example/functional-testing', [Routes.SLIP39Test]: 'expo-example/slip39-test', [Routes.ChainMethodTest]: 'expo-example/chain-method-test', + [Routes.AutomationTest]: 'expo-example/automation-test', }, }, }; @@ -96,6 +98,7 @@ function NavigationContent() { /> + ); diff --git a/packages/connect-examples/expo-example/docs/automation-test-design.md b/packages/connect-examples/expo-example/docs/automation-test-design.md new file mode 100644 index 000000000..36674ac60 --- /dev/null +++ b/packages/connect-examples/expo-example/docs/automation-test-design.md @@ -0,0 +1,656 @@ +# 硬件钱包自动化测试系统设计文档 + +## 概述 + +本文档描述了基于 PhonePilot MCP 的硬件钱包自动化测试系统设计方案。该系统通过机械臂物理控制硬件钱包,实现完全自动化的端到端测试。 + +## 核心原则:职责分离 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 职责划分 │ +├──────────────────────────────┬──────────────────────────────────────┤ +│ PhonePilot 负责 │ expo-example 负责 │ +├──────────────────────────────┼──────────────────────────────────────┤ +│ ✓ 设备重置 (wipe) │ ✓ 执行测试用例 │ +│ ✓ 恢复助记词 │ ✓ 调用 SDK 方法 │ +│ ✓ 恢复 SLIP39 分片 │ ✓ 验证返回结果 │ +│ ✓ 输入 PIN │ ✓ 记录测试状态 │ +│ ✓ 输入 Passphrase │ ✓ 生成测试报告 │ +│ ✓ 点击确认/取消按钮 │ ✓ 测试流程编排 │ +│ ✓ 所有物理操作 │ ✓ 通知 PhonePilot 执行物理操作 │ +└──────────────────────────────┴──────────────────────────────────────┘ +``` + +**expo-example 不直接调用 SDK.resetDevice() 等设备准备方法,而是通知 PhonePilot 来完成。** + +## 目录 + +- [1. 系统架构](#1-系统架构) +- [2. 流程图](#2-流程图) +- [3. 模块设计](#3-模块设计) +- [4. 接口定义](#4-接口定义) +- [5. 状态管理](#5-状态管理) +- [6. 实现计划](#6-实现计划) + +--- + +## 1. 系统架构 + +### 1.1 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 自动化测试系统架构 │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ expo-example │ │ PhonePilot │ │ 硬件钱包设备 │ │ +│ │ (测试控制器) │ │ (物理操作) │ │ (OneKey) │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ │ MCP Protocol │ 机械臂 │ │ +│ │◄──────────────────────►│◄──────────────────────►│ │ +│ │ │ │ │ +│ │ USB/BLE SDK │ │ │ +│ │◄───────────────────────┼───────────────────────►│ │ +│ │ │ │ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 组件职责 + +| 组件 | 职责 | +|------|------| +| **expo-example** | 测试编排、SDK 调用、结果验证、报告生成 | +| **PhonePilot** | MCP Server、机械臂控制、摄像头捕获 | +| **硬件钱包** | 被测设备、执行签名/地址生成等操作 | + +### 1.3 通信协议 + +- **expo-example ↔ PhonePilot**: MCP Protocol (HTTP/SSE) +- **expo-example ↔ 硬件钱包**: USB/Bluetooth (via hardware-js-sdk) +- **PhonePilot ↔ 机械臂**: HTTP (ESP32 Controller) + +--- + +## 2. 流程图 + +### 2.1 测试执行时序图 + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ expo-example │ │ PhonePilot │ │ 机械臂 │ │ 硬件钱包 │ +│ (测试执行) │ │ (设备准备) │ │ Controller │ │ (OneKey) │ +└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ │ + │ 1. 请求准备设备 │ │ │ + │ ─────────────────► │ │ │ + │ prepare-device │ │ │ + │ {mnemonic, type} │ │ │ + │ │ │ │ + │ │ 2. PhonePilot 完成设备准备 (内部流程) │ + │ │ ════════════════════════════════════════│ + │ │ │ │ + │ │ 重置设备 │ │ + │ │───────────────────►│───────────────────►│ + │ │ 输入助记词 │ │ + │ │───────────────────►│───────────────────►│ + │ │ 确认操作 │ │ + │ │───────────────────►│───────────────────►│ + │ │ │ │ + │ device-ready │◄═══════════════════════════════════════│ + │ ◄───────────────── │ │ │ + │ │ │ │ + │ 3. 执行测试用例序列 (expo-example 核心职责) │ + │ ════════════════════════════════════════════════════════════│ + │ │ │ │ + │ SDK.btcGetAddress()│ │ │ + │ ───────────────────┼────────────────────┼───────────────────►│ + │ │ │ 显示确认 │ + │ │ │ │ + │ 请求物理确认 │ │ │ + │ ──────────────────►│ move + click │ 物理点击 │ + │ confirm-action │───────────────────►│───────────────────►│ + │ done │◄───────────────────│ │ + │ ◄─────────────────│ │ │ + │ │ │ │ + │ address: 1A1z... │ │ │ + │ ◄──────────────────┼────────────────────┼────────────────────│ + │ │ │ │ + │ 验证地址 ✓ │ │ │ + │ 记录结果 │ │ │ + │ │ │ │ + │ ... 重复更多测试 ... │ │ │ + │ │ │ │ + │ 4. 测试完成,生成报告 │ │ │ + │ ════════════════════════════════════════════════════════════│ + │ │ │ │ + │ 通知测试完成 │ │ │ + │ ──────────────────►│ │ │ + │ test-complete │ │ │ + │ │ │ │ + ▼ ▼ ▼ ▼ +``` + +### 2.2 测试套件执行流程 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 自动化测试套件执行流程 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ │ +│ │ 开始测试 │ │ +│ └──────┬──────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 1. 初始化阶段 (expo-example) │ │ +│ │ ├─ 连接 PhonePilot MCP │ │ +│ │ └─ 连接硬件钱包 (USB/BLE) │ │ +│ └──────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 2. 按助记词分组执行 (每个助记词只需准备一次) │ │ +│ │ │ │ +│ │ ┌───────────────────────────────────────────────────────┐ │ │ +│ │ │ 助记词组 1: count24_one (24位助记词 - 组1) │ │ │ +│ │ │ ├─ prepare-device {mnemonic} ← PhonePilot 重置恢复 │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 同一助记词下,不需要重置: │ │ │ +│ │ │ ├─ normal: 执行测试 (无 passphrase) │ │ │ +│ │ │ ├─ passphrase_empty: 执行测试 (passphrase="") │ │ │ +│ │ │ ├─ passphrase_1: 执行测试 (passphrase="asdfg7890") │ │ │ +│ │ │ └─ passphrase_2: 执行测试 (passphrase="xxx") │ │ │ +│ │ └───────────────────────────────────────────────────────┘ │ │ +│ │ ▼ 切换助记词,需要重置 │ │ +│ │ ┌───────────────────────────────────────────────────────┐ │ │ +│ │ │ 助记词组 2: count24_two (24位助记词 - 组2) │ │ │ +│ │ │ ├─ prepare-device {mnemonic} ← PhonePilot 重置恢复 │ │ │ +│ │ │ ├─ normal, passphrase_empty, passphrase_1, ... │ │ │ +│ │ └───────────────────────────────────────────────────────┘ │ │ +│ │ ▼ 切换到 SLIP39 │ │ +│ │ ┌───────────────────────────────────────────────────────┐ │ │ +│ │ │ SLIP39 测试组 │ │ │ +│ │ │ ├─ prepare-device {slip39Shares} ← 分片恢复 │ │ │ +│ │ │ └─ 执行 SLIP39 测试用例 │ │ │ +│ │ └───────────────────────────────────────────────────────┘ │ │ +│ │ ▼ │ │ +│ │ ... 更多助记词组 ... │ │ +│ │ │ │ +│ └──────┬──────────────────────────────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 3. 报告生成 (expo-example) │ │ +│ │ ├─ 汇总测试结果 │ │ +│ │ ├─ 生成 Markdown 报告 │ │ +│ │ └─ 导出/保存报告 │ │ +│ └──────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────┐ │ +│ │ 测试完成 │ │ +│ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 2.3 设备重置时机 + +| 场景 | 是否需要重置 | 说明 | +|------|-------------|------| +| 同一助记词下执行多个测试用例 | ❌ 不需要 | 钱包状态不变 | +| 同一助记词,切换 Passphrase | ❌ 不需要 | Passphrase 运行时通过 SDK 输入 | +| 切换到不同助记词 | ✅ 需要 | 调用 `prepare-device` | +| 切换到 SLIP39 恢复 | ✅ 需要 | 调用 `prepare-device` | +| 设置/修改 PIN | ✅ 需要 | 调用 `prepare-device` | + +**说明**: Passphrase 是通过 SDK 的 `UI_REQUEST.REQUEST_PASSPHRASE` 事件在运行时输入的,不需要重置设备。 + +--- + +## 3. 模块设计 + +### 3.1 目录结构 + +``` +expo-example/src/ +├── services/ +│ └── phonePilotMcp/ # 新增: PhonePilot MCP 客户端 +│ ├── index.ts # MCP 客户端主模块 +│ ├── types.ts # 类型定义 +│ ├── walletActions.ts # 钱包物理操作封装 +│ └── screenMapping.ts # 屏幕坐标映射配置 +│ +├── testTools/ +│ └── automationTest/ # 新增: 自动化测试编排 +│ ├── index.ts # 测试套件入口 +│ ├── testSuiteRunner.ts # 测试套件执行器 +│ ├── devicePreparation.ts # 设备准备逻辑 +│ └── reportGenerator.ts # 增强版报告生成 +│ +├── views/ +│ └── AutomationTestScreen.tsx # 新增: 自动化测试界面 +│ +└── atoms/ + └── automationAtoms.ts # 新增: 自动化测试状态 +``` + +### 3.2 模块说明 + +#### 3.2.1 PhonePilot MCP 客户端 (`services/phonePilotMcp/`) + +负责与 PhonePilot MCP Server 通信,封装机械臂控制操作。 + +**核心类:** + +- `PhonePilotClient` - MCP 协议客户端 +- `WalletPhysicalActions` - 钱包物理操作高级封装 +- `ScreenConfig` - 屏幕坐标配置 + +#### 3.2.2 自动化测试编排 (`testTools/automationTest/`) + +负责测试流程编排、设备准备、结果收集。 + +**核心类:** + +- `AutomationTestRunner` - 测试执行引擎 +- `DevicePreparation` - 设备状态准备 +- `ReportGenerator` - 报告生成器 + +#### 3.2.3 自动化测试界面 (`views/AutomationTestScreen.tsx`) + +提供可视化的测试配置和执行界面。 + +--- + +## 4. 接口定义 + +### 4.1 PhonePilot MCP 客户端 + +```typescript +// services/phonePilotMcp/index.ts + +export class PhonePilotClient { + private serverUrl: string; + private sessionId: string | null = null; + + constructor(serverUrl: string = 'http://localhost:3847'); + + // 连接管理 + async connect(): Promise; + async disconnect(): Promise; + async healthCheck(): Promise; + + // 机械臂控制 + async armConnect(): Promise; + async armDisconnect(): Promise; + async armMove(x: number, y: number, captureFrame?: boolean): Promise; + async armClick(depth?: number, captureFrame?: boolean): Promise; + async captureFrame(): Promise; // base64 JPEG + + // 高级操作 + async tapAt(x: number, y: number): Promise; + async inputText(text: string, keyboard: KeyboardLayout): Promise; +} +``` + +### 4.2 PhonePilot MCP 扩展工具 (需在 PhonePilot 侧实现) + +expo-example 通过 MCP 调用以下工具,由 PhonePilot 负责执行: + +```typescript +// PhonePilot 需要新增的 MCP Tools + +// 设备准备 (PhonePilot 内部处理所有物理操作) +interface PrepareDeviceParams { + testType: 'standard' | 'passphrase' | 'slip39' | 'pin'; + mnemonic?: string[]; // 标准助记词 + slip39Shares?: string[][]; // SLIP39 分片 + passphrase?: string; // Passphrase + pin?: string; // PIN +} +// Tool: prepare-device + +// 物理确认操作 +interface ConfirmActionParams { + action: 'confirm' | 'cancel'; +} +// Tool: confirm-action + +// 输入 Passphrase (测试过程中) +interface InputPassphraseParams { + passphrase: string; +} +// Tool: input-passphrase + +// 输入 PIN (测试过程中) +interface InputPinParams { + pin: string; +} +// Tool: input-pin +``` + +### 4.3 expo-example 调用封装 + +```typescript +// services/phonePilotMcp/index.ts + +export class PhonePilotClient { + private serverUrl: string; + + constructor(serverUrl: string = 'http://localhost:3847'); + + // 连接管理 + async connect(): Promise; + async disconnect(): Promise; + async healthCheck(): Promise; + + // 设备准备 (通知 PhonePilot 执行) + async prepareDevice(params: PrepareDeviceParams): Promise<{ success: boolean }>; + + // 物理操作 (通知 PhonePilot 执行) + async confirmAction(): Promise; + async cancelAction(): Promise; + async inputPassphrase(passphrase: string): Promise; + async inputPin(pin: string): Promise; + + // 调试用 + async captureFrame(): Promise; // base64 JPEG +} +``` + +### 4.3 屏幕坐标配置 + +```typescript +// services/phonePilotMcp/screenMapping.ts + +export interface ScreenConfig { + deviceType: 'classic' | 'classic1s' | 'pro' | 'touch' | 'mini'; + + // 键盘布局坐标 + keyboard: { + [key: string]: { x: number; y: number }; + }; + + // 按钮位置 + buttons: { + confirm: { x: number; y: number }; + cancel: { x: number; y: number }; + back: { x: number; y: number }; + }; + + // 功能区域 + areas: { + scrollUp: { x: number; y: number }; + scrollDown: { x: number; y: number }; + }; +} + +// 预设配置 +export const SCREEN_CONFIGS: Record = { + classic1s: { /* ... */ }, + pro: { /* ... */ }, + // ... +}; +``` + +### 4.4 自动化测试配置 + +```typescript +// testTools/automationTest/types.ts + +export interface AutomationTestConfig { + // 测试配置 + testSuites: TestSuiteType[]; + mnemonic: string[]; // 测试用助记词 + passphrase?: string; // 可选 passphrase + slip39Shares?: string[][]; // SLIP39 分片 + pin?: string; // 测试 PIN + + // PhonePilot 配置 + phonePilotUrl: string; + screenConfig: ScreenConfig; + + // 执行选项 + stopOnFirstError: boolean; + retryCount: number; + delayBetweenTests: number; +} + +export type TestSuiteType = + | 'address' + | 'pubkey' + | 'passphrase' + | 'slip39' + | 'security' + | 'functional' + | 'attachToPin' + | 'chainMethod'; +``` + +### 4.5 测试套件执行器 + +```typescript +// testTools/automationTest/testSuiteRunner.ts + +export class AutomationTestRunner { + private config: AutomationTestConfig; + private phonePilot: PhonePilotClient; + private sdk: HardwareSDK; + + constructor(config: AutomationTestConfig); + + // 生命周期 + async initialize(): Promise; + async runAllTests(): Promise; + async stop(): Promise; + async cleanup(): Promise; + + // 请求 PhonePilot 准备设备 (不直接操作设备) + private async requestDevicePreparation(suiteType: TestSuiteType): Promise; + + // 测试执行 (expo-example 核心职责) + private async runTestSuite(suite: TestSuiteType): Promise; + private async executeTestCase(testCase: TestCase): Promise; + + // UI 请求处理 (通知 PhonePilot 执行物理操作) + private async handleUIRequest(request: UIRequest): Promise; + + // 事件回调 + onProgress: (progress: TestProgress) => void; + onTestComplete: (result: TestCaseResult) => void; + onSuiteComplete: (result: SuiteResult) => void; +} +``` + +**核心逻辑示意:** + +```typescript +async function runAutomationTest(config: AutomationTestConfig) { + const phonePilot = new PhonePilotClient(config.phonePilotUrl); + await phonePilot.connect(); + + // 1. 请求 PhonePilot 准备设备 (PhonePilot 内部完成重置、恢复助记词等) + await phonePilot.prepareDevice({ + testType: 'standard', + mnemonic: config.mnemonic, + }); + + // 2. 设置 SDK 监听器 (当需要物理操作时通知 PhonePilot) + sdk.on(UI_EVENT, async (message) => { + if (message.type === UI_REQUEST.REQUEST_BUTTON) { + await phonePilot.confirmAction(); // 通知 PhonePilot 点击确认 + } + if (message.type === UI_REQUEST.REQUEST_PIN) { + await phonePilot.inputPin(config.pin); + sdk.uiResponse({ type: UI_RESPONSE.RECEIVE_PIN, payload: '@@ONEKEY_INPUT_PIN_IN_DEVICE' }); + } + if (message.type === UI_REQUEST.REQUEST_PASSPHRASE) { + await phonePilot.inputPassphrase(config.passphrase); + sdk.uiResponse({ type: UI_RESPONSE.RECEIVE_PASSPHRASE, payload: { passphraseOnDevice: true } }); + } + }); + + // 3. 执行测试 (expo-example 核心职责) + for (const testCase of testCases) { + const result = await sdk.btcGetAddress(...); // 调用 SDK + validateResult(result); // 验证结果 + recordResult(testCase, result); // 记录结果 + } + + // 4. 生成报告 + return generateReport(results); +} +``` + +--- + +## 5. 状态管理 + +### 5.1 Jotai Atoms + +```typescript +// atoms/automationAtoms.ts + +import { atom } from 'jotai'; + +// PhonePilot 连接状态 +export const phonePilotConnectedAtom = atom(false); +export const phonePilotUrlAtom = atom('http://localhost:3847'); + +// 自动化测试配置 +export const automationConfigAtom = atom({ + testSuites: ['address', 'pubkey'], + mnemonic: [], + phonePilotUrl: 'http://localhost:3847', + screenConfig: defaultScreenConfig, + stopOnFirstError: false, + retryCount: 1, + delayBetweenTests: 500, +}); + +// 测试执行状态 +export const automationRunnerStateAtom = atom< + 'idle' | 'preparing' | 'running' | 'paused' | 'done' +>('idle'); + +// 当前进度 +export const automationProgressAtom = atom<{ + currentSuite: TestSuiteType | null; + currentTest: string | null; + completedSuites: number; + totalSuites: number; + completedTests: number; + totalTests: number; +}>({ + currentSuite: null, + currentTest: null, + completedSuites: 0, + totalSuites: 0, + completedTests: 0, + totalTests: 0, +}); + +// 测试结果 +export const automationResultsAtom = atom(null); + +// PhonePilot 摄像头画面 +export const cameraFrameAtom = atom(null); +``` + +--- + +## 6. 实现计划 + +### 6.1 PhonePilot 侧工作 (前置依赖) + +| 任务 | 描述 | 优先级 | +|------|------|--------| +| 屏幕坐标映射 | 标定 Classic1s 设备坐标 | P0 | +| 助记词输入 | 实现键盘输入逻辑 | P0 | +| 设备重置恢复 | wipe + restore 流程 | P0 | +| `prepare-device` Tool | MCP 工具: 设备准备 | P0 | +| `confirm-action` Tool | MCP 工具: 物理确认 | P0 | +| `input-passphrase` Tool | MCP 工具: 输入 Passphrase | P1 | +| `input-pin` Tool | MCP 工具: 输入 PIN | P1 | +| SLIP39 恢复 | 分片恢复流程 | P1 | +| 多设备支持 | Pro/Touch 坐标配置 | P2 | + +### 6.2 expo-example 侧工作 + +#### Phase 1: 基础设施 (P0) + +| 任务 | 描述 | +|------|------| +| PhonePilot MCP 客户端 | 实现 MCP 协议通信封装 | +| 测试配置管理 | 助记词、测试套件选择等 | + +#### Phase 2: 核心功能 (P0) + +| 任务 | 描述 | +|------|------| +| UI 请求处理 | 监听 SDK UI_EVENT,通知 PhonePilot | +| 地址测试自动化 | 集成现有 addressTest | +| 测试结果验证 | 验证 SDK 返回结果 | + +#### Phase 3: 完整集成 (P1) + +| 任务 | 描述 | +|------|------| +| 测试套件集成 | 集成全部 8 类测试 | +| 自动化测试 UI | 实现 AutomationTestScreen | +| 进度显示 | 实时显示测试进度 | + +#### Phase 4: 增强功能 (P2) + +| 任务 | 描述 | +|------|------| +| 报告生成增强 | 详细测试报告 | +| 错误恢复重试 | 失败自动重试 | +| 测试历史记录 | 保存历史测试结果 | + +--- + +## 7. 测试套件清单 + +| 测试类型 | 描述 | 物理操作需求 | +|----------|------|-------------| +| **Address Test** | 地址生成验证 | 确认按钮 | +| **PubKey Test** | 公钥生成验证 | 确认按钮 | +| **Passphrase Test** | 密语保护测试 | 确认 + 密语输入 | +| **SLIP39 Test** | Shamir 恢复测试 | 分片输入 | +| **Security Check** | 安全检查测试 | 确认按钮 | +| **Functional Test** | 功能测试 | 多种操作 | +| **Attach-to-PIN** | PIN 绑定测试 | PIN 输入 | +| **Chain Method** | 链式调用测试 | 多次确认 | + +--- + +## 8. 风险与挑战 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| 屏幕坐标漂移 | 点击位置不准 | 定期校准 + 视觉识别 | +| 设备响应超时 | 测试中断 | 超时重试机制 | +| 助记词输入错误 | 恢复失败 | 逐字验证 + 截图对比 | +| 网络连接不稳定 | MCP 通信失败 | 重连机制 + 本地运行 | + +--- + +## 附录 + +### A. PhonePilot MCP Tools + +| Tool | 描述 | 参数 | +|------|------|------| +| `arm-connect` | 连接机械臂 | - | +| `arm-disconnect` | 断开连接 | - | +| `arm-move` | 移动到坐标 | x, y, captureFrame | +| `arm-click` | 执行点击 | depth, captureFrame | +| `capture-frame` | 截取画面 | - | + +### B. 相关资源 + +- [PhonePilot 项目](../../../../../PhonePilot) +- [hardware-js-sdk 文档](https://developer.onekey.so/) +- [MCP Protocol 规范](https://modelcontextprotocol.io/) diff --git a/packages/connect-examples/expo-example/locale/en-US.json b/packages/connect-examples/expo-example/locale/en-US.json index 67ca03dd7..92cfdb457 100644 --- a/packages/connect-examples/expo-example/locale/en-US.json +++ b/packages/connect-examples/expo-example/locale/en-US.json @@ -8,6 +8,7 @@ "tab__functional_testing": "Functional testing", "tab__attach_to_pin_testing": "Attach to Pin Testing", "tab__chain_method_test": "Chain Method Test", + "tab__automation_test": "Automation Test", "action__search_device": "Search Device", "action__search_device_webusb": "Search Device WebUSB", diff --git a/packages/connect-examples/expo-example/locale/zh-CN.json b/packages/connect-examples/expo-example/locale/zh-CN.json index b57e7a9b2..b60c25c45 100644 --- a/packages/connect-examples/expo-example/locale/zh-CN.json +++ b/packages/connect-examples/expo-example/locale/zh-CN.json @@ -8,6 +8,7 @@ "tab__functional_testing": "功能测试", "tab__attach_to_pin_testing": "Attach to Pin 测试", "tab__chain_method_test": "链方法批量测试", + "tab__automation_test": "自动化测试", "action__search_device": "搜索设备", "action__search_device_webusb": "搜索 WebUSB 设备", diff --git a/packages/connect-examples/expo-example/src/atoms/automationAtoms.ts b/packages/connect-examples/expo-example/src/atoms/automationAtoms.ts new file mode 100644 index 000000000..bcb0d5bf4 --- /dev/null +++ b/packages/connect-examples/expo-example/src/atoms/automationAtoms.ts @@ -0,0 +1,165 @@ +/** + * Automation Test State Management + * + * Jotai atoms for managing automation test state. + */ + +import { atom } from 'jotai'; +import type { + AutomationTestConfig, + TestProgress, + TestReport, + ConnectionState, + MnemonicGroupId, + TestSuiteType, + PassphraseVariantId, +} from '../services/phonePilotMcp/types'; + +// ============================================================================ +// PhonePilot Connection State +// ============================================================================ + +/** PhonePilot MCP connection state */ +export const phonePilotConnectionStateAtom = atom('disconnected'); + +/** PhonePilot server URL */ +export const phonePilotUrlAtom = atom('http://localhost:3847'); + +/** Latest camera frame from PhonePilot (base64 JPEG) */ +export const cameraFrameAtom = atom(null); + +// ============================================================================ +// Automation Test Configuration +// ============================================================================ + +/** Default test configuration */ +const defaultConfig: AutomationTestConfig = { + testSuites: ['address'], + mnemonicGroups: ['count24_one'], + passphraseVariants: ['normal'], + phonePilotUrl: 'http://localhost:3847', + stopOnFirstError: false, + retryCount: 1, + delayBetweenTests: 500, +}; + +/** Automation test configuration */ +export const automationConfigAtom = atom(defaultConfig); + +/** Selected test suites */ +export const selectedTestSuitesAtom = atom( + (get) => get(automationConfigAtom).testSuites, + (get, set, newSuites: TestSuiteType[]) => { + const config = get(automationConfigAtom); + set(automationConfigAtom, { ...config, testSuites: newSuites }); + } +); + +/** Selected mnemonic groups */ +export const selectedMnemonicGroupsAtom = atom( + (get) => get(automationConfigAtom).mnemonicGroups, + (get, set, newGroups: MnemonicGroupId[]) => { + const config = get(automationConfigAtom); + set(automationConfigAtom, { ...config, mnemonicGroups: newGroups }); + } +); + +/** Selected passphrase variants */ +export const selectedPassphraseVariantsAtom = atom( + (get) => get(automationConfigAtom).passphraseVariants, + (get, set, newVariants: PassphraseVariantId[]) => { + const config = get(automationConfigAtom); + set(automationConfigAtom, { ...config, passphraseVariants: newVariants }); + } +); + +// ============================================================================ +// Test Execution State +// ============================================================================ + +/** Default progress state */ +const defaultProgress: TestProgress = { + currentMnemonicGroup: null, + currentPassphrase: null, + currentTestSuite: null, + currentTestIndex: 0, + totalTests: 0, + completedMnemonicGroups: 0, + totalMnemonicGroups: 0, + status: 'idle', +}; + +/** Test execution progress */ +export const automationProgressAtom = atom(defaultProgress); + +/** Reset progress to initial state */ +export const resetProgressAtom = atom(null, (_get, set) => { + set(automationProgressAtom, defaultProgress); +}); + +/** Update progress status */ +export const updateProgressStatusAtom = atom( + null, + (get, set, status: TestProgress['status'], errorMessage?: string) => { + const progress = get(automationProgressAtom); + set(automationProgressAtom, { ...progress, status, errorMessage }); + } +); + +// ============================================================================ +// Test Results +// ============================================================================ + +/** Current test report */ +export const automationReportAtom = atom(null); + +/** Test logs */ +export const automationLogsAtom = atom([]); + +/** Add a log entry */ +export const addLogAtom = atom(null, (get, set, log: string) => { + const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false }); + const logs = get(automationLogsAtom); + set(automationLogsAtom, [...logs, `[${timestamp}] ${log}`]); +}); + +/** Clear logs */ +export const clearLogsAtom = atom(null, (_get, set) => { + set(automationLogsAtom, []); +}); + +// ============================================================================ +// Derived Atoms +// ============================================================================ + +/** Is automation test running */ +export const isAutomationRunningAtom = atom((get) => { + const progress = get(automationProgressAtom); + return progress.status === 'running' || progress.status === 'preparing-device'; +}); + +/** Is PhonePilot connected */ +export const isPhonePilotConnectedAtom = atom((get) => { + return get(phonePilotConnectionStateAtom) === 'connected'; +}); + +/** Can start automation test */ +export const canStartAutomationAtom = atom((get) => { + const isConnected = get(isPhonePilotConnectedAtom); + const isRunning = get(isAutomationRunningAtom); + const config = get(automationConfigAtom); + return ( + isConnected && + !isRunning && + config.mnemonicGroups.length > 0 && + config.testSuites.length > 0 && + config.passphraseVariants.length > 0 + ); +}); + +/** Progress percentage */ +export const progressPercentageAtom = atom((get) => { + const progress = get(automationProgressAtom); + if (progress.totalTests === 0) return 0; + return Math.round((progress.currentTestIndex / progress.totalTests) * 100); +}); diff --git a/packages/connect-examples/expo-example/src/components/ui/Header.tsx b/packages/connect-examples/expo-example/src/components/ui/Header.tsx index df4606f36..e0c7b1747 100644 --- a/packages/connect-examples/expo-example/src/components/ui/Header.tsx +++ b/packages/connect-examples/expo-example/src/components/ui/Header.tsx @@ -23,6 +23,7 @@ const menuItems: MenuItem[] = [ { route: Routes.FunctionalTesting, labelId: 'tab__functional_testing' }, { route: Routes.AttachToPinTestingScreen, labelId: 'tab__attach_to_pin_testing' }, { route: Routes.ChainMethodTest, labelId: 'tab__chain_method_test' }, + { route: Routes.AutomationTest, labelId: 'tab__automation_test' }, ]; // 菜单按钮组件 diff --git a/packages/connect-examples/expo-example/src/route.ts b/packages/connect-examples/expo-example/src/route.ts index 2cf8f39a5..e38961a2d 100644 --- a/packages/connect-examples/expo-example/src/route.ts +++ b/packages/connect-examples/expo-example/src/route.ts @@ -8,4 +8,5 @@ export const enum Routes { AttachToPinTestingScreen = 'attach-to-pin-testing', SLIP39Test = 'slip39-test', ChainMethodTest = 'chain-method-test', + AutomationTest = 'automation-test', } diff --git a/packages/connect-examples/expo-example/src/services/phonePilotMcp/index.ts b/packages/connect-examples/expo-example/src/services/phonePilotMcp/index.ts new file mode 100644 index 000000000..7c48508b6 --- /dev/null +++ b/packages/connect-examples/expo-example/src/services/phonePilotMcp/index.ts @@ -0,0 +1,436 @@ +/** + * PhonePilot MCP Client + * + * Provides communication with PhonePilot MCP Server for mechanical arm control + * and device preparation operations. + */ + +import type { + ConnectionState, + HealthCheckResponse, + ArmConnectResult, + ArmDisconnectResult, + ArmMoveResult, + ArmClickResult, + CaptureFrameResult, + PrepareDeviceParams, + PrepareDeviceResult, + ActionResult, +} from './types'; + +/** Default PhonePilot server URL */ +const DEFAULT_SERVER_URL = 'http://localhost:3847'; + +/** MCP JSON-RPC request ID counter */ +let requestId = 0; + +/** + * PhonePilot MCP Client + * + * Communicates with PhonePilot via MCP Streamable HTTP transport. + */ +export class PhonePilotClient { + private serverUrl: string; + private sessionId: string | null = null; + private connectionState: ConnectionState = 'disconnected'; + private onStateChange?: (state: ConnectionState) => void; + + constructor(serverUrl: string = DEFAULT_SERVER_URL) { + this.serverUrl = serverUrl; + } + + /** + * Sets a callback for connection state changes + */ + setOnStateChange(callback: (state: ConnectionState) => void): void { + this.onStateChange = callback; + } + + /** + * Gets the current connection state + */ + getConnectionState(): ConnectionState { + return this.connectionState; + } + + /** + * Updates connection state and notifies listeners + */ + private updateState(state: ConnectionState): void { + this.connectionState = state; + this.onStateChange?.(state); + } + + /** + * Parses SSE (Server-Sent Events) response format + */ + private parseSSEResponse(sseText: string): { result?: unknown; error?: { message: string } } { + // SSE format: + // event: message + // data: {"jsonrpc":"2.0","id":1,"result":{...}} + // + // event: endpoint + // ... + + const lines = sseText.trim().split('\n'); + let jsonData = ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (line.startsWith('data: ')) { + jsonData = line.substring(6); // Remove "data: " prefix + break; + } + } + + if (!jsonData) { + return { error: { message: 'No data field in SSE response' } }; + } + + try { + return JSON.parse(jsonData); + } catch (error) { + return { error: { message: `Failed to parse SSE data: ${error}` } }; + } + } + + /** + * Performs a health check on the PhonePilot server + */ + async healthCheck(): Promise { + try { + const response = await fetch(`${this.serverUrl}/health`); + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('PhonePilot health check failed:', error); + return null; + } + } + + /** + * Connects to the PhonePilot MCP server + */ + async connect(): Promise { + this.updateState('connecting'); + + try { + // Initialize MCP session + const initRequest = { + jsonrpc: '2.0', + id: ++requestId, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { + name: 'expo-example-automation', + version: '1.0.0', + }, + }, + }; + + const response = await fetch(`${this.serverUrl}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + }, + body: JSON.stringify(initRequest), + }); + + if (!response.ok) { + throw new Error(`MCP connection failed: ${response.status}`); + } + + // Get session ID from response header + this.sessionId = response.headers.get('mcp-session-id'); + + // Send initialized notification + await this.sendNotification('notifications/initialized', {}); + + this.updateState('connected'); + console.log('PhonePilot MCP connected, session:', this.sessionId?.slice(0, 8)); + return true; + } catch (error) { + console.error('PhonePilot MCP connection failed:', error); + this.updateState('error'); + return false; + } + } + + /** + * Disconnects from the PhonePilot MCP server + */ + async disconnect(): Promise { + if (this.sessionId) { + try { + await fetch(`${this.serverUrl}/mcp`, { + method: 'DELETE', + headers: { + 'mcp-session-id': this.sessionId, + }, + }); + } catch (error) { + console.error('PhonePilot MCP disconnect error:', error); + } + this.sessionId = null; + } + this.updateState('disconnected'); + } + + /** + * Sends an MCP request and returns the result + */ + private async sendRequest(method: string, params: Record = {}): Promise { + if (!this.sessionId) { + throw new Error('Not connected to PhonePilot MCP'); + } + + const request = { + jsonrpc: '2.0', + id: ++requestId, + method, + params, + }; + + const response = await fetch(`${this.serverUrl}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'mcp-session-id': this.sessionId, + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error(`MCP request failed: ${response.status}`); + } + + // Check Content-Type to determine response format + const contentType = response.headers.get('content-type') || ''; + + if (contentType.includes('text/event-stream')) { + // SSE stream response - parse SSE format + const text = await response.text(); + const result = this.parseSSEResponse(text); + + if (result.error) { + throw new Error(result.error.message || 'MCP request error'); + } + + return result.result as T; + } else { + // Direct JSON response + const result = await response.json(); + + if (result.error) { + throw new Error(result.error.message || 'MCP request error'); + } + + return result.result as T; + } + } + + /** + * Sends an MCP notification (no response expected) + */ + private async sendNotification(method: string, params: Record = {}): Promise { + if (!this.sessionId) { + return; + } + + const notification = { + jsonrpc: '2.0', + method, + params, + }; + + await fetch(`${this.serverUrl}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'mcp-session-id': this.sessionId, + }, + body: JSON.stringify(notification), + }); + } + + /** + * Calls an MCP tool and parses the response + */ + private async callTool(toolName: string, args: Record = {}): Promise { + const result = await this.sendRequest<{ + content: Array<{ + type: string; + text?: string; + data?: string; + mimeType?: string; + }>; + }>('tools/call', { + name: toolName, + arguments: args, + }); + + // Parse the text content as JSON + const textContent = result.content.find((c) => c.type === 'text'); + if (textContent?.text) { + return JSON.parse(textContent.text) as T; + } + + throw new Error(`Tool ${toolName} returned no text content`); + } + + /** + * Calls an MCP tool and returns both parsed result and optional image + */ + private async callToolWithImage( + toolName: string, + args: Record = {} + ): Promise<{ result: T; frame?: string }> { + const response = await this.sendRequest<{ + content: Array<{ + type: string; + text?: string; + data?: string; + mimeType?: string; + }>; + }>('tools/call', { + name: toolName, + arguments: args, + }); + + const textContent = response.content.find((c) => c.type === 'text'); + const imageContent = response.content.find((c) => c.type === 'image'); + + if (!textContent?.text) { + throw new Error(`Tool ${toolName} returned no text content`); + } + + return { + result: JSON.parse(textContent.text) as T, + frame: imageContent?.data, + }; + } + + // ============================================================================ + // Arm Control Methods + // ============================================================================ + + /** + * Connects to the mechanical arm controller + */ + async armConnect(): Promise { + return this.callTool('arm-connect', {}); + } + + /** + * Disconnects from the mechanical arm controller + */ + async armDisconnect(): Promise { + return this.callTool('arm-disconnect', {}); + } + + /** + * Moves the arm to the specified position + */ + async armMove(x: number, y: number, captureFrame = false): Promise { + const { result, frame } = await this.callToolWithImage('arm-move', { + x, + y, + captureFrame, + }); + return { ...result, frame }; + } + + /** + * Performs a click at the current position + */ + async armClick(depth = 12, captureFrame = false): Promise { + const { result, frame } = await this.callToolWithImage('arm-click', { + depth, + captureFrame, + }); + return { ...result, frame }; + } + + /** + * Captures the current camera frame + */ + async captureFrame(): Promise { + const { result, frame } = await this.callToolWithImage('capture-frame', {}); + return { ...result, frame }; + } + + // ============================================================================ + // High-Level Device Operations + // ============================================================================ + + /** + * Taps at a specific screen coordinate + */ + async tapAt(x: number, y: number): Promise { + await this.armMove(x, y); + await this.armClick(); + } + + /** + * Prepares the device with specified mnemonic/configuration + * + * This calls the PhonePilot prepare-device tool which handles: + * - Device reset/wipe + * - Mnemonic recovery + * - PIN setup + * - All physical operations + */ + async prepareDevice(params: PrepareDeviceParams): Promise { + return this.callTool('prepare-device', params as unknown as Record); + } + + /** + * Performs a confirm action on the device + */ + async confirmAction(): Promise { + return this.callTool('confirm-action', { action: 'confirm' }); + } + + /** + * Performs a cancel action on the device + */ + async cancelAction(): Promise { + return this.callTool('confirm-action', { action: 'cancel' }); + } + + /** + * Inputs a PIN on the device + */ + async inputPin(pin: string): Promise { + return this.callTool('input-pin', { pin }); + } + + /** + * Executes a predefined auto operation sequence + * @param sequenceId The sequence ID from PhonePilot (e.g., 'one-normal-24', 'reset-wallet') + */ + async executeSequence(sequenceId: string): Promise { + return this.callTool('execute-sequence', { sequenceId }); + } + + /** + * Stops the currently running sequence + */ + async stopSequence(): Promise { + return this.callTool('stop-sequence', {}); + } +} + +// Export singleton instance +export const phonePilotClient = new PhonePilotClient(); + +// Re-export types +export * from './types'; diff --git a/packages/connect-examples/expo-example/src/services/phonePilotMcp/types.ts b/packages/connect-examples/expo-example/src/services/phonePilotMcp/types.ts new file mode 100644 index 000000000..c3f29cea4 --- /dev/null +++ b/packages/connect-examples/expo-example/src/services/phonePilotMcp/types.ts @@ -0,0 +1,222 @@ +/** + * PhonePilot MCP Client Type Definitions + */ + +/** MCP Tool call result */ +export interface McpToolResult { + content: Array<{ + type: 'text' | 'image'; + text?: string; + data?: string; + mimeType?: string; + }>; + result?: T; +} + +/** Arm connect result */ +export interface ArmConnectResult { + success: boolean; + handle: number; + message: string; +} + +/** Arm disconnect result */ +export interface ArmDisconnectResult { + success: boolean; + message: string; +} + +/** Arm move result */ +export interface ArmMoveResult { + success: boolean; + position: { x: number; y: number }; + message: string; + frame?: string; // base64 JPEG if captureFrame was true +} + +/** Arm click result */ +export interface ArmClickResult { + success: boolean; + message: string; + frame?: string; // base64 JPEG if captureFrame was true +} + +/** Capture frame result */ +export interface CaptureFrameResult { + success: boolean; + message: string; + frame?: string; // base64 JPEG +} + +/** Device preparation parameters */ +export interface PrepareDeviceParams { + /** Type of test setup */ + testType: 'standard' | 'passphrase' | 'slip39' | 'pin'; + /** Standard mnemonic words */ + mnemonic?: string[]; + /** SLIP39 shares (array of word arrays) */ + slip39Shares?: string[][]; + /** Optional passphrase */ + passphrase?: string; + /** Optional PIN */ + pin?: string; +} + +/** Device preparation result */ +export interface PrepareDeviceResult { + success: boolean; + message: string; +} + +/** Confirm/Cancel action params */ +export interface ConfirmActionParams { + action: 'confirm' | 'cancel'; +} + +/** Action result */ +export interface ActionResult { + success: boolean; + message: string; +} + +/** PhonePilot connection state */ +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +/** PhonePilot health check response */ +export interface HealthCheckResponse { + status: 'ok' | 'error'; + server: string; + version: string; + activeSessions: { + streamable: number; + sse: number; + }; +} + +/** Mnemonic group identifier */ +export type MnemonicGroupId = + | 'count12_one' + | 'count12_two' + | 'count12_three' + | 'count18_one' + | 'count18_two' + | 'count18_three' + | 'count24_one' + | 'count24_two' + | 'count24_three' + | 'slip39_20_one' + | 'slip39_20_two' + | 'slip39_20_three' + | 'slip39_33_one' + | 'slip39_33_two'; + +/** Mnemonic group configuration */ +export interface MnemonicGroup { + id: MnemonicGroupId; + name: string; + type: 'standard' | 'slip39'; + wordCount: number; + /** For standard mnemonic */ + mnemonic?: string[]; + /** For SLIP39 */ + slip39Shares?: string[][]; + /** PhonePilot sequence ID for this mnemonic */ + phonePilotSequenceId: string; +} + +/** Passphrase variant */ +export interface PassphraseVariant { + name: string; + passphrase: string; + passphraseState: string; +} + +/** Passphrase variant identifier */ +export type PassphraseVariantId = 'normal' | 'passphrase_empty' | 'passphrase_1' | 'passphrase_2'; + +/** Passphrase variant display info */ +export const PASSPHRASE_VARIANT_INFO: Record = { + normal: { label: 'Normal', description: '无 Passphrase' }, + passphrase_empty: { label: 'Empty', description: '空字符串 Passphrase' }, + passphrase_1: { label: 'Passphrase 1', description: 'asdfg7890' }, + passphrase_2: { label: 'Passphrase 2', description: '1234567890qwerty...' }, +}; + +/** Test suite type */ +export type TestSuiteType = + | 'address' + | 'pubkey' + | 'passphrase' + | 'slip39' + | 'security' + | 'functional' + | 'attachToPin' + | 'chainMethod'; + +/** Automation test configuration */ +export interface AutomationTestConfig { + /** Test suites to run */ + testSuites: TestSuiteType[]; + /** Mnemonic groups to test */ + mnemonicGroups: MnemonicGroupId[]; + /** Passphrase variants to test */ + passphraseVariants: PassphraseVariantId[]; + /** PhonePilot server URL */ + phonePilotUrl: string; + /** Stop on first error */ + stopOnFirstError: boolean; + /** Retry count for failed tests */ + retryCount: number; + /** Delay between tests in ms */ + delayBetweenTests: number; +} + +/** Test progress state */ +export interface TestProgress { + currentMnemonicGroup: MnemonicGroupId | null; + currentPassphrase: string | null; + currentTestSuite: TestSuiteType | null; + currentTestIndex: number; + totalTests: number; + completedMnemonicGroups: number; + totalMnemonicGroups: number; + status: 'idle' | 'preparing-device' | 'running' | 'paused' | 'done' | 'error'; + errorMessage?: string; +} + +/** Test case result */ +export interface TestCaseResult { + testName: string; + method: string; + expected: string; + actual: string; + passed: boolean; + error?: string; + duration: number; +} + +/** Test suite result */ +export interface TestSuiteResult { + suiteName: string; + mnemonicGroup: MnemonicGroupId; + passphrase: string; + totalTests: number; + passedTests: number; + failedTests: number; + skippedTests: number; + duration: number; + results: TestCaseResult[]; +} + +/** Complete test report */ +export interface TestReport { + startTime: number; + endTime: number; + duration: number; + totalSuites: number; + totalTests: number; + passedTests: number; + failedTests: number; + skippedTests: number; + suiteResults: TestSuiteResult[]; +} diff --git a/packages/connect-examples/expo-example/src/testTools/automationTest/index.ts b/packages/connect-examples/expo-example/src/testTools/automationTest/index.ts new file mode 100644 index 000000000..05129fd21 --- /dev/null +++ b/packages/connect-examples/expo-example/src/testTools/automationTest/index.ts @@ -0,0 +1,9 @@ +/** + * Automation Test Module + * + * Provides automated testing capabilities by integrating PhonePilot MCP + * with the existing test framework. + */ + +export * from './mnemonicGroups'; +export * from './useAutomationTest'; diff --git a/packages/connect-examples/expo-example/src/testTools/automationTest/mnemonicGroups.ts b/packages/connect-examples/expo-example/src/testTools/automationTest/mnemonicGroups.ts new file mode 100644 index 000000000..88ca0ca76 --- /dev/null +++ b/packages/connect-examples/expo-example/src/testTools/automationTest/mnemonicGroups.ts @@ -0,0 +1,433 @@ +/** + * Mnemonic Groups Configuration + * + * Maps test mnemonics to PhonePilot sequences. + * Each group corresponds to a specific mnemonic that PhonePilot can restore on the device. + */ + +import type { + MnemonicGroup, + MnemonicGroupId, + PassphraseVariant, + PassphraseVariantId, +} from '../../services/phonePilotMcp/types'; + +// ============================================================================ +// Standard Mnemonics (12/18/24 words) +// These match the mnemonics defined in PhonePilot ControlPanel.tsx +// ============================================================================ + +/** 12-word mnemonics */ +const MNEMONIC_12_1 = 'air census life sheriff attack include paper provide fantasy left opera sauce'.split(' '); +const MNEMONIC_12_2 = 'relief exchange burst bullet topple manage impose dumb raise panther sibling shove'.split(' '); +const MNEMONIC_12_3 = 'pyramid enforce season tide flag brisk law anchor refuse require reward negative'.split(' '); + +/** 18-word mnemonics */ +const MNEMONIC_18_1 = 'slab canyon coffee wine gold bronze rigid peace output security boy quick vital cat become stove tape super'.split(' '); +const MNEMONIC_18_2 = 'arrange private session nose dial echo skull robust erode rain odor mango solve angle festival amazing decorate menu'.split(' '); +const MNEMONIC_18_3 = 'riot fee raise forget always city spring million spike purse tackle impose faith remove hover snap leopard kitchen'.split(' '); + +/** 24-word mnemonics */ +const MNEMONIC_24_1 = 'gorilla absent bone address stay minimum artist train piano coil gadget truck almost voice runway drip pony pizza uncover expose country enlist avocado hotel'.split(' '); +const MNEMONIC_24_2 = 'jazz cactus tower knee gift crazy tourist exile valid short exhibit cute asthma segment dragon write jacket ribbon cheese ignore use dwarf small dove'.split(' '); +const MNEMONIC_24_3 = 'post flock violin raven size harvest media cash divide blade scale eternal action comic ball increase track unhappy ask speed timber exist trim expose'.split(' '); + +// ============================================================================ +// SLIP39 Mnemonics +// ============================================================================ + +/** slip39 20-word (1 share) */ +const SLIP39_20_1 = [ + 'fake kidney academic academic dwarf orange primary secret mixed auction priority daughter script smell smear judicial ceramic glen theory emphasis'.split(' '), +]; + +/** slip39 20-word (2-3: 3 shares, need 2) */ +const SLIP39_20_2 = [ + 'network vexed academic acid alive forbid database equation average advocate golden careful exhaust dance texture satisfy lair negative earth flash'.split(' '), + 'network vexed academic agency calcium memory elegant merchant welcome oral evidence bulb union company suitable spend loud miracle story withdraw'.split(' '), + // Third share available but only 2 needed: 'network vexed academic always debut unhappy veteran trust goat cluster easel penalty entrance drift mild uncover short sack excuse kitchen'.split(' '), +]; + +/** slip39 20-word (16-16: 16 shares, need 16) */ +const SLIP39_20_16 = [ + 'platform helpful academic afraid custody blind shaft burning visual prune knit clay mason genuine march crisis smug wits woman taught'.split(' '), + 'platform helpful academic alto armed theory alpha paces welcome quick quiet device craft strike chemical ocean briefing space phantom legal'.split(' '), + 'platform helpful academic anxiety cage sympathy dramatic western acrobat transfer oral spew package style scroll pajamas curious grant center alto'.split(' '), + 'platform helpful academic award cards category salt guest pharmacy devote pistol focus identify infant evoke recall shaft empty hazard romantic'.split(' '), + 'platform helpful academic bike clogs estate duke thank bolt floral race phrase preach seafood strategy industry crowd length grant yield'.split(' '), + 'platform helpful academic bracelet clock daughter memory visitor result blanket garbage starting speak clay junction pitch ladybug jacket fluff ultimate'.split(' '), + 'platform helpful academic burning credit install sidewalk level museum evening permit duke cards findings aunt document improve woman general august'.split(' '), + 'platform helpful academic carve ajar edge similar glance darkness random envelope glen ancestor gums view venture wealthy learn ivory exotic'.split(' '), + 'platform helpful academic class depend gather story empty harvest overall craft leaves nuclear reject kernel that temple width presence speak'.split(' '), + 'platform helpful academic company adequate western resident dismiss mortgage emperor coastal sack example ancestor mason length mama timber rhythm buyer'.split(' '), + 'platform helpful academic crucial domain bedroom violence mental multiple language sympathy grin beaver salt excuse pants worthy vegan prepare unfold'.split(' '), + 'platform helpful academic deadline crush depart thank pregnant treat salon ambition miracle sidewalk speak practice taxi soldier scholar vitamins junk'.split(' '), + 'platform helpful academic deploy chemical afraid justice undergo deny excuse famous entrance scene early photo glance salon platform wildlife ladle'.split(' '), + 'platform helpful academic diploma cricket trend loud replace rapids payment paces theory easel spine cultural dictate hormone necklace blimp exact'.split(' '), + 'platform helpful academic dragon company true volume carve dough endorse force plot cinema remember skin transfer criminal hunting axle mayor'.split(' '), + 'platform helpful academic easel deadline evil museum spill funding muscle retreat smart timely oven transfer grownup deal armed merchant flash'.split(' '), +]; + +/** slip39 33-word (1 share) */ +const SLIP39_33_1 = [ + 'station industry academic academic aunt similar picture filter chubby vintage insect hairy charity priority ugly mandate credit faint segment mobile cage junior receiver reject crazy sympathy extra helpful expand force counter lamp rescue'.split(' '), +]; + +/** slip39 33-word (3-2: 3 shares, need 2) */ +const SLIP39_33_2 = [ + 'yoga racism academic acid average silent year kind package pitch bracelet desert aide guilt render belong density forbid spark benefit trend junior fake dough silver spray adequate western liberty hearing strike prepare various'.split(' '), + 'yoga racism academic agency antenna aircraft nervous biology buyer invasion satoshi angry darkness skin guilt market fatal violence item platform painting width involve marathon parking duration pancake wildlife should execute silver metric oven'.split(' '), + // Third share available but only 2 needed +]; + +// ============================================================================ +// Mnemonic Groups Configuration +// ============================================================================ + +/** + * All available mnemonic groups + * Each group maps to: + * - A specific mnemonic or SLIP39 shares + * - A PhonePilot sequence ID for device restoration + * - Test data folders in addressTest/data/ + */ +export const MNEMONIC_GROUPS: Record = { + // 12-word groups + count12_one: { + id: 'count12_one', + name: '12词-1 (air census...)', + type: 'standard', + wordCount: 12, + mnemonic: MNEMONIC_12_1, + phonePilotSequenceId: 'one-normal-12', + }, + count12_two: { + id: 'count12_two', + name: '12词-2 (relief exchange...)', + type: 'standard', + wordCount: 12, + mnemonic: MNEMONIC_12_2, + phonePilotSequenceId: 'two-normal-12', + }, + count12_three: { + id: 'count12_three', + name: '12词-3 (pyramid enforce...)', + type: 'standard', + wordCount: 12, + mnemonic: MNEMONIC_12_3, + phonePilotSequenceId: 'three-normal-12', + }, + + // 18-word groups + count18_one: { + id: 'count18_one', + name: '18词-1 (slab canyon...)', + type: 'standard', + wordCount: 18, + mnemonic: MNEMONIC_18_1, + phonePilotSequenceId: 'one-normal-18', + }, + count18_two: { + id: 'count18_two', + name: '18词-2 (arrange private...)', + type: 'standard', + wordCount: 18, + mnemonic: MNEMONIC_18_2, + phonePilotSequenceId: 'two-normal-18', + }, + count18_three: { + id: 'count18_three', + name: '18词-3 (riot fee...)', + type: 'standard', + wordCount: 18, + mnemonic: MNEMONIC_18_3, + phonePilotSequenceId: 'three-normal-18', + }, + + // 24-word groups + count24_one: { + id: 'count24_one', + name: '24词-1 (gorilla absent...)', + type: 'standard', + wordCount: 24, + mnemonic: MNEMONIC_24_1, + phonePilotSequenceId: 'one-normal-24', + }, + count24_two: { + id: 'count24_two', + name: '24词-2 (jazz cactus...)', + type: 'standard', + wordCount: 24, + mnemonic: MNEMONIC_24_2, + phonePilotSequenceId: 'two-normal-24', + }, + count24_three: { + id: 'count24_three', + name: '24词-3 (post flock...)', + type: 'standard', + wordCount: 24, + mnemonic: MNEMONIC_24_3, + phonePilotSequenceId: 'three-normal-24', + }, + + // SLIP39 20-word groups + slip39_20_one: { + id: 'slip39_20_one', + name: 'SLIP39-20词-1份', + type: 'slip39', + wordCount: 20, + slip39Shares: SLIP39_20_1, + phonePilotSequenceId: 'count20_one_normal', + }, + slip39_20_two: { + id: 'slip39_20_two', + name: 'SLIP39-20词-2/3', + type: 'slip39', + wordCount: 20, + slip39Shares: SLIP39_20_2, + phonePilotSequenceId: 'count20_two_normal', + }, + slip39_20_three: { + id: 'slip39_20_three', + name: 'SLIP39-20词-16/16', + type: 'slip39', + wordCount: 20, + slip39Shares: SLIP39_20_16, + phonePilotSequenceId: 'count20_three_normal', + }, + + // SLIP39 33-word groups + slip39_33_one: { + id: 'slip39_33_one', + name: 'SLIP39-33词-1份', + type: 'slip39', + wordCount: 33, + slip39Shares: SLIP39_33_1, + phonePilotSequenceId: 'count33_one_normal', + }, + slip39_33_two: { + id: 'slip39_33_two', + name: 'SLIP39-33词-3/2', + type: 'slip39', + wordCount: 33, + slip39Shares: SLIP39_33_2, + phonePilotSequenceId: 'count33_two_normal', + }, +}; + +/** + * Passphrase variants for each mnemonic group + * These match the test data files: normal.ts, passphrase_empty.ts, passphrase_1.ts, passphrase_2.ts + */ +export const PASSPHRASE_VARIANTS: Record = { + count12_one: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + { name: 'passphrase_empty', passphrase: '', passphraseState: 'n4KZ2aKvYzJzWM6eG1YhNiP1iuGJiYbEh3' }, + { name: 'passphrase_1', passphrase: 'asdfg7890', passphraseState: 'mxM4v8Eyo9S5BPCB1xbvmDBLXfACf4rPDK' }, + { name: 'passphrase_2', passphrase: '1234567890qwertyuiopasdfghjklzxcvbnm', passphraseState: 'myRpmVHzDdYd1yDm51hghVKKrZrHo9B5HG' }, + ], + count12_two: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + { name: 'passphrase_empty', passphrase: '', passphraseState: 'n3gj4cYqaL2oV2Kqp5kpDtMkfHFNpVLHq2' }, + { name: 'passphrase_1', passphrase: 'asdfg7890', passphraseState: 'mwZpVpJjjYu1NNxfwNRcpM9vVwKJVYpSn4' }, + { name: 'passphrase_2', passphrase: '1234567890qwertyuiopasdfghjklzxcvbnm', passphraseState: 'mnyqRXNUGn52LJKgxz6LWv9nRoYLc5D41B' }, + ], + count12_three: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + { name: 'passphrase_empty', passphrase: '', passphraseState: 'n3H8jZ9iSfPCpWGVMWJT4Y5LoMRSPKoEiy' }, + { name: 'passphrase_1', passphrase: 'asdfg7890', passphraseState: 'mxC7kcQhHvWHTBKLovKdhKnzDHMrFJCGFR' }, + { name: 'passphrase_2', passphrase: '1234567890qwertyuiopasdfghjklzxcvbnm', passphraseState: 'n3wC2CfMy4sD5xnRBVwVq4QeFzaBQPEoEp' }, + ], + count18_one: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + { name: 'passphrase_empty', passphrase: '', passphraseState: 'n3SnW2LPPXgRwT9LxLvxjWVJpNvRFj1bMw' }, + { name: 'passphrase_1', passphrase: 'asdfg7890', passphraseState: 'msSBAbDfbRHgBF6xBgaEd6YupR3qdVBu9H' }, + { name: 'passphrase_2', passphrase: '1234567890qwertyuiopasdfghjklzxcvbnm', passphraseState: 'muuXRoW6N8LZ4SDNMW1g1nvvGNV5e7T42x' }, + ], + count18_two: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + { name: 'passphrase_empty', passphrase: '', passphraseState: 'mzUAusDxuKwPhyLxMJwmPGpThiX1qWYJfK' }, + { name: 'passphrase_1', passphrase: 'asdfg7890', passphraseState: 'n2MpHzYuEaWL81tT2p5LhkjDQgNGYEywcS' }, + { name: 'passphrase_2', passphrase: '1234567890qwertyuiopasdfghjklzxcvbnm', passphraseState: 'mzHxQYjvjJLKQoaDZv2VXYwHy5RAXNvMwH' }, + ], + count18_three: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + { name: 'passphrase_empty', passphrase: '', passphraseState: 'mz2jx4mxw6qfYNJh5oAxLSz7bYsFFqYD9S' }, + { name: 'passphrase_1', passphrase: 'asdfg7890', passphraseState: 'n2NopY1LPRWVGsLTRyMgL8SWXNzxVVNz2A' }, + { name: 'passphrase_2', passphrase: '1234567890qwertyuiopasdfghjklzxcvbnm', passphraseState: 'n3Dj7NJPENKNkXCW78EUK4vX7JXCdvLVTK' }, + ], + count24_one: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + { name: 'passphrase_empty', passphrase: '', passphraseState: 'mpZyZrARXurTXC6fhzHdQzs4xVNXCkCbxW' }, + { name: 'passphrase_1', passphrase: 'asdfg7890', passphraseState: 'n2LkLQUmqRAFpjBwVAbwLmpnC3C43u1uv6' }, + { name: 'passphrase_2', passphrase: '1234567890qwertyuiopasdfghjklzxcvbnm', passphraseState: 'n3dRrM9B4ZtEVNUiHF18LXNh5UUpZ1h8Zd' }, + ], + count24_two: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + { name: 'passphrase_empty', passphrase: '', passphraseState: 'n2ygQ1UrZsUdQQb9VKsKcvCYm9yzRXmWPm' }, + { name: 'passphrase_1', passphrase: 'asdfg7890', passphraseState: 'mxeLmYLWevRtZ2nKkQ1qFjWQ8Q2dX3dU97' }, + { name: 'passphrase_2', passphrase: '1234567890qwertyuiopasdfghjklzxcvbnm', passphraseState: 'mzCw8LvNMSYZzQmgKz4BLQFZfCL9MUH3Ln' }, + ], + count24_three: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + { name: 'passphrase_empty', passphrase: '', passphraseState: 'n2bVqSJcvGDrPcT11tCPE6x1nLUVVyAfKF' }, + { name: 'passphrase_1', passphrase: 'asdfg7890', passphraseState: 'mvCcxPqVVJvbFLKJfvYHvCwD7FdqxGBjUx' }, + { name: 'passphrase_2', passphrase: '1234567890qwertyuiopasdfghjklzxcvbnm', passphraseState: 'n19rHMPLTuHxFBQnq5NMSkKMvPvWhjBpCY' }, + ], + // SLIP39 groups - typically only test with normal/empty passphrase + slip39_20_one: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + ], + slip39_20_two: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + ], + slip39_20_three: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + ], + slip39_33_one: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + ], + slip39_33_two: [ + { name: 'normal', passphrase: '', passphraseState: '' }, + ], +}; + +/** + * Get mnemonic group by ID + */ +export function getMnemonicGroup(id: MnemonicGroupId): MnemonicGroup { + return MNEMONIC_GROUPS[id]; +} + +/** + * Get passphrase variants for a mnemonic group + */ +export function getPassphraseVariants(id: MnemonicGroupId): PassphraseVariant[] { + return PASSPHRASE_VARIANTS[id] || []; +} + +/** + * Get all standard (non-SLIP39) mnemonic group IDs + */ +export function getStandardMnemonicGroupIds(): MnemonicGroupId[] { + return Object.keys(MNEMONIC_GROUPS).filter( + (id) => MNEMONIC_GROUPS[id as MnemonicGroupId].type === 'standard' + ) as MnemonicGroupId[]; +} + +/** + * Get all SLIP39 mnemonic group IDs + */ +export function getSlip39MnemonicGroupIds(): MnemonicGroupId[] { + return Object.keys(MNEMONIC_GROUPS).filter( + (id) => MNEMONIC_GROUPS[id as MnemonicGroupId].type === 'slip39' + ) as MnemonicGroupId[]; +} + +/** + * Get all mnemonic group IDs + */ +export function getAllMnemonicGroupIds(): MnemonicGroupId[] { + return Object.keys(MNEMONIC_GROUPS) as MnemonicGroupId[]; +} + +/** + * Mnemonic organization by word count + */ +export interface MnemonicsByWordCount { + wordCount: number; + label: string; + type: 'standard' | 'slip39'; + groups: Array<{ + shareLabel: string; + id: MnemonicGroupId; + }>; +} + +/** + * Get mnemonics organized by word count for UI display + */ +export function getMnemonicsOrganizedByWordCount(): MnemonicsByWordCount[] { + return [ + { + wordCount: 12, + label: '12-word', + type: 'standard', + groups: [ + { shareLabel: 'one', id: 'count12_one' }, + { shareLabel: 'two', id: 'count12_two' }, + { shareLabel: 'three', id: 'count12_three' }, + ], + }, + { + wordCount: 18, + label: '18-word', + type: 'standard', + groups: [ + { shareLabel: 'one', id: 'count18_one' }, + { shareLabel: 'two', id: 'count18_two' }, + { shareLabel: 'three', id: 'count18_three' }, + ], + }, + { + wordCount: 24, + label: '24-word', + type: 'standard', + groups: [ + { shareLabel: 'one', id: 'count24_one' }, + { shareLabel: 'two', id: 'count24_two' }, + { shareLabel: 'three', id: 'count24_three' }, + ], + }, + { + wordCount: 20, + label: 'SLIP39-20', + type: 'slip39', + groups: [ + { shareLabel: 'one', id: 'slip39_20_one' }, + { shareLabel: 'two', id: 'slip39_20_two' }, + { shareLabel: 'three', id: 'slip39_20_three' }, + ], + }, + { + wordCount: 33, + label: 'SLIP39-33', + type: 'slip39', + groups: [ + { shareLabel: 'one', id: 'slip39_33_one' }, + { shareLabel: 'two', id: 'slip39_33_two' }, + ], + }, + ]; +} + +/** + * Get passphrase variants filtered by selected variant IDs + */ +export function getFilteredPassphraseVariants( + mnemonicGroupId: MnemonicGroupId, + selectedVariantIds: PassphraseVariantId[] +): PassphraseVariant[] { + const allVariants = PASSPHRASE_VARIANTS[mnemonicGroupId] || []; + return allVariants.filter((variant) => + selectedVariantIds.includes(variant.name as PassphraseVariantId) + ); +} + +/** + * Get available passphrase variant IDs for a mnemonic group + */ +export function getAvailablePassphraseVariantIds(mnemonicGroupId: MnemonicGroupId): PassphraseVariantId[] { + const variants = PASSPHRASE_VARIANTS[mnemonicGroupId] || []; + return variants.map((v) => v.name as PassphraseVariantId); +} + +/** + * All passphrase variant IDs + */ +export const ALL_PASSPHRASE_VARIANT_IDS: PassphraseVariantId[] = [ + 'normal', + 'passphrase_empty', + 'passphrase_1', + 'passphrase_2', +]; diff --git a/packages/connect-examples/expo-example/src/testTools/automationTest/useAutomationTest.ts b/packages/connect-examples/expo-example/src/testTools/automationTest/useAutomationTest.ts new file mode 100644 index 000000000..f935202f6 --- /dev/null +++ b/packages/connect-examples/expo-example/src/testTools/automationTest/useAutomationTest.ts @@ -0,0 +1,683 @@ +/** + * useAutomationTest Hook + * + * Integrates PhonePilot MCP with the existing test framework for automated testing. + * Handles device preparation, UI request interception, and test execution flow. + */ + +import { useCallback, useContext, useRef, useEffect } from 'react'; +import { useAtom, useSetAtom, useAtomValue } from 'jotai'; +import { UI_EVENT, UI_REQUEST, UI_RESPONSE } from '@onekeyfe/hd-core'; + +import { PhonePilotClient } from '../../services/phonePilotMcp'; +import { + phonePilotConnectionStateAtom, + phonePilotUrlAtom, + automationConfigAtom, + automationProgressAtom, + automationReportAtom, + automationLogsAtom, + addLogAtom, + clearLogsAtom, + resetProgressAtom, + cameraFrameAtom, +} from '../../atoms/automationAtoms'; +import { getMnemonicGroup, getFilteredPassphraseVariants } from './mnemonicGroups'; +import HardwareSDKContext from '../../provider/HardwareSDKContext'; +import { useDevice } from '../../provider/DeviceProvider'; + +import type { + TestProgress, + TestReport, + TestSuiteResult, + TestCaseResult, + MnemonicGroupId, + PassphraseVariant, +} from '../../services/phonePilotMcp/types'; +import type { CoreApi } from '@onekeyfe/hd-core'; + +/** + * Automation Test Hook + * + * Provides methods to control automated test execution with PhonePilot integration. + */ +export function useAutomationTest() { + // Atoms + const [connectionState, setConnectionState] = useAtom(phonePilotConnectionStateAtom); + const phonePilotUrl = useAtomValue(phonePilotUrlAtom); + const config = useAtomValue(automationConfigAtom); + const [progress, setProgress] = useAtom(automationProgressAtom); + const setReport = useSetAtom(automationReportAtom); + const logs = useAtomValue(automationLogsAtom); + const addLog = useSetAtom(addLogAtom); + const clearLogs = useSetAtom(clearLogsAtom); + const resetProgress = useSetAtom(resetProgressAtom); + const setCameraFrame = useSetAtom(cameraFrameAtom); + + // Context + const { sdk: SDK } = useContext(HardwareSDKContext); + const { selectedDevice } = useDevice(); + + // Refs + const clientRef = useRef(null); + const runningRef = useRef(false); + const currentPassphraseRef = useRef(''); + + // Track URL for client recreation + const lastUrlRef = useRef(phonePilotUrl); + + // Initialize PhonePilot client (recreate if URL changes) + useEffect(() => { + // Recreate client if URL changed or not initialized + if (!clientRef.current || lastUrlRef.current !== phonePilotUrl) { + // Disconnect old client if exists + if (clientRef.current && lastUrlRef.current !== phonePilotUrl) { + clientRef.current.disconnect(); + } + clientRef.current = new PhonePilotClient(phonePilotUrl); + clientRef.current.setOnStateChange(setConnectionState); + lastUrlRef.current = phonePilotUrl; + } + return () => { + clientRef.current?.disconnect(); + }; + }, [phonePilotUrl, setConnectionState]); + + /** + * Connect to PhonePilot MCP server + */ + const connectPhonePilot = useCallback(async (): Promise => { + // Always disconnect old connection first to ensure fresh connection + if (clientRef.current) { + await clientRef.current.disconnect(); + } + + // Create new client + clientRef.current = new PhonePilotClient(phonePilotUrl); + clientRef.current.setOnStateChange(setConnectionState); + lastUrlRef.current = phonePilotUrl; + + addLog(`Connecting to PhonePilot at ${phonePilotUrl}...`); + const success = await clientRef.current.connect(); + + if (success) { + addLog('PhonePilot connected successfully'); + // Also connect to mechanical arm + try { + const armResult = await clientRef.current.armConnect(); + if (armResult.success) { + addLog(`Arm connected: handle=${armResult.handle}`); + } else { + addLog(`Arm connection failed: ${armResult.message}`); + } + } catch (error) { + addLog(`Arm connection error: ${error}`); + } + } else { + addLog('PhonePilot connection failed'); + } + + return success; + }, [phonePilotUrl, setConnectionState, addLog]); + + /** + * Disconnect from PhonePilot + */ + const disconnectPhonePilot = useCallback(async (): Promise => { + if (clientRef.current) { + try { + await clientRef.current.armDisconnect(); + } catch (error) { + // Ignore disconnect errors + } + await clientRef.current.disconnect(); + addLog('PhonePilot disconnected'); + } + }, [addLog]); + + /** + * Setup SDK UI event listener for physical operations + */ + const setupUIListener = useCallback( + (sdk: CoreApi) => { + sdk.removeAllListeners(UI_EVENT); + + sdk.on(UI_EVENT, async (message: { type: string; payload?: unknown }) => { + addLog(`UI Event: ${message.type}`); + + switch (message.type) { + case UI_REQUEST.REQUEST_BUTTON: + // Device needs physical confirmation + addLog('Device requesting button confirmation...'); + if (clientRef.current) { + try { + await clientRef.current.confirmAction(); + addLog('Button confirmed via PhonePilot'); + } catch (error) { + addLog(`Button confirm failed: ${error}`); + } + } + break; + + case UI_REQUEST.REQUEST_PIN: + // Device needs PIN input + addLog('Device requesting PIN...'); + if (clientRef.current) { + try { + await clientRef.current.inputPin('1111'); // Default test PIN + sdk.uiResponse({ + type: UI_RESPONSE.RECEIVE_PIN, + payload: '@@ONEKEY_INPUT_PIN_IN_DEVICE', + }); + addLog('PIN input via PhonePilot'); + } catch (error) { + addLog(`PIN input failed: ${error}`); + } + } + break; + + case UI_REQUEST.REQUEST_PASSPHRASE: + // Passphrase is sent directly via SDK, no physical input needed + const passphrase = currentPassphraseRef.current; + addLog(`Device requesting passphrase, sending via SDK: "${passphrase || '(empty)'}"...`); + sdk.uiResponse({ + type: UI_RESPONSE.RECEIVE_PASSPHRASE, + payload: { value: passphrase || '' }, + }); + addLog('Passphrase sent via SDK'); + break; + + default: + addLog(`Unhandled UI event: ${message.type}`); + } + }); + }, + [addLog] + ); + + /** + * Prepare device with mnemonic using PhonePilot + */ + const prepareDevice = useCallback( + async (mnemonicGroupId: MnemonicGroupId): Promise => { + if (!clientRef.current) { + addLog('PhonePilot client not initialized'); + return false; + } + + // Check if actually connected (not just state) + if (clientRef.current.getConnectionState() !== 'connected') { + addLog('PhonePilot not connected, attempting to connect...'); + const connected = await connectPhonePilot(); + if (!connected) { + addLog('Failed to connect to PhonePilot'); + return false; + } + } + + const group = getMnemonicGroup(mnemonicGroupId); + addLog(`Preparing device with ${group.name}...`); + + setProgress((prev) => ({ + ...prev, + status: 'preparing-device', + currentMnemonicGroup: mnemonicGroupId, + })); + + try { + // First reset the device + addLog('Resetting device...'); + const resetResult = await clientRef.current.executeSequence('reset-wallet'); + if (!resetResult.success) { + throw new Error(`Reset failed: ${resetResult.message}`); + } + addLog('Device reset complete'); + + // Check if stopped + if (!runningRef.current) { + addLog('Test stopped by user during device preparation'); + return false; + } + + // Wait a bit for device to restart + await delay(5000); + + // Check again before restore + if (!runningRef.current) { + addLog('Test stopped by user during device preparation'); + return false; + } + + // Then restore with the mnemonic + addLog(`Restoring with sequence: ${group.phonePilotSequenceId}...`); + const restoreResult = await clientRef.current.executeSequence(group.phonePilotSequenceId); + if (!restoreResult.success) { + throw new Error(`Mnemonic restoration failed: ${restoreResult.message}`); + } + addLog('Mnemonic restoration complete'); + + // Capture a frame to verify + const frameResult = await clientRef.current.captureFrame(); + if (frameResult.frame) { + setCameraFrame(frameResult.frame); + } + + return true; + } catch (error) { + addLog(`Device preparation failed: ${error}`); + return false; + } + }, + [addLog, setProgress, setCameraFrame, connectPhonePilot] + ); + + /** + * Load test cases based on mnemonic group and passphrase variant + */ + const loadTestCases = useCallback( + async (mnemonicGroupId: MnemonicGroupId, variant: PassphraseVariant) => { + // Dynamic import test data based on mnemonic group + try { + // Import the index file which contains all converted test cases for this mnemonic group + const testDataModule = await import(`../addressTest/dataVariant/${mnemonicGroupId}/index.ts`); + + // Get the singleAddressTest array from the module + // The naming convention is: singleAddressTestCount24One, singleAddressTestCount12Two, etc. + const camelCaseName = mnemonicGroupId + .split('_') + .map((word, index) => + index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1) + ) + .join(''); + const arrayKey = `singleAddressTest${camelCaseName.charAt(0).toUpperCase()}${camelCaseName.slice(1)}`; + const testCasesArray = testDataModule[arrayKey]; + + if (!testCasesArray) { + addLog(`No test data array found for ${mnemonicGroupId} (key: ${arrayKey})`); + return null; + } + + // Find the test case that matches the variant's passphrase and passphraseState + // Each converted test case has an 'extra' field with passphrase and passphraseState + const matchingTestCase = testCasesArray.find((testCase: any) => { + if (!testCase.extra) return false; + + // Match based on passphrase and passphraseState + const passphraseMatches = (testCase.extra.passphrase || '') === variant.passphrase; + const stateMatches = (testCase.extra.passphraseState || '') === variant.passphraseState; + + return passphraseMatches && stateMatches; + }); + + if (!matchingTestCase) { + addLog(`No matching test case found for ${mnemonicGroupId}/${variant.name} (passphrase: "${variant.passphrase}", state: "${variant.passphraseState}")`); + return null; + } + + return matchingTestCase; + } catch (error) { + addLog(`Failed to load test data for ${mnemonicGroupId}/${variant.name}: ${error}`); + return null; + } + }, + [addLog] + ); + + /** + * Run a single test case + */ + const runTestCase = useCallback( + async ( + testCase: any, + sdk: CoreApi, + connectId: string, + deviceId: string + ): Promise => { + const testStartTime = Date.now(); + const method = testCase.method; + const params = testCase.params || {}; + + try { + addLog(`Running ${testCase.id || method}...`); + + // Call SDK method + const result = await (sdk as any)[method](connectId, deviceId, { + ...params, + showOnOneKey: false, // Don't show on device for batch testing + }); + + if (result.success) { + const actualAddress = result.payload?.address || result.payload; + const expectedAddress = testCase.address || testCase.expected; + + const passed = !expectedAddress || actualAddress === expectedAddress; + + if (passed) { + addLog(`✅ ${testCase.id || method}: ${actualAddress}`); + } else { + addLog(`❌ ${testCase.id || method}: Expected ${expectedAddress}, got ${actualAddress}`); + } + + return { + testName: testCase.id || method, + method, + expected: expectedAddress || 'any valid address', + actual: String(actualAddress), + passed, + duration: Date.now() - testStartTime, + }; + } else { + addLog(`❌ ${testCase.id || method} failed: ${result.payload?.error || 'Unknown error'}`); + return { + testName: testCase.id || method, + method, + expected: testCase.address || testCase.expected || 'success', + actual: '', + passed: false, + error: result.payload?.error || 'Unknown error', + duration: Date.now() - testStartTime, + }; + } + } catch (error) { + addLog(`❌ ${testCase.id || method} error: ${error}`); + return { + testName: testCase.id || method, + method, + expected: testCase.address || testCase.expected || 'success', + actual: '', + passed: false, + error: String(error), + duration: Date.now() - testStartTime, + }; + } + }, + [addLog] + ); + + /** + * Run tests for a specific mnemonic group and passphrase variant + */ + const runTestsForVariant = useCallback( + async ( + mnemonicGroupId: MnemonicGroupId, + variant: PassphraseVariant, + sdk: CoreApi, + connectId: string, + _deviceId: string + ): Promise => { + const results: TestCaseResult[] = []; + const startTime = Date.now(); + + currentPassphraseRef.current = variant.passphrase; + addLog(`Testing with passphrase variant: ${variant.name}`); + + setProgress((prev) => ({ + ...prev, + currentPassphrase: variant.name, + })); + + // Load test data for this combination + const testData = await loadTestCases(mnemonicGroupId, variant); + + if (!testData) { + addLog(`No test data available for ${mnemonicGroupId}/${variant.name}`); + return { + suiteName: `${mnemonicGroupId}/${variant.name}`, + mnemonicGroup: mnemonicGroupId, + passphrase: variant.passphrase, + totalTests: 0, + passedTests: 0, + failedTests: 0, + skippedTests: 0, + duration: Date.now() - startTime, + results: [], + }; + } + + // Run all test cases from loaded data + const testCases = Array.isArray(testData) ? testData : [testData]; + + for (const testCase of testCases) { + if (!runningRef.current) break; + + // Handle nested test case data + const cases = testCase.data || [testCase]; + + for (const singleCase of cases) { + if (!runningRef.current) break; + + const result = await runTestCase(singleCase, sdk, connectId, _deviceId); + results.push(result); + + // Small delay between tests + await delay(100); + } + } + + return { + suiteName: `${mnemonicGroupId}/${variant.name}`, + mnemonicGroup: mnemonicGroupId, + passphrase: variant.passphrase, + totalTests: results.length, + passedTests: results.filter((r) => r.passed).length, + failedTests: results.filter((r) => !r.passed).length, + skippedTests: 0, + duration: Date.now() - startTime, + results, + }; + }, + [addLog, setProgress, loadTestCases, runTestCase] + ); + + /** + * Start automation test + */ + const startAutomation = useCallback(async (): Promise => { + if (!SDK || !selectedDevice?.connectId) { + addLog('SDK or device not available'); + return; + } + + if (connectionState !== 'connected') { + addLog('PhonePilot not connected, connecting...'); + const connected = await connectPhonePilot(); + if (!connected) { + addLog('Failed to connect to PhonePilot, aborting test'); + return; + } + } + + runningRef.current = true; + clearLogs(); + resetProgress(); + + const connectId = selectedDevice.connectId; + const featuresRes = await SDK.getFeatures(connectId); + if (!featuresRes.success) { + addLog('Failed to get device features'); + return; + } + const deviceId = featuresRes.payload?.device_id ?? ''; + + // Setup UI listener + setupUIListener(SDK); + + const startTime = Date.now(); + const suiteResults: TestSuiteResult[] = []; + + setProgress({ + currentMnemonicGroup: null, + currentPassphrase: null, + currentTestSuite: null, + currentTestIndex: 0, + totalTests: config.mnemonicGroups.length * 4, // Rough estimate + completedMnemonicGroups: 0, + totalMnemonicGroups: config.mnemonicGroups.length, + status: 'running', + }); + + addLog('=== Automation Test Started ==='); + addLog(`Testing ${config.mnemonicGroups.length} mnemonic groups`); + addLog(`Passphrase variants: ${config.passphraseVariants.join(', ')}`); + addLog(`Test suites: ${config.testSuites.join(', ')}`); + + // Process each mnemonic group + for (let groupIndex = 0; groupIndex < config.mnemonicGroups.length; groupIndex++) { + if (!runningRef.current) { + addLog('Test stopped by user'); + break; + } + + const mnemonicGroupId = config.mnemonicGroups[groupIndex]; + const variants = getFilteredPassphraseVariants(mnemonicGroupId, config.passphraseVariants); + + addLog(`\n--- Mnemonic Group ${groupIndex + 1}/${config.mnemonicGroups.length}: ${mnemonicGroupId} ---`); + + // Skip if no matching passphrase variants for this mnemonic + if (variants.length === 0) { + addLog(`No matching passphrase variants for ${mnemonicGroupId}, skipping...`); + setProgress((prev) => ({ + ...prev, + completedMnemonicGroups: groupIndex + 1, + })); + continue; + } + + addLog(`Testing ${variants.length} passphrase variant(s): ${variants.map((v) => v.name).join(', ')}`); + + // Prepare device for this mnemonic (only once per mnemonic) + const prepared = await prepareDevice(mnemonicGroupId); + if (!prepared) { + addLog(`Failed to prepare device for ${mnemonicGroupId}, skipping...`); + continue; + } + + // After device reset/restore, device_id changes - need to re-fetch features + addLog('Re-fetching device features after reset/restore...'); + const newFeaturesRes = await SDK.getFeatures(connectId); + if (!newFeaturesRes.success) { + addLog('Failed to get device features after reset/restore, skipping...'); + continue; + } + const newDeviceId = newFeaturesRes.payload?.device_id ?? ''; + addLog(`Device ID updated: ${newDeviceId}`); + + // Run tests for each passphrase variant (no device reset needed) + for (const variant of variants) { + if (!runningRef.current) break; + + const result = await runTestsForVariant(mnemonicGroupId, variant, SDK, connectId, newDeviceId); + suiteResults.push(result); + + // Delay between variants + await delay(config.delayBetweenTests); + } + + setProgress((prev) => ({ + ...prev, + completedMnemonicGroups: groupIndex + 1, + })); + } + + // Generate report + const endTime = Date.now(); + const report: TestReport = { + startTime, + endTime, + duration: endTime - startTime, + totalSuites: suiteResults.length, + totalTests: suiteResults.reduce((sum, s) => sum + s.totalTests, 0), + passedTests: suiteResults.reduce((sum, s) => sum + s.passedTests, 0), + failedTests: suiteResults.reduce((sum, s) => sum + s.failedTests, 0), + skippedTests: suiteResults.reduce((sum, s) => sum + s.skippedTests, 0), + suiteResults, + }; + + setReport(report); + setProgress((prev) => ({ ...prev, status: 'done' })); + + addLog('\n=== Automation Test Completed ==='); + addLog(`Duration: ${Math.round(report.duration / 1000)}s`); + addLog(`Passed: ${report.passedTests}/${report.totalTests}`); + addLog(`Failed: ${report.failedTests}`); + + // Cleanup + SDK.removeAllListeners(UI_EVENT); + runningRef.current = false; + }, [ + SDK, + selectedDevice, + connectionState, + config, + addLog, + clearLogs, + resetProgress, + connectPhonePilot, + setupUIListener, + prepareDevice, + runTestsForVariant, + setProgress, + setReport, + ]); + + /** + * Stop automation test + */ + const stopAutomation = useCallback(async () => { + runningRef.current = false; + setProgress((prev) => ({ ...prev, status: 'idle' })); + addLog('Stopping automation test...'); + + // Stop PhonePilot sequence execution + if (clientRef.current) { + try { + await clientRef.current.stopSequence(); + addLog('Stop signal sent to PhonePilot'); + } catch (error) { + console.error('Failed to stop PhonePilot sequence:', error); + } + } + + SDK?.cancel(); + }, [SDK, setProgress, addLog]); + + /** + * Capture current camera frame + */ + const captureFrame = useCallback(async (): Promise => { + if (!clientRef.current || connectionState !== 'connected') { + return null; + } + + try { + const result = await clientRef.current.captureFrame(); + if (result.frame) { + setCameraFrame(result.frame); + return result.frame; + } + } catch (error) { + addLog(`Capture frame failed: ${error}`); + } + return null; + }, [connectionState, setCameraFrame, addLog]); + + return { + // State + connectionState, + progress, + logs, + + // Actions + connectPhonePilot, + disconnectPhonePilot, + startAutomation, + stopAutomation, + captureFrame, + prepareDevice, + }; +} + +// Helper +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/connect-examples/expo-example/src/views/AutomationTestScreen.tsx b/packages/connect-examples/expo-example/src/views/AutomationTestScreen.tsx new file mode 100644 index 000000000..2a85ab908 --- /dev/null +++ b/packages/connect-examples/expo-example/src/views/AutomationTestScreen.tsx @@ -0,0 +1,1000 @@ +/** + * Automation Test Screen + * + * Provides UI for configuring and running automated hardware wallet tests + * with PhonePilot MCP integration. + */ + +import React, { useCallback, useMemo } from 'react'; +import { + Button, + Card, + Input, + Image, + ScrollView, + Separator, + Stack, + Text, + XStack, + YStack, + styled, +} from 'tamagui'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; + +import { DeviceProvider } from '../provider/DeviceProvider'; +import { HardwareInputPinDialogProvider } from '../provider/HardwareInputPinProvider'; +import PageView from '../components/ui/Page'; +import PanelView from '../components/ui/Panel'; + +import { useAutomationTest } from '../testTools/automationTest/useAutomationTest'; +import { + getMnemonicsOrganizedByWordCount, + ALL_PASSPHRASE_VARIANT_IDS, +} from '../testTools/automationTest/mnemonicGroups'; +import { PASSPHRASE_VARIANT_INFO } from '../services/phonePilotMcp/types'; +import { + automationConfigAtom, + automationReportAtom, + cameraFrameAtom, + isAutomationRunningAtom, + isPhonePilotConnectedAtom, + progressPercentageAtom, + phonePilotUrlAtom, +} from '../atoms/automationAtoms'; + +import type { MnemonicGroupId, TestSuiteType, PassphraseVariantId } from '../services/phonePilotMcp/types'; + +/** Available test suite types */ +const TEST_SUITE_OPTIONS: { id: TestSuiteType; label: string }[] = [ + { id: 'address', label: 'Address Test' }, + { id: 'pubkey', label: 'Public Key Test' }, + { id: 'passphrase', label: 'Passphrase Test' }, + { id: 'slip39', label: 'SLIP39 Test' }, + { id: 'security', label: 'Security Check' }, + { id: 'functional', label: 'Functional Test' }, + { id: 'attachToPin', label: 'Attach to PIN' }, + { id: 'chainMethod', label: 'Chain Method' }, +]; + +/** Styled link button */ +const LinkButton = styled(Text, { + fontSize: 12, + color: '$blue10', + cursor: 'pointer', + pressStyle: { opacity: 0.7 }, + hoverStyle: { textDecorationLine: 'underline' }, +}); + +/** Styled table cell for checkbox */ +const TableCheckCell = styled(YStack, { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: '$1', +}); + +export default function AutomationTestScreen() { + return ( + + + + + {/* Connection Panel */} + + + {/* Configuration Panel */} + + + {/* Progress Panel */} + + + {/* Logs Panel */} + + + {/* Results Panel */} + + + + + + ); +} + +/** + * PhonePilot Connection Panel + */ +function ConnectionPanel() { + const { + connectionState, + connectPhonePilot, + disconnectPhonePilot, + captureFrame, + } = useAutomationTest(); + + const serverUrl = useAtomValue(phonePilotUrlAtom); + const isConnected = connectionState === 'connected'; + const isConnecting = connectionState === 'connecting'; + const isError = connectionState === 'error'; + + return ( + + + {/* Left: Status */} + + + + + {isConnected + ? 'Connected' + : isError + ? 'Failed' + : isConnecting + ? 'Connecting...' + : 'Disconnected'} + + + {serverUrl} + + + + + {/* Right: Actions */} + + {!isConnected ? ( + + + {isConnecting ? 'Connecting...' : 'Connect'} + + + ) : ( + + Disconnect + + )} + + + + ); +} + +/** + * Test Configuration Panel + */ +function ConfigurationPanel() { + const [config, setConfig] = useAtom(automationConfigAtom); + const isRunning = useAtomValue(isAutomationRunningAtom); + + const mnemonicsByWordCount = useMemo(() => getMnemonicsOrganizedByWordCount(), []); + const standardMnemonics = useMemo( + () => mnemonicsByWordCount.filter((m) => m.type === 'standard'), + [mnemonicsByWordCount] + ); + const slip39Mnemonics = useMemo( + () => mnemonicsByWordCount.filter((m) => m.type === 'slip39'), + [mnemonicsByWordCount] + ); + + const allTestSuites = useMemo(() => TEST_SUITE_OPTIONS.map((s) => s.id), []); + const allMnemonicIds = useMemo( + () => mnemonicsByWordCount.flatMap((m) => m.groups.map((g) => g.id)), + [mnemonicsByWordCount] + ); + + // Toggle functions + const toggleMnemonicGroup = useCallback( + (groupId: MnemonicGroupId) => { + const newGroups = config.mnemonicGroups.includes(groupId) + ? config.mnemonicGroups.filter((g) => g !== groupId) + : [...config.mnemonicGroups, groupId]; + setConfig({ ...config, mnemonicGroups: newGroups }); + }, + [config, setConfig] + ); + + const togglePassphraseVariant = useCallback( + (variantId: PassphraseVariantId) => { + const newVariants = config.passphraseVariants.includes(variantId) + ? config.passphraseVariants.filter((v) => v !== variantId) + : [...config.passphraseVariants, variantId]; + setConfig({ ...config, passphraseVariants: newVariants }); + }, + [config, setConfig] + ); + + const toggleTestSuite = useCallback( + (suiteId: TestSuiteType) => { + const newSuites = config.testSuites.includes(suiteId) + ? config.testSuites.filter((s) => s !== suiteId) + : [...config.testSuites, suiteId]; + setConfig({ ...config, testSuites: newSuites }); + }, + [config, setConfig] + ); + + // Batch select functions + const selectAllMnemonics = useCallback(() => { + setConfig({ ...config, mnemonicGroups: allMnemonicIds }); + }, [config, setConfig, allMnemonicIds]); + + const clearMnemonics = useCallback(() => { + setConfig({ ...config, mnemonicGroups: [] }); + }, [config, setConfig]); + + const selectAllPassphraseVariants = useCallback(() => { + setConfig({ ...config, passphraseVariants: [...ALL_PASSPHRASE_VARIANT_IDS] }); + }, [config, setConfig]); + + const clearPassphraseVariants = useCallback(() => { + setConfig({ ...config, passphraseVariants: [] }); + }, [config, setConfig]); + + const selectAllTestSuites = useCallback(() => { + setConfig({ ...config, testSuites: allTestSuites }); + }, [config, setConfig, allTestSuites]); + + const clearTestSuites = useCallback(() => { + setConfig({ ...config, testSuites: [] }); + }, [config, setConfig]); + + return ( + + + {/* 1. Mnemonic Selection */} + + + + + + + 1 + + + + Mnemonic Selection + + + + Requires device reset when switching + + + + + Select All + + | + + Clear + + + + + {/* Standard Mnemonics Table */} + + Standard Mnemonics + + + {/* Header */} + + + Share + + {standardMnemonics.map((m) => ( + + {m.label} + + ))} + + {/* Rows */} + {['one', 'two', 'three'].map((shareLabel, rowIndex) => ( + + + {shareLabel} + + {standardMnemonics.map((m) => { + const group = m.groups.find((g) => g.shareLabel === shareLabel); + if (!group) return ; + const isSelected = config.mnemonicGroups.includes(group.id); + return ( + !isRunning && toggleMnemonicGroup(group.id)} + cursor={isRunning ? 'not-allowed' : 'pointer'} + > + + {isSelected && ( + + ✓ + + )} + + + ); + })} + + ))} + + + {/* SLIP39 Mnemonics Table */} + + SLIP39 Mnemonics + + + {/* Header */} + + + Share + + {slip39Mnemonics.map((m) => ( + + {m.label} + + ))} + + {/* Rows */} + {['one', 'two', 'three'].map((shareLabel, rowIndex) => ( + + + {shareLabel} + + {slip39Mnemonics.map((m) => { + const group = m.groups.find((g) => g.shareLabel === shareLabel); + if (!group) { + return ( + + + + ); + } + const isSelected = config.mnemonicGroups.includes(group.id); + return ( + !isRunning && toggleMnemonicGroup(group.id)} + cursor={isRunning ? 'not-allowed' : 'pointer'} + > + + {isSelected && ( + + ✓ + + )} + + + ); + })} + + ))} + + + + + + {/* 2. Passphrase Variants */} + + + + + + + 2 + + + + Passphrase Variants + + + + No device reset needed between variants + + + + + Select All + + | + + Clear + + + + + + {ALL_PASSPHRASE_VARIANT_IDS.map((variantId) => { + const info = PASSPHRASE_VARIANT_INFO[variantId]; + const isSelected = config.passphraseVariants.includes(variantId); + return ( + !isRunning && togglePassphraseVariant(variantId)} + opacity={isRunning ? 0.5 : 1} + cursor={isRunning ? 'not-allowed' : 'pointer'} + minWidth={140} + > + + {isSelected && ( + + ✓ + + )} + + + + {info.label} + + + {info.description} + + + + ); + })} + + + + + + {/* 3. Test Types */} + + + + + + 3 + + + + Test Types + + + + + Select All + + | + + Clear + + + + + + {TEST_SUITE_OPTIONS.map((suite) => { + const isSelected = config.testSuites.includes(suite.id); + return ( + !isRunning && toggleTestSuite(suite.id)} + opacity={isRunning ? 0.5 : 1} + cursor={isRunning ? 'not-allowed' : 'pointer'} + > + + {isSelected && ( + + ✓ + + )} + + + {suite.label} + + + ); + })} + + + + + ); +} + +/** + * Progress Panel + */ +function ProgressPanel() { + const { progress, startAutomation, stopAutomation } = useAutomationTest(); + const isConnected = useAtomValue(isPhonePilotConnectedAtom); + const isRunning = useAtomValue(isAutomationRunningAtom); + const progressPercent = useAtomValue(progressPercentageAtom); + const config = useAtomValue(automationConfigAtom); + + const canStart = + isConnected && + !isRunning && + config.mnemonicGroups.length > 0 && + config.passphraseVariants.length > 0 && + config.testSuites.length > 0; + + return ( + + + {/* Configuration Summary */} + {!isRunning && ( + + + + + {config.mnemonicGroups.length} + + + + Mnemonics + + + + + + {config.passphraseVariants.length} + + + + Passphrases + + + + + + {config.testSuites.length} + + + + Test Types + + + + )} + + {/* Status and Controls */} + + + + + + {progress.status.charAt(0).toUpperCase() + progress.status.slice(1)} + + + {progress.currentMnemonicGroup && ( + + {progress.currentMnemonicGroup} + {progress.currentPassphrase && ` → ${progress.currentPassphrase}`} + + )} + + + + + + + + + {/* Progress bar */} + {isRunning && ( + + + + + + {progress.completedMnemonicGroups}/{progress.totalMnemonicGroups} mnemonic + groups completed ({progressPercent}%) + + + )} + + + ); +} + +/** + * Logs Panel + */ +function LogsPanel() { + const { logs } = useAutomationTest(); + + return ( + + + + {logs.length === 0 ? ( + + No logs yet + + ) : ( + logs.map((log, index) => ( + + {log} + + )) + )} + + + + ); +} + +/** + * Results Panel + */ +function ResultsPanel() { + const report = useAtomValue(automationReportAtom); + const [expandedSuite, setExpandedSuite] = React.useState(null); + + if (!report) { + return null; + } + + return ( + + + {/* Summary */} + + + + + {report.passedTests} + + + Passed + + + + + {report.failedTests} + + + Failed + + + + + {report.skippedTests} + + + Skipped + + + + + {report.totalTests} + + + Total + + + + + + {/* Duration */} + + Duration: {Math.round(report.duration / 1000)}s + + + {/* Suite details */} + + Suite Results + {report.suiteResults.map((suite, index) => ( + + {/* Suite header */} + setExpandedSuite(expandedSuite === index ? null : index)} + cursor="pointer" + > + + + {expandedSuite === index ? '▼' : '▶'} + + {suite.suiteName} + + + 0 ? '$red10' : '$green10'} fontWeight="600"> + {suite.passedTests}/{suite.totalTests} + + + {Math.round(suite.duration / 1000)}s + + + + + {/* Test case details - expanded */} + {expandedSuite === index && suite.results && suite.results.length > 0 && ( + + {suite.results.map((testCase, testIndex) => ( + + + + {testCase.passed ? '✅' : '❌'} + + + {testCase.testName || testCase.method} + + + {testCase.duration}ms + + + + {/* Test details */} + + + Method: + {testCase.method} + + + Expected: + + {testCase.expected} + + + + Actual: + + {testCase.actual || '(empty)'} + + + {testCase.error && ( + + Error: + + {testCase.error} + + + )} + + + ))} + + )} + + ))} + + + + ); +} diff --git a/yarn.lock b/yarn.lock index 0754ef425..ac942b4f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8205,6 +8205,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.35.tgz#635b7586086d51fb40de0a2ec9d1014a5283ba4a" integrity sha512-vu1SrqBjbbZ3J6vwY17jBs8Sr/BKA+/a/WtjRG+whKg1iuLFOosq872EXS0eXWILdO36DHQQeku/ZcL6hz2fpg== +"@types/node@^24.9.0": + version "24.10.10" + resolved "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz#ea4813a65368ca7a98dfd75c84d748831b63e1cf" + integrity sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow== + dependencies: + undici-types "~7.16.0" + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" @@ -10403,14 +10410,14 @@ bplist-parser@^0.3.1: brace-expansion@^1.1.7, brace-expansion@^2.0.1, brace-expansion@^2.0.2: version "2.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== dependencies: balanced-match "^1.0.0" braces@3.0.3, braces@^3.0.3, braces@~3.0.2: version "3.0.3" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== dependencies: fill-range "^7.1.1" @@ -11054,7 +11061,7 @@ ci-info@^3.7.0: cipher-base@1.0.5, cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.5" - resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.5.tgz#749f80731c7821e9a5fabd51f6998b696f296686" + resolved "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.5.tgz#749f80731c7821e9a5fabd51f6998b696f296686" integrity sha512-xq7ICKB4TMHUx7Tz1L9O2SGKOhYMOTR32oir45Bq28/AQTpHogKgHcoYFSdRbMtddl+ozNXfXY9jWcgYKmde0w== dependencies: inherits "^2.0.4" @@ -11793,7 +11800,7 @@ cross-fetch@^3.1.5: cross-spawn@7.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5, cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.5.tgz#910aac880ff5243da96b728bc6521a5f6c2f2f82" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz#910aac880ff5243da96b728bc6521a5f6c2f2f82" integrity sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug== dependencies: path-key "^3.1.0" @@ -12782,13 +12789,13 @@ electron@^25.0.0: "@types/node" "^18.11.18" extract-zip "^2.0.1" -electron@^28.0.0: - version "28.2.3" - resolved "https://registry.yarnpkg.com/electron/-/electron-28.2.3.tgz#d26821bcfda7ee445b4b75231da4b057a7ce6e7b" - integrity sha512-he9nGphZo03ejDjYBXpmFVw0KBKogXvR2tYxE4dyYvnfw42uaFIBFrwGeenvqoEOfheJfcI0u4rFG6h3QxDwnA== +electron@^40.1.0: + version "40.1.0" + resolved "https://registry.npmjs.org/electron/-/electron-40.1.0.tgz#e5c45ecd90bfbaa9dd14db2f7fb5ab730e458a9e" + integrity sha512-2j/kvw7uF0H1PnzYBzw2k2Q6q16J8ToKrtQzZfsAoXbbMY0l5gQi2DLOauIZLzwp4O01n8Wt/74JhSRwG0yj9A== dependencies: "@electron/get" "^2.0.0" - "@types/node" "^18.11.18" + "@types/node" "^24.9.0" extract-zip "^2.0.1" elliptic@6.5.4, elliptic@^6.5.3: @@ -13068,7 +13075,7 @@ es-errors@^1.3.0: es-iterator-helpers@1.0.15, es-iterator-helpers@^1.0.12, es-iterator-helpers@^1.0.15, es-iterator-helpers@^1.2.1: version "1.0.15" - resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz#bd81d275ac766431d19305923707c3efd9f1ae40" + resolved "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz#bd81d275ac766431d19305923707c3efd9f1ae40" integrity sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g== dependencies: asynciterator.prototype "^1.0.0" @@ -19211,7 +19218,7 @@ micromark@^4.0.0: micromatch@4.0.8, micromatch@^4.0.0, micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8, micromatch@~4.0.8: version "4.0.8" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== dependencies: braces "^3.0.3" @@ -19667,7 +19674,7 @@ node-gyp-build@^4.5.0, node-gyp-build@^4.8.1, node-gyp-build@^4.8.4: node-gyp@10.0.1, node-gyp@^5.0.2, node-gyp@^7.1.0: version "10.0.1" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-10.0.1.tgz#205514fc19e5830fa991e4a689f9e81af377a966" + resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz#205514fc19e5830fa991e4a689f9e81af377a966" integrity sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg== dependencies: env-paths "^2.2.0" @@ -20494,7 +20501,7 @@ parse-json@^5.0.0, parse-json@^5.2.0: parse-path@^7.0.0: version "7.1.0" - resolved "https://registry.yarnpkg.com/parse-path/-/parse-path-7.1.0.tgz#41fb513cb122831807a4c7b29c8727947a09d8c6" + resolved "https://registry.npmjs.org/parse-path/-/parse-path-7.1.0.tgz#41fb513cb122831807a4c7b29c8727947a09d8c6" integrity sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw== dependencies: protocols "^2.0.0" @@ -20513,7 +20520,7 @@ parse-uri@^1.0.7: parse-url@8.1.0, parse-url@^6.0.0: version "8.1.0" - resolved "https://registry.yarnpkg.com/parse-url/-/parse-url-8.1.0.tgz#972e0827ed4b57fc85f0ea6b0d839f0d8a57a57d" + resolved "https://registry.npmjs.org/parse-url/-/parse-url-8.1.0.tgz#972e0827ed4b57fc85f0ea6b0d839f0d8a57a57d" integrity sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w== dependencies: parse-path "^7.0.0" @@ -21233,7 +21240,7 @@ pretty-format@^29.7.0: proc-log@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-3.0.0.tgz#fb05ef83ccd64fd7b20bbe9c8c1070fc08338dd8" + resolved "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz#fb05ef83ccd64fd7b20bbe9c8c1070fc08338dd8" integrity sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A== proc-log@^4.2.0: @@ -21338,7 +21345,7 @@ protobufjs@^6.11.2: protocols@^2.0.0: version "2.0.2" - resolved "https://registry.yarnpkg.com/protocols/-/protocols-2.0.2.tgz#822e8fcdcb3df5356538b3e91bfd890b067fd0a4" + resolved "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz#822e8fcdcb3df5356538b3e91bfd890b067fd0a4" integrity sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ== protocols@^2.0.1: @@ -23067,7 +23074,7 @@ sf-symbols-typescript@^1.0.0: sha.js@2.4.12, sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8: version "2.4.12" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.12.tgz#eb8b568bf383dfd1867a32c3f2b74eb52bdbf23f" + resolved "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz#eb8b568bf383dfd1867a32c3f2b74eb52bdbf23f" integrity sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w== dependencies: inherits "^2.0.4" @@ -24874,6 +24881,11 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" @@ -25896,7 +25908,7 @@ wordwrap@^1.0.0: wrap-ansi@7.0.0, wrap-ansi@^6.2.0, wrap-ansi@^7.0.0, wrap-ansi@^8.1.0, wrap-ansi@^9.0.0: version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== dependencies: ansi-styles "^4.0.0"