From 7b00a92a3b80ac8450355a4bfc8817dcf91d5c09 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 14 Feb 2024 10:44:41 -0800 Subject: [PATCH] Add CDP-JSI integration tests (#43027) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/43027 Changelog: [Internal] Adds a test suite for the integration between the modern RN CDP backend and Hermes (plus potentially other JS engines), 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. ## Types of tests in this diff * `TEST_F(JsiIntegrationHermesTest, ...)` - tests specific to the Hermes integration. * `TYPED_TEST(JsiIntegrationPortableTest, ...)` - tests that should pass on all engines. * These use gtest's [typed tests](https://google.github.io/googletest/advanced.html#typed-tests) feature. * This is a good fit for testing CDP features that have no strict dependency on Hermes (like the upcoming `Runtime.addBinding` support). **Long term**, aspirationally, all tests should be in this category, covering a consistent baseline of CDP features needed for debugging with any supported engine. * The first "non-Hermes" engine we test against (`GenericEngineAdapter`) is actually Hermes in disguise, minus any Hermes-specific CDP handling. We could conceivably add more engines here, as long as we have the ability to build them (and their JSI bindings) as part of building the tests. bypass-github-export-checks Reviewed By: huntie Differential Revision: D53756996 fbshipit-source-id: fbafb088abd4263ec841bf848185637ec126c6d1 --- .../tests/JsiIntegrationTest.cpp | 254 ++++++++++++++++++ ...JsiIntegrationTestGenericEngineAdapter.cpp | 46 ++++ .../JsiIntegrationTestGenericEngineAdapter.h | 41 +++ .../JsiIntegrationTestHermesEngineAdapter.cpp | 50 ++++ .../JsiIntegrationTestHermesEngineAdapter.h | 41 +++ 5 files changed, 432 insertions(+) create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestGenericEngineAdapter.cpp create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestGenericEngineAdapter.h create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestHermesEngineAdapter.cpp create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/tests/engines/JsiIntegrationTestHermesEngineAdapter.h 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