372 lines
14 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|