Create a dedicated WebTransport-only client class, and switch MoqtClient to using it.

PiperOrigin-RevId: 937254495
diff --git a/build/source_list.bzl b/build/source_list.bzl
index 9d12049..4077291 100644
--- a/build/source_list.bzl
+++ b/build/source_list.bzl
@@ -761,6 +761,7 @@
     "quic/tools/quic_url.h",
     "quic/tools/simple_ticket_crypter.h",
     "quic/tools/web_transport_only_backend.h",
+    "quic/tools/web_transport_only_client.h",
     "quic/tools/web_transport_test_visitors.h",
 ]
 quiche_tool_support_srcs = [
@@ -785,6 +786,7 @@
     "quic/tools/quic_url.cc",
     "quic/tools/simple_ticket_crypter.cc",
     "quic/tools/web_transport_only_backend.cc",
+    "quic/tools/web_transport_only_client.cc",
 ]
 quiche_test_support_hdrs = [
     "common/platform/api/quiche_expect_bug.h",
@@ -1400,6 +1402,7 @@
     "quic/tools/quic_simple_client_session_test.cc",
     "quic/tools/quic_tcp_like_trace_converter_test.cc",
     "quic/tools/simple_ticket_crypter_test.cc",
+    "quic/tools/web_transport_only_client_test.cc",
     "web_transport/encapsulated/encapsulated_web_transport_test.cc",
     "web_transport/test_tools/in_memory_stream_test.cc",
     "web_transport/web_transport_headers_test.cc",
diff --git a/build/source_list.gni b/build/source_list.gni
index fdccb89..e2f084a 100644
--- a/build/source_list.gni
+++ b/build/source_list.gni
@@ -761,6 +761,7 @@
     "src/quiche/quic/tools/quic_url.h",
     "src/quiche/quic/tools/simple_ticket_crypter.h",
     "src/quiche/quic/tools/web_transport_only_backend.h",
+    "src/quiche/quic/tools/web_transport_only_client.h",
     "src/quiche/quic/tools/web_transport_test_visitors.h",
 ]
 quiche_tool_support_srcs = [
@@ -785,6 +786,7 @@
     "src/quiche/quic/tools/quic_url.cc",
     "src/quiche/quic/tools/simple_ticket_crypter.cc",
     "src/quiche/quic/tools/web_transport_only_backend.cc",
+    "src/quiche/quic/tools/web_transport_only_client.cc",
 ]
 quiche_test_support_hdrs = [
     "src/quiche/common/platform/api/quiche_expect_bug.h",
@@ -1401,6 +1403,7 @@
     "src/quiche/quic/tools/quic_simple_client_session_test.cc",
     "src/quiche/quic/tools/quic_tcp_like_trace_converter_test.cc",
     "src/quiche/quic/tools/simple_ticket_crypter_test.cc",
+    "src/quiche/quic/tools/web_transport_only_client_test.cc",
     "src/quiche/web_transport/encapsulated/encapsulated_web_transport_test.cc",
     "src/quiche/web_transport/test_tools/in_memory_stream_test.cc",
     "src/quiche/web_transport/web_transport_headers_test.cc",
diff --git a/build/source_list.json b/build/source_list.json
index ba05903..2444384 100644
--- a/build/source_list.json
+++ b/build/source_list.json
@@ -760,6 +760,7 @@
     "quiche/quic/tools/quic_url.h",
     "quiche/quic/tools/simple_ticket_crypter.h",
     "quiche/quic/tools/web_transport_only_backend.h",
+    "quiche/quic/tools/web_transport_only_client.h",
     "quiche/quic/tools/web_transport_test_visitors.h"
   ],
   "quiche_tool_support_srcs": [
@@ -783,7 +784,8 @@
     "quiche/quic/tools/quic_tcp_like_trace_converter.cc",
     "quiche/quic/tools/quic_url.cc",
     "quiche/quic/tools/simple_ticket_crypter.cc",
-    "quiche/quic/tools/web_transport_only_backend.cc"
+    "quiche/quic/tools/web_transport_only_backend.cc",
+    "quiche/quic/tools/web_transport_only_client.cc"
   ],
   "quiche_test_support_hdrs": [
     "quiche/common/platform/api/quiche_expect_bug.h",
@@ -1400,6 +1402,7 @@
     "quiche/quic/tools/quic_simple_client_session_test.cc",
     "quiche/quic/tools/quic_tcp_like_trace_converter_test.cc",
     "quiche/quic/tools/simple_ticket_crypter_test.cc",
+    "quiche/quic/tools/web_transport_only_client_test.cc",
     "quiche/web_transport/encapsulated/encapsulated_web_transport_test.cc",
     "quiche/web_transport/test_tools/in_memory_stream_test.cc",
     "quiche/web_transport/web_transport_headers_test.cc",
diff --git a/quiche/quic/moqt/tools/moqt_client.cc b/quiche/quic/moqt/tools/moqt_client.cc
index f2d185b..35dcce5 100644
--- a/quiche/quic/moqt/tools/moqt_client.cc
+++ b/quiche/quic/moqt/tools/moqt_client.cc
@@ -9,12 +9,8 @@
 #include <utility>
 
 #include "absl/status/status.h"
-#include "absl/status/statusor.h"
-#include "absl/strings/string_view.h"
 #include "absl/types/span.h"
 #include "quiche/quic/core/crypto/proof_verifier.h"
-#include "quiche/quic/core/http/quic_spdy_client_stream.h"
-#include "quiche/quic/core/http/web_transport_http3.h"
 #include "quiche/quic/core/io/quic_event_loop.h"
 #include "quiche/quic/core/quic_server_id.h"
 #include "quiche/quic/core/quic_types.h"
@@ -24,12 +20,9 @@
 #include "quiche/quic/moqt/moqt_session_callbacks.h"
 #include "quiche/quic/moqt/moqt_session_interface.h"
 #include "quiche/quic/platform/api/quic_socket_address.h"
-#include "quiche/quic/tools/quic_default_client.h"
-#include "quiche/quic/tools/quic_event_loop_tools.h"
 #include "quiche/quic/tools/quic_name_lookup.h"
-#include "quiche/common/http/http_header_block.h"
-#include "quiche/common/platform/api/quiche_logging.h"
-#include "quiche/web_transport/web_transport_headers.h"
+#include "quiche/quic/tools/web_transport_only_client.h"
+#include "quiche/web_transport/web_transport.h"
 
 namespace moqt {
 
@@ -38,11 +31,11 @@
                        std::unique_ptr<quic::ProofVerifier> proof_verifier,
                        quic::QuicEventLoop* event_loop,
                        MoqtSessionParameters parameters)
-    : spdy_client_(peer_address, server_id, GetMoqtSupportedQuicVersions(),
-                   event_loop, std::move(proof_verifier)),
+    : client_(peer_address, server_id, GetMoqtSupportedQuicVersions(),
+              GenerateQuicConfig(), event_loop, nullptr,
+              std::move(proof_verifier), nullptr),
       parameters_(parameters) {
-  TuneQuicConfig(*spdy_client_.config());
-  spdy_client_.set_enable_web_transport(true);
+  TuneQuicConfig(*client_.config());
   parameters_.perspective = quic::Perspective::IS_CLIENT;
 }
 
@@ -55,58 +48,8 @@
 
 absl::Status MoqtClient::ConnectInner(std::string path,
                                       MoqtSessionCallbacks& callbacks) {
-  if (!spdy_client_.Initialize()) {
-    return absl::InternalError("Initialization failed");
-  }
-  if (!spdy_client_.Connect()) {
-    return absl::UnavailableError("Failed to establish a QUIC connection");
-  }
-  bool settings_received = quic::ProcessEventsUntil(
-      spdy_client_.default_network_helper()->event_loop(),
-      [&] { return spdy_client_.client_session()->settings_received(); });
-  if (!settings_received) {
-    return absl::UnavailableError(
-        "Timed out while waiting for server SETTINGS");
-  }
-  if (!spdy_client_.client_session()->SupportsWebTransport()) {
-    QUICHE_DLOG(INFO) << "session: SupportsWebTransport = "
-                      << spdy_client_.client_session()->SupportsWebTransport()
-                      << ", SupportsH3Datagram = "
-                      << spdy_client_.client_session()->SupportsH3Datagram()
-                      << ", OneRttKeysAvailable = "
-                      << spdy_client_.client_session()->OneRttKeysAvailable();
-    return absl::FailedPreconditionError(
-        "Server does not support WebTransport");
-  }
-  auto* stream = static_cast<quic::QuicSpdyClientStream*>(
-      spdy_client_.client_session()->CreateOutgoingBidirectionalStream());
-  if (!stream) {
-    return absl::InternalError("Could not open a CONNECT stream");
-  }
-  spdy_client_.set_store_response(true);
+  const std::string version = std::string(kDefaultMoqtVersion);
 
-  quiche::HttpHeaderBlock headers;
-  headers[":scheme"] = "https";
-  headers[":authority"] = spdy_client_.server_id().host();
-  headers[":path"] = path;
-  headers[":method"] = "CONNECT";
-  headers[":protocol"] = "webtransport";
-  std::string version = std::string(kDefaultMoqtVersion);
-  absl::StatusOr<std::string> serialized_version =
-      webtransport::SerializeSubprotocolRequestHeader(
-          absl::MakeSpan(&version, 1));
-  if (!serialized_version.ok()) {
-    return serialized_version.status();
-  }
-  headers["wt-available-protocols"] = *serialized_version;
-  stream->SendRequest(std::move(headers), "", false);
-
-  quic::WebTransportHttp3* web_transport = stream->web_transport();
-  if (web_transport == nullptr) {
-    return absl::InternalError("Failed to initialize WebTransport session");
-  }
-
-  // Ensure that we never have a dangling pointer to the session.
   MoqtSessionDeletedCallback deleted_callback =
       std::move(callbacks.session_deleted_callback);
   callbacks.session_deleted_callback =
@@ -115,13 +58,19 @@
         std::move(old)();
       };
 
-  auto session = std::make_unique<MoqtSession>(
-      web_transport, parameters_,
-      spdy_client_.default_network_helper()->event_loop()->CreateAlarmFactory(),
-      std::move(callbacks));
-  session_ = session.get();
-  web_transport->SetVisitor(std::move(session));
-  return absl::OkStatus();
+  return client_.ConnectSync(
+      path,
+      [&](webtransport::Session* session) {
+        auto moqt_session =
+            std::make_unique<MoqtSession>(session, parameters_,
+                                          client_.default_network_helper()
+                                              ->event_loop()
+                                              ->CreateAlarmFactory(),
+                                          std::move(callbacks));
+        session_ = moqt_session.get();
+        return moqt_session;
+      },
+      absl::MakeSpan(&version, 1));
 }
 
 }  // namespace moqt
