| // Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| // Warning: This file has to start up fast so we can't import lots of stuff. |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| import 'dart:isolate'; |
| |
| import 'test/utils/io_utils.dart'; |
| |
| Future<void> main(List<String> args) async { |
| Directory.current = Directory.fromUri(_repoDir); |
| Stopwatch stopwatch = new Stopwatch()..start(); |
| // Expect something like /full/path/to/sdk/pkg/some_dir/whatever/else |
| if (args.length != 1) throw "Need exactly one argument."; |
| |
| final List<String> changedFiles = getChangedFiles(collectUncommitted: false); |
| String callerPath = args[0].replaceAll("\\", "/"); |
| if (!_shouldRun(changedFiles, callerPath)) { |
| return; |
| } |
| |
| List<Work> workItems = []; |
| |
| // This run is now the only run that will actually run any smoke tests. |
| // First collect all relevant smoke tests. |
| // Note that this is *not* perfect, e.g. it might think there's no reason for |
| // a test because the tested hasn't changed even though the actual test has. |
| // E.g. if you only update the spelling dictionary no spell test will be run |
| // because the files being spell-tested hasn't changed. |
| workItems.addIfNotNull(_createCompileAndLintTestWork(changedFiles)); |
| workItems.addIfNotNull(_createMessagesTestWork(changedFiles)); |
| workItems.addIfNotNull(_createSpellingTestNotSourceWork(changedFiles)); |
| workItems.addIfNotNull(_createSpellingTestSourceWork(changedFiles)); |
| workItems.addIfNotNull(_createLintWork(changedFiles)); |
| workItems.addIfNotNull(_createDepsTestWork(changedFiles)); |
| bool shouldRunGenerateFilesTest = _shouldRunGenerateFilesTest(changedFiles); |
| |
| // Then run them if we have any. |
| if (workItems.isEmpty && !shouldRunGenerateFilesTest) { |
| print("Nothing to do."); |
| return; |
| } |
| |
| List<Future> futures = []; |
| if (shouldRunGenerateFilesTest) { |
| print("Running generated_files_up_to_date_git_test in different process."); |
| futures.add(_run( |
| "pkg/front_end/test/generated_files_up_to_date_git_test.dart", |
| const [])); |
| } |
| |
| if (workItems.isNotEmpty) { |
| print("Will now run ${workItems.length} tests."); |
| futures.add(_executePendingWorkItems(workItems)); |
| } |
| |
| await Future.wait(futures); |
| print("All done in ${stopwatch.elapsed}"); |
| } |
| |
| /// Map from a dir name in "pkg" to the inner-dir we want to include in the |
| /// compile and lint test. |
| const Map<String, String> _compileAndLintDirs = { |
| "frontend_server": "", |
| "front_end": "lib/", |
| "kernel": "lib/", |
| "_fe_analyzer_shared": "lib/", |
| }; |
| |
| /// This is currently a representative list of the dependencies, but do update |
| /// if it turns out to be needed. |
| const Set<String> _generatedFilesUpToDateFiles = { |
| "pkg/_fe_analyzer_shared/lib/src/experiments/flags.dart", |
| "pkg/_fe_analyzer_shared/lib/src/messages/codes_generated.dart", |
| "pkg/_fe_analyzer_shared/lib/src/parser/listener.dart", |
| "pkg/_fe_analyzer_shared/lib/src/parser/parser_impl.dart", |
| "pkg/front_end/lib/src/api_prototype/experimental_flags_generated.dart", |
| "pkg/front_end/lib/src/codes/cfe_codes_generated.dart", |
| "pkg/front_end/lib/src/util/parser_ast_helper.dart", |
| "pkg/front_end/messages.yaml", |
| "pkg/front_end/test/generated_files_up_to_date_git_test.dart", |
| "pkg/front_end/test/parser_test_listener_creator.dart", |
| "pkg/front_end/test/parser_test_listener.dart", |
| "pkg/front_end/test/parser_test_parser_creator.dart", |
| "pkg/front_end/test/parser_test_parser.dart", |
| "pkg/front_end/tool/generate_messages.dart", |
| "pkg/front_end/tool/parser_ast_helper_creator.dart", |
| "pkg/front_end/tool/generate_ast_coverage.dart", |
| "pkg/front_end/tool/generate_ast_equivalence.dart", |
| "pkg/front_end/tool/visitor_generator.dart", |
| "pkg/kernel/lib/ast.dart", |
| "pkg/kernel/lib/default_language_version.dart", |
| "pkg/kernel/lib/src/ast/patterns.dart", |
| "pkg/kernel/lib/src/coverage.dart", |
| "pkg/kernel/lib/src/equivalence.dart", |
| "sdk/lib/libraries.json", |
| "tools/experimental_features.yaml", |
| }; |
| |
| /// Map from a dir name in "pkg" to the inner-dir we want to include in the |
| /// lint test. |
| const Map<String, String> _lintDirs = { |
| "frontend_server": "", |
| "front_end": "lib/", |
| "kernel": "lib/", |
| "_fe_analyzer_shared": "lib/", |
| }; |
| |
| /// Map from a dir name in "pkg" to the inner-dirs we want to include in the |
| /// spelling (source) test. |
| const Map<String, List<String>> _spellDirs = { |
| "frontend_server": ["lib/", "bin/"], |
| "kernel": ["lib/", "bin/"], |
| "front_end": ["lib/"], |
| "_fe_analyzer_shared": ["lib/"], |
| }; |
| |
| /// Set of dirs in "pkg" we care about. |
| const Set<String> _usDirs = { |
| "kernel", |
| "frontend_server", |
| "front_end", |
| "_fe_analyzer_shared", |
| }; |
| |
| final Uri _repoDir = computeRepoDirUri(); |
| |
| String get _dartVm => Platform.executable; |
| |
| DepsTestWork? _createDepsTestWork(List<String> changedFiles) { |
| bool foundFiles = false; |
| for (String path in changedFiles) { |
| if (!path.endsWith(".dart")) continue; |
| if (path.startsWith("pkg/front_end/lib/")) { |
| foundFiles = true; |
| break; |
| } |
| } |
| |
| if (!foundFiles) return null; |
| |
| return new DepsTestWork(); |
| } |
| |
| CompileAndLintWork? _createCompileAndLintTestWork(List<String> changedFiles) { |
| Set<Uri> includedDirs = {}; |
| for (MapEntry<String, String> entry in _compileAndLintDirs.entries) { |
| includedDirs.add(_repoDir.resolve("pkg/${entry.key}/${entry.value}")); |
| } |
| |
| Set<Uri> files = {}; |
| for (String path in changedFiles) { |
| if (!path.endsWith(".dart")) continue; |
| bool found = false; |
| for (MapEntry<String, String> usDirEntry in _compileAndLintDirs.entries) { |
| if (path.startsWith("pkg/${usDirEntry.key}/${usDirEntry.value}")) { |
| found = true; |
| break; |
| } |
| } |
| if (!found) continue; |
| files.add(_repoDir.resolve(path)); |
| } |
| |
| if (files.isEmpty) return null; |
| |
| return new CompileAndLintWork( |
| includedFiles: files, |
| includedDirectoryUris: includedDirs, |
| repoDir: _repoDir); |
| } |
| |
| LintWork? _createLintWork(List<String> changedFiles) { |
| List<String> filters = []; |
| pathLoop: |
| for (String path in changedFiles) { |
| if (!path.endsWith(".dart")) continue; |
| for (MapEntry<String, String> entry in _lintDirs.entries) { |
| if (path.startsWith("pkg/${entry.key}/${entry.value}")) { |
| String filter = path.substring("pkg/".length, path.length - 5); |
| filters.add("lint/$filter/..."); |
| continue pathLoop; |
| } |
| } |
| } |
| |
| if (filters.isEmpty) return null; |
| |
| return new LintWork(filters: filters, repoDir: _repoDir); |
| } |
| |
| MessagesWork? _createMessagesTestWork(List<String> changedFiles) { |
| // TODO(jensj): Could we detect what ones are changed/added and only test |
| // those? |
| for (String file in changedFiles) { |
| if (file == "pkg/front_end/messages.yaml") { |
| return new MessagesWork(repoDir: _repoDir); |
| } |
| } |
| |
| // messages.yaml not changed. |
| return null; |
| } |
| |
| SpellNotSourceWork? _createSpellingTestNotSourceWork( |
| List<String> changedFiles) { |
| // TODO(jensj): Not here, but I'll add the note here. |
| // package:testing takes *a long time* listing files because it does |
| // ``` |
| // if (suite.exclude.any((RegExp r) => path.contains(r))) continue; |
| // if (suite.pattern.any((RegExp r) => path.contains(r))) {} |
| // ``` |
| // for each file it finds. Maybe it should do something more efficient, |
| // and maybe it should even take given filters into account at this point? |
| // |
| // Also it lists all files in the specified "path", so for instance for the |
| // src spell one we have to list all files in "pkg/", then filter it down to |
| // stuff in one of the dirs we care about. |
| List<String> filters = []; |
| for (String path in changedFiles) { |
| if (!path.endsWith(".dart")) continue; |
| if (path.startsWith("pkg/front_end/") && |
| !path.startsWith("pkg/front_end/lib/")) { |
| // Remove front of path and ".dart". |
| String filter = path.substring("pkg/front_end/".length, path.length - 5); |
| filters.add("spelling_test_not_src/$filter"); |
| } |
| } |
| |
| if (filters.isEmpty) return null; |
| |
| return new SpellNotSourceWork(filters: filters, repoDir: _repoDir); |
| } |
| |
| SpellSourceWork? _createSpellingTestSourceWork(List<String> changedFiles) { |
| List<String> filters = []; |
| pathLoop: |
| for (String path in changedFiles) { |
| if (!path.endsWith(".dart")) continue; |
| for (MapEntry<String, List<String>> entry in _spellDirs.entries) { |
| for (String subPath in entry.value) { |
| if (path.startsWith("pkg/${entry.key}/$subPath")) { |
| String filter = path.substring("pkg/".length, path.length - 5); |
| filters.add("spelling_test_src/$filter"); |
| continue pathLoop; |
| } |
| } |
| } |
| } |
| |
| if (filters.isEmpty) return null; |
| |
| return new SpellSourceWork(filters: filters, repoDir: _repoDir); |
| } |
| |
| Future<void> _executePendingWorkItems(List<Work> workItems) async { |
| int currentlyRunning = 0; |
| SpawnHelper spawnHelper = new SpawnHelper(); |
| print("Waiting for spawn to start up."); |
| Stopwatch stopwatch = new Stopwatch()..start(); |
| await spawnHelper |
| .spawn(_repoDir.resolve("pkg/front_end/presubmit_helper_spawn.dart"), |
| (dynamic ok) { |
| if (ok is! bool) { |
| exitCode = 1; |
| print("Error got message of type ${ok.runtimeType}"); |
| return; |
| } |
| currentlyRunning--; |
| if (!ok) { |
| exitCode = 1; |
| } |
| }); |
| print("Isolate started in ${stopwatch.elapsed}"); |
| |
| for (Work workItem in workItems) { |
| print("Executing ${workItem.name}."); |
| currentlyRunning++; |
| spawnHelper.send(json.encode(workItem.toJson())); |
| } |
| |
| while (currentlyRunning > 0) { |
| await Future.delayed(const Duration(milliseconds: 42)); |
| } |
| spawnHelper.close(); |
| } |
| |
| /// Queries git about changes against upstream, or origin/main if no upstream is |
| /// set. This is similar (but different), I believe, to what |
| /// `git cl presubmit` does. |
| List<String> getChangedFiles({required bool collectUncommitted}) { |
| Set<String> paths = {}; |
| void collectChanges(ProcessResult processResult) { |
| for (String line in processResult.stdout.toString().split("\n")) { |
| List<String> split = line.split("\t"); |
| if (split.length != 2) continue; |
| if (split[0] == 'D') continue; // Don't check deleted files. |
| String path = split[1].trim().replaceAll("\\", "/"); |
| paths.add(path); |
| } |
| } |
| |
| ProcessResult result = Process.runSync( |
| "git", |
| [ |
| "-c", |
| "core.quotePath=false", |
| "diff", |
| "--name-status", |
| "--no-renames", |
| "@{u}...HEAD", |
| ], |
| runInShell: true); |
| if (result.exitCode != 0) { |
| result = Process.runSync( |
| "git", |
| [ |
| "-c", |
| "core.quotePath=false", |
| "diff", |
| "--name-status", |
| "--no-renames", |
| "origin/main...HEAD", |
| ], |
| runInShell: true); |
| } |
| if (result.exitCode != 0) { |
| throw "Failure"; |
| } |
| collectChanges(result); |
| |
| if (collectUncommitted) { |
| result = Process.runSync( |
| "git", |
| [ |
| "-c", |
| "core.quotePath=false", |
| "diff", |
| "--name-status", |
| "--no-renames", |
| "HEAD", |
| ], |
| runInShell: true); |
| collectChanges(result); |
| } |
| |
| return paths.toList(); |
| } |
| |
| /// If [inner] is a dir or file inside [outer] this returns the index into |
| /// `inner.pathSegments` corresponding to the folder- or filename directly |
| /// inside [outer]. |
| /// If [inner] is not inside [outer] it returns null. |
| int? _getPathSegmentIndexIfSubEntry(Uri outer, Uri inner) { |
| List<String> outerPathSegments = outer.pathSegments; |
| List<String> innerPathSegments = inner.pathSegments; |
| if (innerPathSegments.length < outerPathSegments.length) return null; |
| int end = outerPathSegments.length; |
| if (outerPathSegments.last == "") end--; |
| for (int i = 0; i < end; i++) { |
| if (Platform.isWindows) { |
| if (outerPathSegments[i].toLowerCase() != |
| innerPathSegments[i].toLowerCase()) { |
| return null; |
| } |
| } else { |
| if (outerPathSegments[i] != innerPathSegments[i]) { |
| return null; |
| } |
| } |
| } |
| return end; |
| } |
| |
| Future<void> _run( |
| String script, |
| List<String> scriptArguments, |
| ) async { |
| List<String> arguments = []; |
| arguments.add("$script"); |
| arguments.addAll(scriptArguments); |
| |
| Stopwatch stopwatch = new Stopwatch()..start(); |
| ProcessResult result = await Process.run(_dartVm, arguments, |
| workingDirectory: _repoDir.toFilePath()); |
| String runWhat = "${_dartVm} ${arguments.join(' ')}"; |
| if (result.exitCode != 0) { |
| exitCode = result.exitCode; |
| print("-----"); |
| print("Running: $runWhat: " |
| "Failed with exit code ${result.exitCode} " |
| "in ${stopwatch.elapsedMilliseconds} ms."); |
| String stdout = result.stdout.toString(); |
| stdout = stdout.trim(); |
| if (stdout.isNotEmpty) { |
| print("--- stdout start ---"); |
| print(stdout); |
| print("--- stdout end ---"); |
| } |
| |
| String stderr = result.stderr.toString().trim(); |
| if (stderr.isNotEmpty) { |
| print("--- stderr start ---"); |
| print(stderr); |
| print("--- stderr end ---"); |
| } |
| } else { |
| print("Running: $runWhat: Done in ${stopwatch.elapsedMilliseconds} ms."); |
| } |
| } |
| |
| // This script is potentially called from several places (once from each), |
| // but we only want to actually run it once. To that end we - from the changed |
| // files figure out which would call this script, and only if the caller is |
| // the top one (just alphabetically sorted) we actually run. |
| bool _shouldRun(final List<String> changedFiles, final String callerPath) { |
| Uri pkgDir = _repoDir.resolve("pkg/"); |
| Uri callerUri = Uri.base.resolveUri(Uri.file(callerPath)); |
| int? endPathIndex = _getPathSegmentIndexIfSubEntry(pkgDir, callerUri); |
| if (endPathIndex == null) { |
| throw "Unsupported path"; |
| } |
| final String callerPkgDir = callerUri.pathSegments[endPathIndex]; |
| if (!_usDirs.contains(callerPkgDir)) { |
| throw "Unsupported dir: $callerPkgDir -- expected one of $_usDirs."; |
| } |
| |
| final Set<String> changedUsDirsSet = {}; |
| for (String path in changedFiles) { |
| if (!path.startsWith("pkg/")) continue; |
| List<String> paths = path.split("/"); |
| if (paths.length < 2) continue; |
| if (_usDirs.contains(paths[1])) { |
| changedUsDirsSet.add(paths[1]); |
| } |
| } |
| |
| if (changedUsDirsSet.isEmpty) { |
| print("We have no changes."); |
| return false; |
| } |
| |
| final List<String> changedUsDirs = changedUsDirsSet.toList()..sort(); |
| if (changedUsDirs.first != callerPkgDir) { |
| print("We expect this file to be called elsewhere which will do the work."); |
| return false; |
| } |
| return true; |
| } |
| |
| /// The `generated_files_up_to_date_git_test.dart` file imports |
| /// package:dart_style which imports package:analyzer --- so it's a lot of extra |
| /// stuff to compile (and thus an expensive script to start). |
| /// Therefore it's not done in the same way as the other things, but instead |
| /// launched separately. |
| bool _shouldRunGenerateFilesTest(List<String> changedFiles) { |
| for (String path in changedFiles) { |
| if (_generatedFilesUpToDateFiles.contains(path)) { |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| class DepsTestWork extends Work { |
| DepsTestWork(); |
| |
| @override |
| String get name => "Deps test"; |
| |
| @override |
| Map<String, Object?> toJson() { |
| return { |
| "WorkTypeIndex": WorkEnum.DepsTest.index, |
| }; |
| } |
| |
| static Work fromJson(Map<String, Object?> json) { |
| return new DepsTestWork(); |
| } |
| } |
| |
| class CompileAndLintWork extends Work { |
| final Set<Uri> includedFiles; |
| final Set<Uri> includedDirectoryUris; |
| final Uri repoDir; |
| |
| CompileAndLintWork( |
| {required this.includedFiles, |
| required this.includedDirectoryUris, |
| required this.repoDir}); |
| |
| @override |
| String get name => "compile and lint test"; |
| |
| @override |
| Map<String, Object?> toJson() { |
| return { |
| "WorkTypeIndex": WorkEnum.CompileAndLint.index, |
| "includedFiles": includedFiles.map((e) => e.toString()).toList(), |
| "includedDirectoryUris": |
| includedDirectoryUris.map((e) => e.toString()).toList(), |
| "repoDir": repoDir.toString(), |
| }; |
| } |
| |
| static Work fromJson(Map<String, Object?> json) { |
| return new CompileAndLintWork( |
| includedFiles: Set<Uri>.from( |
| (json["includedFiles"] as Iterable).map((e) => Uri.parse(e))), |
| includedDirectoryUris: Set<Uri>.from( |
| (json["includedDirectoryUris"] as Iterable).map((e) => Uri.parse(e))), |
| repoDir: Uri.parse(json["repoDir"] as String), |
| ); |
| } |
| } |
| |
| class LintWork extends Work { |
| final List<String> filters; |
| final Uri repoDir; |
| |
| LintWork({required this.filters, required this.repoDir}); |
| |
| @override |
| String get name => "Lint test"; |
| |
| @override |
| Map<String, Object?> toJson() { |
| return { |
| "WorkTypeIndex": WorkEnum.Lint.index, |
| "filters": filters, |
| "repoDir": repoDir.toString(), |
| }; |
| } |
| |
| static Work fromJson(Map<String, Object?> json) { |
| return new LintWork( |
| filters: List<String>.from(json["filters"] as Iterable), |
| repoDir: Uri.parse(json["repoDir"] as String), |
| ); |
| } |
| } |
| |
| class MessagesWork extends Work { |
| final Uri repoDir; |
| |
| MessagesWork({required this.repoDir}); |
| |
| @override |
| String get name => "messages test"; |
| |
| @override |
| Map<String, Object?> toJson() { |
| return { |
| "WorkTypeIndex": WorkEnum.Messages.index, |
| "repoDir": repoDir.toString(), |
| }; |
| } |
| |
| static Work fromJson(Map<String, Object?> json) { |
| return new MessagesWork( |
| repoDir: Uri.parse(json["repoDir"] as String), |
| ); |
| } |
| } |
| |
| class SpawnHelper { |
| bool _spawned = false; |
| late ReceivePort _receivePort; |
| late SendPort _sendPort; |
| late void Function(dynamic data) onData; |
| final List<dynamic> data = []; |
| |
| void close() { |
| if (!_spawned) throw "Not spawned!"; |
| _receivePort.close(); |
| } |
| |
| void send(Object? message) { |
| if (!_spawned) throw "Not spawned!"; |
| _sendPort.send(message); |
| } |
| |
| Future<void> spawn(Uri spawnUri, void Function(dynamic data) onData) async { |
| if (_spawned) throw "Already spawned!"; |
| _spawned = true; |
| this.onData = onData; |
| _receivePort = ReceivePort(); |
| await Isolate.spawnUri(spawnUri, const [], _receivePort.sendPort); |
| final Completer<SendPort> sendPortCompleter = Completer<SendPort>(); |
| _receivePort.listen((dynamic receivedData) { |
| if (!sendPortCompleter.isCompleted) { |
| sendPortCompleter.complete(receivedData); |
| } else { |
| onData(receivedData); |
| } |
| }); |
| _sendPort = await sendPortCompleter.future; |
| } |
| } |
| |
| class SpellNotSourceWork extends Work { |
| final List<String> filters; |
| final Uri repoDir; |
| |
| SpellNotSourceWork({required this.filters, required this.repoDir}); |
| |
| @override |
| String get name => "spell test not source"; |
| |
| @override |
| Map<String, Object?> toJson() { |
| return { |
| "WorkTypeIndex": WorkEnum.SpellingNotSource.index, |
| "filters": filters, |
| "repoDir": repoDir.toString(), |
| }; |
| } |
| |
| static Work fromJson(Map<String, Object?> json) { |
| return new SpellNotSourceWork( |
| filters: List<String>.from(json["filters"] as Iterable), |
| repoDir: Uri.parse(json["repoDir"] as String), |
| ); |
| } |
| } |
| |
| class SpellSourceWork extends Work { |
| final List<String> filters; |
| final Uri repoDir; |
| |
| SpellSourceWork({required this.filters, required this.repoDir}); |
| |
| @override |
| String get name => "spell test source"; |
| |
| @override |
| Map<String, Object?> toJson() { |
| return { |
| "WorkTypeIndex": WorkEnum.SpellingSource.index, |
| "filters": filters, |
| "repoDir": repoDir.toString(), |
| }; |
| } |
| |
| static Work fromJson(Map<String, Object?> json) { |
| return new SpellSourceWork( |
| filters: List<String>.from(json["filters"] as Iterable), |
| repoDir: Uri.parse(json["repoDir"] as String), |
| ); |
| } |
| } |
| |
| sealed class Work { |
| String get name; |
| |
| Map<String, Object?> toJson(); |
| |
| static Work workFromJson(Map<String, Object?> json) { |
| dynamic workTypeIndex = json["WorkTypeIndex"]; |
| if (workTypeIndex is! int || |
| workTypeIndex < 0 || |
| workTypeIndex >= WorkEnum.values.length) { |
| throw "Cannot convert to a Work object."; |
| } |
| WorkEnum workType = WorkEnum.values[workTypeIndex]; |
| switch (workType) { |
| case WorkEnum.CompileAndLint: |
| return CompileAndLintWork.fromJson(json); |
| case WorkEnum.Messages: |
| return MessagesWork.fromJson(json); |
| case WorkEnum.SpellingNotSource: |
| return SpellNotSourceWork.fromJson(json); |
| case WorkEnum.SpellingSource: |
| return SpellSourceWork.fromJson(json); |
| case WorkEnum.Lint: |
| return LintWork.fromJson(json); |
| case WorkEnum.DepsTest: |
| return DepsTestWork.fromJson(json); |
| } |
| } |
| } |
| |
| enum WorkEnum { |
| CompileAndLint, |
| Messages, |
| SpellingNotSource, |
| SpellingSource, |
| Lint, |
| DepsTest, |
| } |
| |
| extension on List<Work> { |
| void addIfNotNull(Work? element) { |
| if (element == null) return; |
| add(element); |
| } |
| } |