Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
5e4094a64This file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env node
// Automated on-device smoke test. Deploys a smoke MOD to a USB-connected
// device and judges pass/fail from the device log.
//
// Channels:
// xsbug (default) - `mcrun -dn -x` bridges device traces over serial2xsbug
// to a local log server; passes when the smoke MOD traces its completion
// line. Requires a debug-build host firmware on the device (use --flash
// for a full build+deploy first). The xsbug serial bridge is known to be
// flaky on CoreS3, so failed attempts retry automatically.
// serial - watches the raw serial port for crash markers only. trace()
// output is NOT visible on raw serial (it only flows over the xsbug
// protocol in debug builds), so this mode is a boot-stability smoke, not
// a completion check. Requires UPLOAD_PORT.
//
// Usage:
// UPLOAD_PORT=/dev/ttyACM0 npm run test:device
// npm run test:device -- --device stackchan_rt --flash
// UPLOAD_PORT=/dev/ttyACM0 npm run test:device -- --channel serial
import { spawn, spawnSync } from 'node:child_process'
import { createReadStream, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { ensureBuildOutputDirectory, moddableOutputArguments } from './lib/build-output.mjs'
import { devices, resolveDevice } from './lib/devices.mjs'
import { startXsbugServer } from './lib/xsbug-log-server.js'
const TIMEOUT_MS = Number.parseInt(process.env.STACKCHAN_DEVICE_SMOKE_TIMEOUT_MS ?? '120000', 10)
const RETRIES = Number.parseInt(process.env.STACKCHAN_DEVICE_SMOKE_RETRIES ?? '2', 10)
const SERIAL_BAUD = process.env.STACKCHAN_DEVICE_SMOKE_BAUD ?? '115200'
const okPattern = new RegExp(process.env.STACKCHAN_DEVICE_SMOKE_OK ?? 'M5StackChan CoreS3 smoke\\] complete')
const failurePattern =
/XS abort|# Exception|# exception|stack overflow|module not found|Cannot find module|unhandled exception|throw!|smoke\] .*error/i
const crashPattern = /Guru Meditation|abort\(\)|Brownout detector|panic'?ed/i
const rawArgs = process.argv.slice(2)
function readOption(values, name) {
const prefix = `--${name}=`
const index = values.indexOf(`--${name}`)
if (index >= 0) return values[index + 1]
return values.find((value) => value.startsWith(prefix))?.slice(prefix.length)
}
function hasFlag(values, name) {
return values.includes(`--${name}`)
}
const deviceName = resolveDevice(
readOption(rawArgs, 'device') ?? process.env.STACKCHAN_DEVICE ?? 'default',
'[device-smoke]',
)
const device = devices[deviceName]
const platform = `esp32:${device.platform}`
const modManifest = resolve(readOption(rawArgs, 'mod') ?? 'mods/examples/m5stackchan_smoke/manifest.json')
const channel = readOption(rawArgs, 'channel') ?? 'xsbug'
const workRoot = mkdtempSync(join(tmpdir(), 'stackchan-device-smoke-'))
ensureBuildOutputDirectory()
const outputArgs = moddableOutputArguments()
function killProcessGroup(child) {
if (!child.pid || child.killed) return
try {
process.kill(-child.pid, 'SIGTERM')
} catch {
child.kill('SIGTERM')
}
}
function decodeXsbugLog(log) {
return Array.from(log.matchAll(/<log>([\s\S]*?)<\/log>/g), ([, text]) => text)
.join('')
.replaceAll(' ', '\n')
.replaceAll(' ', '\r')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll('&', '&')
}
function flashHostFirmware() {
console.log(`[device-smoke] building and deploying host firmware for ${device.label}`)
const result = spawnSync(
'mcconfig',
['-d', '-m', '-p', platform, '-t', 'deploy', ...outputArgs, resolve(device.manifest)],
{ stdio: 'inherit' },
)
if (result.status !== 0) {
console.error('[device-smoke] host firmware deploy failed')
process.exit(result.status ?? 1)
}
}
async function runXsbugAttempt(attempt) {
const logPath = join(workRoot, `attempt-${attempt}.xsbug.log`)
const logServer = startXsbugServer(logPath)
const port = await logServer.ready
console.log(`[device-smoke] attempt ${attempt}: mcrun -dn -x 127.0.0.1:${port} ${modManifest}`)
return await new Promise((resolveRun) => {
let settled = false
let echoedLength = 0
const child = spawn('mcrun', ['-dn', '-x', `127.0.0.1:${port}`, '-m', '-p', platform, ...outputArgs, modManifest], {
detached: true,
env: process.env,
})
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => process.stdout.write(chunk))
child.stderr.on('data', (chunk) => process.stderr.write(chunk))
const finish = (status) => {
if (settled) return
settled = true
clearTimeout(timeout)
clearInterval(poll)
killProcessGroup(child)
logServer.close().then(() => resolveRun(status))
}
child.on('error', (error) => {
console.error(`[device-smoke] mcrun failed to start: ${error.message}`)
finish('error')
})
child.on('exit', (code, signal) => {
if (!settled) {
console.error(`[device-smoke] mcrun exited early: code=${code ?? 'null'} signal=${signal ?? 'null'}`)
console.error(`[device-smoke] xsbug log: ${logPath}`)
finish('exit')
}
})
const poll = setInterval(() => {
const decoded = decodeXsbugLog(logServer.getLog())
const fresh = decoded.slice(echoedLength)
echoedLength = decoded.length
for (const line of fresh.split('\n')) {
if (line.includes('smoke]')) console.log(`[device] ${line}`)
}
if (okPattern.test(decoded)) {
finish('ok')
} else if (failurePattern.test(decoded)) {
console.error(`[device-smoke] failure marker in device log; full log: ${logPath}`)
finish('failure')
}
}, 200)
const timeout = setTimeout(() => {
console.error(`[device-smoke] timed out after ${TIMEOUT_MS}ms; xsbug log: ${logPath}`)
finish('timeout')
}, TIMEOUT_MS)
})
}
async function runSerialWatch() {
const serialPort = process.env.UPLOAD_PORT
if (!serialPort) {
console.error('[device-smoke] --channel serial requires UPLOAD_PORT=/dev/tty...')
process.exit(1)
}
const stty = spawnSync('stty', ['-F', serialPort, SERIAL_BAUD, 'raw', '-echo'])
if (stty.status !== 0) {
console.error(`[device-smoke] failed to configure ${serialPort}`)
process.exit(1)
}
console.log(`[device-smoke] watching ${serialPort} for ${TIMEOUT_MS}ms (crash markers only)`)
return await new Promise((resolveRun) => {
let output = ''
const stream = createReadStream(serialPort, { encoding: 'utf8' })
const finish = (status) => {
clearTimeout(timer)
stream.close()
resolveRun(status)
}
stream.on('data', (chunk) => {
output += chunk
process.stdout.write(chunk)
if (crashPattern.test(output)) {
console.error('[device-smoke] crash marker on serial console')
finish('failure')
}
})
stream.on('error', (error) => {
console.error(`[device-smoke] serial read failed: ${error.message}`)
finish('error')
})
const timer = setTimeout(() => finish('ok'), TIMEOUT_MS)
})
}
if (hasFlag(rawArgs, 'flash')) flashHostFirmware()
let status
if (channel === 'serial') {
status = await runSerialWatch()
} else if (channel === 'xsbug') {
for (let attempt = 1; attempt <= 1 + RETRIES; attempt++) {
status = await runXsbugAttempt(attempt)
// 'failure' is a real device-side failure; only retry channel flakiness.
if (status === 'ok' || status === 'failure') break
if (attempt <= RETRIES) console.log('[device-smoke] retrying (xsbug serial bridge may have dropped)')
}
} else {
console.error(`[device-smoke] unknown channel: ${channel} (expected xsbug or serial)`)
process.exit(1)
}
if (status === 'ok') {
console.log('[device-smoke] PASS')
process.exit(0)
}
console.error(`[device-smoke] FAIL (${status})`)
process.exit(1)