Implement Runtime.addBinding (global bindings only) (#43065)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/43065

Changelog: [Internal]

Implements support for [`Runtime.addBinding`](https://cdpstatus.reactnative.dev/devtools-protocol/tot/Runtime#method-addBinding) in the new RN CDP backend.

This implementation is mostly complete and matches Chrome's behaviour, but does not include the ability to target bindings by execution context (the optional `executionContextId` and `executionContextName` params) - that will come in a separate diff for ease of review/landing.

Incidentally, this diff also introduces the `JsiIntegrationPortableTest::expectMessageFromPage` helper, which allows us to "asynchronously" extract the contents of an expected message. For consistency and clarity, we refactor all the other `EXPECT_CALL(this->fromPage(), onMessage(JsonEq(...)))` assertions to use it as well.

Reviewed By: huntie

Differential Revision: D53266709

fbshipit-source-id: 046326acdf5dacc18e179e43589cdd2d012f353a
This commit is contained in:
Moti Zilberman
2024-02-16 11:16:41 -08:00
committed by Facebook GitHub Bot
parent 828ad04cef
commit da03d7b829
7 changed files with 410 additions and 82 deletions
@@ -52,4 +52,5 @@ Pod::Spec.new do |s|
s.dependency "React-featureflags"
s.dependency "DoubleConversion"
s.dependency "React-runtimeexecutor", version
s.dependency "React-jsi"
end
@@ -11,24 +11,65 @@ namespace facebook::react::jsinspector_modern {
RuntimeAgent::RuntimeAgent(
FrontendChannel frontendChannel,
RuntimeTarget& target,
RuntimeTargetController& targetController,
const ExecutionContextDescription& executionContextDescription,
SessionState& sessionState,
std::unique_ptr<RuntimeAgentDelegate> delegate)
: frontendChannel_(std::move(frontendChannel)),
target_(target),
targetController_(targetController),
sessionState_(sessionState),
delegate_(std::move(delegate)),
executionContextDescription_(executionContextDescription) {
(void)target_;
(void)sessionState_;
for (auto& name : sessionState_.subscribedBindingNames) {
targetController_.installBindingHandler(name);
}
}
bool RuntimeAgent::handleRequest(const cdp::PreparsedRequest& req) {
if (req.method == "Runtime.addBinding") {
std::string bindingName = req.params["name"].getString();
// TODO: Respect @cdp Runtime.addBinding's executionContextId and
// executionContextName params.
sessionState_.subscribedBindingNames.emplace(bindingName);
targetController_.installBindingHandler(bindingName);
folly::dynamic res = folly::dynamic::object("id", req.id)(
"result", folly::dynamic::object());
std::string json = folly::toJson(res);
frontendChannel_(json);
return true;
}
if (req.method == "Runtime.removeBinding") {
sessionState_.subscribedBindingNames.erase(req.params["name"].getString());
folly::dynamic res = folly::dynamic::object("id", req.id)(
"result", folly::dynamic::object());
std::string json = folly::toJson(res);
frontendChannel_(json);
return true;
}
if (delegate_) {
return delegate_->handleRequest(req);
}
return false;
}
void RuntimeAgent::notifyBindingCalled(
const std::string& bindingName,
const std::string& payload) {
if (!sessionState_.subscribedBindingNames.count(bindingName)) {
return;
}
frontendChannel_(
folly::toJson(folly::dynamic::object("method", "Runtime.bindingCalled")(
"params",
folly::dynamic::object(
"executionContextId", executionContextDescription_.id)(
"name", bindingName)("payload", payload))));
}
} // namespace facebook::react::jsinspector_modern
@@ -16,7 +16,7 @@
namespace facebook::react::jsinspector_modern {
class RuntimeTarget;
class RuntimeTargetController;
/**
* An Agent that handles requests from the Chrome DevTools Protocol
@@ -30,9 +30,9 @@ class RuntimeAgent final {
/**
* \param frontendChannel A channel used to send responses and events to the
* frontend.
* \param target The RuntimeTarget that this agent is attached to. The
* caller is responsible for ensuring that the RuntimeTarget outlives this
* object.
* \param targetController An interface to the RuntimeTarget that this agent
* is attached to. The caller is responsible for ensuring that the
* RuntimeTarget and controller outlive this object.
* \param executionContextDescription A description of the execution context
* represented by this runtime. This is used for disambiguating the
* source/destination of CDP messages when there are multiple runtimes
@@ -43,7 +43,7 @@ class RuntimeAgent final {
*/
RuntimeAgent(
FrontendChannel frontendChannel,
RuntimeTarget& target,
RuntimeTargetController& targetController,
const ExecutionContextDescription& executionContextDescription,
SessionState& sessionState,
std::unique_ptr<RuntimeAgentDelegate> delegate);
@@ -65,9 +65,13 @@ class RuntimeAgent final {
return executionContextDescription_;
}
void notifyBindingCalled(
const std::string& bindingName,
const std::string& payload);
private:
FrontendChannel frontendChannel_;
RuntimeTarget& target_;
RuntimeTargetController& targetController_;
SessionState& sessionState_;
const std::unique_ptr<RuntimeAgentDelegate> delegate_;
const ExecutionContextDescription executionContextDescription_;
@@ -7,6 +7,8 @@
#include <jsinspector-modern/RuntimeTarget.h>
using namespace facebook::jsi;
namespace facebook::react::jsinspector_modern {
std::shared_ptr<RuntimeTarget> RuntimeTarget::create(
@@ -33,7 +35,7 @@ std::shared_ptr<RuntimeAgent> RuntimeTarget::createAgent(
SessionState& sessionState) {
auto runtimeAgent = std::make_shared<RuntimeAgent>(
channel,
*this,
controller_,
executionContextDescription_,
sessionState,
delegate_.createAgentDelegate(
@@ -50,4 +52,51 @@ RuntimeTarget::~RuntimeTarget() {
"RuntimeAgent objects must be destroyed before their RuntimeTarget. Did you call InstanceTarget::unregisterRuntime()?");
}
void RuntimeTarget::installBindingHandler(const std::string& bindingName) {
jsExecutor_([bindingName,
selfExecutor = executorFromThis()](jsi::Runtime& runtime) {
auto globalObj = runtime.global();
try {
auto bindingNamePropID = jsi::PropNameID::forUtf8(runtime, bindingName);
globalObj.setProperty(
runtime,
bindingNamePropID,
jsi::Function::createFromHostFunction(
runtime,
bindingNamePropID,
1,
[bindingName, selfExecutor](
jsi::Runtime& rt,
const jsi::Value&,
const jsi::Value* args,
size_t count) -> jsi::Value {
if (count != 1 || !args[0].isString()) {
throw jsi::JSError(
rt, "Invalid arguments: should be exactly one string.");
}
std::string payload = args[0].getString(rt).utf8(rt);
selfExecutor([bindingName, payload](auto& self) {
self.agents_.forEach([bindingName, payload](auto& agent) {
agent.notifyBindingCalled(bindingName, payload);
});
});
return jsi::Value::undefined();
}));
} catch (jsi::JSError&) {
// Per Chrome's implementation, @cdp Runtime.createBinding swallows
// JavaScript exceptions that occur while setting up the binding.
}
});
}
RuntimeTargetController::RuntimeTargetController(RuntimeTarget& target)
: target_(target) {}
void RuntimeTargetController::installBindingHandler(
const std::string& bindingName) {
target_.installBindingHandler(bindingName);
}
} // namespace facebook::react::jsinspector_modern
@@ -34,6 +34,7 @@ namespace facebook::react::jsinspector_modern {
class RuntimeAgent;
class RuntimeAgentDelegate;
class RuntimeTarget;
/**
* Receives events from a RuntimeTarget. This is a shared interface that
@@ -49,6 +50,24 @@ class RuntimeTargetDelegate {
const ExecutionContextDescription& executionContextDescription) = 0;
};
/**
* The limited interface that RuntimeTarget exposes to its connected agents.
*/
class RuntimeTargetController {
public:
explicit RuntimeTargetController(RuntimeTarget& target);
/**
* Adds a function with the given name on the runtime's global object, that
* when called will send a Runtime.bindingCalled event through all connected
* sessions that have registered to receive binding events for that name.
*/
void installBindingHandler(const std::string& bindingName);
private:
RuntimeTarget& target_;
};
/**
* A Target corresponding to a JavaScript runtime.
*/
@@ -123,6 +142,19 @@ class JSINSPECTOR_EXPORT RuntimeTarget
RuntimeTargetDelegate& delegate_;
RuntimeExecutor jsExecutor_;
WeakList<RuntimeAgent> agents_;
RuntimeTargetController controller_{*this};
/**
* Adds a function with the given name on the runtime's global object, that
* when called will send a Runtime.bindingCalled event through all connected
* sessions that have registered to receive binding events for that name.
*/
void installBindingHandler(const std::string& bindingName);
// Necessary to allow RuntimeAgent to access RuntimeTarget's internals in a
// controlled way (i.e. only RuntimeTargetController gets friend access, while
// RuntimeAgent itself doesn't).
friend class RuntimeTargetController;
};
} // namespace facebook::react::jsinspector_modern
@@ -7,7 +7,9 @@
#pragma once
#include <string>
#include <string_view>
#include <unordered_set>
namespace facebook::react::jsinspector_modern {
@@ -17,6 +19,15 @@ struct SessionState {
bool isLogDomainEnabled{false};
bool isRuntimeDomainEnabled{false};
/**
* The set of bindings registered during this session using @cdp
* Runtime.addBinding. Even though bindings get added to the global scope as
* functions that can outlive a session, they are treated as session state,
* matching Chrome's behaviour (a binding not added by the current session
* will not emit events on it).
*/
std::unordered_set<std::string> subscribedBindingNames;
// Here, we will eventually allow RuntimeAgents to store their own arbitrary
// state (e.g. some sort of K/V storage of folly::dynamic?)
@@ -5,7 +5,9 @@
* LICENSE file in the root directory of this source tree.
*/
#include <folly/dynamic.h>
#include <folly/executors/QueuedImmediateExecutor.h>
#include <folly/json.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
@@ -109,6 +111,22 @@ class JsiIntegrationPortableTest : public Test, private PageTargetDelegate {
std::make_shared<jsi::StringBuffer>(std::string(code)), "<eval>");
}
/**
* Expect a message matching the provided gmock \c matcher and return a holder
* that will eventually contain the parsed JSON payload.
*/
template <typename Matcher>
std::shared_ptr<const std::optional<folly::dynamic>> expectMessageFromPage(
Matcher&& matcher) {
std::shared_ptr result =
std::make_shared<std::optional<folly::dynamic>>(std::nullopt);
EXPECT_CALL(fromPage(), onMessage(matcher))
.WillOnce(
([result](auto message) { *result = folly::parseJson(message); }))
.RetiresOnSaturation();
return result;
}
std::shared_ptr<PageTarget> page_ =
PageTarget::create(*this, inspectorExecutor_);
InstanceTarget* instance_{};
@@ -162,11 +180,8 @@ TYPED_TEST(JsiIntegrationPortableTest, ConnectWithoutCrashing) {
TYPED_TEST(JsiIntegrationPortableTest, ErrorOnUnknownMethod) {
this->connect();
EXPECT_CALL(
this->fromPage(),
onMessage(JsonParsed(
AllOf(AtJsonPtr("/id", 1), AtJsonPtr("/error/code", -32601)))))
.RetiresOnSaturation();
this->expectMessageFromPage(
JsonParsed(AllOf(AtJsonPtr("/id", 1), AtJsonPtr("/error/code", -32601))));
this->toPage_->sendMessage(R"({
"id": 1,
@@ -179,65 +194,58 @@ TYPED_TEST(JsiIntegrationPortableTest, ExecutionContextNotifications) {
InSequence s;
EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({
"method": "Runtime.executionContextCreated",
"params": {
"context": {
"id": 1,
"origin": "",
"name": "main"
}
}
})")))
.RetiresOnSaturation();
EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({
"id": 1,
"result": {}
})")));
this->expectMessageFromPage(JsonEq(R"({
"method": "Runtime.executionContextCreated",
"params": {
"context": {
"id": 1,
"origin": "",
"name": "main"
}
}
})"));
this->expectMessageFromPage(JsonEq(R"({
"id": 1,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 1,
"method": "Runtime.enable"
})");
EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({
"method": "Runtime.executionContextDestroyed",
"params": {
"executionContextId": 1
}
})")))
.RetiresOnSaturation();
EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({
"method": "Runtime.executionContextsCleared"
})")))
.RetiresOnSaturation();
this->expectMessageFromPage(JsonEq(R"({
"method": "Runtime.executionContextDestroyed",
"params": {
"executionContextId": 1
}
})"));
this->expectMessageFromPage(JsonEq(R"({
"method": "Runtime.executionContextsCleared"
})"));
EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({
"method": "Runtime.executionContextCreated",
"params": {
"context": {
"id": 2,
"origin": "",
"name": "main"
}
}
})")))
.RetiresOnSaturation();
this->expectMessageFromPage(JsonEq(R"({
"method": "Runtime.executionContextCreated",
"params": {
"context": {
"id": 2,
"origin": "",
"name": "main"
}
}
})"));
// Simulate a reload triggered by the app (not by the debugger).
this->reload();
EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({
"method": "Runtime.executionContextDestroyed",
"params": {
"executionContextId": 2
}
})")))
.RetiresOnSaturation();
EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({
"method": "Runtime.executionContextsCleared"
})")))
.RetiresOnSaturation();
EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({
this->expectMessageFromPage(JsonEq(R"({
"method": "Runtime.executionContextDestroyed",
"params": {
"executionContextId": 2
}
})"));
this->expectMessageFromPage(JsonEq(R"({
"method": "Runtime.executionContextsCleared"
})"));
this->expectMessageFromPage(JsonEq(R"({
"method": "Runtime.executionContextCreated",
"params": {
"context": {
@@ -246,33 +254,215 @@ TYPED_TEST(JsiIntegrationPortableTest, ExecutionContextNotifications) {
"name": "main"
}
}
})")))
.RetiresOnSaturation();
EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({
"id": 2,
"result": {}
})")))
.RetiresOnSaturation();
})"));
this->expectMessageFromPage(JsonEq(R"({
"id": 2,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 2,
"method": "Page.reload"
})");
}
TYPED_TEST(JsiIntegrationPortableTest, AddBinding) {
this->connect();
InSequence s;
auto executionContextInfo = this->expectMessageFromPage(JsonParsed(
AllOf(AtJsonPtr("/method", "Runtime.executionContextCreated"))));
this->expectMessageFromPage(JsonEq(R"({
"id": 1,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 1,
"method": "Runtime.enable"
})");
auto executionContextId =
executionContextInfo->value()["params"]["context"]["id"];
this->expectMessageFromPage(JsonEq(R"({
"id": 2,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 2,
"method": "Runtime.addBinding",
"params": {"name": "foo"}
})");
this->expectMessageFromPage(JsonParsed(AllOf(
AtJsonPtr("/method", "Runtime.bindingCalled"),
AtJsonPtr("/params/name", "foo"),
AtJsonPtr("/params/payload", "bar"),
AtJsonPtr("/params/executionContextId", executionContextId))));
this->eval("globalThis.foo('bar');");
}
TYPED_TEST(JsiIntegrationPortableTest, AddedBindingSurvivesReload) {
this->connect();
InSequence s;
this->expectMessageFromPage(JsonEq(R"({
"id": 1,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 1,
"method": "Runtime.addBinding",
"params": {"name": "foo"}
})");
this->reload();
// Get the new context ID by sending Runtime.enable now.
auto executionContextInfo = this->expectMessageFromPage(JsonParsed(
AllOf(AtJsonPtr("/method", "Runtime.executionContextCreated"))));
this->expectMessageFromPage(JsonEq(R"({
"id": 1,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 1,
"method": "Runtime.enable"
})");
auto executionContextId =
executionContextInfo->value()["params"]["context"]["id"];
this->expectMessageFromPage(JsonParsed(AllOf(
AtJsonPtr("/method", "Runtime.bindingCalled"),
AtJsonPtr("/params/name", "foo"),
AtJsonPtr("/params/payload", "bar"),
AtJsonPtr("/params/executionContextId", executionContextId))));
this->eval("globalThis.foo('bar');");
}
TYPED_TEST(JsiIntegrationPortableTest, RemovedBindingRemainsInstalled) {
this->connect();
InSequence s;
this->expectMessageFromPage(JsonEq(R"({
"id": 1,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 1,
"method": "Runtime.addBinding",
"params": {"name": "foo"}
})");
this->expectMessageFromPage(JsonEq(R"({
"id": 2,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 2,
"method": "Runtime.removeBinding",
"params": {"name": "foo"}
})");
this->eval("globalThis.foo('bar');");
}
TYPED_TEST(JsiIntegrationPortableTest, RemovedBindingDoesNotSurviveReload) {
this->connect();
InSequence s;
this->expectMessageFromPage(JsonEq(R"({
"id": 1,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 1,
"method": "Runtime.addBinding",
"params": {"name": "foo"}
})");
this->expectMessageFromPage(JsonEq(R"({
"id": 2,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 2,
"method": "Runtime.removeBinding",
"params": {"name": "foo"}
})");
this->reload();
EXPECT_TRUE(this->eval("typeof globalThis.foo === 'undefined'").getBool());
}
TYPED_TEST(JsiIntegrationPortableTest, AddBindingClobbersExistingProperty) {
this->connect();
InSequence s;
this->eval(R"(
globalThis.foo = 'clobbered value';
)");
this->expectMessageFromPage(JsonEq(R"({
"id": 1,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 1,
"method": "Runtime.addBinding",
"params": {"name": "foo"}
})");
this->expectMessageFromPage(JsonParsed(AllOf(
AtJsonPtr("/method", "Runtime.bindingCalled"),
AtJsonPtr("/params/name", "foo"),
AtJsonPtr("/params/payload", "bar"))));
this->eval("globalThis.foo('bar');");
}
TYPED_TEST(JsiIntegrationPortableTest, ExceptionDuringAddBindingIsIgnored) {
this->connect();
InSequence s;
this->eval(R"(
Object.defineProperty(globalThis, 'foo', {
get: function () { return 42; },
set: function () { throw new Error('nope'); },
});
)");
this->expectMessageFromPage(JsonEq(R"({
"id": 1,
"result": {}
})"));
this->toPage_->sendMessage(R"({
"id": 1,
"method": "Runtime.addBinding",
"params": {"name": "foo"}
})");
EXPECT_TRUE(this->eval("globalThis.foo === 42").getBool());
}
////////////////////////////////////////////////////////////////////////////////
TEST_F(JsiIntegrationHermesTest, EvaluateExpression) {
connect();
EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({
"id": 1,
"result": {
"result": {
"type": "number",
"value": 42
}
}
})")));
expectMessageFromPage(JsonEq(R"({
"id": 1,
"result": {
"result": {
"type": "number",
"value": 42
}
}
})"));
toPage_->sendMessage(R"({
"id": 1,
"method": "Runtime.evaluate",