Add webdriver test for compiler query parameter (#9608)

diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 7b70852..11c0e27 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -290,6 +290,31 @@
           DEVTOOLS_PACKAGE: devtools_extensions
         run: ./tool/ci/bots.sh
 
+  devtools-webdriver-test:
+    name: ${{ matrix.os }} devtools webdriver test
+    needs: flutter-prep
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        bot:
+          - test_webdriver
+        os: [ubuntu-latest, windows-latest]
+    steps:
+      - name: git clone
+        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
+      - name: Load Cached Flutter SDK
+        uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf
+        with:
+          path: |
+            ./tool/flutter-sdk
+          key: flutter-sdk-${{ runner.os }}-${{ needs.flutter-prep.outputs.latest_flutter_candidate }}
+      - name: tool/ci/bots.sh
+        env:
+          BOT: ${{ matrix.bot }}
+          PLATFORM: vm
+        run: ./tool/ci/bots.sh
+
   benchmark-performance:
     name: benchmark-performance
     needs: flutter-prep
diff --git a/packages/devtools_app/benchmark/devtools_benchmarks_test.dart b/packages/devtools_app/benchmark/devtools_benchmarks_test.dart
index 987d06c..441fa73 100644
--- a/packages/devtools_app/benchmark/devtools_benchmarks_test.dart
+++ b/packages/devtools_app/benchmark/devtools_benchmarks_test.dart
@@ -277,7 +277,7 @@
 
 Future<bool> _isPortAvailable(int port) async {
   try {
-    final RawSocket socket = await RawSocket.connect('localhost', port);
+    final socket = await RawSocket.connect('localhost', port);
     socket.shutdown(SocketDirection.both);
     await socket.close();
     return false;
diff --git a/packages/devtools_app/integration_test/test_infra/run/run_test.dart b/packages/devtools_app/integration_test/test_infra/run/run_test.dart
index 5f7cba5..3daeee0 100644
--- a/packages/devtools_app/integration_test/test_infra/run/run_test.dart
+++ b/packages/devtools_app/integration_test/test_infra/run/run_test.dart
@@ -35,50 +35,10 @@
     // TODO(https://github.com/flutter/devtools/issues/9196): support starting
     // DTD and passing the URI to DevTools server. Workspace roots should be set
     // on the DTD instance based on the connected test app.
-
-    // Start the DevTools server. This will use the DevTools server that is
-    // shipped with the Dart SDK.
-    // TODO(https://github.com/flutter/devtools/issues/9197): launch the
-    // DevTools server from source so that end to end changes (server + app) can
-    // be tested.
-    devToolsServerProcess = await Process.start('dart', [
-      'devtools',
-      // Do not launch DevTools app in the browser. This DevTools server
-      // instance will be used to connect to the DevTools app that is run from
-      // Flutter driver from the integration test runner.
-      '--no-launch-browser',
-      // Disable CORS restrictions so that we can connect to the server from
-      // DevTools app that is served on a different origin.
-      '--disable-cors',
-    ]);
-
-    final addressCompleter = Completer<void>();
-    final sub = devToolsServerProcess.stdout.transform(utf8.decoder).listen((
-      line,
-    ) {
-      if (line.startsWith(_devToolsServerAddressLine)) {
-        // This will pull the server address from a String like:
-        // "Serving DevTools at http://127.0.0.1:9104.".
-        final regexp = RegExp(
-          r'http:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+',
-        );
-        final match = regexp.firstMatch(line);
-        if (match != null) {
-          devToolsServerAddress = match.group(0);
-          addressCompleter.complete();
-        }
-      }
-    });
-
-    await addressCompleter.future.timeout(
-      const Duration(seconds: 10),
-      onTimeout: () async {
-        await sub.cancel();
-        devToolsServerProcess?.kill();
-        throw Exception('Timed out waiting for DevTools server to start.');
-      },
+    devToolsServerProcess = await startDevToolsServer();
+    devToolsServerAddress = await listenForDevToolsAddress(
+      devToolsServerProcess,
     );
-    await sub.cancel();
   }
 
   if (!offline) {
@@ -195,3 +155,60 @@
       );
   }
 }
+
+/// Starts the DevTools server.
+///
+/// Note: This will use the DevTools server that is shipped with the Dart SDK.
+///
+/// TODO(https://github.com/flutter/devtools/issues/9197): launch the
+/// DevTools server from source so that end to end changes (server + app) can
+/// be tested.
+Future<Process> startDevToolsServer() async {
+  final devToolsServerProcess = await Process.start('dart', [
+    'devtools',
+    // Do not launch DevTools app in the browser. This DevTools server
+    // instance will be used to connect to the DevTools app that is run from
+    // Flutter driver from the integration test runner.
+    '--no-launch-browser',
+    // Disable CORS restrictions so that we can connect to the server from
+    // DevTools app that is served on a different origin.
+    '--disable-cors',
+  ]);
+  return devToolsServerProcess;
+}
+
+/// Listens on the [devToolsServerProcess] stdout for the DevTool's address and
+/// returns it.
+Future<String> listenForDevToolsAddress(
+  Process devToolsServerProcess, {
+  Duration timeout = const Duration(seconds: 10),
+}) async {
+  final devToolsAddressCompleter = Completer<String>();
+
+  final sub = devToolsServerProcess.stdout.transform(utf8.decoder).listen((
+    line,
+  ) {
+    if (line.startsWith(_devToolsServerAddressLine)) {
+      // This will pull the server address from a String like:
+      // "Serving DevTools at http://127.0.0.1:9104.".
+      final regexp = RegExp(r'http:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+');
+      final match = regexp.firstMatch(line);
+      if (match != null) {
+        final devToolsServerAddress = match.group(0);
+        devToolsAddressCompleter.complete(devToolsServerAddress);
+      }
+    }
+  });
+
+  await devToolsAddressCompleter.future.timeout(
+    timeout,
+    onTimeout: () async {
+      await sub.cancel();
+      devToolsServerProcess.kill();
+      throw Exception('Timed out waiting for DevTools server to start.');
+    },
+  );
+  await sub.cancel();
+
+  return devToolsAddressCompleter.future;
+}
diff --git a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart
index 359f3f3..038bf98 100644
--- a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart
+++ b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart
@@ -744,12 +744,12 @@
 
   List<CpuStackFrame>? _bottomUpRoots;
 
-  late final Iterable<String> userTags = {
+  late final userTags = <String>{
     for (final cpuSample in cpuSamples)
       if (cpuSample.userTag case final userTag?) userTag,
   };
 
-  late final Iterable<String> vmTags = {
+  late final vmTags = <String>{
     for (final cpuSample in cpuSamples)
       if (cpuSample.vmTag case final vmTag?) vmTag,
   };
diff --git a/packages/devtools_app/lib/src/shared/http/http_request_data.dart b/packages/devtools_app/lib/src/shared/http/http_request_data.dart
index 8be9458..aeb478e 100644
--- a/packages/devtools_app/lib/src/shared/http/http_request_data.dart
+++ b/packages/devtools_app/lib/src/shared/http/http_request_data.dart
@@ -33,7 +33,7 @@
   TimeRange get timeRange => _timeRangeBuilder.build();
 
   // This is modified from within HttpRequestData.
-  final TimeRangeBuilder _timeRangeBuilder = TimeRangeBuilder();
+  final _timeRangeBuilder = TimeRangeBuilder();
 }
 
 /// An abstraction of an HTTP request made through dart:io.
diff --git a/packages/devtools_app/pubspec.yaml b/packages/devtools_app/pubspec.yaml
index d986231..ebd0887 100644
--- a/packages/devtools_app/pubspec.yaml
+++ b/packages/devtools_app/pubspec.yaml
@@ -77,6 +77,7 @@
   stream_channel: ^2.1.1
   test: ^1.21.0
   web_benchmarks: ^4.0.0
+  webdriver: ^3.1.0
   webkit_inspection_protocol: ">=0.5.0 <2.0.0"
 
 flutter:
diff --git a/packages/devtools_app/webdriver_test/web_compiler_test.dart b/packages/devtools_app/webdriver_test/web_compiler_test.dart
new file mode 100644
index 0000000..7bc64c5
--- /dev/null
+++ b/packages/devtools_app/webdriver_test/web_compiler_test.dart
@@ -0,0 +1,98 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:devtools_shared/devtools_test_utils.dart';
+import 'package:devtools_test/helpers.dart';
+import 'package:devtools_test/integration_test.dart';
+import 'package:test/test.dart';
+import 'package:webdriver/async_io.dart';
+
+import '../integration_test/test_infra/run/run_test.dart';
+
+void main() {
+  late Process devtoolsProcess;
+  late WebDriver driver;
+  late String devToolsServerAddress;
+
+  const serverStartupTimeout = Duration(minutes: 1);
+
+  setUpAll(() async {
+    await ChromeDriver().start(debugLogging: true);
+
+    // TODO(https://github.com/flutter/devtools/issues/9197): Launch the
+    // DevTools server from source.
+    // For now, this test uses the version of DevTools bundled in the Dart SDK
+    // because building and running from source is too prohibitive until
+    // DevTools is moved into the Dart SDK. See issue for details.
+    devtoolsProcess = await startDevToolsServer();
+    devToolsServerAddress = await listenForDevToolsAddress(
+      devtoolsProcess,
+      timeout: serverStartupTimeout,
+    );
+
+    driver = await createDriver(
+      uri: Uri.parse('http://127.0.0.1:${ChromeDriver.port}'),
+      desired: {
+        ...Capabilities.chrome,
+        Capabilities.chromeOptions: {
+          'args': ['--headless'],
+        },
+      },
+    );
+  });
+
+  tearDownAll(() async {
+    await driver.quit();
+    devtoolsProcess.kill();
+  });
+
+  /// Reads the "flt-renderer" attribute on the body element.
+  ///
+  /// This can be used to determine whether the render is canvaskit or skwasm:
+  /// https://github.com/flutter/devtools/pull/9406#pullrequestreview-3142210823
+  Future<String?> readRendererAttribute() => retryAsync<String?>(
+    () async {
+      final body = await driver.findElement(const By.tagName('body'));
+      return body.attributes['flt-renderer'];
+    },
+    condition: (result) => result != null,
+    onRetry: () => Future.delayed(const Duration(milliseconds: 250)),
+  );
+
+  test(
+    'compiler query param determines skwasm/canvaskit renderer',
+    timeout: longTimeout,
+    () async {
+      // Open the DevTools URL with ?compiler=wasm.
+      await driver.get(
+        _addQueryParam(devToolsServerAddress, param: 'compiler', value: 'wasm'),
+      );
+
+      // Verify we are using the skwasm renderer.
+      expect(await readRendererAttribute(), equals('skwasm'));
+
+      // Open the DevTools URL with ?compiler=js.
+      await driver.get(
+        _addQueryParam(devToolsServerAddress, param: 'compiler', value: 'js'),
+      );
+
+      // Verify we are using the canvaskit renderer.
+      expect(await readRendererAttribute(), equals('canvaskit'));
+    },
+  );
+}
+
+String _addQueryParam(
+  String url, {
+  required String param,
+  required String value,
+}) {
+  final uri = Uri.parse(url);
+  final newQueryParameters = Map<String, dynamic>.of(uri.queryParameters);
+  newQueryParameters[param] = value;
+  return uri.replace(queryParameters: newQueryParameters).toString();
+}
diff --git a/packages/devtools_app_shared/lib/src/service/flutter_version.dart b/packages/devtools_app_shared/lib/src/service/flutter_version.dart
index f2819bb..5e41454 100644
--- a/packages/devtools_app_shared/lib/src/service/flutter_version.dart
+++ b/packages/devtools_app_shared/lib/src/service/flutter_version.dart
@@ -4,7 +4,7 @@
 
 import 'package:devtools_shared/devtools_shared.dart';
 
-import '../../utils.dart';
+import '../utils/enum_utils.dart';
 
 /// Flutter version service registered by Flutter Tools.
 ///
@@ -122,7 +122,7 @@
     String versionStr, {
     String? channelStr,
   }) {
-    // Check if channel string is valid. 
+    // Check if channel string is valid.
     if (channelStr != null) {
       final channel = FlutterChannel.fromName(channelStr);
       if (channel != null) return channel;
diff --git a/packages/devtools_shared/lib/src/test/chrome_driver.dart b/packages/devtools_shared/lib/src/test/chrome_driver.dart
index be78cc9..ab84cba 100644
--- a/packages/devtools_shared/lib/src/test/chrome_driver.dart
+++ b/packages/devtools_shared/lib/src/test/chrome_driver.dart
@@ -9,6 +9,8 @@
 import 'io_utils.dart';
 
 class ChromeDriver with IOMixin {
+  static const port = 4444;
+
   Process? _process;
 
   // TODO(kenz): add error messaging if the chromedriver executable is not
@@ -17,17 +19,20 @@
   Future<void> start({bool debugLogging = false}) async {
     try {
       const chromedriverExe = 'chromedriver';
-      const chromedriverArgs = ['--port=4444'];
+      const chromedriverArgs = ['--port=$port'];
       if (debugLogging) {
         print('${DateTime.now()}: starting the chromedriver process');
-        print('${DateTime.now()}: > $chromedriverExe '
-            '${chromedriverArgs.join(' ')}');
+        print(
+          '${DateTime.now()}: > $chromedriverExe '
+          '${chromedriverArgs.join(' ')}',
+        );
       }
       final process = _process = await Process.start(
         chromedriverExe,
         chromedriverArgs,
       );
       listenToProcessOutput(process, printTag: 'ChromeDriver');
+      await _waitForPortOpen(port);
     } catch (e) {
       // ignore: avoid-throw-in-catch-block, by design
       throw Exception('Error starting chromedriver: $e');
@@ -47,4 +52,27 @@
     }
     await killGracefully(process, debugLogging: debugLogging);
   }
+
+  Future<void> _waitForPortOpen(
+    int port, {
+    Duration timeout = const Duration(seconds: 10),
+  }) async {
+    final stopwatch = Stopwatch()..start();
+
+    while (stopwatch.elapsed < timeout) {
+      try {
+        final socket = await Socket.connect('127.0.0.1', port);
+        socket.destroy();
+        stopwatch.stop();
+        return;
+      } catch (_) {
+        await Future.delayed(const Duration(milliseconds: 200));
+      }
+    }
+
+    stopwatch.stop();
+    throw Exception(
+      'ChromeDriver failed to start on port $port within ${timeout.inSeconds} seconds.',
+    );
+  }
 }
diff --git a/packages/devtools_test/lib/src/helpers/utils.dart b/packages/devtools_test/lib/src/helpers/utils.dart
index 2ff7fe5..a11e365 100644
--- a/packages/devtools_test/lib/src/helpers/utils.dart
+++ b/packages/devtools_test/lib/src/helpers/utils.dart
@@ -224,21 +224,39 @@
   }
 }
 
+/// Retries the given async [action] until the [condition] is met.
+Future<T> retryAsync<T>(
+  FutureOr<T> Function() action, {
+  required bool Function(T result) condition,
+  required Future<void> Function() onRetry,
+  int retries = 3,
+}) async {
+  final result = await action();
+
+  if (condition(result) || retries == 1) return result;
+
+  await onRetry();
+
+  return retryAsync(
+    action,
+    condition: condition,
+    onRetry: onRetry,
+    retries: retries - 1,
+  );
+}
+
 /// Given a [finder], repeatedly pumps until found, or until there are no more
 /// retries.
 Future<Finder> retryUntilFound(
   Finder finder, {
   required WidgetTester tester,
   int retries = 3,
-}) async {
-  if (retries == 0) return finder;
-
-  final found = tester.any(finder);
-  if (found) return finder;
-
-  await tester.pump(safePumpDuration);
-  return retryUntilFound(finder, tester: tester, retries: retries - 1);
-}
+}) => retryAsync(
+  () => finder,
+  condition: (f) => tester.any(f),
+  onRetry: () => tester.pump(safePumpDuration),
+  retries: retries,
+);
 
 void logStatus(String message) {
   // ignore: avoid_print, intentional print for test output
diff --git a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart
index 6187893..605df1e 100644
--- a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart
+++ b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart
@@ -156,16 +156,16 @@
 ///
 /// Adjust as needed; this is used to override the 10-minute or infinite timeout
 /// in [testWidgets].
-const Timeout shortTimeout = Timeout(Duration(minutes: 2));
+const shortTimeout = Timeout(Duration(minutes: 2));
 
 /// A timeout for a "medium" integration test.
 ///
 /// Adjust as needed; this is used to override the 10-minute or infinite timeout
 /// in [testWidgets].
-const Timeout mediumTimeout = Timeout(Duration(minutes: 3));
+const mediumTimeout = Timeout(Duration(minutes: 3));
 
 /// A timeout for a "long" integration test.
 ///
 /// Adjust as needed; this is used to override the 10-minute or infinite timeout
 /// in [testWidgets].
-const Timeout longTimeout = Timeout(Duration(minutes: 4));
+const longTimeout = Timeout(Duration(minutes: 4));
diff --git a/tool/ci/bots.sh b/tool/ci/bots.sh
index c4eda2d..60ed776 100755
--- a/tool/ci/bots.sh
+++ b/tool/ci/bots.sh
@@ -64,6 +64,9 @@
         exit 1
     fi
 
+elif [ "$BOT" = "test_webdriver" ]; then
+    flutter test webdriver_test
+
 # TODO(https://github.com/flutter/devtools/issues/1987): consider running integration tests
 # for a DDC build of DevTools
 # elif [ "$BOT" = "integration_ddc" ]; then