Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Core/JsRuntime/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
set(SOURCES
"Include/Babylon/JsConsoleLogger.h"
"Include/Babylon/JsRuntime.h"
"Include/Babylon/JsRuntimeScheduler.h"
"Source/JsConsoleLogger.cpp"
"Source/JsRuntime.cpp")

add_library(JsRuntime ${SOURCES})
Expand Down
25 changes: 25 additions & 0 deletions Core/JsRuntime/Include/Babylon/JsConsoleLogger.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#pragma once

#include <napi/env.h>

namespace Babylon
{
/**
* Utility struct to log messages to the JavaScript console.
*
* Native code that writes diagnostics to stdout/stderr is invisible on the platforms
* where diagnostics matter most: on Android and iOS there is no attached terminal, so
* the message reaches nobody. Routing through the JS console instead puts native
* messages in the same place as the script's own, wherever the host has directed it.
*
* Each call is a no-op if no console object, or no such method on it, is present.
*/
struct JsConsoleLogger final
{
JsConsoleLogger() = delete;

static void LogInfo(Napi::Env env, const char* message);
static void LogWarn(Napi::Env env, const char* message);
static void LogError(Napi::Env env, const char* message);
};
}
56 changes: 56 additions & 0 deletions Core/JsRuntime/Source/JsConsoleLogger.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#include <Babylon/JsConsoleLogger.h>

