import { DeviceDriver, ElementLocator, Platform } from './types'; /** * Hub Show Driver — 直连 AI Hub Show 设备屏幕 * * 与 AndroidDriver 的区别: * - 通过 udid 指定 Hub Show 设备(区分手机) * - 不启动/激活任何 App(测试设备本机固件 UI) * - 使用独立的 Appium 端口 (默认 4724) */ export class HubShowDriver implements DeviceDriver { readonly platform: Platform = 'android'; private sessionId: string | null = null; private baseUrl: string; private udid: string; constructor( host = process.env.HUBSHOW_APPIUM_HOST || 'localhost', port = Number(process.env.HUBSHOW_APPIUM_PORT) || 4724, udid = process.env.HUBSHOW_UDID || '' ) { this.baseUrl = `http://${host}:${port}`; this.udid = udid; } private get sessionUrl(): string { if (!this.sessionId) throw new Error('No active Appium session'); return `${this.baseUrl}/session/${this.sessionId}`; } private async request(method: string, path: string, body?: any): Promise { const url = path.startsWith('/') ? `${this.baseUrl}${path}` : path; const opts: RequestInit = { method, headers: { 'Content-Type': 'application/json' } }; if (body !== undefined) opts.body = JSON.stringify(body); const resp = await fetch(url, opts); const json = await resp.json(); if (json.value && json.value.error) { throw new Error(`Appium: ${json.value.message || json.value.error}`); } return json.value; } async createSession(): Promise { const caps: Record = { platformName: 'Android', 'appium:automationName': 'UiAutomator2', 'appium:noReset': true, 'appium:autoLaunch': false, 'appium:newCommandTimeout': 300, 'appium:uiautomator2ServerInstallTimeout': 60000, }; if (this.udid) caps['appium:udid'] = this.udid; const result = await this.request('POST', '/session', { capabilities: { alwaysMatch: caps } }); this.sessionId = result.sessionId; await new Promise(r => setTimeout(r, 2000)); } async destroySession(): Promise { if (!this.sessionId) return; await this.request('DELETE', `/session/${this.sessionId}`); this.sessionId = null; } // Hub Show 直连设备固件 UI,不管理 App;activateApp 为空实现以满足接口。 async activateApp(_appId: string): Promise { /* no-op */ } async findElement(locator: ElementLocator): Promise { if (!locator.android) return null; return this.findElementRaw(locator.android.using, locator.android.value); } async findElements(locator: ElementLocator): Promise { if (!locator.android) return []; return this.findElementsRaw(locator.android.using, locator.android.value); } private mapStrategy(using: string, value: string): { using: string; value: string } { if (using === 'name' || using === 'text') { return { using: '-android uiautomator', value: `new UiSelector().text("${value}")` }; } if (using === 'accessibility id' || using === 'content-desc') { return { using: 'accessibility id', value }; } if (using === 'id') { return { using: 'id', value }; } if (using === 'predicate string') { if (value.includes('textContains')) { const match = value.match(/textContains\("([^"]+)"\)/); if (match) return { using: '-android uiautomator', value: `new UiSelector().textContains("${match[1]}")` }; } return { using: '-android uiautomator', value: `new UiSelector().textContains("${value}")` }; } return { using, value }; } async findElementRaw(using: string, value: string): Promise { const mapped = this.mapStrategy(using, value); try { const el = await this.request('POST', `${this.sessionUrl}/element`, mapped); return el?.ELEMENT || el?.['element-6066-11e4-a52e-4f735466cecf'] || null; } catch { return null; } } async findElementsRaw(using: string, value: string): Promise { const mapped = this.mapStrategy(using, value); try { const els = await this.request('POST', `${this.sessionUrl}/elements`, mapped); if (!Array.isArray(els)) return []; return els.map((e: any) => e.ELEMENT || e['element-6066-11e4-a52e-4f735466cecf']).filter(Boolean); } catch { return []; } } async getElementRect(elementId: string): Promise<{ x: number; y: number; width: number; height: number }> { return await this.request('GET', `${this.sessionUrl}/element/${elementId}/rect`); } async getElementAttribute(elementId: string, attr: string): Promise { return await this.request('GET', `${this.sessionUrl}/element/${elementId}/attribute/${attr}`) || ''; } async tap(x: number, y: number): Promise { await this.request('POST', `${this.sessionUrl}/actions`, { actions: [{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [ { type: 'pointerMove', duration: 0, x: Math.round(x), y: Math.round(y) }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }, ]}] }); } async doubleTap(x: number, y: number): Promise { await this.tap(x, y); await new Promise(r => setTimeout(r, 100)); await this.tap(x, y); } async longPress(x: number, y: number, duration = 2000): Promise { await this.request('POST', `${this.sessionUrl}/actions`, { actions: [{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [ { type: 'pointerMove', duration: 0, x: Math.round(x), y: Math.round(y) }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration }, { type: 'pointerUp', button: 0 }, ]}] }); } async tapElement(elementId: string): Promise { await this.request('POST', `${this.sessionUrl}/element/${elementId}/click`, {}); } async clickElement(elementId: string): Promise { await this.tapElement(elementId); } async typeText(elementId: string, text: string): Promise { await this.request('POST', `${this.sessionUrl}/element/${elementId}/value`, { text }); } async clearText(elementId: string): Promise { await this.request('POST', `${this.sessionUrl}/element/${elementId}/clear`, {}); } async swipe(fromX: number, fromY: number, toX: number, toY: number, duration = 0.5): Promise { const ms = Math.round(duration * 1000); await this.request('POST', `${this.sessionUrl}/actions`, { actions: [{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [ { type: 'pointerMove', duration: 0, x: Math.round(fromX), y: Math.round(fromY) }, { type: 'pointerDown', button: 0 }, { type: 'pointerMove', duration: ms, x: Math.round(toX), y: Math.round(toY) }, { type: 'pointerUp', button: 0 }, ]}] }); } async scrollDown(distance = 300): Promise { const midX = 540; await this.swipe(midX, 600, midX, 600 - distance, 0.5); } async scrollUp(distance = 300): Promise { const midX = 540; await this.swipe(midX, 300, midX, 300 + distance, 0.5); } async goBack(): Promise { await this.request('POST', `${this.sessionUrl}/back`, {}); } async getSource(): Promise { return await this.request('GET', `${this.sessionUrl}/source`); } async getWindowSize(): Promise<{ width: number; height: number }> { const rect = await this.request('GET', `${this.sessionUrl}/window/rect`); return { width: rect.width, height: rect.height }; } async screenshot(): Promise { return await this.request('GET', `${this.sessionUrl}/screenshot`); } async tapByLocator(locator: ElementLocator): Promise { const el = await this.findElement(locator); if (!el) return false; await this.tapElement(el); return true; } async waitForElement(locator: ElementLocator, timeoutMs = 10000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const el = await this.findElement(locator); if (el) return el; await new Promise(r => setTimeout(r, 1000)); } return null; } async isElementVisible(locator: ElementLocator): Promise { const el = await this.findElement(locator); return el !== null; } async goBackToHomepage(): Promise { for (let i = 0; i < 5; i++) { await this.goBack(); await new Promise(r => setTimeout(r, 1500)); const src = await this.getSource(); if (src.includes('安防') || src.includes('Security') || src.includes('主页')) return true; } return false; } async dismissPopupIfPresent(): Promise { const dismissTexts = ['Got it', 'OK', 'I know', '我知道了', 'Confirm', 'Allow']; for (const text of dismissTexts) { const el = await this.findElementRaw('-android uiautomator', `new UiSelector().text("${text}")`); if (el) { await this.tapElement(el); await new Promise(r => setTimeout(r, 1000)); return true; } } return false; } } export function createHubShowDriver(): HubShowDriver { return new HubShowDriver(); }