/* * 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 "JsiIntegrationTest.h" #include "engines/JsiIntegrationTestGenericEngineAdapter.h" #include "engines/JsiIntegrationTestHermesEngineAdapter.h" using namespace ::testing; using folly::sformat; namespace facebook::react::jsinspector_modern { //////////////////////////////////////////////////////////////////////////////// // Some tests are specific to Hermes's CDP capabilities and some are not. // We'll use JsiIntegrationHermesTest as an alias for Hermes-specific tests // and JsiIntegrationPortableTest for the engine-agnostic ones. /** * The list of engine adapters for which engine-agnostic tests should pass. */ using AllEngines = Types< JsiIntegrationTestHermesEngineAdapter, JsiIntegrationTestGenericEngineAdapter>; using AllHermesVariants = Types; template using JsiIntegrationPortableTest = JsiIntegrationPortableTestBase< EngineAdapter, folly::QueuedImmediateExecutor>; TYPED_TEST_SUITE(JsiIntegrationPortableTest, AllEngines); template using JsiIntegrationHermesTest = JsiIntegrationPortableTestBase< EngineAdapter, folly::QueuedImmediateExecutor>; /** * Fixture class for tests that run on a ManualExecutor. Work scheduled * on the executor is *not* run automatically; it must be manually advanced * in the body of the test. */ template class JsiIntegrationHermesTestAsync : public JsiIntegrationPortableTestBase< EngineAdapter, folly::ManualExecutor> { public: void TearDown() override { // Assert there are no pending tasks on the ManualExecutor. auto tasksCleared = this->executor_.clear(); EXPECT_EQ(tasksCleared, 0) << "There were still pending tasks on executor_ at the end of the test. Use advance() or run() as needed."; JsiIntegrationPortableTestBase:: TearDown(); } }; TYPED_TEST_SUITE(JsiIntegrationHermesTest, AllHermesVariants); TYPED_TEST_SUITE(JsiIntegrationHermesTestAsync, AllHermesVariants); #pragma region AllEngines TYPED_TEST(JsiIntegrationPortableTest, ConnectWithoutCrashing) { this->connect(); } TYPED_TEST(JsiIntegrationPortableTest, ErrorOnUnknownMethod) { this->connect(); this->expectMessageFromPage( JsonParsed(AllOf(AtJsonPtr("/id", 1), AtJsonPtr("/error/code", -32601)))); this->toPage_->sendMessage(R"({ "id": 1, "method": "Foobar.unknownMethod" })"); } TYPED_TEST(JsiIntegrationPortableTest, ExecutionContextNotifications) { this->connect(); InSequence s; 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" })"); this->expectMessageFromPage(JsonEq(R"({ "method": "Runtime.executionContextDestroyed", "params": { "executionContextId": 1 } })")); this->expectMessageFromPage(JsonEq(R"({ "method": "Runtime.executionContextsCleared" })")); 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(); 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": { "id": 3, "origin": "", "name": "main" } } })")); 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" })"); ASSERT_TRUE(executionContextInfo->has_value()); 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" })"); ASSERT_TRUE(executionContextInfo->has_value()); 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()); } TYPED_TEST(JsiIntegrationPortableTest, FuseboxSetClientMetadata) { this->connect(); this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": {} })")); this->toPage_->sendMessage(R"({ "id": 1, "method": "FuseboxClient.setClientMetadata", "params": {} })"); } TYPED_TEST(JsiIntegrationPortableTest, ReactNativeApplicationEnable) { this->connect(); this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": {} })")); this->expectMessageFromPage(JsonEq(R"({ "method": "ReactNativeApplication.metadataUpdated", "params": { "integrationName": "JsiIntegrationTest" } })")); this->toPage_->sendMessage(R"({ "id": 1, "method": "ReactNativeApplication.enable", "params": {} })"); } TYPED_TEST(JsiIntegrationPortableTest, ReactNativeApplicationDisable) { this->connect(); this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": {} })")); this->toPage_->sendMessage(R"({ "id": 1, "method": "ReactNativeApplication.disable", "params": {} })"); } #pragma endregion // AllEngines #pragma region AllHermesVariants TYPED_TEST(JsiIntegrationHermesTestAsync, HermesObjectsTableDoesNotMemoryLeak) { // This is a regression test for T186157855 (CDPAgent leaking JSI data in // RemoteObjectsTable past the Runtime's lifetime) this->connect(); this->executor_.run(); InSequence s; this->expectMessageFromPage(JsonParsed( AllOf(AtJsonPtr("/method", "Runtime.executionContextCreated")))); this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": {} })")); this->toPage_->sendMessage(R"({ "id": 1, "method": "Runtime.enable" })"); this->executor_.run(); this->expectMessageFromPage(JsonParsed(AllOf( AtJsonPtr("/method", "Runtime.consoleAPICalled"), AtJsonPtr("/params/args/0/objectId", "1")))); this->eval(R"(console.log({a: 1});)"); this->executor_.run(); this->expectMessageFromPage(JsonEq(R"({ "method": "Runtime.executionContextDestroyed", "params": { "executionContextId": 1 } })")); this->expectMessageFromPage(JsonEq(R"({ "method": "Runtime.executionContextsCleared" })")); this->expectMessageFromPage(JsonEq(R"({ "method": "Runtime.executionContextCreated", "params": { "context": { "id": 2, "origin": "", "name": "main" } } })")); // NOTE: Doesn't crash when Hermes checks for JSI value leaks this->reload(); this->executor_.run(); } TYPED_TEST(JsiIntegrationHermesTest, EvaluateExpression) { this->connect(); this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": { "result": { "type": "number", "value": 42 } } })")); this->toPage_->sendMessage(R"({ "id": 1, "method": "Runtime.evaluate", "params": {"expression": "42"} })"); } TYPED_TEST(JsiIntegrationHermesTest, EvaluateExpressionInExecutionContext) { 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" })"); ASSERT_TRUE(executionContextInfo->has_value()); auto executionContextId = executionContextInfo->value()["params"]["context"]["id"].getInt(); this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": { "result": { "type": "number", "value": 42 } } })")); this->toPage_->sendMessage(sformat( R"({{ "id": 1, "method": "Runtime.evaluate", "params": {{"expression": "42", "contextId": {0}}} }})", std::to_string(executionContextId))); // Silence notifications about execution contexts. this->expectMessageFromPage(JsonEq(R"({ "id": 2, "result": {} })")); this->toPage_->sendMessage(R"({ "id": 2, "method": "Runtime.disable" })"); this->reload(); // Now the old execution context is stale. this->expectMessageFromPage( JsonParsed(AllOf(AtJsonPtr("/id", 3), AtJsonPtr("/error/code", -32600)))); this->toPage_->sendMessage(sformat( R"({{ "id": 3, "method": "Runtime.evaluate", "params": {{"expression": "10000", "contextId": {0}}} }})", std::to_string(executionContextId))); } TYPED_TEST(JsiIntegrationHermesTest, ResolveBreakpointAfterEval) { this->connect(); InSequence s; this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": {} })")); this->toPage_->sendMessage(R"({ "id": 1, "method": "Debugger.enable" })"); auto scriptInfo = this->expectMessageFromPage(JsonParsed(AllOf( AtJsonPtr("/method", "Debugger.scriptParsed"), AtJsonPtr("/params/url", "breakpointTest.js")))); this->eval(R"( // line 0 globalThis.foo = function() { // line 1 Date.now(); // line 2 }; //# sourceURL=breakpointTest.js )"); ASSERT_TRUE(scriptInfo->has_value()); this->expectMessageFromPage(JsonParsed(AllOf( AtJsonPtr("/id", 2), AtJsonPtr("/result/locations/0/lineNumber", 2), AtJsonPtr( "/result/locations/0/scriptId", scriptInfo->value()["params"]["scriptId"])))); this->toPage_->sendMessage(R"({ "id": 2, "method": "Debugger.setBreakpointByUrl", "params": {"lineNumber": 2, "url": "breakpointTest.js"} })"); } TYPED_TEST(JsiIntegrationHermesTest, ResolveBreakpointAfterReload) { this->connect(); InSequence s; this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": {} })")); this->toPage_->sendMessage(R"({ "id": 1, "method": "Debugger.enable" })"); this->expectMessageFromPage(JsonParsed(AtJsonPtr("/id", 2))); this->toPage_->sendMessage(R"({ "id": 2, "method": "Debugger.setBreakpointByUrl", "params": {"lineNumber": 2, "url": "breakpointTest.js"} })"); this->reload(); auto scriptInfo = this->expectMessageFromPage(JsonParsed(AllOf( AtJsonPtr("/method", "Debugger.scriptParsed"), AtJsonPtr("/params/url", "breakpointTest.js")))); auto breakpointInfo = this->expectMessageFromPage(JsonParsed(AllOf( AtJsonPtr("/method", "Debugger.breakpointResolved"), AtJsonPtr("/params/location/lineNumber", 2)))); this->eval(R"( // line 0 globalThis.foo = function() { // line 1 Date.now(); // line 2 }; //# sourceURL=breakpointTest.js )"); ASSERT_TRUE(breakpointInfo->has_value()); ASSERT_TRUE(scriptInfo->has_value()); EXPECT_EQ( breakpointInfo->value()["params"]["location"]["scriptId"], scriptInfo->value()["params"]["scriptId"]); } TYPED_TEST(JsiIntegrationHermesTest, CDPAgentReentrancyRegressionTest) { this->connect(); InSequence s; this->inspectorExecutor_([&]() { // Tasks scheduled on our executor here will be executed when this lambda // returns. This is integral to the bug we're trying to reproduce, so we // place the EXPECT_* calls at the end of the lambda body to ensure the // test fails if we get eager (unexpected) responses. // 1. Cause CDPAgent to schedule a task to process the message. Originally, // the task would be simultaneously scheduled on the JS executor, and as // an interrupt on the JS interpreter. It's called via the executor // regardless, since the interpreter is idle at the moment. this->toPage_->sendMessage(R"({ "id": 1, "method": "Runtime.evaluate", "params": {"expression": "Math.random(); /* Interrupts processed here. */ globalThis.x = 1 + 2"} })"); // 2. Cause CDPAgent to schedule another task. If scheduled as an interrupt, // this task will run _during_ the first task. this->toPage_->sendMessage(R"({ "id": 2, "method": "Runtime.evaluate", "params": {"expression": "globalThis.x = 3 + 4"} })"); // This setup used to trigger three distinct bugs in CDPAgent: // - The first task would be triggered twice due to a race condition // between the executor and the interrupt handler. (D54771697) // - The second task would deadlock due to the first task holding a lock // preventing any other CDPAgent tasks from running. (D54838179) // - The second task would complete first, returning `evaluate` // responses out of order and (crucially) performing any JS side // effects out of order. (D55250610) this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": { "result": { "type": "number", "value": 3 } } })")); this->expectMessageFromPage(JsonEq(R"({ "id": 2, "result": { "result": { "type": "number", "value": 7 } } })")); }); // Make sure the second task ran last. EXPECT_EQ(this->eval("globalThis.x").getNumber(), 7); } TYPED_TEST(JsiIntegrationHermesTest, ScriptParsedExactlyOnce) { // Regression test for T182003727 (multiple scriptParsed events for a single // script under Hermes lazy compilation). this->connect(); InSequence s; this->eval(R"( // NOTE: Triggers lazy compilation in Hermes when running with // CompilationMode::ForceLazyCompilation. (function foo(){var x = 2;})() //# sourceURL=script.js )"); this->expectMessageFromPage(JsonParsed(AllOf( AtJsonPtr("/method", "Debugger.scriptParsed"), AtJsonPtr("/params/url", "script.js")))); this->expectMessageFromPage(JsonEq(R"({ "id": 1, "result": {} })")); this->toPage_->sendMessage(R"({ "id": 1, "method": "Debugger.enable" })"); } TYPED_TEST(JsiIntegrationHermesTest, FunctionDescriptionIncludesName) { // See // https://github.com/facebookexperimental/rn-chrome-devtools-frontend/blob/9a23d4c7c4c2d1a3d9e913af38d6965f474c4284/front_end/ui/legacy/components/object_ui/ObjectPropertiesSection.ts#L311-L391 this->connect(); InSequence s; this->expectMessageFromPage(JsonParsed(AllOf( AtJsonPtr("/id", 1), AtJsonPtr("/result/result/type", "function"), AtJsonPtr( "/result/result/description", DynamicString(StartsWith("function foo() {")))))); this->toPage_->sendMessage(R"({ "id": 1, "method": "Runtime.evaluate", "params": {"expression": "(function foo() {Math.random()});"} })"); } TYPED_TEST(JsiIntegrationHermesTest, ReleaseRemoteObject) { this->connect(); InSequence s; // Create a remote object. auto objectInfo = this->expectMessageFromPage(JsonParsed(AllOf( AtJsonPtr("/id", 1), AtJsonPtr("/result/result/type", "object"), AtJsonPtr("/result/result/objectId", Not(IsEmpty()))))); this->toPage_->sendMessage(R"({ "id": 1, "method": "Runtime.evaluate", "params": {"expression": "[]"} })"); ASSERT_TRUE(objectInfo->has_value()); auto objectId = objectInfo->value()["result"]["result"]["objectId"]; // Ensure we can get the properties of the object. this->expectMessageFromPage(JsonParsed( AllOf(AtJsonPtr("/id", 2), AtJsonPtr("/result/result", SizeIs(Gt(0)))))); this->toPage_->sendMessage(sformat( R"({{ "id": 2, "method": "Runtime.getProperties", "params": {{"objectId": {}, "ownProperties": true}} }})", folly::toJson(objectId))); // Release the object. this->expectMessageFromPage(JsonEq(R"({ "id": 3, "result": {} })")); this->toPage_->sendMessage(sformat( R"({{ "id": 3, "method": "Runtime.releaseObject", "params": {{"objectId": {}, "ownProperties": true}} }})", folly::toJson(objectId))); // Getting properties for a released object results in an error. this->expectMessageFromPage( JsonParsed(AllOf(AtJsonPtr("/id", 4), AtJsonPtr("/error/code", -32000)))); this->toPage_->sendMessage(sformat( R"({{ "id": 4, "method": "Runtime.getProperties", "params": {{"objectId": {}, "ownProperties": true}} }})", folly::toJson(objectId))); // Releasing an already released object is an error. this->expectMessageFromPage( JsonParsed(AllOf(AtJsonPtr("/id", 5), AtJsonPtr("/error/code", -32000)))); this->toPage_->sendMessage(sformat( R"({{ "id": 5, "method": "Runtime.releaseObject", "params": {{"objectId": {}, "ownProperties": true}} }})", folly::toJson(objectId))); } TYPED_TEST(JsiIntegrationHermesTest, ReleaseRemoteObjectGroup) { this->connect(); InSequence s; // Create a remote object. auto objectInfo = this->expectMessageFromPage(JsonParsed(AllOf( AtJsonPtr("/id", 1), AtJsonPtr("/result/result/type", "object"), AtJsonPtr("/result/result/objectId", Not(IsEmpty()))))); this->toPage_->sendMessage(R"({ "id": 1, "method": "Runtime.evaluate", "params": {"expression": "[]", "objectGroup": "foo"} })"); ASSERT_TRUE(objectInfo->has_value()); auto objectId = objectInfo->value()["result"]["result"]["objectId"]; // Ensure we can get the properties of the object. this->expectMessageFromPage(JsonParsed( AllOf(AtJsonPtr("/id", 2), AtJsonPtr("/result/result", SizeIs(Gt(0)))))); this->toPage_->sendMessage(sformat( R"({{ "id": 2, "method": "Runtime.getProperties", "params": {{"objectId": {}, "ownProperties": true}} }})", folly::toJson(objectId))); // Release the object group containing our object. this->expectMessageFromPage(JsonEq(R"({ "id": 3, "result": {} })")); this->toPage_->sendMessage(R"({ "id": 3, "method": "Runtime.releaseObjectGroup", "params": {"objectGroup": "foo"} })"); // Getting properties for a released object results in an error. this->expectMessageFromPage( JsonParsed(AllOf(AtJsonPtr("/id", 4), AtJsonPtr("/error/code", -32000)))); this->toPage_->sendMessage(sformat( R"({{ "id": 4, "method": "Runtime.getProperties", "params": {{"objectId": {}, "ownProperties": true}} }})", folly::toJson(objectId))); // Releasing an already released object group is a no-op. this->expectMessageFromPage(JsonEq(R"({ "id": 5, "result": {} })")); this->toPage_->sendMessage(R"({ "id": 5, "method": "Runtime.releaseObjectGroup", "params": {"objectGroup": "foo"} })"); } #pragma endregion // AllHermesVariants } // namespace facebook::react::jsinspector_modern