#!/usr/bin/env node import { spawn } from 'node:child_process' import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const WEB_BAK_DIR = path.resolve(__dirname, '..') const ROOT_DIR = path.resolve(WEB_BAK_DIR, '..') const AND_BAK_DIR = path.resolve(ROOT_DIR, 'and-bak') const APP_PACKAGE = 'com.hikoncont' const APP_MAIN_ACTIVITY = 'com.hikoncont.MainActivity' const DEFAULTS = { serverHttp: process.env.RC_SERVER_HTTP || '', socketUrl: process.env.RC_SOCKET_URL || '', username: process.env.RC_USER || process.env.SUPERADMIN_USERNAME || 'superadmin', password: process.env.RC_PASS || process.env.SUPERADMIN_PASSWORD || 'superadmin123456789', apkPath: process.env.RC_APK_PATH || path.resolve(AND_BAK_DIR, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'), reportPath: process.env.RC_MATRIX_REPORT || path.resolve(ROOT_DIR, `_tmp_adb_control_matrix_result_${Date.now()}.json`), smokeTimeoutMs: Number(process.env.RC_SMOKE_TIMEOUT_MS || 240000), waitOnlineMs: Number(process.env.RC_WAIT_ONLINE_MS || 90000), installTimeoutMs: Number(process.env.RC_INSTALL_TIMEOUT_MS || 240000), settleMs: Number(process.env.RC_SETTLE_MS || 7000), parallelDevices: Number(process.env.RC_PARALLEL_DEVICES || 0), triageLogcatLines: Number(process.env.RC_TRIAGE_LOGCAT_LINES || 2500) } const PROFILE_RULES = [ { id: 'oppo_family', priority: 120, maxAttempts: 4, allowedWarn: 5, settleMs: 10000, smokeTimeoutMs: 270000, waitOnlineMs: 120000, recoveryActions: ['wake', 'force_stop', 'home', 'launch', 'wait_long'], match: (d) => hasAny(`${d.brand} ${d.manufacturer}`, ['oppo', 'oneplus', 'realme']) }, { id: 'vivo_iqoo', priority: 115, maxAttempts: 4, allowedWarn: 5, settleMs: 10000, smokeTimeoutMs: 270000, waitOnlineMs: 120000, recoveryActions: ['wake', 'force_stop', 'home', 'launch', 'wait_long'], match: (d) => hasAny(`${d.brand} ${d.manufacturer} ${d.model}`, ['vivo', 'iqoo']) }, { id: 'xiaomi_hyperos', priority: 110, maxAttempts: 3, allowedWarn: 4, settleMs: 8000, smokeTimeoutMs: 240000, waitOnlineMs: 100000, recoveryActions: ['wake', 'home', 'launch', 'wait_medium', 'reconnect_if_wifi'], match: (d) => hasAny(`${d.brand} ${d.manufacturer} ${d.model}`, ['xiaomi', 'redmi', 'poco']) }, { id: 'huawei_honor', priority: 100, maxAttempts: 3, allowedWarn: 4, settleMs: 9000, smokeTimeoutMs: 240000, waitOnlineMs: 100000, recoveryActions: ['wake', 'force_stop', 'launch', 'wait_medium'], match: (d) => hasAny(`${d.brand} ${d.manufacturer} ${d.model}`, ['huawei', 'honor']) }, { id: 'default', priority: 0, maxAttempts: 3, allowedWarn: 4, settleMs: 7000, smokeTimeoutMs: 240000, waitOnlineMs: 90000, recoveryActions: ['wake', 'launch', 'wait_short'], match: () => true } ] const CRITICAL_SMOKE_STEPS = new Set([ 'request_device_control', 'enable_black_screen', 'disable_black_screen', 'app_list', 'camera_permission_check', 'microphone_permission_check', 'sms_permission_check', 'gallery_permission_check' ]) const RUNTIME_PERMISSION_LIST = [ 'android.permission.CAMERA', 'android.permission.RECORD_AUDIO', 'android.permission.READ_SMS', 'android.permission.SEND_SMS', 'android.permission.RECEIVE_SMS', 'android.permission.READ_MEDIA_IMAGES', 'android.permission.READ_MEDIA_VIDEO', 'android.permission.READ_EXTERNAL_STORAGE' ] function nowIso() { return new Date().toISOString() } function log(...args) { console.log(`[${nowIso()}]`, ...args) } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } function hasAny(raw, needles) { const source = String(raw || '').toLowerCase() return needles.some((needle) => source.includes(needle)) } function parseArgs(argv) { const options = { buildApk: false, skipInstall: false, dryRun: false } for (let i = 2; i < argv.length; i += 1) { const token = argv[i] if (!token.startsWith('--')) { continue } const [rawKey, inlineValue] = token.includes('=') ? token.split(/=(.*)/s) : [token, undefined] const key = rawKey.replace(/^--/, '') const value = inlineValue ?? argv[i + 1] const hasExplicitValue = inlineValue !== undefined || (!String(argv[i + 1] || '').startsWith('--') && argv[i + 1] !== undefined) const setValue = (targetKey) => { if (hasExplicitValue) { options[targetKey] = value if (inlineValue === undefined) i += 1 } } switch (key) { case 'adb': setValue('adbPath') break case 'apk': setValue('apkPath') break case 'server-http': setValue('serverHttp') break case 'socket-url': setValue('socketUrl') break case 'user': setValue('username') break case 'pass': setValue('password') break case 'report': setValue('reportPath') break case 'max-devices': setValue('maxDevices') break case 'only-serials': setValue('onlySerials') break case 'attempts': setValue('attempts') break case 'parallel-devices': setValue('parallelDevices') break case 'smoke-timeout-ms': setValue('smokeTimeoutMs') break case 'wait-online-ms': setValue('waitOnlineMs') break case 'install-timeout-ms': setValue('installTimeoutMs') break case 'settle-ms': setValue('settleMs') break case 'triage-logcat-lines': setValue('triageLogcatLines') break case 'build-apk': options.buildApk = true break case 'skip-install': options.skipInstall = true break case 'dry-run': options.dryRun = true break case 'help': options.help = true break default: break } } return options } function printHelp() { console.log(` Usage: node scripts/adb_control_matrix_orchestrator.mjs [options] Options: --adb ADB executable path --apk APK file path --build-apk Build debug APK before testing --skip-install Skip APK install stage --server-http Server HTTP URL (default from config/env) --socket-url Socket URL for smoke script --user Login username --pass Login password --only-serials Restrict ADB serials --max-devices Limit tested devices --attempts Override attempts per device --parallel-devices Number of devices tested concurrently (default: all selected) --smoke-timeout-ms Smoke timeout --wait-online-ms Wait online timeout --install-timeout-ms Install timeout --settle-ms Delay after app launch --triage-logcat-lines Logcat lines kept in per-attempt triage bundle --report Output JSON report path --dry-run Print planned actions only `) } async function runCommand(command, args, opts = {}) { const { cwd = process.cwd(), env = process.env, timeoutMs = 0, allowFailure = true } = opts return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' let stderr = '' let settled = false let timeoutHandle = null if (timeoutMs > 0) { timeoutHandle = setTimeout(() => { if (settled) return settled = true child.kill('SIGTERM') resolve({ ok: false, command, args, cwd, stdout, stderr: `${stderr}\n`, code: null, timedOut: true }) }, timeoutMs) } child.stdout.on('data', (chunk) => { stdout += chunk.toString() }) child.stderr.on('data', (chunk) => { stderr += chunk.toString() }) child.on('error', (error) => { if (settled) return settled = true if (timeoutHandle) clearTimeout(timeoutHandle) if (allowFailure) { resolve({ ok: false, command, args, cwd, stdout, stderr: `${stderr}\n${error.message}`, code: null, timedOut: false }) } else { reject(error) } }) child.on('close', (code) => { if (settled) return settled = true if (timeoutHandle) clearTimeout(timeoutHandle) const ok = code === 0 const result = { ok, command, args, cwd, stdout, stderr, code, timedOut: false } if (ok || allowFailure) { resolve(result) } else { reject(new Error(`command_failed ${command} ${args.join(' ')} code=${code}`)) } }) }) } async function readIfExists(filePath) { try { return await readFile(filePath, 'utf8') } catch { return '' } } async function fileExists(filePath) { try { await access(filePath) return true } catch { return false } } function parseServerHttpFromConfig(raw) { try { const json = JSON.parse(raw) const serverUrl = String(json?.serverUrl || '').trim() if (!serverUrl) return '' if (serverUrl.startsWith('ws://')) return `http://${serverUrl.slice(5)}` if (serverUrl.startsWith('wss://')) return `https://${serverUrl.slice(6)}` return serverUrl } catch { return '' } } async function getDefaultServerHttp() { if (DEFAULTS.serverHttp) return DEFAULTS.serverHttp const cfgPath = path.resolve(AND_BAK_DIR, 'app', 'src', 'main', 'assets', 'server_config.json') const raw = await readIfExists(cfgPath) return parseServerHttpFromConfig(raw) } async function getSdkDirFromLocalProperties() { const localPropertiesPath = path.resolve(AND_BAK_DIR, 'local.properties') const raw = await readIfExists(localPropertiesPath) const line = raw .split(/\r?\n/) .map((v) => v.trim()) .find((v) => v.startsWith('sdk.dir=')) if (!line) return '' return line.slice('sdk.dir='.length).replace(/\\/g, '/').trim() } async function resolveAdbPath(explicitAdbPath) { const exeName = process.platform === 'win32' ? 'adb.exe' : 'adb' const candidates = [] if (explicitAdbPath) candidates.push(explicitAdbPath) if (process.env.RC_ADB_PATH) candidates.push(process.env.RC_ADB_PATH) for (const envKey of ['ANDROID_HOME', 'ANDROID_SDK_ROOT']) { if (process.env[envKey]) { candidates.push(path.join(process.env[envKey], 'platform-tools', exeName)) } } const sdkDir = await getSdkDirFromLocalProperties() if (sdkDir) { candidates.push(path.join(sdkDir, 'platform-tools', exeName)) } candidates.push('adb') for (const candidate of candidates) { const result = await runCommand(candidate, ['version'], { cwd: ROOT_DIR, timeoutMs: 6000, allowFailure: true }) if (result.ok) { return candidate } } throw new Error('ADB not found. Provide --adb or configure Android SDK path.') } async function adb(adbPath, args, opts = {}) { return runCommand(adbPath, args, opts) } async function adbForSerial(adbPath, serial, args, opts = {}) { return runCommand(adbPath, ['-s', serial, ...args], opts) } function parseAdbDeviceList(raw) { const devices = [] const lines = String(raw || '').split(/\r?\n/) for (const line of lines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('List of devices attached')) continue const match = trimmed.match(/^(\S+)\s+(\S+)\s*(.*)$/) if (!match) continue const [, serial, status, extraRaw] = match if (status !== 'device') continue const extras = {} if (extraRaw) { for (const token of extraRaw.split(/\s+/)) { const [k, v] = token.split(':') if (k && v !== undefined) extras[k] = v } } devices.push({ serial, status, extras }) } return devices } function sanitizeFileToken(input) { return String(input || 'unknown') .replace(/[^\w.-]+/g, '_') .replace(/^_+|_+$/g, '') } function simplifyTransportEntry(item) { return { serial: item.serial, status: item.status, transportId: item.extras?.transport_id || '', model: item.extras?.model || '', device: item.extras?.device || '', product: item.extras?.product || '' } } async function listOnlineAdbTransports(adbPath) { const listResult = await adb(adbPath, ['devices', '-l'], { cwd: ROOT_DIR, timeoutMs: 10000, allowFailure: true }) if (!listResult.ok) { return { ok: false, error: `${listResult.stderr || ''}\n${listResult.stdout || ''}`.trim(), transports: [] } } return { ok: true, error: '', transports: parseAdbDeviceList(listResult.stdout) } } function isTransportLostOutput(raw) { const text = String(raw || '').toLowerCase() if (!text) return false return ( text.includes('device not found') || text.includes('no devices/emulators found') || text.includes('device offline') || text.includes('more than one device/emulator') || text.includes('cannot connect to') || text.includes('closed') ) } async function adbGetProp(adbPath, serial, prop) { const result = await adbForSerial(adbPath, serial, ['shell', 'getprop', prop], { cwd: ROOT_DIR, timeoutMs: 6000, allowFailure: true }) if (!result.ok) return '' return result.stdout.trim() } async function adbGetAndroidId(adbPath, serial) { const main = await adbForSerial(adbPath, serial, ['shell', 'settings', 'get', 'secure', 'android_id'], { cwd: ROOT_DIR, timeoutMs: 6000, allowFailure: true }) const value = main.stdout.trim() if (value && value.toLowerCase() !== 'null') return value const fallback = await adbGetProp(adbPath, serial, 'ro.serialno') if (fallback) return fallback return serial } async function adbReadAppDeviceIdFromLogcat(adbPath, serial) { const result = await adbForSerial(adbPath, serial, ['logcat', '-d', '-t', '5000'], { cwd: ROOT_DIR, timeoutMs: 12000, allowFailure: true }) if (!result.ok) return '' const raw = `${result.stdout}\n${result.stderr}` const regex = /device_register\s*#\d+:\s*deviceId=([0-9a-fA-F]+)/g let match = null let last = '' // eslint-disable-next-line no-cond-assign while ((match = regex.exec(raw)) !== null) { last = String(match[1] || '').trim() } return last } function transportScore(serial) { if (serial.includes(':')) return 20 if (serial.startsWith('adb-')) return 10 return 30 } function selectBestTransport(candidates) { return [...candidates].sort((a, b) => { const scoreDiff = transportScore(b.serial) - transportScore(a.serial) if (scoreDiff !== 0) return scoreDiff return a.serial.localeCompare(b.serial) })[0] } function normalizeDevicePayload(raw) { if (Array.isArray(raw)) return raw if (Array.isArray(raw?.data)) return raw.data if (Array.isArray(raw?.devices)) return raw.devices if (Array.isArray(raw?.value)) return raw.value return [] } async function loginServer(serverHttp, username, password) { const response = await fetch(`${serverHttp}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }) const body = await response.json().catch(() => ({})) if (!response.ok || !body?.success || !body?.token) { throw new Error(`server_login_failed status=${response.status}`) } return body.token } async function fetchDevices(serverHttp, token) { const response = await fetch(`${serverHttp}/api/devices`, { headers: { Authorization: `Bearer ${token}` } }) const body = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(`fetch_devices_failed status=${response.status}`) } return normalizeDevicePayload(body) } async function waitForDeviceOnline(serverHttp, token, candidateDeviceIds, timeoutMs, hint = {}) { const deadline = Date.now() + timeoutMs const ids = Array.from(new Set((candidateDeviceIds || []).map((v) => String(v || '').trim()).filter(Boolean))) let last = null while (Date.now() < deadline) { try { const devices = await fetchDevices(serverHttp, token) for (const id of ids) { const found = devices.find((item) => String(item?.id || '') === id) if (found && String(found?.status || '').toLowerCase() === 'online') { return { device: found, matchedBy: id } } if (found) last = { device: found, matchedBy: id } } const onlineDevices = devices.filter((item) => String(item?.status || '').toLowerCase() === 'online') const hintModel = String(hint?.model || '').trim().toLowerCase() if (hintModel) { const modelMatches = onlineDevices.filter((item) => String(item?.model || '').trim().toLowerCase() === hintModel) if (modelMatches.length === 1) { return { device: modelMatches[0], matchedBy: 'model_fallback' } } } } catch { // transient server errors should not terminate the full matrix run } await sleep(3000) } return last } function resolveProfile(device) { return [...PROFILE_RULES] .sort((a, b) => b.priority - a.priority) .find((rule) => rule.match(device)) || PROFILE_RULES[PROFILE_RULES.length - 1] } async function buildApkIfNeeded(options) { if (!options.buildApk) return log('Building APK via Gradle...') const gradle = process.platform === 'win32' ? 'gradlew.bat' : './gradlew' const result = await runCommand(gradle, [':app:assembleDebug'], { cwd: AND_BAK_DIR, timeoutMs: 30 * 60 * 1000, allowFailure: true }) if (!result.ok) { throw new Error(`apk_build_failed: ${result.stderr || result.stdout}`) } log('APK build finished.') } async function installApk(adbPath, serial, apkPath, timeoutMs) { const first = await adbForSerial(adbPath, serial, ['install', '-r', '-d', apkPath], { cwd: ROOT_DIR, timeoutMs, allowFailure: true }) if (first.ok && /success/i.test(first.stdout + first.stderr)) { return { ok: true, output: first.stdout + first.stderr, mode: 'install-r-d' } } const fallback = await adbForSerial(adbPath, serial, ['install', '-r', apkPath], { cwd: ROOT_DIR, timeoutMs, allowFailure: true }) if (fallback.ok && /success/i.test(fallback.stdout + fallback.stderr)) { return { ok: true, output: fallback.stdout + fallback.stderr, mode: 'install-r' } } const combined = `${first.stdout}\n${first.stderr}\n${fallback.stdout}\n${fallback.stderr}` if (/INSTALL_FAILED_UPDATE_INCOMPATIBLE/i.test(combined)) { await adbForSerial(adbPath, serial, ['uninstall', APP_PACKAGE], { cwd: ROOT_DIR, timeoutMs: 45000, allowFailure: true }) const clean = await adbForSerial(adbPath, serial, ['install', apkPath], { cwd: ROOT_DIR, timeoutMs, allowFailure: true }) if (clean.ok && /success/i.test(clean.stdout + clean.stderr)) { return { ok: true, output: clean.stdout + clean.stderr, mode: 'uninstall-install' } } return { ok: false, output: `${combined}\n${clean.stdout}\n${clean.stderr}`, mode: 'failed_after_uninstall' } } return { ok: false, output: combined, mode: 'failed' } } async function launchApp(adbPath, serial) { await adbForSerial(adbPath, serial, ['shell', 'input', 'keyevent', '224'], { cwd: ROOT_DIR, timeoutMs: 5000, allowFailure: true }) const monkey = await adbForSerial(adbPath, serial, ['shell', 'monkey', '-p', APP_PACKAGE, '-c', 'android.intent.category.LAUNCHER', '1'], { cwd: ROOT_DIR, timeoutMs: 20000, allowFailure: true }) if (monkey.ok) return { ok: true, mode: 'monkey' } const fallback = await adbForSerial(adbPath, serial, ['shell', 'am', 'start', '-n', `${APP_PACKAGE}/${APP_MAIN_ACTIVITY}`], { cwd: ROOT_DIR, timeoutMs: 20000, allowFailure: true }) return { ok: fallback.ok, mode: fallback.ok ? 'am_start' : 'launch_failed', output: `${monkey.stderr}\n${fallback.stderr}` } } function evaluateSmoke(report, allowedWarn) { if (!report || !report.summary) { return { pass: false, reason: 'missing_smoke_report' } } if (Number(report.summary.fail || 0) > 0) { return { pass: false, reason: `smoke_fail_${report.summary.fail}` } } if (Number(report.summary.warn || 0) > allowedWarn) { return { pass: false, reason: `smoke_warn_${report.summary.warn}_gt_${allowedWarn}` } } const failedCritical = [] const steps = Array.isArray(report.steps) ? report.steps : [] for (const name of CRITICAL_SMOKE_STEPS) { const step = steps.find((item) => item?.name === name) if (!step || step.status !== 'ok') { failedCritical.push(name) } } if (failedCritical.length > 0) { return { pass: false, reason: `critical_steps_failed:${failedCritical.join(',')}` } } return { pass: true, reason: 'pass' } } async function applyRecovery(adbPath, serial, profile) { for (const action of profile.recoveryActions || []) { switch (action) { case 'wake': await adbForSerial(adbPath, serial, ['shell', 'input', 'keyevent', '224'], { cwd: ROOT_DIR, timeoutMs: 5000, allowFailure: true }) break case 'force_stop': await adbForSerial(adbPath, serial, ['shell', 'am', 'force-stop', APP_PACKAGE], { cwd: ROOT_DIR, timeoutMs: 10000, allowFailure: true }) break case 'home': await adbForSerial(adbPath, serial, ['shell', 'input', 'keyevent', '3'], { cwd: ROOT_DIR, timeoutMs: 5000, allowFailure: true }) break case 'launch': await launchApp(adbPath, serial) break case 'reconnect_if_wifi': if (serial.includes(':')) { await adb(adbPath, ['connect', serial], { cwd: ROOT_DIR, timeoutMs: 15000, allowFailure: true }) } break case 'wait_short': await sleep(2500) break case 'wait_medium': await sleep(5000) break case 'wait_long': await sleep(9000) break default: break } } } async function runSmokeForDevice(params) { const { deviceId, serverHttp, socketUrl, username, password, reportPath, timeoutMs, stdoutPath, stderrPath } = params const env = { ...process.env, RC_DEVICE_ID: deviceId, RC_SERVER_HTTP: serverHttp, RC_SOCKET_URL: socketUrl, RC_USER: username, RC_PASS: password, RC_REPORT_PATH: reportPath } const result = await runCommand('node', ['_tmp_control_panel_smoke.mjs', deviceId], { cwd: WEB_BAK_DIR, env, timeoutMs, allowFailure: true }) const rawReport = await readIfExists(reportPath) let parsedReport = null if (rawReport) { try { parsedReport = JSON.parse(rawReport) } catch { parsedReport = null } } if (stdoutPath) { await writeFile(stdoutPath, String(result.stdout || ''), 'utf8') } if (stderrPath) { await writeFile(stderrPath, String(result.stderr || ''), 'utf8') } return { processResult: result, report: parsedReport, stdoutPath: stdoutPath || '', stderrPath: stderrPath || '' } } async function collectDeviceRuntime(adbPath, baseDevice) { const serial = baseDevice.serial const brand = await adbGetProp(adbPath, serial, 'ro.product.brand') const manufacturer = await adbGetProp(adbPath, serial, 'ro.product.manufacturer') const model = await adbGetProp(adbPath, serial, 'ro.product.model') const device = await adbGetProp(adbPath, serial, 'ro.product.device') const product = await adbGetProp(adbPath, serial, 'ro.product.name') const sdk = await adbGetProp(adbPath, serial, 'ro.build.version.sdk') const release = await adbGetProp(adbPath, serial, 'ro.build.version.release') const androidId = await adbGetAndroidId(adbPath, serial) const appDeviceId = await adbReadAppDeviceIdFromLogcat(adbPath, serial) return { ...baseDevice, androidId, appDeviceId, brand, manufacturer, model, device, product, sdkInt: Number(sdk || 0), release } } async function collectTransportFingerprint(adbPath, baseDevice) { const serial = baseDevice.serial const androidId = await adbGetAndroidId(adbPath, serial) const brand = await adbGetProp(adbPath, serial, 'ro.product.brand') const manufacturer = await adbGetProp(adbPath, serial, 'ro.product.manufacturer') const model = await adbGetProp(adbPath, serial, 'ro.product.model') const appDeviceId = await adbReadAppDeviceIdFromLogcat(adbPath, serial) return { ...baseDevice, androidId, brand, manufacturer, model, appDeviceId } } async function resolveDeviceTransport(adbPath, device) { const listed = await listOnlineAdbTransports(adbPath) const fallback = { ok: false, serial: device.serial, changed: false, matchedBy: 'unresolved', transports: [], message: listed.error || 'adb_list_failed' } if (!listed.ok) { return fallback } const transports = listed.transports const direct = transports.find((item) => item.serial === device.serial) if (direct) { return { ok: true, serial: device.serial, changed: false, matchedBy: 'current_serial', transports, message: '' } } const knownSerials = new Set([device.serial, ...(device.duplicateSerials || [])]) const knownOnline = transports.filter((item) => knownSerials.has(item.serial)) if (knownOnline.length > 0) { const selected = selectBestTransport(knownOnline) return { ok: true, serial: selected.serial, changed: selected.serial !== device.serial, matchedBy: 'known_serial', transports, message: '' } } const fingerprints = [] for (const transport of transports) { const fp = await collectTransportFingerprint(adbPath, transport) fingerprints.push(fp) } const androidIdMatches = fingerprints.filter((item) => item.androidId && item.androidId === device.androidId) if (androidIdMatches.length > 0) { const selected = selectBestTransport(androidIdMatches) return { ok: true, serial: selected.serial, changed: selected.serial !== device.serial, matchedBy: 'android_id', transports, message: '' } } const modelNeedle = String(device.model || '').trim().toLowerCase() if (modelNeedle) { const modelMatches = fingerprints.filter((item) => String(item.model || '').trim().toLowerCase() === modelNeedle) if (modelMatches.length === 1) { return { ok: true, serial: modelMatches[0].serial, changed: modelMatches[0].serial !== device.serial, matchedBy: 'model_fallback', transports, message: '' } } } return { ok: false, serial: device.serial, changed: false, matchedBy: 'not_found', transports, message: 'device_transport_not_found' } } async function adbReadRuntimePermissionState(adbPath, serial, permission) { const result = await adbForSerial(adbPath, serial, ['shell', 'pm', 'check-permission', APP_PACKAGE, permission], { cwd: ROOT_DIR, timeoutMs: 7000, allowFailure: true }) if (!result.ok) { return { permission, state: 'unknown', output: `${result.stdout}\n${result.stderr}`.trim() } } const output = `${result.stdout}\n${result.stderr}`.trim() const normalized = output.toLowerCase() const granted = normalized.includes('granted') const denied = normalized.includes('denied') return { permission, state: granted ? 'granted' : denied ? 'denied' : 'unknown', output } } async function collectAttemptTriage({ adbPath, serverHttp, token, runDir, device, serial, attempt, failureReason, smokeResult, triageLogcatLines }) { const tokenSafe = sanitizeFileToken(device.androidId || device.serial) const triageDir = path.resolve(runDir, `triage_${tokenSafe}_attempt${attempt}`) await mkdir(triageDir, { recursive: true }) const adbDevicesResult = await adb(adbPath, ['devices', '-l'], { cwd: ROOT_DIR, timeoutMs: 10000, allowFailure: true }) const adbDevicesPath = path.resolve(triageDir, 'adb_devices.txt') await writeFile(adbDevicesPath, `${adbDevicesResult.stdout || ''}\n${adbDevicesResult.stderr || ''}`, 'utf8') const accessibilityEnabled = await adbForSerial( adbPath, serial, ['shell', 'settings', 'get', 'secure', 'accessibility_enabled'], { cwd: ROOT_DIR, timeoutMs: 7000, allowFailure: true } ) const accessibilityServices = await adbForSerial( adbPath, serial, ['shell', 'settings', 'get', 'secure', 'enabled_accessibility_services'], { cwd: ROOT_DIR, timeoutMs: 7000, allowFailure: true } ) const appops = await adbForSerial( adbPath, serial, ['shell', 'cmd', 'appops', 'get', APP_PACKAGE], { cwd: ROOT_DIR, timeoutMs: 10000, allowFailure: true } ) const logcat = await adbForSerial( adbPath, serial, ['logcat', '-d', '-t', String(Math.max(200, Number(triageLogcatLines || 2500)))], { cwd: ROOT_DIR, timeoutMs: 20000, allowFailure: true } ) const a11yEnabledRaw = (accessibilityEnabled.stdout || accessibilityEnabled.stderr || '').trim() const a11yServicesRaw = (accessibilityServices.stdout || accessibilityServices.stderr || '').trim() const appopsRaw = `${appops.stdout || ''}\n${appops.stderr || ''}`.trim() const logcatRaw = `${logcat.stdout || ''}\n${logcat.stderr || ''}`.trim() const targetA11yService = 'com.hikoncont/com.hikoncont.service.AccessibilityRemoteService'.toLowerCase() const hasServiceInSettings = String(a11yServicesRaw || '').toLowerCase().includes(targetA11yService) const hasServiceLogHint = /AccessibilityRemoteService/.test(logcatRaw) || /KeepAliveWorker/.test(logcatRaw) const a11yEnabled = a11yEnabledRaw === '1' || hasServiceInSettings || hasServiceLogHint const appopsPath = path.resolve(triageDir, 'appops.txt') const logcatPath = path.resolve(triageDir, 'logcat_tail.txt') await writeFile(appopsPath, appopsRaw, 'utf8') await writeFile(logcatPath, logcatRaw, 'utf8') const runtimePermissions = [] for (const permission of RUNTIME_PERMISSION_LIST) { runtimePermissions.push(await adbReadRuntimePermissionState(adbPath, serial, permission)) } const smokeStdoutPath = path.resolve(triageDir, 'smoke_stdout.log') const smokeStderrPath = path.resolve(triageDir, 'smoke_stderr.log') if (smokeResult?.processResult) { await writeFile(smokeStdoutPath, String(smokeResult.processResult.stdout || ''), 'utf8') await writeFile(smokeStderrPath, String(smokeResult.processResult.stderr || ''), 'utf8') } else { await writeFile(smokeStdoutPath, '', 'utf8') await writeFile(smokeStderrPath, '', 'utf8') } let serverDevices = [] let serverError = '' try { serverDevices = await fetchDevices(serverHttp, token) } catch (error) { serverError = error instanceof Error ? error.message : String(error) } const serverSnapshotPath = path.resolve(triageDir, 'server_devices.json') await writeFile( serverSnapshotPath, JSON.stringify( { collectedAt: nowIso(), error: serverError, total: Array.isArray(serverDevices) ? serverDevices.length : 0, devices: Array.isArray(serverDevices) ? serverDevices : [] }, null, 2 ), 'utf8' ) const triage = { collectedAt: nowIso(), failureReason, serial, androidId: device.androidId, brand: device.brand, manufacturer: device.manufacturer, model: device.model, release: device.release, sdkInt: device.sdkInt, accessibility: { enabledRaw: a11yEnabledRaw, enabled: a11yEnabled, servicesRaw: a11yServicesRaw, inferredFromServiceSetting: hasServiceInSettings, inferredFromLogcat: hasServiceLogHint }, runtimePermissions, files: { adbDevicesPath, appopsPath, logcatPath, smokeStdoutPath, smokeStderrPath, serverSnapshotPath } } const triagePath = path.resolve(triageDir, 'triage.json') await writeFile(triagePath, JSON.stringify(triage, null, 2), 'utf8') return { triagePath, triageDir, triage, smokeStdoutPath, smokeStderrPath } } async function runWithConcurrency(items, maxConcurrency, worker) { const results = new Array(items.length) const limit = Math.max(1, Math.min(items.length, Number(maxConcurrency || 1))) let cursor = 0 const runners = Array.from({ length: limit }, async () => { while (true) { const index = cursor cursor += 1 if (index >= items.length) return results[index] = await worker(items[index], index) } }) await Promise.all(runners) return results } async function runSingleDeviceMatrix(params) { const { device, adbPath, apkPath, options, token, runDir } = params const profile = resolveProfile(device) const maxAttempts = options.attempts || profile.maxAttempts const allowedWarn = profile.allowedWarn const smokeTimeoutMs = options.smokeTimeoutMs || profile.smokeTimeoutMs const waitOnlineMs = options.waitOnlineMs || profile.waitOnlineMs const settleMs = options.settleMs || profile.settleMs const tokenSafe = sanitizeFileToken(device.androidId || device.serial) const deviceState = { ...device, duplicateSerials: Array.isArray(device.duplicateSerials) ? [...device.duplicateSerials] : [] } log( `Testing serial=${deviceState.serial} androidId=${deviceState.androidId} brand=${deviceState.brand} model=${deviceState.model} profile=${profile.id}` ) const deviceResult = { serial: deviceState.serial, androidId: deviceState.androidId, appDeviceId: deviceState.appDeviceId || '', duplicateSerials: deviceState.duplicateSerials, brand: deviceState.brand, manufacturer: deviceState.manufacturer, model: deviceState.model, sdkInt: deviceState.sdkInt, release: deviceState.release, profile: profile.id, attempts: [], finalStatus: 'failed', finalReason: 'not_started' } for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { log(` [${deviceState.androidId}] attempt ${attempt}/${maxAttempts}`) const attemptRecord = { attempt, startedAt: nowIso(), serialBeforeResolve: deviceState.serial, serialInUse: deviceState.serial, transportResolution: null, install: null, launch: null, onlineWait: null, smoke: null, evaluation: null, triage: null, recoveryApplied: false } if (options.dryRun) { attemptRecord.evaluation = { pass: true, reason: 'dry_run' } deviceResult.attempts.push(attemptRecord) deviceResult.finalStatus = 'passed' deviceResult.finalReason = 'dry_run' break } const transportResolution = await resolveDeviceTransport(adbPath, deviceState) attemptRecord.transportResolution = { ok: transportResolution.ok, matchedBy: transportResolution.matchedBy, changed: transportResolution.changed, message: transportResolution.message || '', transports: transportResolution.transports.map(simplifyTransportEntry) } if (!transportResolution.ok) { attemptRecord.evaluation = { pass: false, reason: 'adb_transport_lost' } const triage = await collectAttemptTriage({ adbPath, serverHttp: options.serverHttp, token, runDir, device: deviceState, serial: deviceState.serial, attempt, failureReason: attemptRecord.evaluation.reason, smokeResult: null, triageLogcatLines: options.triageLogcatLines }) attemptRecord.triage = { path: triage.triagePath, accessibilityEnabled: triage.triage?.accessibility?.enabled === true } deviceResult.attempts.push(attemptRecord) continue } if (transportResolution.changed) { log(` [${deviceState.androidId}] transport switched ${deviceState.serial} -> ${transportResolution.serial} (${transportResolution.matchedBy})`) if (!deviceState.duplicateSerials.includes(deviceState.serial)) { deviceState.duplicateSerials.push(deviceState.serial) } deviceState.serial = transportResolution.serial deviceResult.serial = transportResolution.serial deviceResult.duplicateSerials = Array.from( new Set([...(deviceResult.duplicateSerials || []), ...(transportResolution.transports || []).map((item) => item.serial)]) ).filter((serial) => serial !== transportResolution.serial) } attemptRecord.serialInUse = deviceState.serial if (!options.skipInstall) { const install = await installApk(adbPath, deviceState.serial, apkPath, options.installTimeoutMs) attemptRecord.install = install if (!install.ok) { const reason = isTransportLostOutput(install.output) ? 'adb_transport_lost_install' : 'install_failed' attemptRecord.evaluation = { pass: false, reason } const triage = await collectAttemptTriage({ adbPath, serverHttp: options.serverHttp, token, runDir, device: deviceState, serial: deviceState.serial, attempt, failureReason: reason, smokeResult: null, triageLogcatLines: options.triageLogcatLines }) attemptRecord.triage = { path: triage.triagePath, accessibilityEnabled: triage.triage?.accessibility?.enabled === true } deviceResult.attempts.push(attemptRecord) await applyRecovery(adbPath, deviceState.serial, profile) attemptRecord.recoveryApplied = true continue } } const launch = await launchApp(adbPath, deviceState.serial) attemptRecord.launch = launch if (!launch.ok) { const reason = isTransportLostOutput(launch.output) ? 'adb_transport_lost_launch' : 'launch_failed' attemptRecord.evaluation = { pass: false, reason } const triage = await collectAttemptTriage({ adbPath, serverHttp: options.serverHttp, token, runDir, device: deviceState, serial: deviceState.serial, attempt, failureReason: reason, smokeResult: null, triageLogcatLines: options.triageLogcatLines }) attemptRecord.triage = { path: triage.triagePath, accessibilityEnabled: triage.triage?.accessibility?.enabled === true } deviceResult.attempts.push(attemptRecord) await applyRecovery(adbPath, deviceState.serial, profile) attemptRecord.recoveryApplied = true continue } await sleep(settleMs) const logcatDeviceId = await adbReadAppDeviceIdFromLogcat(adbPath, deviceState.serial) const candidateDeviceIds = Array.from( new Set([logcatDeviceId, deviceState.appDeviceId, deviceState.androidId].map((v) => String(v || '').trim()).filter(Boolean)) ) attemptRecord.candidateDeviceIds = candidateDeviceIds const onlineResult = await waitForDeviceOnline(options.serverHttp, token, candidateDeviceIds, waitOnlineMs, { brand: deviceState.brand, manufacturer: deviceState.manufacturer, model: deviceState.model }) const onlineDevice = onlineResult?.device || null attemptRecord.onlineWait = { found: !!onlineDevice, status: onlineDevice?.status || 'missing', matchedBy: onlineResult?.matchedBy || null } if (!onlineDevice || String(onlineDevice?.status || '').toLowerCase() !== 'online') { let reason = 'server_device_not_online' const triage = await collectAttemptTriage({ adbPath, serverHttp: options.serverHttp, token, runDir, device: deviceState, serial: deviceState.serial, attempt, failureReason: reason, smokeResult: null, triageLogcatLines: options.triageLogcatLines }) if (triage.triage?.accessibility?.enabled === false) { reason = 'a11y_disabled' } attemptRecord.evaluation = { pass: false, reason } attemptRecord.triage = { path: triage.triagePath, accessibilityEnabled: triage.triage?.accessibility?.enabled === true } deviceResult.attempts.push(attemptRecord) await applyRecovery(adbPath, deviceState.serial, profile) attemptRecord.recoveryApplied = true continue } const smokeReportPath = path.resolve(runDir, `smoke_${tokenSafe}_${attempt}.json`) const smokeStdoutPath = path.resolve(runDir, `smoke_${tokenSafe}_${attempt}.stdout.log`) const smokeStderrPath = path.resolve(runDir, `smoke_${tokenSafe}_${attempt}.stderr.log`) const smoke = await runSmokeForDevice({ deviceId: onlineDevice.id, serverHttp: options.serverHttp, socketUrl: options.socketUrl, username: options.username, password: options.password, reportPath: smokeReportPath, timeoutMs: smokeTimeoutMs, stdoutPath: smokeStdoutPath, stderrPath: smokeStderrPath }) attemptRecord.smoke = { deviceId: onlineDevice.id, reportPath: smokeReportPath, stdoutPath: smoke.stdoutPath || smokeStdoutPath, stderrPath: smoke.stderrPath || smokeStderrPath, processCode: smoke.processResult.code, timedOut: smoke.processResult.timedOut, summary: smoke.report?.summary || null } const evaluation = evaluateSmoke(smoke.report, allowedWarn) attemptRecord.evaluation = evaluation deviceResult.attempts.push(attemptRecord) if (evaluation.pass) { deviceResult.finalStatus = 'passed' deviceResult.finalReason = evaluation.reason break } const triage = await collectAttemptTriage({ adbPath, serverHttp: options.serverHttp, token, runDir, device: deviceState, serial: deviceState.serial, attempt, failureReason: evaluation.reason, smokeResult: smoke, triageLogcatLines: options.triageLogcatLines }) attemptRecord.triage = { path: triage.triagePath, accessibilityEnabled: triage.triage?.accessibility?.enabled === true } if (attempt < maxAttempts) { await applyRecovery(adbPath, deviceState.serial, profile) attemptRecord.recoveryApplied = true } } if (deviceResult.finalStatus !== 'passed') { const last = deviceResult.attempts[deviceResult.attempts.length - 1] deviceResult.finalReason = last?.evaluation?.reason || 'unknown' } return deviceResult } async function main() { const cli = parseArgs(process.argv) if (cli.help) { printHelp() return } const serverHttp = cli.serverHttp || await getDefaultServerHttp() if (!serverHttp) { throw new Error('server_http_missing: use --server-http or set RC_SERVER_HTTP') } const socketUrl = cli.socketUrl || cli.serverHttp || serverHttp const options = { ...DEFAULTS, ...cli, serverHttp, socketUrl, smokeTimeoutMs: Number(cli.smokeTimeoutMs || DEFAULTS.smokeTimeoutMs), waitOnlineMs: Number(cli.waitOnlineMs || DEFAULTS.waitOnlineMs), installTimeoutMs: Number(cli.installTimeoutMs || DEFAULTS.installTimeoutMs), settleMs: Number(cli.settleMs || DEFAULTS.settleMs), parallelDevices: Number(cli.parallelDevices || DEFAULTS.parallelDevices || 0), triageLogcatLines: Number(cli.triageLogcatLines || DEFAULTS.triageLogcatLines || 2500), maxDevices: cli.maxDevices ? Number(cli.maxDevices) : undefined, attempts: cli.attempts ? Number(cli.attempts) : undefined } log('Resolving ADB executable...') const adbPath = await resolveAdbPath(options.adbPath) log(`ADB: ${adbPath}`) await buildApkIfNeeded(options) const apkPath = path.resolve(options.apkPath) const apkExists = await fileExists(apkPath) if (!apkExists && !options.skipInstall) { throw new Error(`apk_not_found: ${apkPath}`) } const token = await loginServer(options.serverHttp, options.username, options.password) log(`Server login success: ${options.serverHttp}`) const adbListResult = await adb(adbPath, ['devices', '-l'], { cwd: ROOT_DIR, timeoutMs: 10000, allowFailure: false }) const adbDevices = parseAdbDeviceList(adbListResult.stdout) if (adbDevices.length === 0) { throw new Error('no_adb_devices_online') } const onlySerialSet = new Set( String(options.onlySerials || '') .split(',') .map((v) => v.trim()) .filter(Boolean) ) const filteredAdb = adbDevices.filter((item) => { if (onlySerialSet.size === 0) return true return onlySerialSet.has(item.serial) }) if (filteredAdb.length === 0) { throw new Error('no_adb_devices_after_filter') } log(`Detected ${filteredAdb.length} ADB transports. Collecting runtime fingerprints...`) const runtimes = [] for (const item of filteredAdb) { runtimes.push(await collectDeviceRuntime(adbPath, item)) } const byAndroidId = new Map() for (const runtime of runtimes) { const key = runtime.androidId || runtime.serial if (!byAndroidId.has(key)) byAndroidId.set(key, []) byAndroidId.get(key).push(runtime) } const devices = [] for (const [androidId, candidates] of byAndroidId.entries()) { const selected = selectBestTransport(candidates) devices.push({ ...selected, androidId, duplicateSerials: candidates.map((v) => v.serial).filter((v) => v !== selected.serial) }) } const selectedDevices = options.maxDevices ? devices.slice(0, options.maxDevices) : devices log(`Selected ${selectedDevices.length} physical devices after transport dedupe.`) const runDir = path.resolve(ROOT_DIR, `_tmp_adb_control_matrix_runs_${Date.now()}`) await mkdir(runDir, { recursive: true }) const matrixReport = { startedAt: nowIso(), serverHttp: options.serverHttp, socketUrl: options.socketUrl, adbPath, apkPath, runDir, options: { buildApk: !!options.buildApk, skipInstall: !!options.skipInstall, dryRun: !!options.dryRun, maxDevices: options.maxDevices || null, attempts: options.attempts || null, parallelDevices: options.parallelDevices || null, triageLogcatLines: options.triageLogcatLines || null }, devices: [], summary: { pass: 0, fail: 0 } } const parallelDevices = options.parallelDevices && options.parallelDevices > 0 ? options.parallelDevices : selectedDevices.length log(`Running matrix with concurrency=${Math.max(1, parallelDevices)} on ${selectedDevices.length} devices.`) const deviceResults = await runWithConcurrency(selectedDevices, parallelDevices, async (device) => { return runSingleDeviceMatrix({ device, adbPath, apkPath, options, token, runDir }) }) for (const deviceResult of deviceResults) { if (!deviceResult) continue if (deviceResult.finalStatus === 'passed') { matrixReport.summary.pass += 1 } else { matrixReport.summary.fail += 1 } matrixReport.devices.push(deviceResult) } matrixReport.finishedAt = nowIso() await writeFile(options.reportPath, JSON.stringify(matrixReport, null, 2), 'utf8') log(`Matrix report saved: ${options.reportPath}`) log(`Summary: pass=${matrixReport.summary.pass}, fail=${matrixReport.summary.fail}`) } main().catch((error) => { console.error(`[${nowIso()}] fatal: ${error instanceof Error ? error.message : String(error)}`) process.exit(1) })