diff --git a/quiche/quic/moqt/tools/moqt_client.h b/quiche/quic/moqt/tools/moqt_client.h
index 011ceb1..d78fd54 100644
--- a/quiche/quic/moqt/tools/moqt_client.h
+++ b/quiche/quic/moqt/tools/moqt_client.h
@@ -17,12 +17,12 @@
 #include "quiche/quic/moqt/moqt_session_callbacks.h"
 #include "quiche/quic/moqt/moqt_session_interface.h"
 #include "quiche/quic/platform/api/quic_socket_address.h"
-#include "quiche/quic/tools/quic_default_client.h"
+#include "quiche/quic/tools/web_transport_only_client.h"
 #include "quiche/common/platform/api/quiche_export.h"
 
 namespace moqt {
 
-// A synchronous MoQT client based on QuicDefaultClient.
+// A synchronous MoQT client based on WebTransportOnlyClient.
 class QUICHE_EXPORT MoqtClient {
  public:
   MoqtClient(quic::QuicSocketAddress peer_address,
@@ -36,12 +36,12 @@
   void Connect(std::string path, MoqtSessionCallbacks callbacks);
 
   MoqtSession* session() { return session_; }
-  quic::QuicSession* quic_session() { return spdy_client_.session(); }
+  quic::QuicSession* quic_session() { return client_.session(); }
 
  private:
   absl::Status ConnectInner(std::string path, MoqtSessionCallbacks& callbacks);
 
-  quic::QuicDefaultClient spdy_client_;
+  quic::WebTransportOnlyClient client_;
   MoqtSessionParameters parameters_;
   MoqtSession* session_ = nullptr;
 };
diff --git a/quiche/quic/tools/web_transport_only_client.cc b/quiche/quic/tools/web_transport_only_client.cc
new file mode 100644
index 0000000..8cdb06f
--- /dev/null
+++ b/quiche/quic/tools/web_transport_only_client.cc
@@ -0,0 +1,213 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "quiche/quic/tools/web_transport_only_client.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/base/casts.h"
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "quiche/quic/core/crypto/proof_verifier.h"
+#include "quiche/quic/core/crypto/quic_crypto_client_config.h"
+#include "quiche/quic/core/http/quic_connection_migration_manager.h"
+#include "quiche/quic/core/http/quic_spdy_client_session.h"
+#include "quiche/quic/core/http/quic_spdy_client_stream.h"
+#include "quiche/quic/core/http/quic_spdy_session.h"
+#include "quiche/quic/core/http/web_transport_http3.h"
+#include "quiche/quic/core/io/quic_event_loop.h"
+#include "quiche/quic/core/quic_config.h"
+#include "quiche/quic/core/quic_connection.h"
+#include "quiche/quic/core/quic_default_connection_helper.h"
+#include "quiche/quic/core/quic_packet_writer.h"
+#include "quiche/quic/core/quic_path_validator.h"
+#include "quiche/quic/core/quic_server_id.h"
+#include "quiche/quic/core/quic_session.h"
+#include "quiche/quic/core/quic_types.h"
+#include "quiche/quic/core/quic_versions.h"
+#include "quiche/quic/platform/api/quic_socket_address.h"
+#include "quiche/quic/tools/quic_client_base.h"
+#include "quiche/quic/tools/quic_client_default_network_helper.h"
+#include "quiche/quic/tools/quic_event_loop_tools.h"
+#include "quiche/common/http/http_header_block.h"
+#include "quiche/web_transport/web_transport_headers.h"
+
+namespace quic {
+
+WebTransportOnlyClient::Session::Session(
+    const QuicConfig& config, const ParsedQuicVersionVector& supported_versions,
+    QuicConnection* connection, QuicClientBase::NetworkHelper* network_helper,
+    const QuicServerId& server_id, QuicCryptoClientConfig* crypto_config)
+    : QuicSpdyClientSession(
+          config, supported_versions, connection,
+          /*visitor=*/nullptr, /*writer=*/nullptr,
+          /*migration_helper=*/nullptr,
+          QuicConnectionMigrationConfig{.allow_server_preferred_address =
+                                            false},
+          server_id, crypto_config, QuicPriorityType::kWebTransport),
+      network_helper_(network_helper) {}
+
+WebTransportHttp3VersionSet
+WebTransportOnlyClient::Session::LocallySupportedWebTransportVersions() const {
+  return kDefaultSupportedWebTransportVersions;
+}
+
+HttpDatagramSupport
+WebTransportOnlyClient::Session::LocalHttpDatagramSupport() {
+  return HttpDatagramSupport::kRfcAndDraft04;
+}
+
+// TODO(vasilvv): the connection migration code below comes from
+// QuicSimpleClientSession; should it live in QuicClientBase?
+void WebTransportOnlyClient::Session::CreateContextForMultiPortPath(
+    std::unique_ptr<MultiPortPathContextObserver> context_observer) {
+  if (!network_helper_ || connection()->multi_port_stats() == nullptr) {
+    return;
+  }
+  QuicSocketAddress self_address = connection()->self_address();
+  QuicSocketAddress server_address = connection()->peer_address();
+  if (!network_helper_->CreateUDPSocketAndBind(
+          server_address, self_address.host(), self_address.port() + 1)) {
+    return;
+  }
+  QuicPacketWriter* writer = network_helper_->CreateQuicPacketWriter();
+  if (writer == nullptr) {
+    return;
+  }
+  context_observer->OnMultiPortPathContextAvailable(
+      std::make_unique<PathMigrationContext>(
+          std::unique_ptr<QuicPacketWriter>(writer),
+          network_helper_->GetLatestClientAddress(), peer_address()));
+}
+
+void WebTransportOnlyClient::Session::MigrateToMultiPortPath(
+    std::unique_ptr<QuicPathValidationContext> context) {
+  auto* path_migration_context =
+      static_cast<PathMigrationContext*>(context.get());
+  MigratePath(path_migration_context->self_address(),
+              path_migration_context->peer_address(),
+              path_migration_context->ReleaseWriter(), /*owns_writer=*/true);
+}
+
+WebTransportOnlyClient::WebTransportOnlyClient(
+    QuicSocketAddress server_address, const QuicServerId& server_id,
+    const ParsedQuicVersionVector& supported_versions, const QuicConfig& config,
+    QuicEventLoop* event_loop,
+    std::unique_ptr<QuicClientDefaultNetworkHelper> absl_nullable
+    network_helper,
+    std::unique_ptr<ProofVerifier> absl_nonnull proof_verifier,
+    std::unique_ptr<SessionCache> absl_nullable session_cache)
+    : QuicClientBase(server_id, supported_versions, config,
+                     new QuicDefaultConnectionHelper(),
+                     event_loop->CreateAlarmFactory().release(),
+                     network_helper != nullptr
+                         ? std::move(network_helper)
+                         : std::make_unique<QuicClientDefaultNetworkHelper>(
+                               event_loop, this),
+                     std::move(proof_verifier), std::move(session_cache)) {
+  set_server_address(server_address);
+}
+
+WebTransportOnlyClient::~WebTransportOnlyClient() { ResetSession(); }
+
+absl::Status WebTransportOnlyClient::ConnectSync(
+    std::string path, VisitorFactory visitor_factory,
+    absl::Span<const std::string> subprotocols,
+    const quiche::HttpHeaderBlock& extra_headers) {
+  if (!Initialize()) {
+    return absl::InternalError("Failed to initialize the client");
+  }
+  if (!QuicClientBase::Connect()) {
+    return absl::InternalError("Failed to establish a QUIC connection");
+  }
+  bool settings_received =
+      ProcessEventsUntil(default_network_helper()->event_loop(),
+                         [&] { return client_session()->settings_received(); });
+  if (!settings_received) {
+    return absl::DeadlineExceededError(
+        "Timed out while waiting for HTTP/3 SETTINGS");
+  }
+  if (!client_session()->SupportsWebTransport()) {
+    return absl::FailedPreconditionError(
+        "QUIC server does not support WebTransport");
+  }
+  auto* stream = absl::down_cast<QuicSpdyClientStream*>(
+      client_session()->CreateOutgoingBidirectionalStream());
+  if (!stream) {
+    return absl::InternalError("Failed to create a CONNECT request stream");
+  }
+
+  quiche::HttpHeaderBlock headers = extra_headers.Clone();
+  headers[":scheme"] = "https";
+  headers[":authority"] = server_id().host();
+  headers[":path"] = std::move(path);
+  headers[":method"] = "CONNECT";
+  headers[":protocol"] = "webtransport";
+  if (!subprotocols.empty()) {
+    absl::StatusOr<std::string> serialized =
+        webtransport::SerializeSubprotocolRequestHeader(subprotocols);
+    if (!serialized.ok()) {
+      return absl::InvalidArgumentError("Invalid subprotocol name supplied");
+    }
+    headers["wt-available-protocols"] = *serialized;
+  }
+  stream->SendRequest(std::move(headers), "", false);
+
+  WebTransportHttp3* web_transport = stream->web_transport();
+  if (!web_transport) {
+    return absl::InternalError(
+        "Failed to associate a WebTransport session with an HTTP request");
+  }
+  web_transport->SetVisitor(std::move(visitor_factory)(web_transport));
+  return absl::OkStatus();
+}
+
+QuicClientDefaultNetworkHelper*
+WebTransportOnlyClient::default_network_helper() {
+  return absl::down_cast<QuicClientDefaultNetworkHelper*>(network_helper());
+}
+
+WebTransportOnlyClient::Session* WebTransportOnlyClient::client_session() {
+  return absl::down_cast<Session*>(QuicClientBase::session());
+}
+
+void WebTransportOnlyClient::InitializeSession() {
+  client_session()->Initialize();
+  client_session()->CryptoConnect();
+}
+
+bool WebTransportOnlyClient::EarlyDataAccepted() {
+  return client_session()->EarlyDataAccepted();
+}
+
+bool WebTransportOnlyClient::ReceivedInchoateReject() {
+  return client_session()->ReceivedInchoateReject();
+}
+
+int WebTransportOnlyClient::GetNumSentClientHellosFromSession() {
+  return client_session()->GetNumSentClientHellos();
+}
+
+int WebTransportOnlyClient::GetNumReceivedServerConfigUpdatesFromSession() {
+  return client_session()->GetNumReceivedServerConfigUpdates();
+}
+
+std::unique_ptr<QuicSession> WebTransportOnlyClient::CreateQuicClientSession(
+    const ParsedQuicVersionVector& supported_versions,
+    QuicConnection* connection) {
+  return std::make_unique<Session>(*config(), supported_versions, connection,
+                                   network_helper(), server_id(),
+                                   crypto_config());
+}
+
+bool WebTransportOnlyClient::HasActiveRequests() {
+  return client_session()->HasActiveRequestStreams();
+}
+
+}  // namespace quic
diff --git a/quiche/quic/tools/web_transport_only_client.h b/quiche/quic/tools/web_transport_only_client.h
new file mode 100644
index 0000000..278b87f
--- /dev/null
+++ b/quiche/quic/tools/web_transport_only_client.h
@@ -0,0 +1,116 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef QUICHE_QUIC_TOOLS_WEB_TRANSPORT_ONLY_CLIENT_H_
+#define QUICHE_QUIC_TOOLS_WEB_TRANSPORT_ONLY_CLIENT_H_
+
+#include <memory>
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "quiche/quic/core/crypto/proof_verifier.h"
+#include "quiche/quic/core/crypto/quic_crypto_client_config.h"
+#include "quiche/quic/core/http/quic_spdy_client_session.h"
+#include "quiche/quic/core/http/quic_spdy_client_stream.h"
+#include "quiche/quic/core/http/quic_spdy_session.h"
+#include "quiche/quic/core/io/quic_event_loop.h"
+#include "quiche/quic/core/quic_config.h"
+#include "quiche/quic/core/quic_connection.h"
+#include "quiche/quic/core/quic_path_validator.h"
+#include "quiche/quic/core/quic_server_id.h"
+#include "quiche/quic/core/quic_session.h"
+#include "quiche/quic/core/quic_versions.h"
+#include "quiche/quic/platform/api/quic_socket_address.h"
+#include "quiche/quic/tools/quic_client_base.h"
+#include "quiche/quic/tools/quic_client_default_network_helper.h"
+#include "quiche/common/http/http_header_block.h"
+#include "quiche/common/quiche_callbacks.h"
+#include "quiche/web_transport/web_transport.h"
+
+namespace quic {
+
+// WebTransportOnlyClient is a dedicated client for applications that
+// are written against the webtransport::Session API. It serves as the client
+// counterpart to WebTransportOnlyDispatcher.
+class WebTransportOnlyClient : public QuicClientBase {
+ public:
+  using VisitorFactory =
+      quiche::UnretainedCallback<std::unique_ptr<webtransport::SessionVisitor>(
+          webtransport::Session*)>;
+
+  WebTransportOnlyClient(
+      QuicSocketAddress server_address, const QuicServerId& server_id,
+      const ParsedQuicVersionVector& supported_versions,
+      const QuicConfig& config, QuicEventLoop* event_loop,
+      std::unique_ptr<QuicClientDefaultNetworkHelper> absl_nullable
+      network_helper,
+      std::unique_ptr<ProofVerifier> absl_nonnull proof_verifier,
+      std::unique_ptr<SessionCache> absl_nullable session_cache);
+
+  WebTransportOnlyClient(const WebTransportOnlyClient&) = delete;
+  WebTransportOnlyClient& operator=(const WebTransportOnlyClient&) = delete;
+
+  ~WebTransportOnlyClient() override;
+
+  // Synchronously establishes a WebTransport session to the server at `path`,
+  // configuring the session visitor by calling `visitor_factory` if the
+  // connection succeeds.
+  //
+  // Note that the function returns when the client creates the CONNECT stream,
+  // and not when the HTTP response for it is received. Doing so allows the
+  // client to open streams before receiving the HTTP response. To wait until
+  // the actual response is received, use OnSessionReady in the session visitor.
+  absl::Status ConnectSync(std::string path, VisitorFactory visitor_factory,
+                           absl::Span<const std::string> subprotocols = {},
+                           const quiche::HttpHeaderBlock& extra_headers = {});
+
+  QuicClientDefaultNetworkHelper* default_network_helper();
+
+ protected:
+  // QuicClientBase implementation.
+  void InitializeSession() override;
+  bool EarlyDataAccepted() override;
+  bool ReceivedInchoateReject() override;
+  int GetNumSentClientHellosFromSession() override;
+  int GetNumReceivedServerConfigUpdatesFromSession() override;
+  bool HasActiveRequests() override;
+
+  std::unique_ptr<QuicSession> CreateQuicClientSession(
+      const ParsedQuicVersionVector& supported_versions,
+      QuicConnection* connection) override;
+
+ private:
+  class Session : public QuicSpdyClientSession {
+   public:
+    Session(const QuicConfig& config,
+            const ParsedQuicVersionVector& supported_versions,
+            QuicConnection* connection,
+            QuicClientBase::NetworkHelper* network_helper,
+            const QuicServerId& server_id,
+            QuicCryptoClientConfig* crypto_config);
+    ~Session() override = default;
+
+    WebTransportHttp3VersionSet LocallySupportedWebTransportVersions()
+        const override;
+    HttpDatagramSupport LocalHttpDatagramSupport() override;
+    void CreateContextForMultiPortPath(
+        std::unique_ptr<MultiPortPathContextObserver> context_observer)
+        override;
+    void MigrateToMultiPortPath(
+        std::unique_ptr<QuicPathValidationContext> context) override;
+
+   private:
+    QuicClientBase::NetworkHelper* network_helper_;
+  };
+
+  Session* client_session();
+};
+
+}  // namespace quic
+
+#endif  // QUICHE_QUIC_TOOLS_WEB_TRANSPORT_ONLY_CLIENT_H_
diff --git a/quiche/quic/tools/web_transport_only_client_test.cc b/quiche/quic/tools/web_transport_only_client_test.cc
new file mode 100644
index 0000000..33ebb5c
--- /dev/null
+++ b/quiche/quic/tools/web_transport_only_client_test.cc
@@ -0,0 +1,162 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "quiche/quic/tools/web_transport_only_client.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "quiche/quic/core/crypto/quic_crypto_server_config.h"
+#include "quiche/quic/core/crypto/quic_random.h"
+#include "quiche/quic/core/deterministic_connection_id_generator.h"
+#include "quiche/quic/core/http/web_transport_only_dispatcher.h"
+#include "quiche/quic/core/http/web_transport_only_server_session.h"
+#include "quiche/quic/core/io/quic_default_event_loop.h"
+#include "quiche/quic/core/io/quic_event_loop.h"
+#include "quiche/quic/core/io/quic_server_io_harness.h"
+#include "quiche/quic/core/io/socket.h"
+#include "quiche/quic/core/quic_config.h"
+#include "quiche/quic/core/quic_connection_id.h"
+#include "quiche/quic/core/quic_default_clock.h"
+#include "quiche/quic/core/quic_default_connection_helper.h"
+#include "quiche/quic/core/quic_version_manager.h"
+#include "quiche/quic/core/quic_versions.h"
+#include "quiche/quic/platform/api/quic_socket_address.h"
+#include "quiche/quic/platform/api/quic_test.h"
+#include "quiche/quic/test_tools/crypto_test_utils.h"
+#include "quiche/quic/tools/quic_event_loop_tools.h"
+#include "quiche/quic/tools/quic_simple_crypto_server_stream_helper.h"
+#include "quiche/common/platform/api/quiche_logging.h"
+#include "quiche/common/quiche_ip_address.h"
+#include "quiche/common/test_tools/quiche_test_utils.h"
+#include "quiche/web_transport/web_transport.h"
+
+namespace quic::test {
+namespace {
+
+class TestSessionVisitor : public webtransport::SessionVisitor {
+ public:
+  explicit TestSessionVisitor(bool* established, bool* closed)
+      : established_(*established), closed_(*closed) {}
+
+  void OnSessionReady() override { established_ = true; }
+  void OnSessionClosed(webtransport::SessionErrorCode /*error_code*/,
+                       const std::string& /*error_message*/) override {
+    closed_ = true;
+  }
+
+  void OnIncomingBidirectionalStreamAvailable() override {}
+  void OnIncomingUnidirectionalStreamAvailable() override {}
+  void OnDatagramReceived(absl::string_view /*datagram*/) override {}
+  void OnCanCreateNewOutgoingBidirectionalStream() override {}
+  void OnCanCreateNewOutgoingUnidirectionalStream() override {}
+
+ private:
+  bool& established_;
+  bool& closed_;
+};
+
+class WebTransportOnlyTestServer {
+ public:
+  explicit WebTransportOnlyTestServer(bool* server_established,
+                                      bool* server_closed)
+      : crypto_config_("secret", QuicRandom::GetInstance(),
+                       crypto_test_utils::ProofSourceForTesting(),
+                       KeyExchangeSource::Default()),
+        version_manager_(CurrentSupportedVersionsWithTls()),
+        connection_id_generator_(kQuicDefaultConnectionIdLength),
+        event_loop_(GetDefaultEventLoop()->Create(QuicDefaultClock::Get())),
+        dispatcher_(&config_, &crypto_config_, &version_manager_,
+                    std::make_unique<QuicDefaultConnectionHelper>(),
+                    std::make_unique<QuicSimpleCryptoServerStreamHelper>(),
+                    event_loop_->CreateAlarmFactory(),
+                    kQuicDefaultConnectionIdLength, connection_id_generator_) {
+    dispatcher_.parameters().handler_factory =
+        [server_established, server_closed](
+            webtransport::Session*, const WebTransportIncomingRequestDetails&)
+        -> absl::StatusOr<WebTransportConnectResponse> {
+      WebTransportConnectResponse response;
+      response.visitor = std::make_unique<TestSessionVisitor>(
+          server_established, server_closed);
+      return response;
+    };
+    dispatcher_.parameters().subprotocol_callback =
+        [](absl::Span<const absl::string_view> subprotocols) {
+          return subprotocols.empty() ? -1 : 0;
+        };
+
+    QuicSocketAddress address(quiche::QuicheIpAddress::Loopback6(), /*port=*/0);
+    absl::StatusOr<OwnedSocketFd> fd = CreateAndBindServerSocket(address);
+    QUICHE_CHECK(fd.ok());
+    fd_ = std::move(*fd);
+
+    absl::StatusOr<std::unique_ptr<QuicServerIoHarness>> io =
+        QuicServerIoHarness::Create(event_loop_.get(), &dispatcher_, *fd_);
+    QUICHE_CHECK(io.ok());
+    io_ = std::move(*io);
+    io_->InitializeWriter();
+  }
+
+  QuicSocketAddress server_address() const { return io_->local_address(); }
+  QuicEventLoop* event_loop() { return event_loop_.get(); }
+
+ private:
+  QuicConfig config_;
+  QuicCryptoServerConfig crypto_config_;
+  QuicVersionManager version_manager_;
+  DeterministicConnectionIdGenerator connection_id_generator_;
+  std::unique_ptr<QuicEventLoop> event_loop_;
+  WebTransportOnlyDispatcher dispatcher_;
+  OwnedSocketFd fd_;
+  std::unique_ptr<QuicServerIoHarness> io_;
+};
+
+class WebTransportOnlyClientTest : public QuicTest {};
+
+TEST_F(WebTransportOnlyClientTest, SuccessfulConnection) {
+  bool server_established = false;
+  bool server_closed = false;
+  WebTransportOnlyTestServer server(&server_established, &server_closed);
+
+  bool client_established = false;
+  bool client_closed = false;
+
+  auto client = std::make_unique<WebTransportOnlyClient>(
+      server.server_address(), QuicServerId("test.example.com", 443),
+      CurrentSupportedVersionsWithTls(), QuicConfig(), server.event_loop(),
+      /*network_helper=*/nullptr, crypto_test_utils::ProofVerifierForTesting(),
+      /*session_cache=*/nullptr);
+
+  // Note: the `ConnectSync` call below implicitly drives both the client and
+  // the server, since both use the same event loop.
+  absl::Status status =
+      client->ConnectSync("/test", [&](webtransport::Session*) {
+        return std::make_unique<TestSessionVisitor>(&client_established,
+                                                    &client_closed);
+      });
+  QUICHE_ASSERT_OK(status);
+
+  bool handshake_success = ProcessEventsUntil(server.event_loop(), [&] {
+    return client_established && server_established;
+  });
+  EXPECT_TRUE(handshake_success);
+  EXPECT_FALSE(client_closed);
+  EXPECT_FALSE(server_closed);
+
+  client->Disconnect();
+  EXPECT_TRUE(client_closed);
+
+  bool server_close_success =
+      ProcessEventsUntil(server.event_loop(), [&] { return server_closed; });
+  EXPECT_TRUE(server_close_success);
+  EXPECT_TRUE(server_closed);
+}
+
+}  // namespace
+}  // namespace quic::test