diff --git a/.github/actions/assemble-test-project/default-smoke-test.js b/.github/actions/assemble-test-project/default-smoke-test.js index 8255f9e..7493089 100644 --- a/.github/actions/assemble-test-project/default-smoke-test.js +++ b/.github/actions/assemble-test-project/default-smoke-test.js @@ -3,8 +3,63 @@ // file the action drops next to this file at CI time, so this template is // a plain, runnable file — no placeholder substitution. +const fs = require('fs') + const { moduleName } = require('./harness-config.json') +// `err.stack` omits `cause`, so print the chain. +function errorChain(err) { + const lines = [] + let e = err + for (let depth = 0; e && depth < 5; depth++) { + if (depth > 0) lines.push('caused by:') + for (const line of String(e.stack || e).split('\n')) lines.push(line) + e = e.cause + } + return lines +} + +// require-addon raises "Cannot find addon" when every candidate fails to +// *load* — not only when none exist. A prebuild that is present but can't +// dlopen (built without linking libnode.so so napi_* never resolves, wrong +// ABI, …) therefore reports exactly like a missing file, which is precisely +// the failure this smoke test exists to catch. Its `cause` doesn't settle it +// either: the resolver overwrites `cause` per candidate, so it ends up holding +// the last candidate's error, normally a "Cannot find module" for a path that +// never existed. +// +// The candidate list is on the error, so retry the ones actually on disk and +// report why each failed. That separates "prebuild wasn't installed for this +// target" from "prebuild is installed but broken". +function addonDiagnostics(err) { + if (!err || err.code !== 'ADDON_NOT_FOUND') return [] + if (!Array.isArray(err.candidates)) return [] + + const { fileURLToPath } = require('url') + const lines = [] + + for (const candidate of err.candidates) { + let file + try { + file = fileURLToPath(candidate) + } catch { + continue // non-file: candidate (e.g. `linked:`) + } + if (!fs.existsSync(file)) continue + try { + process.dlopen({ exports: {} }, file) + lines.push(file + ': loaded on retry (original failure was elsewhere)') + } catch (e) { + lines.push(file + ': ' + (e && e.message ? e.message : e)) + } + } + + if (lines.length === 0) { + return ['no candidate exists on disk — no prebuild was installed for this target'] + } + return ['candidate(s) present but failed to load:'].concat(lines) +} + try { require(moduleName) console.log('TAP version 13') @@ -17,8 +72,13 @@ try { console.log('not ok 1 - require(' + moduleName + ') threw') console.log(' ---') console.log(' message: ' + JSON.stringify(err && err.message)) + const diagnostics = addonDiagnostics(err) + if (diagnostics.length > 0) { + console.log(' addon_diagnostics: |') + for (const line of diagnostics) console.log(' ' + line) + } console.log(' stack: |') - for (const line of String((err && err.stack) || err).split('\n')) { + for (const line of errorChain(err)) { console.log(' ' + line) } console.log(' ...') diff --git a/.github/actions/assemble-test-project/module-tests-runner.js b/.github/actions/assemble-test-project/module-tests-runner.js index 283fe2c..b12a190 100644 --- a/.github/actions/assemble-test-project/module-tests-runner.js +++ b/.github/actions/assemble-test-project/module-tests-runner.js @@ -84,8 +84,62 @@ async function main() { } } +// `err.stack` omits `cause`, so print the chain. +function formatError(err) { + const parts = [] + let e = err + for (let depth = 0; e && depth < 5; depth++) { + parts.push((depth === 0 ? '' : 'caused by: ') + String(e.stack || e)) + e = e.cause + } + return parts.join('\n') +} + +// require-addon raises "Cannot find addon" when every candidate fails to +// *load* — not only when none exist. A prebuild that is present but can't +// dlopen (built without linking libnode.so so napi_* never resolves, wrong +// ABI, …) therefore reports exactly like a missing file. Its `cause` doesn't +// settle it either: the resolver overwrites `cause` per candidate, so it ends +// up holding the last candidate's error, normally a "Cannot find module" for a +// path that never existed. +// +// The candidate list is on the error, so retry the ones actually on disk and +// report why each failed. That separates "prebuild wasn't installed for this +// target" from "prebuild is installed but broken". +function addonDiagnostics(err) { + if (!err || err.code !== 'ADDON_NOT_FOUND') return [] + if (!Array.isArray(err.candidates)) return [] + + const { fileURLToPath } = require('url') + const lines = [] + + for (const candidate of err.candidates) { + let file + try { + file = fileURLToPath(candidate) + } catch { + continue // non-file: candidate (e.g. `linked:`) + } + if (!fs.existsSync(file)) continue + try { + process.dlopen({ exports: {} }, file) + lines.push(' ' + file + ': loaded on retry (original failure was elsewhere)') + } catch (e) { + lines.push(' ' + file + ': ' + (e && e.message ? e.message : e)) + } + } + + if (lines.length === 0) { + return [ + 'Addon diagnostics: no candidate exists on disk — no prebuild was installed for this target.' + ] + } + return ['Addon diagnostics: candidate(s) present but failed to load:'].concat(lines) +} + main().catch((err) => { console.error('Fatal error loading tests from ' + moduleName + ':') - console.error(err && err.stack ? err.stack : err) + console.error(formatError(err)) + for (const line of addonDiagnostics(err)) console.error(line) process.exit(1) }) diff --git a/test-harness/android/run-test.sh b/test-harness/android/run-test.sh index 27b4f4f..404189b 100755 --- a/test-harness/android/run-test.sh +++ b/test-harness/android/run-test.sh @@ -20,7 +20,20 @@ adb logcat -c # notices the pipe has closed when it next tries to write, and no further # lines are coming once the app has exited.) Started BEFORE the launch so # no early NODEJS-MOBILE output is missed. -coproc LOGCAT { adb logcat -v raw -s NODEJS-MOBILE:V; } +# +# Tags beyond NODEJS-MOBILE (node's stdout/stderr, pumped by native-lib.cpp) +# carry failures that are otherwise invisible here, which makes a broken run +# look like silence: +# nodejs node's own fatal-error reports, logged by nodejs-mobile's +# libnode rather than through our stdout/stderr pipe — this is +# where an uncaught exception's message actually lands. +# System.err Java stack traces. TestActivity's asset copy catches and +# prints per-file failures, so a file missing on device (and a +# resulting "cannot find addon") is only explained here. +# AndroidRuntime uncaught Java exceptions killing the app before node starts. +# `-v raw` is kept so TAP output stays clean and the sentinel parsing below is +# unchanged; extra tags interleave without a prefix. +coproc LOGCAT { adb logcat -v raw -s NODEJS-MOBILE:V nodejs:V System.err:W AndroidRuntime:E; } # Launch the activity. The app pumps node's stdout/stderr to logcat tag # NODEJS-MOBILE and emits __NODE_EXIT__: when done. Fire-and-forget,