[web] validate WebDriver responses (#96884)

Validate WebDriver responses
diff --git a/packages/flutter_driver/lib/src/driver/web_driver.dart b/packages/flutter_driver/lib/src/driver/web_driver.dart
index 4e7852b..7852bfe 100644
--- a/packages/flutter_driver/lib/src/driver/web_driver.dart
+++ b/packages/flutter_driver/lib/src/driver/web_driver.dart
@@ -105,25 +105,60 @@
     );
   }
 
+  static DriverError _createMalformedExtensionResponseError(Object? data) {
+    throw DriverError(
+      'Received malformed response from the FlutterDriver extension.\n'
+      'Expected a JSON map containing a "response" field and, optionally, an '
+      '"isError" field, but got ${data.runtimeType}: $data'
+    );
+  }
+
   @override
   Future<Map<String, dynamic>> sendCommand(Command command) async {
-    Map<String, dynamic> response;
+    final Map<String, dynamic> response;
+    final Object? data;
     final Map<String, String> serialized = command.serialize();
     _logCommunication('>>> $serialized');
     try {
-      final dynamic data = await _connection.sendCommand("window.\$flutterDriver('${jsonEncode(serialized)}')", command.timeout);
-      response = data != null ? (json.decode(data as String) as Map<String, dynamic>?)! : <String, dynamic>{};
+      data = await _connection.sendCommand("window.\$flutterDriver('${jsonEncode(serialized)}')", command.timeout);
+
+      // The returned data is expected to be a string. If it's null or anything
+      // other than a string, something's wrong.
+      if (data is! String) {
+        throw _createMalformedExtensionResponseError(data);
+      }
+
+      final Object? decoded = json.decode(data);
+      if (decoded is! Map<String, dynamic>) {
+        throw _createMalformedExtensionResponseError(data);
+      } else {
+        response = decoded;
+      }
+
       _logCommunication('<<< $response');
+    } on DriverError catch(_) {
+      rethrow;
     } catch (error, stackTrace) {
       throw DriverError(
-        "Failed to respond to $command due to remote error\n : \$flutterDriver('${jsonEncode(serialized)}')",
+        'FlutterDriver command ${command.runtimeType} failed due to a remote error.\n'
+        'Command sent: ${jsonEncode(serialized)}',
         error,
         stackTrace
       );
     }
-    if (response['isError'] == true)
-      throw DriverError('Error in Flutter application: ${response['response']}');
-    return response['response'] as Map<String, dynamic>;
+
+    final Object? isError = response['isError'];
+    final Object? responseData = response['response'];
+    if (isError is! bool?) {
+      throw _createMalformedExtensionResponseError(data);
+    } else if (isError == true) {
+      throw DriverError('Error in Flutter application: $responseData');
+    }
+
+    if (responseData is! Map<String, dynamic>) {
+      throw _createMalformedExtensionResponseError(data);
+    }
+    return responseData;
   }
 
   @override
diff --git a/packages/flutter_driver/test/src/real_tests/flutter_driver_test.dart b/packages/flutter_driver/test/src/real_tests/flutter_driver_test.dart
index ff7661e..8679f66 100644
--- a/packages/flutter_driver/test/src/real_tests/flutter_driver_test.dart
+++ b/packages/flutter_driver/test/src/real_tests/flutter_driver_test.dart
@@ -745,8 +745,8 @@
       const String waitForCommandLog = '>>> {command: waitFor, timeout: 1234, finderType: ByTooltipMessage, text: logCommunicationToFile test}';
       const String responseLog = '<<< {isError: false, response: {status: ok}, type: Response}';
 
