257 lines
9.1 KiB
TypeScript
257 lines
9.1 KiB
TypeScript
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<any> {
|
||
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<void> {
|
||
const caps: Record<string, any> = {
|
||
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<void> {
|
||
if (!this.sessionId) return;
|
||
await this.request('DELETE', `/session/${this.sessionId}`);
|
||
this.sessionId = null;
|
||
}
|
||
|
||
// Hub Show 直连设备固件 UI,不管理 App;activateApp 为空实现以满足接口。
|
||
async activateApp(_appId: string): Promise<void> {
|
||
/* no-op */
|
||
}
|
||
|
||
async findElement(locator: ElementLocator): Promise<string | null> {
|
||
if (!locator.android) return null;
|
||
return this.findElementRaw(locator.android.using, locator.android.value);
|
||
}
|
||
|
||
async findElements(locator: ElementLocator): Promise<string[]> {
|
||
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<string | null> {
|
||
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<string[]> {
|
||
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<string> {
|
||
return await this.request('GET', `${this.sessionUrl}/element/${elementId}/attribute/${attr}`) || '';
|
||
}
|
||
|
||
async tap(x: number, y: number): Promise<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
await this.request('POST', `${this.sessionUrl}/element/${elementId}/click`, {});
|
||
}
|
||
|
||
async clickElement(elementId: string): Promise<void> {
|
||
await this.tapElement(elementId);
|
||
}
|
||
|
||
async typeText(elementId: string, text: string): Promise<void> {
|
||
await this.request('POST', `${this.sessionUrl}/element/${elementId}/value`, { text });
|
||
}
|
||
|
||
async clearText(elementId: string): Promise<void> {
|
||
await this.request('POST', `${this.sessionUrl}/element/${elementId}/clear`, {});
|
||
}
|
||
|
||
async swipe(fromX: number, fromY: number, toX: number, toY: number, duration = 0.5): Promise<void> {
|
||
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<void> {
|
||
const midX = 540;
|
||
await this.swipe(midX, 600, midX, 600 - distance, 0.5);
|
||
}
|
||
|
||
async scrollUp(distance = 300): Promise<void> {
|
||
const midX = 540;
|
||
await this.swipe(midX, 300, midX, 300 + distance, 0.5);
|
||
}
|
||
|
||
async goBack(): Promise<void> {
|
||
await this.request('POST', `${this.sessionUrl}/back`, {});
|
||
}
|
||
|
||
async getSource(): Promise<string> {
|
||
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<string> {
|
||
return await this.request('GET', `${this.sessionUrl}/screenshot`);
|
||
}
|
||
|
||
async tapByLocator(locator: ElementLocator): Promise<boolean> {
|
||
const el = await this.findElement(locator);
|
||
if (!el) return false;
|
||
await this.tapElement(el);
|
||
return true;
|
||
}
|
||
|
||
async waitForElement(locator: ElementLocator, timeoutMs = 10000): Promise<string | null> {
|
||
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<boolean> {
|
||
const el = await this.findElement(locator);
|
||
return el !== null;
|
||
}
|
||
|
||
async goBackToHomepage(): Promise<boolean> {
|
||
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<boolean> {
|
||
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();
|
||
}
|