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 { try { return await driver.screenshot(); } catch { return undefined; } } async function enterHubFunctionPage(): Promise { 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 { 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 = /]*?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 { 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 { 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 = /]*?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; } }); });