-      expect(commandLog.contains(waitForCommandLog), true, reason: '$commandLog not contains $waitForCommandLog');
-      expect(commandLog.contains(responseLog), true, reason: '$commandLog not contains $responseLog');
+      expect(commandLog, contains(waitForCommandLog), reason: '$commandLog not contains $waitForCommandLog');
+      expect(commandLog, contains(responseLog), reason: '$commandLog not contains $responseLog');
     });
 
     test('logCommunicationToFile = false', () async {
diff --git a/packages/flutter_driver/test/src/web_tests/web_driver_test.dart b/packages/flutter_driver/test/src/web_tests/web_driver_test.dart
new file mode 100644
index 0000000..9370e0e
--- /dev/null
+++ b/packages/flutter_driver/test/src/web_tests/web_driver_test.dart
@@ -0,0 +1,139 @@
+// 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.
+
+import 'package:flutter_driver/src/common/error.dart';
+import 'package:flutter_driver/src/common/health.dart';
+import 'package:flutter_driver/src/driver/web_driver.dart';
+import 'package:webdriver/src/common/log.dart';
+
+import '../../common.dart';
+
+void main() {
+  group('WebDriver', () {
+    late FakeFlutterWebConnection fakeConnection;
+    late WebFlutterDriver driver;
+
+    setUp(() {
+      fakeConnection = FakeFlutterWebConnection();
+      driver = WebFlutterDriver.connectedTo(fakeConnection);
+    });
+
+    test('sendCommand succeeds', () async {
+      fakeConnection.fakeResponse = '''
+{
+  "isError": false,
+  "response": {
+    "test": "hello"
+  }
+}
+''';
+
+      final Map<String, Object?> response = await driver.sendCommand(const GetHealth());
+      expect(response['test'], 'hello');
+    });
+
+    test('sendCommand fails on communication error', () async {
+      fakeConnection.communicationError = Error();
+      expect(
+        () => driver.sendCommand(const GetHealth()),
+        _throwsDriverErrorWithMessage(
+          'FlutterDriver command GetHealth failed due to a remote error.\n'
+          'Command sent: {"command":"get_health"}'
+        ),
+      );
+    });
+
+    test('sendCommand fails on null', () async {
+      fakeConnection.fakeResponse = null;
+      expect(
+        () => driver.sendCommand(const GetHealth()),
+        _throwsDriverErrorWithDataString('Null', 'null'),
+      );
+    });
+
+    test('sendCommand fails when response data is not a string', () async {
+      fakeConnection.fakeResponse = 1234;
+      expect(
+        () => driver.sendCommand(const GetHealth()),
+        _throwsDriverErrorWithDataString('int', '1234'),
+      );
+    });
+
+    test('sendCommand fails when isError is true', () async {
+      fakeConnection.fakeResponse = '''
+{
+  "isError": true,
+  "response": "test error message"
+}
+''';
+      expect(
+        () => driver.sendCommand(const GetHealth()),
+        _throwsDriverErrorWithMessage(
+          'Error in Flutter application: test error message'
+        ),
+      );
+    });
+
+    test('sendCommand fails when isError is not bool', () async {
+      fakeConnection.fakeResponse = '{ "isError": 5 }';
+      expect(
+        () => driver.sendCommand(const GetHealth()),
+        _throwsDriverErrorWithDataString('String', '{ "isError": 5 }'),
+      );
+    });
+
+    test('sendCommand fails when "response" field is not a JSON map', () async {
+      fakeConnection.fakeResponse = '{ "response": 5 }';
+      expect(
+        () => driver.sendCommand(const GetHealth()),
+        _throwsDriverErrorWithDataString('String', '{ "response": 5 }'),
+      );
+    });
+  });
+}
+
+Matcher _throwsDriverErrorWithMessage(String expectedMessage) {
+  return throwsA(allOf(
+    isA<DriverError>(),
+    predicate<DriverError>((DriverError error) {
+      final String actualMessage = error.message;
+      return actualMessage == expectedMessage;
+    }, 'contains message: $expectedMessage'),
+  ));
+}
+
+Matcher _throwsDriverErrorWithDataString(String dataType, String dataString) {
+  return _throwsDriverErrorWithMessage(
+    'Received malformed response from the FlutterDriver extension.\n'
+    'Expected a JSON map containing a "response" field and, optionally, an '
+    '"isError" field, but got $dataType: $dataString'
+  );
+}
+
+class FakeFlutterWebConnection implements FlutterWebConnection {
+  @override
+  bool supportsTimelineAction = false;
+
+  @override
+  Future<void> close() async {}
+
+  @override
+  Stream<LogEntry> get logs => throw UnimplementedError();
+
+  @override
+  Future<List<int>> screenshot() {
+    throw UnimplementedError();
+  }
+
+  Object? fakeResponse;
+  Error? communicationError;
+
+  @override
+  Future<Object?> sendCommand(String script, Duration? duration) async {
+    if (communicationError != null) {
+      throw communicationError!;
+    }
+    return fakeResponse;
+  }
+}