Make device debuggable if useDwdsWebSocketConnection is true and added simple test case (#171648)

- Make device debuggable if useDwdsWebSocketConnection is true - This
change is needed to support hot reload on web-server devices over
websocket-based DWDS.
- Added simple test case to test hot reload over websocket

related to https://github.com/dart-lang/webdev/issues/2605,
https://github.com/dart-lang/webdev/issues/2645 and
https://github.com/dart-lang/webdev/issues/2646

---------

Co-authored-by: Ben Konyi <bkonyi@google.com>
diff --git a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart
index 25173ae..9be9111 100644
--- a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart
+++ b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart
@@ -158,8 +158,17 @@
   @override
   bool get debuggingEnabled => isRunningDebug && deviceIsDebuggable;
 
-  /// WebServer device is debuggable when running with --start-paused.
-  bool get deviceIsDebuggable => device!.device is! WebServerDevice || debuggingOptions.startPaused;
+  /// Device is debuggable if not a WebServer device, or if running with
+  /// --start-paused or using DWDS WebSocket connection (WebServer device).
+  bool get deviceIsDebuggable =>
+      device!.device is! WebServerDevice ||
+      debuggingOptions.startPaused ||
+      _useDwdsWebSocketConnection;
+
+  bool get _useDwdsWebSocketConnection {
+    final DevFS? devFS = device?.devFS;
+    return devFS is WebDevFS && devFS.useDwdsWebSocketConnection;
+  }
 
   @override
   // Web uses a different plugin registry.
@@ -790,86 +799,102 @@
     Uri? websocketUri;
     if (supportsServiceProtocol) {
       assert(connectDebug != null);
-      _connectionResult = await connectDebug;
-      unawaited(_connectionResult!.debugConnection!.onDone.whenComplete(_cleanupAndExit));
+      unawaited(
+        connectDebug!.then((connectionResult) async {
+          _connectionResult = connectionResult;
+          unawaited(_connectionResult!.debugConnection!.onDone.whenComplete(_cleanupAndExit));
 
-      void onLogEvent(vmservice.Event event) {
-        final String message = processVmServiceMessage(event);
-        _logger.printStatus(message);
-      }
-
-      // This flag is needed to manage breakpoints properly.
-      if (debuggingOptions.startPaused && debuggingOptions.debuggingEnabled) {
-        try {
-          final vmservice.Response result = await _vmService.service.setFlag(
-            'pause_isolates_on_start',
-            'true',
-          );
-          if (result is! vmservice.Success) {
-            _logger.printError('setFlag failure: $result');
+          void onLogEvent(vmservice.Event event) {
+            final String message = processVmServiceMessage(event);
+            _logger.printStatus(message);
           }
-        } on Exception catch (e) {
-          _logger.printError(
-            'Failed to set pause_isolates_on_start=true, proceeding. '
-            'Error: $e',
+
+          // This flag is needed to manage breakpoints properly.
+          if (debuggingOptions.startPaused && debuggingOptions.debuggingEnabled) {
+            try {
+              final vmservice.Response result = await _vmService.service.setFlag(
+                'pause_isolates_on_start',
+                'true',
+              );
+              if (result is! vmservice.Success) {
+                _logger.printError('setFlag failure: $result');
+              }
+            } on Exception catch (e) {
+              _logger.printError(
+                'Failed to set pause_isolates_on_start=true, proceeding. '
+                'Error: $e',
+              );
+            }
+          }
+
+          _stdOutSub = _vmService.service.onStdoutEvent.listen(onLogEvent);
+          _stdErrSub = _vmService.service.onStderrEvent.listen(onLogEvent);
+          _serviceSub = _vmService.service.onServiceEvent.listen(_onServiceEvent);
+          try {
+            await _vmService.service.streamListen(vmservice.EventStreams.kStdout);
+          } on vmservice.RPCError {
+            // It is safe to ignore this error because we expect an error to be
+            // thrown if we're already subscribed.
+          }
+          try {
+            await _vmService.service.streamListen(vmservice.EventStreams.kStderr);
+          } on vmservice.RPCError {
+            // It is safe to ignore this error because we expect an error to be
+            // thrown if we're already subscribed.
+          }
+          try {
+            await _vmService.service.streamListen(vmservice.EventStreams.kService);
+          } on vmservice.RPCError {
+            // It is safe to ignore this error because we expect an error to be
+            // thrown if we're already subscribed.
+          }
+          try {
+            await _vmService.service.streamListen(vmservice.EventStreams.kIsolate);
+          } on vmservice.RPCError {
+            // It is safe to ignore this error because we expect an error to be
+            // thrown if we're not already subscribed.
+          }
+          await setUpVmService(
+            reloadSources: (String isolateId, {bool? force, bool? pause}) async {
+              await restart(pause: pause);
+            },
+            device: device!.device,
+            flutterProject: flutterProject,
+            printStructuredErrorLogMethod: printStructuredErrorLog,
+            vmService: _vmService.service,
           );
-        }
-      }
 
-      _stdOutSub = _vmService.service.onStdoutEvent.listen(onLogEvent);
-      _stdErrSub = _vmService.service.onStderrEvent.listen(onLogEvent);
-      _serviceSub = _vmService.service.onServiceEvent.listen(_onServiceEvent);
-      try {
-        await _vmService.service.streamListen(vmservice.EventStreams.kStdout);
-      } on vmservice.RPCError {
-        // It is safe to ignore this error because we expect an error to be
-        // thrown if we're already subscribed.
-      }
-      try {
-        await _vmService.service.streamListen(vmservice.EventStreams.kStderr);
-      } on vmservice.RPCError {
-        // It is safe to ignore this error because we expect an error to be
-        // thrown if we're already subscribed.
-      }
-      try {
-        await _vmService.service.streamListen(vmservice.EventStreams.kService);
-      } on vmservice.RPCError {
-        // It is safe to ignore this error because we expect an error to be
-        // thrown if we're already subscribed.
-      }
-      try {
-        await _vmService.service.streamListen(vmservice.EventStreams.kIsolate);
-      } on vmservice.RPCError {
-        // It is safe to ignore this error because we expect an error to be
-        // thrown if we're not already subscribed.
-      }
-      await setUpVmService(
-        reloadSources: (String isolateId, {bool? force, bool? pause}) async {
-          await restart(pause: pause);
-        },
-        device: device!.device,
-        flutterProject: flutterProject,
-        printStructuredErrorLogMethod: printStructuredErrorLog,
-        vmService: _vmService.service,
-      );
+          websocketUri = Uri.parse(_connectionResult!.debugConnection!.uri);
+          device!.vmService = _vmService;
 
-      websocketUri = Uri.parse(_connectionResult!.debugConnection!.uri);
-      device!.vmService = _vmService;
-
-      // Run main immediately if the app is not started paused or if there
-      // is no debugger attached. Otherwise, runMain when a resume event
-      // is received.
-      if (!debuggingOptions.startPaused || !supportsServiceProtocol) {
-        _connectionResult!.appConnection!.runMain();
-      } else {
-        late StreamSubscription<void> resumeSub;
-        resumeSub = _vmService.service.onDebugEvent.listen((vmservice.Event event) {
-          if (event.type == vmservice.EventKind.kResume) {
+          // Run main immediately if the app is not started paused or if there
+          // is no debugger attached. Otherwise, runMain when a resume event
+          // is received.
+          if (!debuggingOptions.startPaused || !supportsServiceProtocol) {
             _connectionResult!.appConnection!.runMain();
-            resumeSub.cancel();
+          } else {
+            late StreamSubscription<void> resumeSub;
+            resumeSub = _vmService.service.onDebugEvent.listen((vmservice.Event event) {
+              if (event.type == vmservice.EventKind.kResume) {
+                _connectionResult!.appConnection!.runMain();
+                resumeSub.cancel();
+              }
+            });
           }
-        });
-      }
+
+          if (websocketUri != null) {
+            if (debuggingOptions.vmserviceOutFile != null) {
+              _fileSystem.file(debuggingOptions.vmserviceOutFile)
+                ..createSync(recursive: true)
+                ..writeAsStringSync(websocketUri.toString());
+            }
+            _logger.printStatus('Debug service listening on $websocketUri');
+          }
+          connectionInfoCompleter?.complete(DebugConnectionInfo(wsUri: websocketUri));
+        }),
+      );
+    } else {
+      connectionInfoCompleter?.complete(DebugConnectionInfo());
     }
     // TODO(bkonyi): remove when ready to serve DevTools from DDS.
     if (debuggingOptions.enableDevTools) {
@@ -881,16 +906,8 @@
         ),
       );
     }
-    if (websocketUri != null) {
-      if (debuggingOptions.vmserviceOutFile != null) {
-        _fileSystem.file(debuggingOptions.vmserviceOutFile)
-          ..createSync(recursive: true)
-          ..writeAsStringSync(websocketUri.toString());
-      }
-      _logger.printStatus('Debug service listening on $websocketUri');
-    }
+
     appStartedCompleter?.complete();
-    connectionInfoCompleter?.complete(DebugConnectionInfo(wsUri: websocketUri));
     if (stayResident) {
       await waitForAppToFinish();
     } else {
diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml
index fa69ee5..14485a3 100644
--- a/packages/flutter_tools/pubspec.yaml
+++ b/packages/flutter_tools/pubspec.yaml
@@ -13,7 +13,7 @@
   archive: 3.6.1
   args: 2.7.0
   dds: 5.0.3
-  dwds: 24.4.0
+  dwds: 24.4.1
   code_builder: 4.10.1
   collection: 1.19.1
   completion: 1.0.2
@@ -126,4 +126,4 @@
 dartdoc:
   # Exclude this package from the hosted API docs.
   nodoc: true
-# PUBSPEC CHECKSUM: kav93d
+# PUBSPEC CHECKSUM: 932agm
diff --git a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart
index 9895d19..00ae682 100644
--- a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart
+++ b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart
@@ -1665,7 +1665,9 @@
         mainLibName: 'my_app',
         packages: <String, String>{'path_provider_linux': '../../path_provider_linux'},
       );
-      expect(await residentWebRunner.run(), 0);
+      final connectionInfoCompleter = Completer<DebugConnectionInfo>();
+      expect(await residentWebRunner.run(connectionInfoCompleter: connectionInfoCompleter), 0);
+      await connectionInfoCompleter.future;
       final File generatedLocalizationsFile = globals.fs
           .directory('lib')
           .childDirectory('l10n')
@@ -2056,6 +2058,9 @@
   PackageConfig? lastPackageConfig = PackageConfig.empty;
 
   @override
+  var useDwdsWebSocketConnection = false;
+
+  @override
   Future<Uri> create() async {
     return baseUri;
   }
diff --git a/packages/flutter_tools/test/integration.shard/hot_reload_websocket_test.dart b/packages/flutter_tools/test/integration.shard/hot_reload_websocket_test.dart
new file mode 100644
index 0000000..c28ca83
--- /dev/null
+++ b/packages/flutter_tools/test/integration.shard/hot_reload_websocket_test.dart
@@ -0,0 +1,193 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+@Tags(<String>['flutter-test-driver'])
+library;
+
+import 'dart:async';
+import 'dart:io' as io;
+
+import 'package:file/file.dart';
+import 'package:flutter_tools/src/web/chrome.dart';
+import 'package:flutter_tools/src/web/web_device.dart' show WebServerDevice;
+
+import '../src/common.dart';
+import 'test_data/hot_reload_project.dart';
+import 'test_driver.dart';
+import 'test_utils.dart';
+import 'transition_test_utils.dart';
+
+void main() {
+  testAll();
+}
+
+void testAll({List<String> additionalCommandArgs = const <String>[]}) {
+  group('WebSocket DWDS connection'
+      '${additionalCommandArgs.isEmpty ? '' : ' with args: $additionalCommandArgs'}', () {
+    // Test configuration constants
+    const debugUrlTimeout = Duration(seconds: 20);
+    const appStartTimeout = Duration(seconds: 15);
+    const hotReloadTimeout = Duration(seconds: 10);
+
+    late Directory tempDir;
+    final project = HotReloadProject();
+    late FlutterRunTestDriver flutter;
+
+    setUp(() async {
+      tempDir = createResolvedTempDirectorySync('hot_reload_websocket_test.');
+      await project.setUpIn(tempDir);
+      flutter = FlutterRunTestDriver(tempDir);
+    });
+
+    tearDown(() async {
+      await flutter.stop();
+      tryToDelete(tempDir);
+    });
+
+    testWithoutContext(
+      'hot reload with headless Chrome WebSocket connection',
+      () async {
+        debugPrint('Starting WebSocket DWDS test with headless Chrome...');
+
+        // Set up listening for app output before starting
+        final stdout = StringBuffer();
+        final sawDebugUrl = Completer<String>();
+        final StreamSubscription<String> subscription = flutter.stdout.listen((String e) {
+          stdout.writeln(e);
+          // Extract the debug connection URL
+          if (e.contains('Waiting for connection from Dart debug extension at http://')) {
+            final debugUrlPattern = RegExp(
+              r'Waiting for connection from Dart debug extension at (http://[^\s]+)',
+            );
+            final Match? match = debugUrlPattern.firstMatch(e);
+            if (match != null && !sawDebugUrl.isCompleted) {
+              sawDebugUrl.complete(match.group(1)!);
+            }
+          }
+        });
+
+        io.Process? chromeProcess;
+        try {
+          // Step 1: Start Flutter app with web-server device (will wait for debug connection)
+          debugPrint('Step 1: Starting Flutter app with web-server device...');
+          // Start the app but don't wait for it to complete - it won't complete until Chrome connects
+          final Future<void> appStartFuture = runFlutterWithWebServerDevice(
+            flutter,
+            additionalCommandArgs: [...additionalCommandArgs, '--no-web-resources-cdn'],
+          );
+
+          // Step 2: Wait for DWDS debug URL to be available
+          debugPrint('Step 2: Waiting for DWDS debug service URL...');
+          final String debugUrl = await sawDebugUrl.future.timeout(
+            debugUrlTimeout,
+            onTimeout: () {
+              throw Exception('DWDS debug URL not found - app may not have started correctly');
+            },
+          );
+          debugPrint('✓ DWDS debug service available at: $debugUrl');
+
+          // Step 3: Launch headless Chrome to connect to DWDS
+          debugPrint('Step 3: Launching headless Chrome to connect to DWDS...');
+          chromeProcess = await _launchHeadlessChrome(debugUrl);
+          debugPrint('✓ Headless Chrome launched and connecting to DWDS');
+
+          // Step 4: Wait for app to start (Chrome connection established)
+          debugPrint('Step 4: Waiting for Flutter app to start after Chrome connection...');
+          await appStartFuture.timeout(
+            appStartTimeout,
+            onTimeout: () {
+              throw Exception('App startup did not complete after Chrome connection');
+            },
+          );
+          debugPrint('✓ Flutter app started successfully with WebSocket connection');
+
+          // Step 5: Test hot reload functionality
+          debugPrint('Step 5: Testing hot reload with WebSocket connection...');
+          await flutter.hotReload().timeout(
+            hotReloadTimeout,
+            onTimeout: () {
+              throw Exception('Hot reload timed out');
+            },
+          );
+
+          // Give some time for logs to capture
+          await Future<void>.delayed(const Duration(seconds: 2));
+
+          final output = stdout.toString();
+          expect(output, contains('Reloaded'), reason: 'Hot reload should complete successfully');
+          debugPrint('✓ Hot reload completed successfully with WebSocket connection');
+
+          // Verify the correct infrastructure was used
+          expect(
+            output,
+            contains('Waiting for connection from Dart debug extension'),
+            reason: 'Should wait for debug connection (WebSocket infrastructure)',
+          );
+          expect(output, contains('web-server'), reason: 'Should use web-server device');
+
+          debugPrint('✓ WebSocket DWDS test completed successfully');
+          debugPrint('✓ Verified: web-server device + DWDS + WebSocket connection + hot reload');
+        } finally {
+          await _cleanupResources(chromeProcess, subscription);
+        }
+      },
+      skip: !platform.isMacOS, // Skip on non-macOS platforms where Chrome paths may differ
+    );
+  });
+}
+
+/// Launches headless Chrome with the given debug URL.
+/// Uses findChromeExecutable to locate Chrome on the current platform.
+Future<io.Process> _launchHeadlessChrome(String debugUrl) async {
+  const chromeArgs = [
+    '--headless',
+    '--disable-gpu',
+    '--no-sandbox',
+    '--disable-extensions',
+    '--disable-dev-shm-usage',
+    '--remote-debugging-port=0',
+  ];
+
+  final String chromePath = findChromeExecutable(platform, fileSystem);
+
+  try {
+    return await io.Process.start(chromePath, [...chromeArgs, debugUrl]);
+  } on Exception catch (e) {
+    throw Exception(
+      'Could not launch Chrome at $chromePath: $e. Please ensure Chrome is installed.',
+    );
+  }
+}
+
+/// Cleans up test resources (Chrome process and stdout subscription).
+Future<void> _cleanupResources(
+  io.Process? chromeProcess,
+  StreamSubscription<String> subscription,
+) async {
+  if (chromeProcess != null) {
+    try {
+      chromeProcess.kill();
+      await chromeProcess.exitCode;
+      debugPrint('Chrome process cleaned up');
+    } on Exception catch (e) {
+      debugPrint('Warning: Failed to clean up Chrome process: $e');
+    }
+  }
+  await subscription.cancel();
+}
+
+// Helper to run flutter with web-server device using WebSocket connection.
+Future<void> runFlutterWithWebServerDevice(
+  FlutterRunTestDriver flutter, {
+  bool verbose = false,
+  bool withDebugger = true, // Enable debugger by default for WebSocket connection
+  bool startPaused = false, // Don't start paused for this test
+  List<String> additionalCommandArgs = const <String>[],
+}) => flutter.run(
+  verbose: verbose,
+  withDebugger: withDebugger, // Enable debugger to establish WebSocket connection
+  startPaused: startPaused, // Let the app start normally after debugger connects
+  device: WebServerDevice.kWebServerDeviceId,
+  additionalCommandArgs: additionalCommandArgs,
+);
diff --git a/packages/flutter_tools/test/integration.shard/test_driver.dart b/packages/flutter_tools/test/integration.shard/test_driver.dart
index e173616..a6c5887 100644
--- a/packages/flutter_tools/test/integration.shard/test_driver.dart
+++ b/packages/flutter_tools/test/integration.shard/test_driver.dart
@@ -11,7 +11,7 @@
 import 'package:flutter_tools/src/base/io.dart';
 import 'package:flutter_tools/src/base/utils.dart';
 import 'package:flutter_tools/src/tester/flutter_tester.dart';
-import 'package:flutter_tools/src/web/web_device.dart' show GoogleChromeDevice;
+import 'package:flutter_tools/src/web/web_device.dart' show GoogleChromeDevice, WebServerDevice;
 import 'package:meta/meta.dart';
 import 'package:process/process.dart';
 import 'package:vm_service/vm_service.dart';
@@ -568,6 +568,7 @@
       ],
       withDebugger: withDebugger,
       startPaused: startPaused,
+      waitForDebugPort: device != WebServerDevice.kWebServerDeviceId,
       pauseOnExceptions: pauseOnExceptions,
       script: script,
       verbose: verbose,
@@ -608,6 +609,7 @@
     bool withDebugger = false,
     bool startPaused = false,
     bool pauseOnExceptions = false,
+    bool waitForDebugPort = false,
     bool verbose = false,
     int? attachPort,
   }) async {
@@ -648,12 +650,11 @@
           event: 'app.started',
           timeout: appStartTimeout,
         );
-
+        late final Map<String, Object?> debugPort;
+        if (waitForDebugPort || withDebugger) {
+          debugPort = await _waitFor(event: 'app.debugPort', timeout: appStartTimeout);
+        }
         if (withDebugger) {
-          final Map<String, Object?> debugPort = await _waitFor(
-            event: 'app.debugPort',
-            timeout: appStartTimeout,
-          );
           final wsUriString = (debugPort['params']! as Map<String, Object?>)['wsUri']! as String;
           _vmServiceWsUri = Uri.parse(wsUriString);
           await connectToVmService(pauseOnExceptions: pauseOnExceptions);