namespace Babylon
{
namespace
{
void LogMethod(Napi::Env env, const char* methodName, const char* message)
{
try
{
auto console = env.Global().Get("console");

if (console.IsObject())
{
auto consoleLog{console.ToObject().Get(methodName)};

if (consoleLog.IsFunction())
{
auto consoleLogFunction = consoleLog.As<Napi::Function>();
auto messageStr = Napi::String::New(env, message);
consoleLogFunction.Call(console, {messageStr});
}
}
}
catch (...)
{
}

// Every step above can fail on script the host does not control: `console` and the
// method can be accessors that throw, and the call itself is arbitrary user code.
// N-API also leaves a pending exception on `env` independently of throwing a C++
// exception, so swallowing the C++ side is not enough. Returning with one pending
// would surface the failure at some unrelated later point in the caller, which is
// never an acceptable outcome for a diagnostic helper.
if (env.IsExceptionPending())
{
(void)env.GetAndClearPendingException();
}
}
}

void JsConsoleLogger::LogInfo(Napi::Env env, const char* message)
{
LogMethod(env, "log", message);
}

void JsConsoleLogger::LogWarn(Napi::Env env, const char* message)
{
LogMethod(env, "warn", message);
}

void JsConsoleLogger::LogError(Napi::Env env, const char* message)
{
LogMethod(env, "error", message);
}
}
1 change: 1 addition & 0 deletions Tests/UnitTests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ target_link_libraries(UnitTests
PRIVATE WebSocket
PRIVATE gtest_main
PRIVATE Foundation
PRIVATE JsRuntime
PRIVATE Blob
PRIVATE File
PRIVATE Performance
Expand Down
153 changes: 153 additions & 0 deletions Tests/UnitTests/Shared/Shared.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "Shared.h"
#include <Babylon/AppRuntime.h>
#include <Babylon/JsConsoleLogger.h>
#include <Babylon/ScriptLoader.h>
#include <Babylon/Polyfills/AbortController.h>
#include <Babylon/Polyfills/Console.h>
Expand All @@ -21,6 +22,8 @@
#include <future>
#include <iostream>
#include <thread>
#include <utility>
#include <vector>

namespace
{
Expand Down Expand Up @@ -139,6 +142,156 @@ TEST(Console, Log)
done.get_future().get();
}

TEST(JsConsoleLogger, RoutesToConsole)
{
// JsConsoleLogger exists so native diagnostics land wherever the host has pointed the
// JS console. On Android and iOS there is no attached terminal, so a message written to
// stdout/stderr instead reaches nobody.
Babylon::AppRuntime runtime{};

std::promise<void> done;
std::vector<std::pair<Babylon::Polyfills::Console::LogLevel, std::string>> received;

runtime.Dispatch([&received](Napi::Env env) mutable {
Babylon::Polyfills::Console::Initialize(env, [&received](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) {
received.emplace_back(logLevel, message);
});

Babylon::JsConsoleLogger::LogInfo(env, "info message");
Babylon::JsConsoleLogger::LogWarn(env, "warn message");
Babylon::JsConsoleLogger::LogError(env, "error message");
});

Babylon::ScriptLoader loader{runtime};
loader.Dispatch([&done](auto) {
done.set_value();
});
done.get_future().get();

ASSERT_EQ(received.size(), 3u);
EXPECT_EQ(received[0].first, Babylon::Polyfills::Console::LogLevel::Log);
EXPECT_EQ(received[0].second, "info message");
EXPECT_EQ(received[1].first, Babylon::Polyfills::Console::LogLevel::Warn);
EXPECT_EQ(received[1].second, "warn message");
EXPECT_EQ(received[2].first, Babylon::Polyfills::Console::LogLevel::Error);
EXPECT_EQ(received[2].second, "error message");
}

TEST(JsConsoleLogger, NoConsoleIsNotFatal)
{
// Every method is documented as a no-op when there is no console object, or no such
// method on it. Nothing here installs the Console polyfill.
Babylon::AppRuntime runtime{};

std::promise<void> done;

runtime.Dispatch([](Napi::Env env) mutable {
Babylon::JsConsoleLogger::LogInfo(env, "dropped");
Babylon::JsConsoleLogger::LogWarn(env, "dropped");
Babylon::JsConsoleLogger::LogError(env, "dropped");

// A console whose methods are not functions must be tolerated too.
auto console = Napi::Object::New(env);
console.Set("warn", Napi::Number::New(env, 42));
env.Global().Set("console", console);
Babylon::JsConsoleLogger::LogWarn(env, "dropped");
});

Babylon::ScriptLoader loader{runtime};
loader.Dispatch([&done](auto) {
done.set_value();
});
done.get_future().get();

SUCCEED();
}

// The V8JSI Node-API shim aborts the process on any JS exception raised through a native
// property access ("Fatal error in v8::ToLocalChecked: Empty MaybeLocal"), so it dies inside
// env.Global().Get("console") before LogMethod can guard anything. Nothing JsConsoleLogger
// does can survive that, so exercise this on the other backends only.
#if !defined(JSRUNTIMEHOST_NAPI_ENGINE_JSI)
TEST(JsConsoleLogger, ThrowingConsoleLeavesNoPendingException)
{
// Every step of the log path runs script the host does not control: `console` and the
// method can be accessors that throw, and the call itself is arbitrary user code. If
// any of that escaped -- as a C++ exception at the call site, or as an exception left
// pending on `env` to surface at some unrelated later point -- a diagnostic helper
// would be corrupting whatever the caller was doing.
Babylon::AppRuntime runtime{};

std::promise<std::string> result;

runtime.Dispatch([&result](Napi::Env env) mutable {
std::string failures{};

// Everything here is built out of plain JS rather than Node-API host functions.
// Napi::Object::DefineProperty and Napi::PropertyDescriptor do not exist in the JSI
// port at all, and throwing out of a Napi::Function callback aborts the process there
// ("Fatal error in v8::ToLocalChecked"), so the throwers have to be real JS functions.
// Redefining is also why plain Set will not do: once `console` is an accessor without
// a setter, assigning to it is either silently dropped or a throw.
auto functionCtor = env.Global().Get("Function").As<Napi::Function>();
auto objectCtor = env.Global().Get("Object").As<Napi::Object>();
auto defineProperty = objectCtor.Get("defineProperty").As<Napi::Function>();

auto makeThrower = [&env, &functionCtor](const char* what) {
return functionCtor
.New({Napi::String::New(env, std::string{"throw new Error('"} + what + "');")})
.As<Napi::Function>();
};
auto defineConsole = [&env, &defineProperty](Napi::Object descriptor) {
descriptor.Set("configurable", Napi::Boolean::New(env, true));
defineProperty.Call({env.Global(), Napi::String::New(env, "console"), descriptor});
};

// A `console` accessor that throws.
auto getterDescriptor = Napi::Object::New(env);
getterDescriptor.Set("get", makeThrower("console getter threw"));
defineConsole(getterDescriptor);

try
{
Babylon::JsConsoleLogger::LogWarn(env, "swallowed");
}
catch (...)
{
failures += "throwing console getter escaped as a C++ exception; ";
}
if (env.IsExceptionPending())
{
(void)env.GetAndClearPendingException();
failures += "throwing console getter left a pending exception; ";
}

// A `console.warn` that throws when called.
auto console = Napi::Object::New(env);
console.Set("warn", makeThrower("warn threw"));
auto valueDescriptor = Napi::Object::New(env);
valueDescriptor.Set("value", console);
defineConsole(valueDescriptor);

try
{
Babylon::JsConsoleLogger::LogWarn(env, "swallowed");
}
catch (...)
{
failures += "throwing console.warn escaped as a C++ exception; ";
}
if (env.IsExceptionPending())
{
(void)env.GetAndClearPendingException();
failures += "throwing console.warn left a pending exception; ";
}

result.set_value(failures);
});

EXPECT_EQ(result.get_future().get(), "");
}
#endif

TEST(Console, CaptureCurrentJsStack)
{
// Regression: Console::CaptureCurrentJsStack must return a non-empty stack when called from
Expand Down
Loading