1576 lines
47 KiB
JavaScript
1576 lines
47 KiB
JavaScript
#!/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)
|
|
})
|