diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp index f818438db6ef9e..1d1d501c0504f4 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp @@ -32,6 +32,7 @@ HostAgent::HostAgent( : frontendChannel_(frontendChannel), targetController_(targetController), sessionMetadata_(std::move(sessionMetadata)), + networkIO_(targetController.createNetworkHandler()), sessionState_(sessionState) {} void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { @@ -151,6 +152,15 @@ void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { folly::dynamic::object("dataLossOccurred", false))); shouldSendOKResponse = true; isFinishedHandlingRequest = true; + } else if (req.method == "Network.loadNetworkResource") { + handleLoadNetworkResource(req); + return; + } else if (req.method == "IO.read") { + handleIoRead(req); + return; + } else if (req.method == "IO.close") { + handleIoClose(req); + return; } if (!isFinishedHandlingRequest && instanceAgent_ && @@ -163,10 +173,131 @@ void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { return; } - frontendChannel_(cdp::jsonError( - req.id, - cdp::ErrorCode::MethodNotFound, - req.method + " not implemented yet")); + throw NotImplementedException(req.method); +} + +void HostAgent::handleLoadNetworkResource(const cdp::PreparsedRequest& req) { + long long requestId = req.id; + auto res = folly::dynamic::object("id", requestId); + if (!req.params.isObject()) { + frontendChannel_(cdp::jsonError( + req.id, + cdp::ErrorCode::InvalidParams, + "Invalid params: not an object.")); + return; + } + if ((req.params.count("url") == 0u) || !req.params.at("url").isString()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: url is missing or not a string.")); + return; + } + + networkIO_->loadNetworkResource( + {.url = req.params.at("url").asString()}, + targetController_.getDelegate(), + // This callback is always called, with resource.success=false on failure. + [requestId, + frontendChannel = frontendChannel_](NetworkResource resource) { + auto dynamicResource = + folly::dynamic::object("success", resource.success); + + if (resource.stream) { + dynamicResource("stream", *resource.stream); + } + + if (resource.netErrorName) { + dynamicResource("netErrorName", *resource.netErrorName); + } + + if (resource.httpStatusCode) { + dynamicResource("httpStatusCode", *resource.httpStatusCode); + } + + if (resource.headers) { + auto dynamicHeaders = folly::dynamic::object(); + for (const auto& pair : *resource.headers) { + dynamicHeaders(pair.first, pair.second); + } + dynamicResource("headers", std::move(dynamicHeaders)); + } + + frontendChannel(cdp::jsonResult( + requestId, + folly::dynamic::object("resource", std::move(dynamicResource)))); + }); +} + +void HostAgent::handleIoRead(const cdp::PreparsedRequest& req) { + long long requestId = req.id; + if (!req.params.isObject()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: not an object.")); + return; + } + if ((req.params.count("handle") == 0u) || + !req.params.at("handle").isString()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: handle is missing or not a string.")); + return; + } + std::optional size = std::nullopt; + if ((req.params.count("size") != 0u) && req.params.at("size").isInt()) { + size = req.params.at("size").asInt(); + } + networkIO_->readStream( + {.handle = req.params.at("handle").asString(), .size = size}, + [requestId, frontendChannel = frontendChannel_]( + std::variant resultOrError) { + if (std::holds_alternative(resultOrError)) { + frontendChannel(cdp::jsonError( + requestId, + cdp::ErrorCode::InternalError, + std::get(resultOrError))); + } else { + const auto& result = std::get(resultOrError); + auto stringResult = cdp::jsonResult( + requestId, + folly::dynamic::object("data", result.data)("eof", result.eof)( + "base64Encoded", result.base64Encoded)); + frontendChannel(stringResult); + } + }); +} + +void HostAgent::handleIoClose(const cdp::PreparsedRequest& req) { + long long requestId = req.id; + if (!req.params.isObject()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: not an object.")); + return; + } + if ((req.params.count("handle") == 0u) || + !req.params.at("handle").isString()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: handle is missing or not a string.")); + return; + } + networkIO_->closeStream( + req.params.at("handle").asString(), + [requestId, frontendChannel = frontendChannel_]( + std::optional maybeError) { + if (maybeError) { + frontendChannel(cdp::jsonError( + requestId, cdp::ErrorCode::InternalError, *maybeError)); + } else { + frontendChannel(cdp::jsonResult(requestId)); + } + }); } HostAgent::~HostAgent() { diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h index 3424f79bd9bdc7..941341a88fcb68 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h @@ -7,7 +7,9 @@ #pragma once +#include "CdpJson.h" #include "HostTarget.h" +#include "NetworkIO.h" #include "SessionState.h" #include @@ -98,12 +100,20 @@ class HostAgent final { std::shared_ptr instanceAgent_; FuseboxClientType fuseboxClientType_{FuseboxClientType::Unknown}; bool isPausedInDebuggerOverlayVisible_{false}; + std::shared_ptr networkIO_; /** * A shared reference to the session's state. This is only safe to access * during handleRequest and other method calls on the same thread. */ SessionState& sessionState_; + + /** Handle a Network.loadNetworkResource CDP request. */ + void handleLoadNetworkResource(const cdp::PreparsedRequest& req); + /** Handle an IO.read CDP request. */ + void handleIoRead(const cdp::PreparsedRequest& req); + /** Handle an IO.close CDP request. */ + void handleIoClose(const cdp::PreparsedRequest& req); }; } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp index 2e682460a94539..3a10f8ff935c72 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp @@ -62,15 +62,22 @@ class HostTargetSession { return; } - // Catch exceptions that may arise from accessing dynamic params during - // request handling. try { hostAgent_.handleRequest(request); - } catch (const cdp::TypeError& e) { + } + // Catch exceptions that may arise from accessing dynamic params during + // request handling. + catch (const cdp::TypeError& e) { frontendChannel_( cdp::jsonError(request.id, cdp::ErrorCode::InvalidRequest, e.what())); return; } + // Catch exceptions for unrecognised or partially implemented CDP methods. + catch (const NotImplementedException& e) { + frontendChannel_( + cdp::jsonError(request.id, cdp::ErrorCode::MethodNotFound, e.what())); + return; + } } /** @@ -198,6 +205,12 @@ void HostTarget::sendCommand(HostCommand command) { }); } +std::shared_ptr HostTarget::createNetworkHandler() { + auto networkIO = std::make_shared(); + networkIO->setExecutor(executorFromThis()); + return networkIO; +} + HostTargetController::HostTargetController(HostTarget& target) : target_(target) {} @@ -205,6 +218,10 @@ HostTargetDelegate& HostTargetController::getDelegate() { return target_.getDelegate(); } +std::shared_ptr HostTargetController::createNetworkHandler() { + return target_.createNetworkHandler(); +}; + bool HostTargetController::hasInstance() const { return target_.hasInstance(); } diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h index 6e4fafbd30d2e6..6d59b6a7c7ee7d 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h @@ -11,6 +11,7 @@ #include "HostCommand.h" #include "InspectorInterfaces.h" #include "InstanceTarget.h" +#include "NetworkIO.h" #include "ScopedExecutor.h" #include "WeakList.h" @@ -41,13 +42,13 @@ class HostTarget; * React Native platform needs to implement in order to integrate with the * debugging stack. */ -class HostTargetDelegate { +class HostTargetDelegate : public NetworkRequestDelegate { public: HostTargetDelegate() = default; HostTargetDelegate(const HostTargetDelegate&) = delete; - HostTargetDelegate(HostTargetDelegate&&) = default; + HostTargetDelegate(HostTargetDelegate&&) = delete; HostTargetDelegate& operator=(const HostTargetDelegate&) = delete; - HostTargetDelegate& operator=(HostTargetDelegate&&) = default; + HostTargetDelegate& operator=(HostTargetDelegate&&) = delete; // TODO(moti): This is 1:1 the shape of the corresponding CDP message - // consider reusing typed/generated CDP interfaces when we have those. @@ -104,6 +105,19 @@ class HostTargetDelegate { */ virtual void onSetPausedInDebuggerMessage( const OverlaySetPausedInDebuggerMessageRequest& request) = 0; + + /** + * Called by NetworkIO on handling a `Network.loadNetworkResource` CDP + * request. Platform implementations should override this to perform a + * network request of the given URL, and use listener's callbacks on receipt + * of headers, data chunks, and errors. + */ + void networkRequest( + const std::string& /*url*/, + std::shared_ptr /*listener*/) override { + throw NotImplementedException( + "NetworkRequestDelegate.networkRequest is not implemented by this host target delegate."); + } }; /** @@ -116,6 +130,13 @@ class HostTargetController final { HostTargetDelegate& getDelegate(); + /** + * Instantiate a new NetworkIO with a scoped executor derived from the + * HostTarget's executor. Neither HostTarget nor HostTargetController + * retain a reference to the shared_ptr. + */ + std::shared_ptr createNetworkHandler(); + bool hasInstance() const; /** @@ -213,6 +234,12 @@ class JSINSPECTOR_EXPORT HostTarget */ void sendCommand(HostCommand command); + /** + * Instantiate a new NetworkIO with a scoped executor derived from the + * HostTarget's executor. HostTarget does not retain a reference. + */ + std::shared_ptr createNetworkHandler(); + private: /** * Constructs a new HostTarget. diff --git a/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h b/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h index 86c9a4b03b9d03..c2f67e0bc10df1 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h @@ -130,6 +130,19 @@ class JSINSPECTOR_EXPORT IInspector : public IDestructible { std::weak_ptr listener) = 0; }; +class NotImplementedException : public std::exception { + public: + explicit NotImplementedException(std::string message) + : msg_(std::move(message)) {} + + const char* what() const noexcept override { + return msg_.c_str(); + } + + private: + std::string msg_; +}; + /// getInspectorInstance retrieves the singleton inspector that tracks all /// debuggable pages in this process. extern IInspector& getInspectorInstance(); diff --git a/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.cpp b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.cpp new file mode 100644 index 00000000000000..8f21df81dc4034 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.cpp @@ -0,0 +1,261 @@ +/* + * 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 "NetworkIO.h" +#include + +namespace facebook::react::jsinspector_modern { + +static constexpr long DEFAULT_BYTES_PER_READ = + 1048576; // 1MB (Chrome v112 default) + +namespace { + +struct InitStreamResult { + int httpStatusCode; + const Headers& headers; +}; +using InitStreamError = const std::string; + +/** + * Private class owning state and implementing the listener for a particular + * request + * + * NetworkRequestListener overrides are thread safe, all other methods must be + * called from the same thread. + */ +class Stream : public NetworkRequestListener, + public EnableExecutorFromThis { + public: + explicit Stream( + std::function)> + initCb) + : initCb_(std::move(initCb)) {} + + /** + * NetworkIO-facing API. Enqueue a read request for up to maxBytesToRead + * bytes, starting from the end of the previous read. + */ + void read( + long maxBytesToRead, + std::function)> callback) { + pendingReadRequests_.emplace_back( + std::make_tuple(maxBytesToRead, callback)); + processPending(); + } + + /** + * NetworkIO-facing API. Call the platform-provided cancelFunction, if any, + * call the error callbacks of any in-flight read requests, and the initial + * error callback if it has not already fulfilled with success or error. + */ + void cancel() { + executorFromThis()([](Stream& self) { + if (self.cancelFunction_) { + (*self.cancelFunction_)(); + } + self.error_ = "Cancelled"; + if (self.initCb_) { + self.initCb_(InitStreamError{"Cancelled"}); + self.initCb_ = nullptr; + } + // Respond to any in-flight read requests with an error. + self.processPending(); + }); + } + + /** + * Implementation of NetworkRequestListener, to be called by platform + * HostTargetDelegate. Any of these methods may be called from any thread. + */ + void onData(std::string_view data) override { + executorFromThis()([copy = std::string(data)](Stream& self) { + self.data_ << copy; + self.bytesReceived_ += copy.length(); + self.processPending(); + }); + } + + void onHeaders(int httpStatusCode, const Headers& headers) override { + executorFromThis()([=](Stream& self) { + // If we've already seen an error, the initial callback as already been + // called with it. + if (self.initCb_) { + self.initCb_(InitStreamResult{httpStatusCode, headers}); + self.initCb_ = nullptr; + } + }); + } + + void onError(const std::string& message) override { + executorFromThis()([=](Stream& self) { + // Only call the error callback once. + if (!self.error_) { + self.error_ = message; + if (self.initCb_) { + self.initCb_(InitStreamError{message}); + self.initCb_ = nullptr; + } + } + self.processPending(); + }); + } + + void onEnd() override { + executorFromThis()([](Stream& self) { + self.completed_ = true; + self.processPending(); + }); + } + + void setCancelFunction(std::function cancelFunction) override { + cancelFunction_ = std::move(cancelFunction); + } + /* End NetworkRequestListener */ + + private: + void processPending() { + // Go through each pending request in insertion order - execute the + // callback and remove it from pending if it can be satisfied. + for (auto it = pendingReadRequests_.begin(); + it != pendingReadRequests_.end();) { + auto maxBytesToRead = std::get<0>(*it); + auto callback = std::get<1>(*it); + + if (error_) { + callback(IOReadError{*error_}); + } else if ( + completed_ || (bytesReceived_ - data_.tellg() >= maxBytesToRead)) { + try { + callback(respond(maxBytesToRead)); + } catch (const std::runtime_error& error) { + callback(IOReadError{error.what()}); + } + } else { + // Not yet received enough data + ++it; + continue; + } + it = pendingReadRequests_.erase(it); + } + } + + IOReadResult respond(long maxBytesToRead) { + std::vector buffer(maxBytesToRead); + data_.read(buffer.data(), maxBytesToRead); + auto bytesRead = data_.gcount(); + buffer.resize(bytesRead); + return IOReadResult{ + .data = + folly::base64Encode(std::string_view(buffer.data(), buffer.size())), + .eof = bytesRead == 0 && completed_, + // TODO: Support UTF-8 string responses + .base64Encoded = true}; + } + + bool completed_{false}; + std::optional error_; + std::stringstream data_; + long bytesReceived_{0}; + std::optional> cancelFunction_{std::nullopt}; + std::function)> initCb_; + std::vector)> /* read callback */>> + pendingReadRequests_; +}; +} // namespace + +void NetworkIO::loadNetworkResource( + const LoadNetworkResourceParams& params, + NetworkRequestDelegate& delegate, + std::function callback) { + // This is an opaque identifier, but an incrementing integer in a string is + // consistent with Chrome. + StreamID streamId = std::to_string(nextStreamId_++); + auto stream = std::make_shared( + [streamId, callback, weakSelf = weak_from_this()]( + std::variant resultOrError) { + NetworkResource toReturn; + if (std::holds_alternative(resultOrError)) { + auto& result = std::get(resultOrError); + if (result.httpStatusCode >= 200 && result.httpStatusCode < 400) { + toReturn = NetworkResource{ + .success = true, + .stream = streamId, + .httpStatusCode = result.httpStatusCode, + .headers = result.headers}; + } else { + toReturn = NetworkResource{ + .success = false, + .httpStatusCode = result.httpStatusCode, + .headers = result.headers}; + } + } else { + auto& error = std::get(resultOrError); + toReturn = NetworkResource{.success = false, .netErrorName = error}; + } + if (!toReturn.success) { + if (auto strongSelf = weakSelf.lock()) { + strongSelf->cancelAndRemoveStreamIfExists(streamId); + } + } + callback(toReturn); + }); + stream->setExecutor(executorFromThis()); + streams_[streamId] = stream; + // Begin the network request on the platform, passing a shared_ptr to stream + // (a NetworkRequestListener) for platform code to call back into. + delegate.networkRequest(params.url, stream); +} + +void NetworkIO::readStream( + const ReadStreamParams& params, + std::function)> callback) { + auto it = streams_.find(params.handle); + if (it == streams_.end()) { + callback(IOReadError{"Stream not found with handle " + params.handle}); + } else { + it->second->read( + params.size ? *params.size : DEFAULT_BYTES_PER_READ, callback); + return; + } +} + +void NetworkIO::closeStream( + const StreamID& streamId, + std::function error)> callback) { + if (cancelAndRemoveStreamIfExists(streamId)) { + callback(std::nullopt); + } else { + callback("Stream not found: " + streamId); + } +} + +bool NetworkIO::cancelAndRemoveStreamIfExists(const StreamID& streamId) { + auto it = streams_.find(streamId); + if (it == streams_.end()) { + return false; + } else { + it->second->cancel(); + streams_.erase(it->first); + return true; + } +} + +NetworkIO::~NetworkIO() { + // Each stream is also retained by the delegate for as long as the request + // is in progress. Cancel the network operation (if implemented by the + // platform) to avoid unnecessary traffic and allow cleanup as soon as + // possible. + for (auto& [_, stream] : streams_) { + stream->cancel(); + } +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.h b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.h new file mode 100644 index 00000000000000..cd54fb00f85fe2 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIO.h @@ -0,0 +1,140 @@ +/* + * 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 "InspectorInterfaces.h" +#include "ScopedExecutor.h" + +#include +#include +#include +#include + +namespace facebook::react::jsinspector_modern { + +using StreamID = const std::string; +using Headers = std::map; +using IOReadError = const std::string; + +namespace { +class Stream; +} + +struct LoadNetworkResourceParams { + std::string url; +}; + +struct ReadStreamParams { + StreamID handle; + std::optional size; + std::optional offset; +}; + +struct NetworkResource { + bool success{}; + std::optional stream; + std::optional httpStatusCode; + std::optional netErrorName; + std::optional headers; +}; + +struct IOReadResult { + std::string data; + bool eof; + bool base64Encoded; +}; + +class NetworkRequestListener { + public: + NetworkRequestListener() = default; + NetworkRequestListener(const NetworkRequestListener&) = delete; + NetworkRequestListener& operator=(const NetworkRequestListener&) = delete; + NetworkRequestListener(NetworkRequestListener&&) noexcept = default; + NetworkRequestListener& operator=(NetworkRequestListener&&) noexcept = + default; + virtual ~NetworkRequestListener() = default; + virtual void onData(std::string_view data) = 0; + virtual void onHeaders(int httpStatusCode, const Headers& headers) = 0; + virtual void onError(const std::string& message) = 0; + virtual void onEnd() = 0; + virtual void setCancelFunction(std::function cancelFunction) = 0; +}; + +class NetworkRequestDelegate { + public: + NetworkRequestDelegate() = default; + NetworkRequestDelegate(const NetworkRequestDelegate&) = delete; + NetworkRequestDelegate& operator=(const NetworkRequestDelegate&) = delete; + NetworkRequestDelegate(NetworkRequestDelegate&&) noexcept = delete; + NetworkRequestDelegate& operator=(NetworkRequestDelegate&&) noexcept = delete; + virtual ~NetworkRequestDelegate() = default; + virtual void networkRequest( + const std::string& /*url*/, + std::shared_ptr /*listener*/) { + throw NotImplementedException( + "NetworkRequestDelegate.networkRequest is not implemented by this delegate."); + } +}; + +/** + * Provides the core implementation for handling CDP's + * Network.loadNetworkResource, IO.read and IO.close. + * + * Owns state of all in-progress and completed HTTP requests - ensure + * closeStream is used to free resources once consumed. + * + * Public methods must be called on the same thread. Callbacks will be called + * through the given executor. + */ +class NetworkIO : public EnableExecutorFromThis { + public: + ~NetworkIO(); + + /** + * Begin loading an HTTP resource, delegating platform-specific + * implementation. The callback will be called when either headers are + * received or an error occurs. If successful, the Stream ID provided to the + * callback can be used to read the contents of the resource via readStream(). + */ + void loadNetworkResource( + const LoadNetworkResourceParams& params, + NetworkRequestDelegate& delegate, + std::function callback); + + /** + * Close a given stream by its handle, call the callback with std::nullopt if + * a stream is found and destroyed, or with an error message if the stream is + * not found. Safely aborts any in-flight request. + */ + void closeStream( + const StreamID& streamId, + std::function error)> callback); + + /** + * Read a chunk of data from the stream, once enough has been downloaded, or + * call back with an error. + */ + void readStream( + const ReadStreamParams& params, + std::function result)> + callback); + + private: + /** + * Map of stream objects, which contain data received, accept read requests + * and listen for delegate events. Delegates have a shared_ptr to the Stream + * instance, but Streams should not live beyond the destruction of this + * NetworkIO instance. + */ + std::unordered_map> streams_; + unsigned long nextStreamId_{0}; + + bool cancelAndRemoveStreamIfExists(const StreamID& streamId); +}; + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp index d9a38c120d62de..d38e47a6264e3e 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp @@ -700,4 +700,283 @@ TEST_F(HostTargetTest, HostCommands) { page_->unregisterInstance(instanceTarget); } +TEST_F(HostTargetTest, NetworkLoadNetworkResourceSuccess) { + auto& instanceTarget = page_->registerInstance(instanceTargetDelegate_); + + connect(); + + InSequence s; + + std::shared_ptr listener; + EXPECT_CALL(hostTargetDelegate_, networkRequest(Eq("http://example.com"), _)) + .Times(1) + .WillOnce([&listener]( + const std::string& /*url*/, + std::shared_ptr listenerArg) { + // Capture the NetworkRequestLister to use later. + listener = listenerArg; + }) + .RetiresOnSaturation(); + + // Load the resource, expect a CDP response as soon as headers are received. + toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.loadNetworkResource", + "params": { + "url": "http://example.com" + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": { + "resource": { + "success": true, + "stream": "0", + "httpStatusCode": 200, + "headers": { + "x-test": "foo" + } + } + } + })"))); + + listener->onHeaders(200, Headers{{"x-test", "foo"}}); + + // Retrieve the first chunk of data. + toPage_->sendMessage(R"({ + "id": 2, + "method": "IO.read", + "params": { + "handle": "0", + "size": 8 + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 2, + "result": { + "data": "SGVsbG8sIFc=", + "eof": false, + "base64Encoded": true + } + })"))); + listener->onData("Hello, World!"); + + // Retrieve the remaining data. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 3, + "result": { + "data": "b3JsZCE=", + "eof": false, + "base64Encoded": true + } + })"))); + toPage_->sendMessage(R"({ + "id": 3, + "method": "IO.read", + "params": { + "handle": "0", + "size": 8 + } + })"); + + // No more data - expect empty payload with eof: true. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 4, + "result": { + "data": "", + "eof": true, + "base64Encoded": true + } + })"))); + toPage_->sendMessage(R"({ + "id": 4, + "method": "IO.read", + "params": { + "handle": "0", + "size": 8 + } + })"); + listener->onEnd(); + + // Close the stream. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 5, + "result": {} + })"))); + toPage_->sendMessage(R"({ + "id": 5, + "method": "IO.close", + "params": { + "handle": "0" + } + })"); + + page_->unregisterInstance(instanceTarget); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResourceStreamInterrupted) { + auto& instanceTarget = page_->registerInstance(instanceTargetDelegate_); + + connect(); + + InSequence s; + + std::shared_ptr listener; + EXPECT_CALL(hostTargetDelegate_, networkRequest(Eq("http://example.com"), _)) + .Times(1) + .WillOnce([&listener]( + const std::string& /*url*/, + std::shared_ptr listenerArg) { + listener = listenerArg; + }) + .RetiresOnSaturation(); + + // Load the resource, receiving headers succesfully. + toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.loadNetworkResource", + "params": { + "url": "http://example.com" + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": { + "resource": { + "success": true, + "stream": "0", + "httpStatusCode": 200, + "headers": { + "x-test": "foo" + } + } + } + })"))); + + listener->onHeaders(200, Headers{{"x-test", "foo"}}); + + // Retrieve the first chunk of data. + toPage_->sendMessage(R"({ + "id": 2, + "method": "IO.read", + "params": { + "handle": "0", + "size": 20 + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 2, + "result": { + "data": "VGhlIG1lYW5pbmcgb2YgbGlmZSA=", + "eof": false, + "base64Encoded": true + } + })"))); + listener->onData("The meaning of life is..."); + + // Simulate an error mid-stream, expect in-flight IO.reads to return a CDP + // error. + toPage_->sendMessage(R"({ + "id": 3, + "method": "IO.read", + "params": { + "handle": "0", + "size": 20 + } + })"); + + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 3, + "error": { + "code": -32603, + "message": "Connection lost" + } + })"))); + listener->onError("Connection lost"); + + // IO.close should be a successful no-op after an error. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 4, + "result": {} + })"))); + toPage_->sendMessage(R"({ + "id": 4, + "method": "IO.close", + "params": { + "handle": "0" + } + })"); + + page_->unregisterInstance(instanceTarget); +} + +TEST_F(HostTargetTest, NetworkLoadNetworkResource404) { + auto& instanceTarget = page_->registerInstance(instanceTargetDelegate_); + + connect(); + + InSequence s; + + std::shared_ptr listener; + EXPECT_CALL( + hostTargetDelegate_, networkRequest(Eq("http://notexists.com"), _)) + .Times(1) + .WillOnce([&listener]( + const std::string& /*url*/, + std::shared_ptr listenerArg) { + listener = std::move(listenerArg); + }) + .RetiresOnSaturation(); + + // A 404 response should trigger a CDP result with success: false, including + // the status code, headers, but *no* stream handle. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": { + "resource": { + "success": false, + "httpStatusCode": 404, + "headers": { + "x-test": "foo" + } + } + } + })"))); + + toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.loadNetworkResource", + "params": { + "url": "http://notexists.com" + } + })"); + + listener->onHeaders(404, Headers{{"x-test", "foo"}}); + + // Assuming a successful request would have assigned handle "0", verify that + // handle has *not* been assigned. + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 2, + "error": { + "code": -32603, + "message": "Stream not found with handle 0" + } + })"))); + + toPage_->sendMessage(R"({ + "id": 2, + "method": "IO.read", + "params": { + "handle": "0", + "size": 20 + } + })"); + + page_->unregisterInstance(instanceTarget); +} + } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h index abc1acfe0a3b07..678abed310aa4e 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h @@ -124,6 +124,12 @@ class MockHostTargetDelegate : public HostTargetDelegate { onSetPausedInDebuggerMessage, (const OverlaySetPausedInDebuggerMessageRequest& request), (override)); + MOCK_METHOD( + void, + networkRequest, + (const std::string& url, + std::shared_ptr listener), + (override)); }; class MockInstanceTargetDelegate : public InstanceTargetDelegate {};