Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
914ca23baThis file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { basename, dirname, join, relative, resolve } from 'node:path'
const DEFAULT_ROOTS = ['host/app', 'host/modules', 'mods/examples']
const XSBUG_HOST = process.env.STACKCHAN_MODULE_TEST_XSBUG_HOST ?? '127.0.0.1'
const RUNTIME_TIMEOUT_MS = Number.parseInt(process.env.STACKCHAN_MODULE_TEST_TIMEOUT_MS ?? '8000', 10)
const BUILD_TIMEOUT_MS = Number.parseInt(process.env.STACKCHAN_MODULE_TEST_BUILD_TIMEOUT_MS ?? '180000', 10)
const FILTER = process.env.STACKCHAN_MODULE_TEST_FILTER
const MODDABLE = process.env.MODDABLE
if (!MODDABLE) {
console.error('MODDABLE is not set. Source xs-dev-export.sh before running module tests.')
process.exit(1)
}
const firmwareRoot = process.cwd()
const workRoot = mkdtempSync(join(tmpdir(), 'stackchan-module-tests-'))
const failurePattern =
/XS abort|# Exception|# exception|stack overflow|module not found|Cannot find module|unhandled exception|throw!/i
const okPattern = /<log>ok(?: |\n)<\/log>/
function relativePath(path) {
return relative(firmwareRoot, path)
}
async function walk(root) {
const entries = await readdir(root, { withFileTypes: true })
const files = []
for (const entry of entries) {
const path = join(root, entry.name)
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
files.push(...(await walk(path)))
} else {
files.push(path)
}
}
return files
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'))
}
function isRunnableManifest(path) {
const manifest = readJson(path)
return typeof manifest.modules?.main === 'string'
}
function localManifestPath(basePath, includePath) {
if (!includePath.startsWith('.')) return undefined
const path = resolve(dirname(basePath), includePath)
return existsSync(path) && statSync(path).isFile() ? path : undefined
}
function manifestUsesPiu(manifestPath, seen = new Set()) {
const normalizedPath = relativePath(manifestPath)
if (normalizedPath.includes('host/modules/ui/')) return true
if (seen.has(manifestPath)) return false
seen.add(manifestPath)
const manifest = readJson(manifestPath)
const values = JSON.stringify({
include: manifest.include ?? [],
modules: manifest.modules ?? {},
platforms: manifest.platforms ?? {},
})
if (/host\/modules\/ui\/manifest|modules\/ui\/manifest|startup-splash|settings-view|piu\/MC/.test(values)) {
return true
}
for (const includePath of manifest.include ?? []) {
const localPath = localManifestPath(manifestPath, includePath)
if (localPath && manifestUsesPiu(localPath, seen)) return true
}
return false
}
function selectPlatform(manifestPath) {
return manifestUsesPiu(manifestPath) ? 'lin/m5stack' : 'lin'
}
function platformBuildSegment(platform) {
return platform === 'lin' ? 'lin/mc' : platform
}
function removeBuildOutput(platform, name) {
const segment = platformBuildSegment(platform)
rmSync(join(MODDABLE, 'build', 'tmp', segment, 'debug', name), { recursive: true, force: true })
rmSync(join(MODDABLE, 'build', 'bin', segment, 'debug', name), { recursive: true, force: true })
}
function readBinDir(platform, name) {
const makefile = join(MODDABLE, 'build', 'tmp', platformBuildSegment(platform), 'debug', name, 'makefile')
const source = readFileSync(makefile, 'utf8')
const match = source.match(/^BIN_DIR = (.+)$/m)
if (!match) throw new Error(`BIN_DIR not found in ${makefile}`)
return match[1]
}
function printProcessFailure(label, result) {
const stdout = result.stdout?.toString() ?? ''
const stderr = result.stderr?.toString() ?? ''
console.error(`${label} failed with exit code ${result.status}`)
if (stdout.length > 0) console.error(stdout)
if (stderr.length > 0) console.error(stderr)
}
function buildManifest({ manifestPath, platform, port, name }) {
removeBuildOutput(platform, name)
const result = spawnSync(
'mcconfig',
['-d', '-x', `${XSBUG_HOST}:${port}`, '-m', '-p', platform, '-t', 'build', manifestPath],
{
cwd: firmwareRoot,
encoding: 'utf8',
timeout: BUILD_TIMEOUT_MS,
maxBuffer: 20 * 1024 * 1024,
},
)
if (result.error) {
console.error(result.error.stack ?? result.error.message)
return false
}
if (result.status !== 0) {
printProcessFailure(`mcconfig ${relativePath(manifestPath)}`, result)
return false
}
return true
}
function startXsbugServer(logPath) {
let log = ''
let promptBuffer = ''
let resolveReady
writeFileSync(logPath, '')
const ready = new Promise((resolveReadyPromise) => {
resolveReady = resolveReadyPromise
})
const append = (chunk) => {
log += chunk
writeFileSync(logPath, log)
}
const server = createServer((socket) => {
socket.setEncoding('utf8')
socket.on('data', (chunk) => {
append(chunk)
promptBuffer += chunk
if (/<(login|break|bubble)\b/.test(promptBuffer)) {
socket.write('\r\n<go/>\r\n')
promptBuffer = ''
} else {
promptBuffer = promptBuffer.slice(-128)
}
})
socket.on('error', (error) => {
append(`\n[xsbug socket error] ${error.stack ?? error.message}\n`)
})
})
server.listen(0, XSBUG_HOST, () => {
resolveReady(server.address().port)
})
return {
ready,
close: () =>
new Promise((resolveClose) => {
server.close(() => resolveClose())
}),
getLog: () => log,
}
}
function killProcessGroup(child) {
if (!child.pid || child.killed) return
try {
process.kill(-child.pid, 'SIGTERM')
} catch {
child.kill('SIGTERM')
}
}
async function runSimulator({ binDir, port, logServer }) {
const simulator = join(MODDABLE, 'build', 'bin', 'lin', 'release', 'mcsim')
if (!existsSync(simulator)) throw new Error(`mcsim not found at ${simulator}`)
return await new Promise((resolveRun) => {
let settled = false
let stdout = ''
let stderr = ''
const child = spawn('xvfb-run', ['-a', simulator, join(binDir, 'mc.so')], {
cwd: firmwareRoot,
detached: true,
env: {
...process.env,
XSBUG_HOST,
XSBUG_PORT: String(port),
},
})
const finish = (result) => {
if (settled) return
settled = true
clearTimeout(timeout)
clearInterval(poll)
killProcessGroup(child)
resolveRun({ ...result, stdout, stderr, log: logServer.getLog() })
}
child.stdout.on('data', (chunk) => {
stdout += chunk
})
child.stderr.on('data', (chunk) => {
stderr += chunk
})
child.on('error', (error) => {
finish({ status: 'error', message: error.stack ?? error.message })
})
child.on('exit', (code, signal) => {
if (!settled) finish({ status: 'exit', code, signal })
})
const poll = setInterval(() => {
const log = logServer.getLog()
if (failurePattern.test(log)) {
finish({ status: 'failure-marker' })
} else if (okPattern.test(log)) {
finish({ status: 'ok' })
}
}, 100)
const timeout = setTimeout(() => {
finish({ status: 'timeout' })
}, RUNTIME_TIMEOUT_MS)
})
}
function printRuntimeFailure(manifestPath, result, logPath) {
console.error(`Runtime failed: ${relativePath(manifestPath)} (${result.status})`)
if (result.message) console.error(result.message)
if (result.code != null || result.signal != null) {
console.error(`mcsim exit: code=${result.code ?? 'null'} signal=${result.signal ?? 'null'}`)
}
if (result.stdout) console.error(result.stdout)
if (result.stderr) console.error(result.stderr)
console.error(`xsbug log: ${logPath}`)
console.error(result.log.split('\n').slice(-40).join('\n'))
}
async function collectManifestPaths(args) {
const roots = args.length > 0 ? args : DEFAULT_ROOTS
const manifests = []
for (const root of roots) {
const path = resolve(firmwareRoot, root)
if (!existsSync(path)) throw new Error(`${root} does not exist`)
const stat = statSync(path)
if (stat.isDirectory()) {
manifests.push(...(await walk(path)).filter((file) => file.endsWith('manifest.test.json')))
} else if (path.endsWith('manifest.test.json')) {
manifests.push(path)
}
}
const runnable = manifests.filter(isRunnableManifest).sort()
if (FILTER) {
return runnable.filter((path) => relativePath(path).includes(FILTER))
}
return runnable
}
const manifestPaths = await collectManifestPaths(process.argv.slice(2))
if (manifestPaths.length === 0) {
console.log('No runnable Moddable test manifests found.')
process.exit(0)
}
let failures = 0
console.log(`Running ${manifestPaths.length} Moddable test manifest(s)`)
for (const manifestPath of manifestPaths) {
const name = basename(dirname(manifestPath))
const platform = selectPlatform(manifestPath)
const logPath = join(workRoot, `${name}.xsbug.log`)
const logServer = startXsbugServer(logPath)
const port = await logServer.ready
const label = `${relativePath(manifestPath)} [${platform}]`
try {
process.stdout.write(`- ${label} ... `)
if (!buildManifest({ manifestPath, platform, port, name })) {
failures += 1
console.log('build failed')
continue
}
const binDir = readBinDir(platform, name)
const result = await runSimulator({ binDir, port, logServer })
if (result.status === 'ok') {
console.log('ok')
} else {
failures += 1
console.log('failed')
printRuntimeFailure(manifestPath, result, logPath)
}
} finally {
await logServer.close()
}
}
if (failures > 0) {
console.error(`${failures} Moddable test manifest(s) failed`)
process.exit(1)
}
console.log('All Moddable test manifests passed')