diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7b70852fb71..11c0e27382a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -290,6 +290,31 @@ jobs: 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 987d06c5e81..441fa73ea5d 100644 --- a/packages/devtools_app/benchmark/devtools_benchmarks_test.dart +++ b/packages/devtools_app/benchmark/devtools_benchmarks_test.dart @@ -277,7 +277,7 @@ Future _findAvailablePort({required int startingAt}) async { Future _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 5f7cba5846b..3daeee093f2 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 @@ Future runFlutterIntegrationTest( // 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(); - 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 @@ class DevToolsAppTestRunnerArgs extends IntegrationTestRunnerArgs { ); } } + +/// 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 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 listenForDevToolsAddress( + Process devToolsServerProcess, { + Duration timeout = const Duration(seconds: 10), +}) async { + final devToolsAddressCompleter = Completer(); + + 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 359f3f31cd8..038bf98459a 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 @@ class CpuProfileData with Serializable { List? _bottomUpRoots; - late final Iterable userTags = { + late final userTags = { for (final cpuSample in cpuSamples) if (cpuSample.userTag case final userTag?) userTag, }; - late final Iterable vmTags = { + late final vmTags = { 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 8be9458d23f..aeb478e44bb 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 @@ class DartIOHttpInstantEvent { 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 e17f6a0db18..e531d296421 100644 --- a/packages/devtools_app/pubspec.yaml +++ b/packages/devtools_app/pubspec.yaml @@ -77,6 +77,7 @@ dev_dependencies: 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 00000000000..7bc64c58d23 --- /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 readRendererAttribute() => retryAsync( + () 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.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 f2819bb0d60..5e4145499e7 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 @@ final class FlutterVersion extends SemanticVersion { 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 be78cc97985..ab84cba8eb1 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 'dart:io'; 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 @@ class ChromeDriver with IOMixin { Future 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 @@ class ChromeDriver with IOMixin { } await killGracefully(process, debugLogging: debugLogging); } + + Future _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 2ff7fe58caf..a11e3657368 100644 --- a/packages/devtools_test/lib/src/helpers/utils.dart +++ b/packages/devtools_test/lib/src/helpers/utils.dart @@ -224,21 +224,39 @@ void verifyIsSearchMatchForTreeData>( } } +/// Retries the given async [action] until the [condition] is met. +Future retryAsync( + FutureOr Function() action, { + required bool Function(T result) condition, + required Future 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 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 6187893ad20..605df1e5e47 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 @@ Future verifyScreenshot( /// /// 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/pubspec.lock b/pubspec.lock index c6ab1cdb244..7127d25f17d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: diff --git a/tool/ci/bots.sh b/tool/ci/bots.sh index c4eda2d2c81..60ed7763025 100755 --- a/tool/ci/bots.sh +++ b/tool/ci/bots.sh @@ -64,6 +64,9 @@ elif [[ "$BOT" == "test_ddc" || "$BOT" == "test_dart2js" ]]; then 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