Files
web-client/scripts/adb_control_matrix_orchestrator.mjs

1576 lines
47 KiB
JavaScript
Raw Permalink Normal View History

2026-03-03 22:16:16 +08:00
#!/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 <path> ADB executable path
--apk <path> APK file path
--build-apk Build debug APK before testing
--skip-install Skip APK install stage
--server-http <url> Server HTTP URL (default from config/env)
--socket-url <url> Socket URL for smoke script
--user <username> Login username
--pass <password> Login password
--only-serials <s1,s2> Restrict ADB serials
--max-devices <n> Limit tested devices
--attempts <n> Override attempts per device
--parallel-devices <n> Number of devices tested concurrently (default: all selected)
--smoke-timeout-ms <n> Smoke timeout
--wait-online-ms <n> Wait online timeout
--install-timeout-ms <n> Install timeout
--settle-ms <n> Delay after app launch
--triage-logcat-lines <n> Logcat lines kept in per-attempt triage bundle
--report <path> 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<timed_out_${timeoutMs}ms>`,
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)
})