| // Copyright 2013 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 'dart:io' as io; |
| |
| import 'package:path/path.dart' as pathlib; |
| // TODO(yjbanov): remove hacks when this is fixed: |
| // https://github.com/dart-lang/test/issues/1521 |
| import 'package:test_api/src/backend/group.dart' as hack; |
| import 'package:test_api/src/backend/live_test.dart' as hack; |
| import 'package:test_api/src/backend/runtime.dart' as hack; |
| import 'package:test_core/src/executable.dart' as test; |
| import 'package:test_core/src/runner/configuration/reporters.dart' as hack; |
| import 'package:test_core/src/runner/engine.dart' as hack; |
| import 'package:test_core/src/runner/hack_register_platform.dart' as hack; |
| import 'package:test_core/src/runner/reporter.dart' as hack; |
| |
| import '../browser.dart'; |
| import '../environment.dart'; |
| import '../exceptions.dart'; |
| import '../pipeline.dart'; |
| import '../test_platform.dart'; |
| import '../utils.dart'; |
| |
| // Maximum number of tests that run concurrently. |
| const int _testConcurrency = int.fromEnvironment('FELT_TEST_CONCURRENCY', defaultValue: 10); |
| |
| /// Runs web tests. |
| /// |
| /// Assumes the artifacts from [CompileTestsStep] are available, either from |
| /// running it prior to this step locally, or by having the build graph copy |
| /// them from another bot. |
| class RunTestsStep implements PipelineStep { |
| RunTestsStep({ |
| required this.browserEnvironment, |
| required this.isDebug, |
| required this.doUpdateScreenshotGoldens, |
| this.testFiles, |
| }); |
| |
| final BrowserEnvironment browserEnvironment; |
| final List<FilePath>? testFiles; |
| final bool isDebug; |
| final bool doUpdateScreenshotGoldens; |
| |
| /// Global list of shards that failed. |
| /// |
| /// This is used to make sure that when there's a test failure anywhere we |
| /// exit with a non-zero exit code. |
| /// |
| /// Shards must never be removed from this list, only added. |
| List<String> failedShards = <String>[]; |
| |
| /// Whether all test shards succeeded. |
| bool get allShardsPassed => failedShards.isEmpty; |
| |
| @override |
| String get description => 'run_tests'; |
| |
| @override |
| bool get isSafeToInterrupt => true; |
| |
| @override |
| Future<void> interrupt() async {} |
| |
| @override |
| Future<void> run() async { |
| _copyTestFontsIntoWebUi(); |
| await _prepareTestResultsDirectory(); |
| await browserEnvironment.prepareEnvironment(); |
| |
| final List<FilePath> testFiles = this.testFiles ?? findAllTests(); |
| |
| // Separate screenshot tests from unit-tests. Screenshot tests must run |
| // one at a time. Otherwise, they will end up screenshotting each other. |
| // This is not an issue for unit-tests. |
| final FilePath failureSmokeTestPath = FilePath.fromWebUi( |
| 'test/golden_tests/golden_failure_smoke_test.dart', |
| ); |
| final List<FilePath> screenshotTestFiles = <FilePath>[]; |
| final List<FilePath> unitTestFiles = <FilePath>[]; |
| |
| for (final FilePath testFilePath in testFiles) { |
| if (!testFilePath.absolute.endsWith('_test.dart')) { |
| // Not a test file at all. Skip. |
| continue; |
| } |
| if (testFilePath == failureSmokeTestPath) { |
| // A smoke test that fails on purpose. Skip. |
| continue; |
| } |
| |
| // All files under test/golden_tests are considered golden tests. |
| final bool isUnderGoldenTestsDirectory = |
| pathlib.split(testFilePath.relativeToWebUi).contains('golden_tests'); |
| // Any file whose name ends with "_golden_test.dart" is run as a golden test. |
| final bool isGoldenTestFile = pathlib |
| .basename(testFilePath.relativeToWebUi) |
| .endsWith('_golden_test.dart'); |
| if (isUnderGoldenTestsDirectory || isGoldenTestFile) { |
| screenshotTestFiles.add(testFilePath); |
| } else { |
| unitTestFiles.add(testFilePath); |
| } |
| } |
| |
| // This test returns a non-zero exit code on purpose. Run it separately. |
| if (testFiles.contains(failureSmokeTestPath)) { |
| await _runTestBatch( |
| testFiles: <FilePath>[failureSmokeTestPath], |
| browserEnvironment: browserEnvironment, |
| concurrency: 1, |
| expectFailure: true, |
| isDebug: isDebug, |
| doUpdateScreenshotGoldens: doUpdateScreenshotGoldens, |
| ); |
| } |
| |
| // Run all unit-tests as a single batch and with high concurrency. |
| if (unitTestFiles.isNotEmpty) { |
| await _runTestBatch( |
| testFiles: unitTestFiles, |
| browserEnvironment: browserEnvironment, |
| concurrency: _testConcurrency, |
| expectFailure: false, |
| isDebug: isDebug, |
| doUpdateScreenshotGoldens: doUpdateScreenshotGoldens, |
| ); |
| _checkExitCode('Unit tests'); |
| } |
| |
| // Run screenshot tests one at a time. Otherwise, tests end up |
| // screenshotting each other. |
| for (final FilePath screenshotTestFilePath in screenshotTestFiles) { |
| await _runTestBatch( |
| testFiles: <FilePath>[screenshotTestFilePath], |
| browserEnvironment: browserEnvironment, |
| concurrency: 1, |
| expectFailure: false, |
| isDebug: isDebug, |
| doUpdateScreenshotGoldens: doUpdateScreenshotGoldens, |
| ); |
| _checkExitCode('Golden tests'); |
| } |
| |
| if (!allShardsPassed) { |
| throw ToolExit(_createFailedShardsMessage()); |
| } |
| } |
| |
| void _checkExitCode(String shard) { |
| if (io.exitCode != 0) { |
| failedShards.add(shard); |
| } |
| } |
| |
| String _createFailedShardsMessage() { |
| final StringBuffer message = StringBuffer( |
| 'The following test shards failed:\n', |
| ); |
| for (final String failedShard in failedShards) { |
| message.writeln(' - $failedShard'); |
| } |
| return message.toString(); |
| } |
| } |
| |
| Future<void> _prepareTestResultsDirectory() async { |
| if (environment.webUiTestResultsDirectory.existsSync()) { |
| environment.webUiTestResultsDirectory.deleteSync(recursive: true); |
| } |
| environment.webUiTestResultsDirectory.createSync(recursive: true); |
| } |
| |
| const List<String> _kTestFonts = <String>[ |
| 'ahem.ttf', |
| 'Roboto-Regular.ttf', |
| 'NotoNaskhArabic-Regular.ttf', |
| 'NotoColorEmoji.ttf', |
| ]; |
| |
| void _copyTestFontsIntoWebUi() { |
| final String fontsPath = pathlib.join( |
| environment.flutterDirectory.path, |
| 'third_party', |
| 'txt', |
| 'third_party', |
| 'fonts', |
| ); |
| |
| for (final String fontFile in _kTestFonts) { |
| final io.File sourceTtf = io.File(pathlib.join(fontsPath, fontFile)); |
| final String destinationTtfPath = |
| pathlib.join(environment.webUiRootDir.path, 'lib', 'assets', fontFile); |
| sourceTtf.copySync(destinationTtfPath); |
| } |
| } |
| |
| /// Runs a batch of tests. |
| /// |
| /// Unless [expectFailure] is set to false, sets [io.exitCode] to a non-zero |
| /// value if any tests fail. |
| Future<void> _runTestBatch({ |
| required List<FilePath> testFiles, |
| required bool isDebug, |
| required BrowserEnvironment browserEnvironment, |
| required bool doUpdateScreenshotGoldens, |
| required int concurrency, |
| required bool expectFailure, |
| }) async { |
| final String configurationFilePath = pathlib.join( |
| environment.webUiRootDir.path, |
| browserEnvironment.packageTestConfigurationYamlFile, |
| ); |
| final List<String> testArgs = <String>[ |
| ...<String>['-r', 'compact'], |
| '--concurrency=$concurrency', |
| if (isDebug) '--pause-after-load', |
| // Don't pollute logs with output from tests that are expected to fail. |
| if (expectFailure) |
| '--reporter=name-only', |
| '--platform=${browserEnvironment.packageTestRuntime.identifier}', |
| '--precompiled=${environment.webUiBuildDir.path}', |
| '--configuration=$configurationFilePath', |
| '--', |
| ...testFiles.map((FilePath f) => f.relativeToWebUi).toList(), |
| ]; |
| |
| if (expectFailure) { |
| hack.registerReporter( |
| 'name-only', |
| hack.ReporterDetails( |
| 'Prints the name of the test, but suppresses all other test output.', |
| (_, hack.Engine engine, __) => NameOnlyReporter(engine)), |
| ); |
| } |
| |
| hack.registerPlatformPlugin(<hack.Runtime>[ |
| browserEnvironment.packageTestRuntime, |
| ], () { |
| return BrowserPlatform.start( |
| browserEnvironment: browserEnvironment, |
| // It doesn't make sense to update a screenshot for a test that is |
| // expected to fail. |
| doUpdateScreenshotGoldens: !expectFailure && doUpdateScreenshotGoldens, |
| ); |
| }); |
| |
| // We want to run tests with `web_ui` as a working directory. |
| final dynamic originalCwd = io.Directory.current; |
| io.Directory.current = environment.webUiRootDir.path; |
| try { |
| await test.main(testArgs); |
| } finally { |
| io.Directory.current = originalCwd; |
| } |
| |
| if (expectFailure) { |
| if (io.exitCode != 0) { |
| // It failed, as expected. |
| print('Test successfully failed, as expected.'); |
| io.exitCode = 0; |
| } else { |
| io.stderr.writeln( |
| 'Tests ${testFiles.join(', ')} did not fail. Expected failure.', |
| ); |
| io.exitCode = 1; |
| } |
| } |
| } |
| |
| /// Prints the name of the test, but suppresses all other test output. |
| /// |
| /// This is useful to prevent pollution of logs by tests that are expected to |
| /// fail. |
| class NameOnlyReporter implements hack.Reporter { |
| NameOnlyReporter(hack.Engine testEngine) { |
| testEngine.onTestStarted.listen(_printTestName); |
| } |
| |
| void _printTestName(hack.LiveTest test) { |
| print('Running ${test.groups.map((hack.Group group) => group.name).join(' ')} ${test.individualName}'); |
| } |
| |
| @override |
| void pause() {} |
| |
| @override |
| void resume() {} |
| } |