AI_UIAutomation/tests/aihub/aihub_camera_bind.test.ts

372 lines
14 KiB
TypeScript

import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest';
import { DeviceDriver } from '../../drivers/types';
import { createDriver } from '../../drivers/factory';
import { TestReporter } from '../../utils/test-reporter';
import { sleep } from '../../utils/common';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const AIHUB_NAME = process.env.AIHUB_NAME || 'AI Hub 6C';
describe('AIHub Camera Bind - 摄像头绑定管理', () => {
let driver: DeviceDriver;
let reporter: TestReporter;
let boundCameraName = '';
const BACK_BTN = { x: 39, y: 70 };
const BOTTOM_LEFT_BTN = { x: 115, y: 784 };
beforeAll(async () => {
driver = createDriver();
await driver.createSession();
reporter = new TestReporter('AIHub_Camera_Bind', driver.platform.toUpperCase());
});
beforeEach(async () => {
await driver.dismissPopupIfPresent();
});
afterAll(async () => {
reporter.generate();
await driver.destroySession();
});
async function captureScreenshot(): Promise<string | undefined> {
try { return await driver.screenshot(); } catch { return undefined; }
}
async function enterHubFunctionPage(): Promise<boolean> {
await driver.goBackToHomepage();
await sleep(1000);
await driver.dismissPopupIfPresent();
const maxScroll = 5;
for (let i = 0; i <= maxScroll; i++) {
let hubEl: string | null = null;
if (driver.platform === 'ios') {
hubEl = await driver.findElementRaw('predicate string', `name CONTAINS "${AIHUB_NAME}" AND type == "XCUIElementTypeCell"`);
if (!hubEl) {
hubEl = await driver.findElementRaw('predicate string', `label CONTAINS "${AIHUB_NAME}"`);
}
} else {
hubEl = await driver.findElementRaw('-android uiautomator', `new UiSelector().textContains("${AIHUB_NAME}")`);
}
if (hubEl) {
const rect = await driver.getElementRect(hubEl);
await driver.tap(rect.x + rect.width / 2, rect.y + rect.height / 4);
await sleep(5000);
const s = await driver.getSource();
if (s.includes('Try OpenClaw') || (s.includes('Cameras') && s.includes('AI Events'))) {
await driver.dismissPopupIfPresent();
return true;
}
await driver.clickElement(hubEl);
await sleep(5000);
const s2 = await driver.getSource();
if (s2.includes('Try OpenClaw') || (s2.includes('Cameras') && s2.includes('AI Events'))) {
await driver.dismissPopupIfPresent();
return true;
}
}
if (i < maxScroll) {
await driver.swipe(195, 650, 195, 300, 0.5);
await sleep(1500);
}
}
return false;
}
async function enterCameraManagement(): Promise<boolean> {
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) await sleep(3000);
// Check if already on Manage Cameras page
const preCheck = await driver.getSource();
if (preCheck.includes('Manage Cameras') || (preCheck.includes('Paired') && preCheck.includes('Not paired'))) {
return true;
}
if (driver.platform === 'ios') {
// Strategy 1: find bottom-left button by position range (tolerant matching)
const elements = await driver.findElementsRaw('predicate string',
'type == "XCUIElementTypeOther" AND visible == true AND accessible == true');
let clicked = false;
for (const el of elements) {
const rect = await driver.getElementRect(el);
if (rect.x >= 90 && rect.x <= 110 && rect.y >= 750 && rect.y <= 790 && rect.width >= 30 && rect.width <= 50) {
await driver.clickElement(el);
clicked = true;
break;
}
}
if (clicked) {
await sleep(5000);
const source = await driver.getSource();
if (source.includes('Manage Cameras') || source.includes('Paired')) {
return true;
}
}
// Strategy 2: find by name/label containing camera-related text
if (!clicked) {
const camBtn = await driver.findElementRaw('predicate string',
'label CONTAINS "camera" OR label CONTAINS "Camera" OR name CONTAINS "camera"');
if (camBtn) {
const rect = await driver.getElementRect(camBtn);
if (rect.y > 700) {
await driver.clickElement(camBtn);
await sleep(5000);
const source = await driver.getSource();
if (source.includes('Manage Cameras') || source.includes('Paired')) {
return true;
}
}
}
}
}
// Strategy 3: coordinate tap with slight position variations
const tapX = BOTTOM_LEFT_BTN.x + (attempt * 5);
const tapY = BOTTOM_LEFT_BTN.y - (attempt * 5);
await driver.tap(tapX, tapY);
await sleep(5000);
const source = await driver.getSource();
if (source.includes('Manage Cameras') || source.includes('Paired')) {
return true;
}
}
return false;
}
async function findAndBindAvailableCamera(): Promise<{ success: boolean; cameraName: string }> {
const source = await driver.getSource();
if (!source.includes('Not paired')) {
return { success: false, cameraName: '' };
}
// Parse camera rows from page source XML directly (faster than per-element API calls)
const notPairedIdx = source.indexOf('Not paired');
const addNewIdx = source.indexOf('Add New Device');
const notPairedSection = source.substring(notPairedIdx, addNewIdx > 0 ? addNewIdx : undefined);
// XML attr order: type, name, label, enabled, visible, accessible, x, y, width, height
const cameraRegex = /<XCUIElementTypeOther[^>]*?name="([^"]+)"[^>]*?x="(\d+)"[^>]*?y="(\d+)"[^>]*?width="(\d+)"[^>]*?height="(\d+)"/g;
const cameras: { label: string; x: number; y: number; width: number; height: number }[] = [];
let match;
while ((match = cameraRegex.exec(notPairedSection)) !== null) {
const [, label, x, y, w, h] = match;
const width = parseInt(w), height = parseInt(h);
if (width > 300 && height > 80 && height < 100) {
cameras.push({ label, x: parseInt(x), y: parseInt(y), width, height });
}
}
if (cameras.length === 0) {
return { success: false, cameraName: '' };
}
// Try each camera - tap its + button (left side) and check response
for (const cam of cameras) {
await driver.tap(cam.x + 20, cam.y + cam.height / 2);
await sleep(3000);
const afterTap = await driver.getSource();
// Error toasts = can't be added
if (afterTap.includes('Device is offline') || afterTap.includes('Already paired')) {
await sleep(2000);
continue;
}
// "Enable Now" dialog = RTSP camera
if (afterTap.includes('Enable Now')) {
const enableEl = await driver.findElementRaw('predicate string', 'label == "Enable Now"');
if (enableEl) {
await driver.tapElement(enableEl);
for (let w = 0; w < 10; w++) {
await sleep(3000);
const s = await driver.getSource();
if (!s.includes('Loading') && !s.includes('In progress')) break;
}
}
}
// Verify camera moved to Paired section
await sleep(2000);
const result = await driver.getSource();
const pairedI = result.indexOf('Paired');
const npI = result.indexOf('Not paired');
if (pairedI >= 0 && npI > pairedI) {
const pairedSection = result.substring(pairedI, npI);
const shortName = cam.label.substring(0, 12);
if (pairedSection.includes(shortName)) {
return { success: true, cameraName: cam.label.split(/\s{2,}/)[0] };
}
}
// No error appeared = likely successful
if (!afterTap.includes('Device is offline') && !afterTap.includes('Already paired')
&& afterTap.includes('Manage Cameras')) {
return { success: true, cameraName: cam.label.split(/\s{2,}/)[0] };
}
}
return { success: false, cameraName: '' };
}
async function saveCameraConfig(): Promise<boolean> {
const saveEl = await driver.findElementRaw('predicate string', 'label == "Save" AND visible == true');
if (saveEl) {
await driver.tapElement(saveEl);
// Wait for save to complete (may show "In progress" / "Loading")
for (let i = 0; i < 10; i++) {
await sleep(3000);
const s = await driver.getSource();
if (!s.includes('In progress') && !s.includes('Loading')) break;
}
// Handle possible "Please Note" confirmation dialog
const afterSave = await driver.getSource();
if (afterSave.includes('Please Note')) {
const confirmSave = await driver.findElementRaw('predicate string', 'label == "Save" AND visible == true');
if (confirmSave) {
await driver.tapElement(confirmSave);
await sleep(5000);
}
}
return true;
}
return false;
}
async function unbindCamera(cameraName: string): Promise<boolean> {
const source = await driver.getSource();
if (!source.includes('Manage Cameras')) return false;
// Parse Paired cameras from XML source (between "Paired" and "Not paired")
const pairedIdx = source.indexOf('"Paired"');
const npIdx = source.indexOf('"Not paired"');
if (pairedIdx < 0 || npIdx < 0) return false;
const pairedSection = source.substring(pairedIdx, npIdx);
// Find camera rows in Paired section by regex
const regex = /<XCUIElementTypeOther[^>]*?name="([^"]+)"[^>]*?x="(\d+)"[^>]*?y="(\d+)"[^>]*?width="(\d+)"[^>]*?height="(\d+)"/g;
let match;
const pairedCameras: { label: string; x: number; y: number; width: number; height: number }[] = [];
while ((match = regex.exec(pairedSection)) !== null) {
const [, label, x, y, w, h] = match;
const width = parseInt(w), height = parseInt(h);
if (width > 300 && height > 80 && height < 100) {
pairedCameras.push({ label, x: parseInt(x), y: parseInt(y), width, height });
}
}
// Find the camera we just bound (match first 10 chars of name)
const shortName = cameraName.substring(0, 10);
const target = pairedCameras.find(c => c.label.includes(shortName));
if (!target) return false;
// Tap the ⊖ button on the left side of the Paired camera row
await driver.tap(target.x + 20, target.y + target.height / 2);
await sleep(2000);
// Verify it moved to Not paired
const after = await driver.getSource();
const afterPairedIdx = after.indexOf('"Paired"');
const afterNpIdx = after.indexOf('"Not paired"');
if (afterPairedIdx >= 0 && afterNpIdx > afterPairedIdx) {
const afterPaired = after.substring(afterPairedIdx, afterNpIdx);
return !afterPaired.includes(shortName);
}
return true;
}
it('绑定摄像头 - 添加可用摄像头到Hub', { timeout: 180000 }, async () => {
const start = Date.now();
try {
const entered = await enterHubFunctionPage();
expect(entered).toBe(true);
const inManage = await enterCameraManagement();
expect(inManage).toBe(true);
const { success, cameraName } = await findAndBindAvailableCamera();
if (!success) {
reporter.record('绑定摄像头', 'SKIP', Date.now() - start, '无可绑定摄像头(所有设备离线/已被绑定), skip');
console.log('无可绑定摄像头,跳过');
return;
}
boundCameraName = cameraName;
// Verify camera is now in Paired section
const source = await driver.getSource();
const pairedIdx = source.indexOf('Paired');
const notPairedIdx = source.indexOf('Not paired');
const pairedSection = notPairedIdx > 0 ? source.substring(pairedIdx, notPairedIdx) : source.substring(pairedIdx);
expect(pairedSection).toContain(cameraName.substring(0, 10));
// Save the configuration
const saved = await saveCameraConfig();
expect(saved).toBe(true);
const detail = `绑定 ${cameraName} 成功并保存`;
console.log(detail);
reporter.record('绑定摄像头', 'PASS', Date.now() - start, detail);
} catch (e: any) {
const ss = await captureScreenshot();
reporter.record('绑定摄像头', 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});
it('解绑摄像头 - 从Hub移除已绑定摄像头', { timeout: 120000 }, async () => {
const start = Date.now();
try {
if (!boundCameraName) {
reporter.record('解绑摄像头', 'SKIP', Date.now() - start, '前置绑定未执行, skip');
console.log('无已绑定摄像头,跳过');
return;
}
// Wait for page to settle after bind's save
await sleep(3000);
// Navigate to Manage Cameras if not already there
const source = await driver.getSource();
if (source.includes('Manage Cameras') && source.includes('Paired')) {
// Already on Manage Cameras
} else if (source.includes('Try OpenClaw') || source.includes('AI Routines') || source.includes('Cameras')) {
// On Hub page, enter camera management directly
const inManage = await enterCameraManagement();
expect(inManage).toBe(true);
} else {
// Need full navigation
const entered = await enterHubFunctionPage();
expect(entered).toBe(true);
const inManage = await enterCameraManagement();
expect(inManage).toBe(true);
}
const unbound = await unbindCamera(boundCameraName);
expect(unbound).toBe(true);
// Save
const saved = await saveCameraConfig();
expect(saved).toBe(true);
const detail = `解绑 ${boundCameraName} 成功并保存`;
console.log(detail);
reporter.record('解绑摄像头', 'PASS', Date.now() - start, detail);
boundCameraName = '';
} catch (e: any) {
const ss = await captureScreenshot();
reporter.record('解绑摄像头', 'FAIL', Date.now() - start, e.message, ss);
throw e;
}
});
});