diff --git a/lib/execjs/external_runtime.rb b/lib/execjs/external_runtime.rb index a0325f4..47b50b9 100644 --- a/lib/execjs/external_runtime.rb +++ b/lib/execjs/external_runtime.rb @@ -194,36 +194,56 @@ def shell_escape(*args) require 'shellwords' def exec_runtime(filename) - command = "#{Shellwords.join(binary.split(' ') << filename)}" - io = IO.popen(command, **@popen_options) - output = io.read - io.close + stderr_path = Dir::Tmpname.create(['execjs', 'stderr']) {} + begin + command = "#{Shellwords.join(binary.split(' ') << filename)}" + io = IO.popen(command, err: stderr_path, **@popen_options) + output = io.read + io.close + status = $? + stderr = File.file?(stderr_path) ? File.read(stderr_path) : "" + ensure + File.unlink(stderr_path) if stderr_path && File.exist?(stderr_path) + end - if $?.success? + if status.success? output else - raise exec_runtime_error(output) + raise exec_runtime_error(output, stderr) end end else def exec_runtime(filename) - io = IO.popen(binary.split(' ') << filename, **@popen_options) - output = io.read - io.close + stderr_path = Dir::Tmpname.create(['execjs', 'stderr']) {} + begin + io = IO.popen(binary.split(' ') << filename, err: stderr_path, **@popen_options) + output = io.read + io.close + status = $? + stderr = File.file?(stderr_path) ? File.read(stderr_path) : "" + ensure + File.unlink(stderr_path) if stderr_path && File.exist?(stderr_path) + end - if $?.success? + if status.success? output else - raise exec_runtime_error(output) + raise exec_runtime_error(output, stderr) end end end # Internally exposed for Context. public :exec_runtime - def exec_runtime_error(output) - error = RuntimeError.new(output) - lines = output.split("\n") + def exec_runtime_error(output, stderr = nil) + message = output.to_s + unless stderr.nil? || stderr.empty? + extra = stderr.dup.force_encoding(message.encoding) + extra = extra.scrub if extra.respond_to?(:scrub) && !extra.valid_encoding? + message = message.empty? ? extra : "#{message}\n#{extra}" + end + error = RuntimeError.new(message) + lines = message.split("\n") lineno = lines[0][/:(\d+)$/, 1] if lines[0] lineno ||= 1 error.set_backtrace(["(execjs):#{lineno}"] + caller) diff --git a/test/test_execjs.rb b/test/test_execjs.rb index f2a5e23..64b51cb 100644 --- a/test/test_execjs.rb +++ b/test/test_execjs.rb @@ -67,6 +67,18 @@ def test_call_with_env_file end end + def test_runtime_error_includes_stderr + # Regression for #142: when the runtime exits non-zero it writes its + # diagnostic to stderr (here, a parse error). 5cce03a stopped capturing + # stderr, so the raised error lost that trace; it should be surfaced again. + # The diagnostic text is Node-style, so guard to Node-family runtimes. + skip unless ExecJS.runtime.name.to_s =~ /Node|Bun|Deno/i + err = assert_raises(ExecJS::RuntimeError) do + ExecJS.exec("?? not valid javascript ??") + end + assert err.message =~ /SyntaxError/, err.message + end + def test_call_with_this # Known bug: https://github.com/cowboyd/therubyrhino/issues/39 skip if ExecJS.runtime.is_a?(ExecJS::RubyRhinoRuntime)