diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp new file mode 100644 index 00000000000..ed1fb11d234 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp @@ -0,0 +1,254 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include + +#include +#include + +#include + +#include "FollyDynamicMatchers.h" +#include "InspectorMocks.h" +#include "UniquePtrFactory.h" +#include "engines/JsiIntegrationTestGenericEngineAdapter.h" +#include "engines/JsiIntegrationTestHermesEngineAdapter.h" + +using namespace ::testing; + +namespace facebook::react::jsinspector_modern { + +namespace { + +/** + * A text fixture class for the integration between the modern RN CDP backend + * and a JSI engine, mocking out the rest of RN. For simplicity, everything is + * single-threaded and "async" work is actually done through a queued immediate + * executor ( = run immediately and finish all queued sub-tasks before + * returning). + * + * The main limitation of the simpler threading model is that we can't cover + * breakpoints etc - since pausing during JS execution would prevent the test + * from making progress. Such functionality is better suited for a full RN+CDP + * integration test (using RN's own thread management) as well as for each + * engine's unit tests. + * + * \tparam EngineAdapter An adapter class that implements RuntimeTargetDelegate + * for a particular engine, plus exposes access to a RuntimeExecutor (based on + * the provided folly::Executor) and the corresponding jsi::Runtime. + */ +template +class JsiIntegrationPortableTest : public Test, private PageTargetDelegate { + folly::QueuedImmediateExecutor immediateExecutor_; + + protected: + JsiIntegrationPortableTest() : engineAdapter_{immediateExecutor_} { + instance_ = &page_->registerInstance(instanceTargetDelegate_); + runtimeTarget_ = &instance_->registerRuntime( + engineAdapter_, engineAdapter_.getRuntimeExecutor()); + } + + ~JsiIntegrationPortableTest() override { + toPage_.reset(); + if (runtimeTarget_) { + EXPECT_TRUE(instance_); + instance_->unregisterRuntime(*runtimeTarget_); + runtimeTarget_ = nullptr; + } + if (instance_) { + page_->unregisterInstance(*instance_); + instance_ = nullptr; + } + } + + void connect() { + ASSERT_FALSE(toPage_) << "Can only connect once in a JSI integration test."; + toPage_ = page_->connect( + remoteConnections_.make_unique(), + {.integrationName = "JsiIntegrationTest"}); + + // We'll always get an onDisconnect call when we tear + // down the test. Expect it in order to satisfy the strict mock. + EXPECT_CALL(*remoteConnections_[0], onDisconnect()); + } + + void reload() { + if (runtimeTarget_) { + ASSERT_TRUE(instance_); + instance_->unregisterRuntime(*runtimeTarget_); + runtimeTarget_ = nullptr; + } + if (instance_) { + page_->unregisterInstance(*instance_); + instance_ = nullptr; + } + instance_ = &page_->registerInstance(instanceTargetDelegate_); + runtimeTarget_ = &instance_->registerRuntime( + engineAdapter_, engineAdapter_.getRuntimeExecutor()); + } + + MockRemoteConnection& fromPage() { + assert(toPage_); + return *remoteConnections_[0]; + } + + VoidExecutor inspectorExecutor_ = [this](auto callback) { + immediateExecutor_.add(callback); + }; + + jsi::Value eval(std::string_view code) { + return engineAdapter_.getRuntime().evaluateJavaScript( + std::make_shared(std::string(code)), ""); + } + + std::shared_ptr page_ = + PageTarget::create(*this, inspectorExecutor_); + InstanceTarget* instance_{}; + RuntimeTarget* runtimeTarget_{}; + + MockInstanceTargetDelegate instanceTargetDelegate_; + EngineAdapter engineAdapter_; + + private: + UniquePtrFactory> remoteConnections_; + + protected: + // NOTE: Needs to be destroyed before page_. + std::unique_ptr toPage_; + + private: + // PageTargetDelegate methods + + void onReload(const PageReloadRequest& request) override { + (void)request; + reload(); + } +}; + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// + +// Some tests are specific to Hermes's CDP capabilities and some are not. +// We'll use JsiIntegrationHermesTest as a fixture for Hermes-specific tests +// and typed tests for the engine-agnostic ones. + +using JsiIntegrationHermesTest = + JsiIntegrationPortableTest; + +/** + * The list of engine adapters for which engine-agnostic tests should pass. + */ +using AllEngines = Types< + JsiIntegrationTestHermesEngineAdapter, + JsiIntegrationTestGenericEngineAdapter>; + +TYPED_TEST_SUITE(JsiIntegrationPortableTest, AllEngines); + +//////////////////////////////////////////////////////////////////////////////// + +TYPED_TEST(JsiIntegrationPortableTest, ConnectWithoutCrashing) { + this->connect(); +} + +TYPED_TEST(JsiIntegrationPortableTest, ErrorOnUnknownMethod) { + this->connect(); + + EXPECT_CALL( + this->fromPage(), + onMessage(JsonParsed( + AllOf(AtJsonPtr("/id", 1), AtJsonPtr("/error/code", -32601))))) + .RetiresOnSaturation(); + + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Foobar.unknownMethod" + })"); +} + +//////////////////////////////////////////////////////////////////////////////// + +TEST_F(JsiIntegrationHermesTest, EvaluateExpression) { + connect(); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": { + "result": { + "type": "number", + "value": 42 + } + } + })"))); + toPage_->sendMessage(R"({ + "id": 1, + "method": "Runtime.evaluate", + "params": {"expression": "42"} + })"); +} + +TEST_F(JsiIntegrationHermesTest, ExecutionContextNotifications) { + connect(); + + InSequence s; + + // NOTE: This is the wrong sequence of responses from Hermes - the + // notification should come before the method response. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": {} + })"))); + EXPECT_CALL( + fromPage(), + onMessage(JsonParsed( + AllOf(AtJsonPtr("/method", Eq("Runtime.executionContextCreated")))))) + .RetiresOnSaturation(); + + toPage_->sendMessage(R"({ + "id": 1, + "method": "Runtime.enable" + })"); + + // NOTE: Missing a Runtime.executionContextDestroyed notification here. + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "method": "Runtime.executionContextsCleared" + })"))) + .RetiresOnSaturation(); + EXPECT_CALL( + fromPage(), + onMessage(JsonParsed( + AllOf(AtJsonPtr("/method", Eq("Runtime.executionContextCreated")))))) + .RetiresOnSaturation(); + // Simulate a reload triggered by the app (not by the debugger). + reload(); + + // NOTE: Missing a Runtime.executionContextDestroyed notification here. + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "method": "Runtime.executionContextsCleared" + })"))) + .RetiresOnSaturation(); + EXPECT_CALL( + fromPage(), + onMessage(JsonParsed( + AllOf(AtJsonPtr("/method", Eq("Runtime.executionContextCreated")))))) + .RetiresOnSaturation(); + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 2, + "result": {} + })"))) + .RetiresOnSaturation(); + toPage_->sendMessage(R"({ + "id": 2, + "method": "Page.reload" + })"); +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestGenericEngineAdapter.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestGenericEngineAdapter.cpp new file mode 100644 index 00000000000..2ea6f7cdb43 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestGenericEngineAdapter.cpp @@ -0,0 +1,46 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include +#include + +#include "JsiIntegrationTestGenericEngineAdapter.h" + +using facebook::hermes::makeHermesRuntime; + +namespace facebook::react::jsinspector_modern { + +JsiIntegrationTestGenericEngineAdapter::JsiIntegrationTestGenericEngineAdapter( + folly::Executor& jsExecutor) + : runtime_{hermes::makeHermesRuntime()}, jsExecutor_{jsExecutor} {} + +std::unique_ptr +JsiIntegrationTestGenericEngineAdapter::createAgentDelegate( + FrontendChannel frontendChannel, + SessionState& sessionState) { + return std::unique_ptr( + new FallbackRuntimeAgentDelegate( + frontendChannel, + sessionState, + "Generic engine (" + runtime_->description() + ")")); +} + +jsi::Runtime& JsiIntegrationTestGenericEngineAdapter::getRuntime() + const noexcept { + return *runtime_; +} + +RuntimeExecutor JsiIntegrationTestGenericEngineAdapter::getRuntimeExecutor() + const noexcept { + return [&jsExecutor = jsExecutor_, &runtime = getRuntime()](auto fn) { + jsExecutor.add([fn, &runtime]() { fn(runtime); }); + }; +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestGenericEngineAdapter.h b/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestGenericEngineAdapter.h new file mode 100644 index 00000000000..83ea5952acb --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestGenericEngineAdapter.h @@ -0,0 +1,41 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include +#include + +#include + +namespace facebook::react::jsinspector_modern { + +/** + * An engine adapter for JsiIntegrationTest that represents a generic + * JSI-compatible engine, with no engine-specific CDP support. Uses Hermes under + * the hood, without Hermes's CDP support. + */ +class JsiIntegrationTestGenericEngineAdapter : public RuntimeTargetDelegate { + public: + explicit JsiIntegrationTestGenericEngineAdapter(folly::Executor& jsExecutor); + + virtual std::unique_ptr createAgentDelegate( + FrontendChannel frontendChannel, + SessionState& sessionState) override; + + jsi::Runtime& getRuntime() const noexcept; + + RuntimeExecutor getRuntimeExecutor() const noexcept; + + private: + std::unique_ptr runtime_; + folly::Executor& jsExecutor_; +}; + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestHermesEngineAdapter.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestHermesEngineAdapter.cpp new file mode 100644 index 00000000000..5caa9980a94 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestHermesEngineAdapter.cpp @@ -0,0 +1,50 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include + +#include "JsiIntegrationTestHermesEngineAdapter.h" + +using facebook::hermes::makeHermesRuntime; + +namespace facebook::react::jsinspector_modern { + +JsiIntegrationTestHermesEngineAdapter::JsiIntegrationTestHermesEngineAdapter( + folly::Executor& jsExecutor) + : runtime_{hermes::makeHermesRuntime()}, jsExecutor_{jsExecutor} {} + +std::unique_ptr +JsiIntegrationTestHermesEngineAdapter::createAgentDelegate( + FrontendChannel frontendChannel, + SessionState& sessionState) { + return std::unique_ptr( + new HermesRuntimeAgentDelegate( + frontendChannel, sessionState, runtime_, getRuntimeExecutor())); +} + +jsi::Runtime& JsiIntegrationTestHermesEngineAdapter::getRuntime() + const noexcept { + return *runtime_; +} + +RuntimeExecutor JsiIntegrationTestHermesEngineAdapter::getRuntimeExecutor() + const noexcept { + auto& jsExecutor = jsExecutor_; + return [runtimeWeak = std::weak_ptr(runtime_), &jsExecutor](auto fn) { + jsExecutor.add([runtimeWeak, fn]() { + auto runtime = runtimeWeak.lock(); + if (!runtime) { + return; + } + fn(*runtime); + }); + }; +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestHermesEngineAdapter.h b/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestHermesEngineAdapter.h new file mode 100644 index 00000000000..bffce0db32d --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestHermesEngineAdapter.h @@ -0,0 +1,41 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include +#include +#include + +#include + +namespace facebook::react::jsinspector_modern { + +/** + * An engine adapter for JsiIntegrationTest that uses Hermes (and Hermes's + * CDP support). + */ +class JsiIntegrationTestHermesEngineAdapter : public RuntimeTargetDelegate { + public: + explicit JsiIntegrationTestHermesEngineAdapter(folly::Executor& jsExecutor); + + virtual std::unique_ptr createAgentDelegate( + FrontendChannel frontendChannel, + SessionState& sessionState) override; + + jsi::Runtime& getRuntime() const noexcept; + + RuntimeExecutor getRuntimeExecutor() const noexcept; + + private: + std::shared_ptr runtime_; + folly::Executor& jsExecutor_; +}; + +} // namespace facebook::react::jsinspector_modern