Loading files
This 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, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import { availableParallelism, tmpdir } from 'node:os'
import { basename, dirname, join, relative, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { buildOutputDirectory, ensureBuildOutputDirectory, moddableOutputArguments } from './lib/build-output.mjs'
import { parseModuleTestShard, selectModuleTestShard } from './lib/module-test-sharding.mjs'
import { startXsbugServer } from './lib/xsbug-log-server.js'
const DEFAULT_ROOTS = ['host/app', 'host/modules', 'mods/examples']
const XSBUG_HOST = process.env.STACKCHAN_MODULE_TEST_XSBUG_HOST ?? '127.0.0.1'
// Piu/GTK tests can spend more than 15 seconds starting mcsim while another
// manifest is compiling on a shared CI runner. Successful tests still return
// as soon as they trace `ok`; this only gives a cold simulator more headroom.
const RUNTIME_TIMEOUT_MS = Number.parseInt(process.env.STACKCHAN_MODULE_TEST_TIMEOUT_MS ?? '30000', 10)
const BUILD_TIMEOUT_MS = Number.parseInt(process.env.STACKCHAN_MODULE_TEST_BUILD_TIMEOUT_MS ?? '360000', 10)
const FILTER = process.env.STACKCHAN_MODULE_TEST_FILTER
// Incremental builds are safe: mcconfig regenerates the makefile on every run and
// the generated mc.xs.c rule depends on every manifest in the include chain, so
// stale outputs cannot survive a manifest or module edit. CLEAN exists as an
// escape hatch for a corrupted build tree.
const CLEAN = process.env.STACKCHAN_MODULE_TEST_CLEAN === '1'
const JOBS = (() => {
const parsed = Number.parseInt(process.env.STACKCHAN_MODULE_TEST_JOBS ?? '', 10)
if (Number.isFinite(parsed) && parsed >= 1) return parsed
// Each mcconfig build already compiles with make --jobs 8 internally; leave
// CPU headroom or cold builds on 4-core CI runners exceed the build timeout.
return Math.min(4, Math.max(1, availableParallelism() - 2))
})()
// Explicit per-test X display numbers: xvfb-run -a probes lock files and races
// when several instances start concurrently.
const DISPLAY_BASE = 100 + (process.pid % 1000)
// mcsim is a GtkApplication with a fixed application-id and no
// G_APPLICATION_NON_UNIQUE, so a second instance on the same DBus session bus
// hands its .so to the first one and exits 0. A private bus per test keeps
// concurrent simulators (and any mcsim the user has open) independent.
const HAS_DBUS_RUN_SESSION = spawnSync('dbus-run-session', ['--version'], { stdio: 'ignore' }).status === 0
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)
}
ensureBuildOutputDirectory()
const outputArgs = moddableOutputArguments()
const firmwareRoot = process.cwd()
const workRoot = mkdtempSync(join(tmpdir(), 'stackchan-module-tests-'))
// xsbug emits `# Exception` for caught Promise rejections too. Fatal aborts are
// explicit; a stopped runtime without either a fatal marker or `ok` times out.
const fatalFailurePattern = /XS abort|stack overflow|module not found|Cannot find module|unhandled exception/i
const okPattern = /<log>ok(?: |\n)<\/log>/
function relativePath(path) {
return relative(firmwareRoot, path)
}
function formatDuration(milliseconds) {
return `${(milliseconds / 1000).toFixed(1)}s`
}
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(buildOutputDirectory, 'tmp', segment, 'debug', name), { recursive: true, force: true })
rmSync(join(buildOutputDirectory, 'bin', segment, 'debug', name), { recursive: true, force: true })
}
function readBinDir(platform, name) {
const makefile = join(buildOutputDirectory, '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]
}
// Tests sharing a build-output directory would corrupt each other under
// parallel execution; the directory name is the test directory basename.
function assertUniqueNames(manifestPaths) {
const seen = new Map()
for (const manifestPath of manifestPaths) {
const key = `${platformBuildSegment(selectPlatform(manifestPath))}/${basename(dirname(manifestPath))}`
const existing = seen.get(key)
if (existing) {
console.error(`Duplicate test directory name shares build output "${key}":`)
console.error(` ${relativePath(existing)}`)
console.error(` ${relativePath(manifestPath)}`)
process.exit(1)
}
seen.set(key, manifestPath)
}
}
function runProcess(command, args, { timeout }) {
return new Promise((resolveRun) => {
const child = spawn(command, args, { cwd: firmwareRoot })
let stdout = ''
let stderr = ''
let timedOut = false
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
stdout += chunk
})
child.stderr.on('data', (chunk) => {
stderr += chunk
})
const timer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
}, timeout)
child.on('error', (error) => {
clearTimeout(timer)
resolveRun({ status: null, stdout, stderr, error, timedOut })
})
child.on('close', (status) => {
clearTimeout(timer)
resolveRun({ status, stdout, stderr, timedOut })
})
})
}
function formatProcessFailure(label, result) {
const lines = [`${label} failed with exit code ${result.status}`]
if (result.stdout.length > 0) lines.push(result.stdout)
if (result.stderr.length > 0) lines.push(result.stderr)
return lines
}
async function buildManifest({ manifestPath, platform, port, name, output }) {
if (CLEAN) removeBuildOutput(platform, name)
const label = `mcconfig ${relativePath(manifestPath)}`
const result = await runProcess(
'mcconfig',
['-d', '-x', `${XSBUG_HOST}:${port}`, '-m', '-p', platform, '-t', 'build', ...outputArgs, manifestPath],
{ timeout: BUILD_TIMEOUT_MS },
)
if (result.error) {
output.push(result.error.stack ?? result.error.message)
return false
}
if (result.timedOut) {
output.push(`${label} timed out after ${BUILD_TIMEOUT_MS}ms`)
if (result.stdout.length > 0) output.push(result.stdout)
if (result.stderr.length > 0) output.push(result.stderr)
return false
}
if (result.status !== 0) {
output.push(...formatProcessFailure(label, result))
return false
}
return true
}
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, display, configHome }) {
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 xvfbCommand = ['xvfb-run', '-n', String(display), simulator, join(binDir, 'mc.so')]
const [command, ...args] = HAS_DBUS_RUN_SESSION ? ['dbus-run-session', '--', ...xvfbCommand] : xvfbCommand
const child = spawn(command, args, {
cwd: firmwareRoot,
detached: true,
env: {
...process.env,
XSBUG_HOST,
XSBUG_PORT: String(port),
// mcsim copies mc.so into $XDG_CONFIG_HOME/tech.moddable.mcsim/ and
// runs it from there; concurrent instances sharing the default
// ~/.config would overwrite each other's mapped archive (SIGBUS).
XDG_CONFIG_HOME: configHome,
// Keep the private session bus from auto-spawning accessibility,
// gvfs, and portal daemons per test.
NO_AT_BRIDGE: '1',
GTK_A11Y: 'none',
GIO_USE_VFS: 'local',
GTK_USE_PORTAL: '0',
},
})
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 (fatalFailurePattern.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 formatRuntimeFailure(manifestPath, result, logPath) {
const lines = [`Runtime failed: ${relativePath(manifestPath)} (${result.status})`]
if (result.message) lines.push(result.message)
if (result.code != null || result.signal != null) {
lines.push(`mcsim exit: code=${result.code ?? 'null'} signal=${result.signal ?? 'null'}`)
}
if (result.stdout) lines.push(result.stdout)
if (result.stderr) lines.push(result.stderr)
lines.push(`xsbug log: ${logPath}`)
lines.push(result.log.split('\n').slice(-40).join('\n'))
return lines
}
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
}
async function runOne(manifestPath, index) {
const name = basename(dirname(manifestPath))
const platform = selectPlatform(manifestPath)
const logPath = join(workRoot, `${name}.xsbug.log`)
const logServer = startXsbugServer(logPath, XSBUG_HOST)
const port = await logServer.ready
const label = `${relativePath(manifestPath)} [${platform}]`
const output = []
try {
if (!(await buildManifest({ manifestPath, platform, port, name, output }))) {
return { label, ok: false, reason: 'build failed', output }
}
const binDir = readBinDir(platform, name)
const configHome = join(workRoot, `config-${name}`)
mkdirSync(configHome, { recursive: true })
const result = await runSimulator({ binDir, port, logServer, display: DISPLAY_BASE + index, configHome })
if (result.status === 'ok') {
return { label, ok: true, output }
}
output.push(...formatRuntimeFailure(manifestPath, result, logPath))
return { label, ok: false, reason: result.status, output }
} finally {
await logServer.close()
}
}
const allManifestPaths = await collectManifestPaths(process.argv.slice(2))
if (allManifestPaths.length === 0) {
console.log('No runnable Moddable test manifests found.')
process.exit(0)
}
assertUniqueNames(allManifestPaths)
const shard = parseModuleTestShard()
const manifestPaths = selectModuleTestShard(allManifestPaths, shard)
const shardLabel = `${shard.index + 1}/${shard.total}`
const suiteStartedAt = performance.now()
let jobs = Math.min(JOBS, manifestPaths.length)
if (jobs > 1 && !HAS_DBUS_RUN_SESSION) {
console.warn('dbus-run-session not found; running 1 job at a time (concurrent mcsim instances would collide)')
jobs = 1
}
console.log(
`Running ${manifestPaths.length}/${allManifestPaths.length} Moddable test manifest(s) in shard ${shardLabel} with ${jobs} job(s)${CLEAN ? ', clean build' : ''}`,
)
// The XS core objects live in a lib directory shared by every app of the same
// platform segment; build one manifest per segment up front so parallel builds
// never race on populating it.
if (jobs > 1) {
const warmups = new Map()
for (const manifestPath of manifestPaths) {
const segment = platformBuildSegment(selectPlatform(manifestPath))
if (!warmups.has(segment)) warmups.set(segment, manifestPath)
}
for (const [segment, manifestPath] of warmups) {
const startedAt = performance.now()
process.stdout.write(`- warm-up build [${segment}] ${relativePath(manifestPath)} ... `)
const output = []
const ok = await buildManifest({
manifestPath,
platform: selectPlatform(manifestPath),
port: 5002,
name: basename(dirname(manifestPath)),
output,
})
console.log(`${ok ? 'ok' : 'failed'} (${formatDuration(performance.now() - startedAt)})`)
if (!ok && output.length > 0) console.error(output.join('\n'))
}
}
let cursor = 0
let failures = 0
async function worker() {
while (true) {
const index = cursor++
if (index >= manifestPaths.length) return
const manifestPath = manifestPaths[index]
const startedAt = performance.now()
let result
try {
result = await runOne(manifestPath, index)
} catch (error) {
result = {
label: relativePath(manifestPath),
ok: false,
output: [error.stack ?? String(error)],
}
}
const duration = formatDuration(performance.now() - startedAt)
if (result.ok) {
console.log(`- ${result.label} ... ok (${duration})`)
} else {
failures += 1
console.log(`- ${result.label} ... failed (${duration})`)
if (result.output.length > 0) console.error(result.output.join('\n'))
}
}
}
await Promise.all(Array.from({ length: jobs }, () => worker()))
if (failures > 0) {
console.error(`${failures} Moddable test manifest(s) failed`)
process.exit(1)
}
console.log(
`All Moddable test manifests passed in shard ${shardLabel} (${formatDuration(performance.now() - suiteStartedAt)})`,
)
d163e9a1eThis file is pinned to the selected release. Download the original to open it in your design tool.