blob: ed20ef2dbee19ef4604c25b7a90d2f63bc624b7b [file] [log] [blame]
// Copyright (c) 2022, 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.
import 'dart:async' show StreamController, Zone;
import 'dart:collection' show HashSet;
import 'dart:convert' show Encoding, LineSplitter, utf8;
import 'dart:io'
show
Directory,
File,
FileMode,
FileSystemEntity,
IOOverrides,
IOSink,
exitCode;
import 'dart:typed_data' show BytesBuilder, Uint8List;
import 'package:front_end/src/api_prototype/language_version.dart'
show uriUsesLegacyLanguageVersion;
import 'package:front_end/src/api_unstable/vm.dart'
show CompilerOptions, NnbdMode, StandardFileSystem;
import 'package:frontend_server/starter.dart';
import 'package:kernel/ast.dart' show Component, Library;
import 'package:kernel/binary/multi_binary_loader.dart' show MultiBinaryLoader;
import 'package:kernel/target/targets.dart';
import 'package:kernel/verifier.dart' show VerificationStage, verifyComponent;
import 'package:vm/kernel_front_end.dart';
Future<void> main(List<String> args) async {
String? flutterDir;
String? flutterPlatformDir;
for (String arg in args) {
if (arg.startsWith("--flutterDir=")) {
flutterDir = arg.substring(13);
} else if (arg.startsWith("--flutterPlatformDir=")) {
flutterPlatformDir = arg.substring(21);
}
}
await compileTests(flutterDir, flutterPlatformDir, new StdoutLogger());
}
Future<NnbdMode> _getNNBDMode(Uri script, Uri packageConfigUri) async {
final CompilerOptions compilerOptions = new CompilerOptions()
..sdkRoot = null
..fileSystem = StandardFileSystem.instance
..packagesFileUri = packageConfigUri
..sdkSummary = null
..nnbdMode = NnbdMode.Weak;
if (await uriUsesLegacyLanguageVersion(script, compilerOptions)) {
return NnbdMode.Weak;
}
return NnbdMode.Strong;
}
Future compileTests(
String? flutterDir, String? flutterPlatformDir, Logger logger,
{String? filter, int shards = 1, int shard = 0}) async {
if (flutterDir == null || !(new Directory(flutterDir).existsSync())) {
throw "Didn't get a valid flutter directory to work with.";
}
if (shards < 1) {
throw "Shards must be >= 1";
}
if (shard < 0) {
throw "Shard must be >= 0";
}
if (shard >= shards) {
throw "Shard must be < shards";
}
// Ensure the path ends in a slash.
final Directory flutterDirectory =
new Directory.fromUri(new Directory(flutterDir).uri);
List<FileSystemEntity> allFlutterFiles =
flutterDirectory.listSync(recursive: true, followLinks: false);
Directory flutterPlatformDirectoryTmp;
if (flutterPlatformDir == null) {
List<File> platformFiles = new List<File>.from(allFlutterFiles.where((f) =>
f.uri
.toString()
.endsWith("/flutter_patched_sdk/platform_strong.dill")));
if (platformFiles.isEmpty) {
throw "Expected to find a flutter platform file but didn't.";
}
flutterPlatformDirectoryTmp = platformFiles.first.parent;
} else {
flutterPlatformDirectoryTmp = new Directory(flutterPlatformDir);
}
if (!flutterPlatformDirectoryTmp.existsSync()) {
throw "$flutterPlatformDirectoryTmp doesn't exist.";
}
// Ensure the path ends in a slash.
final Directory flutterPlatformDirectory =
new Directory.fromUri(flutterPlatformDirectoryTmp.uri);
if (!new File.fromUri(
flutterPlatformDirectory.uri.resolve("platform_strong.dill"))
.existsSync()) {
throw "$flutterPlatformDirectory doesn't contain a "
"platform_strong.dill file.";
}
logger.notice("Using $flutterPlatformDirectory as platform directory.");
List<File> packageConfigFiles = new List<File>.from(allFlutterFiles.where(
(f) =>
(f.uri.toString().contains("/examples/") ||
f.uri.toString().contains("/packages/")) &&
f.uri.toString().endsWith("/.dart_tool/package_config.json")));
List<String> allCompilationErrors = [];
final Directory systemTempDir = Directory.systemTemp;
List<_QueueEntry> queue = [];
int totalFiles = 0;
for (int i = 0; i < packageConfigFiles.length; i++) {
File packageConfig = packageConfigFiles[i];
Directory testDir =
new Directory.fromUri(packageConfig.parent.uri.resolve("../test/"));
if (!testDir.existsSync()) continue;
if (testDir.toString().contains("packages/flutter_web_plugins/test/")) {
// TODO(jensj): Figure out which tests are web-tests, and compile those
// in a setup that can handle that.
continue;
}
List<File> testFiles =
new List<File>.from(testDir.listSync(recursive: true).where((f) {
if (!f.path.endsWith("_test.dart")) return false;
if (filter != null) {
String testName = f.path.substring(flutterDirectory.path.length);
if (!testName.startsWith(filter)) return false;
}
return true;
}));
// Split into NNBD Strong and Weak so only the ones that match are
// compiled together. If mixing-and-matching the first file (which could
// be either) will setup the compiler which can lead to compilation errors
// for another file, for instance if the first one is strong but a
// subsequent one tries to opt out (i.e. is weak) an error is issued that
// that's not possible.
List<File> weak = [];
List<File> strong = [];
for (File file in testFiles) {
if (await _getNNBDMode(file.uri, packageConfig.uri) == NnbdMode.Weak) {
weak.add(file);
} else {
strong.add(file);
}
}
for (List<File> files in [weak, strong]) {
if (files.isEmpty) continue;
queue.add(new _QueueEntry(files, packageConfig, testDir));
totalFiles += files.length;
}
}
// Process queue, taking shards into account.
// This involves ignoring some queue entries and cutting others up to
// process exactly the files assigned to this shard.
int shardChunkSize = (totalFiles + shards - 1) ~/ shards;
int chunkStart = shard * shardChunkSize;
int chunkEnd = (shard + 1) * shardChunkSize;
int processedFiles = 0;
for (_QueueEntry queueEntry in queue) {
if (processedFiles < chunkEnd &&
processedFiles + queueEntry.files.length >= chunkStart) {
List<File> chunk = [];
for (File file in queueEntry.files) {
if (processedFiles >= chunkStart && processedFiles < chunkEnd) {
chunk.add(file);
}
processedFiles++;
}
await _processFiles(
systemTempDir,
chunk,
flutterPlatformDirectory,
queueEntry.packageConfig,
queueEntry.testDir,
flutterDirectory,
logger,
filter,
allCompilationErrors);
} else {
// None of these files are part of the chunk.
processedFiles += queueEntry.files.length;
}
}
if (allCompilationErrors.isNotEmpty) {
logger.notice(
"Had a total of ${allCompilationErrors.length} compilation errors:");
allCompilationErrors.forEach(logger.notice);
exitCode = 1;
}
}
class _QueueEntry {
final List<File> files;
final File packageConfig;
final Directory testDir;
_QueueEntry(this.files, this.packageConfig, this.testDir);
}
Future<void> _processFiles(
Directory systemTempDir,
List<File> files,
Directory flutterPlatformDirectory,
File packageConfig,
Directory testDir,
Directory flutterDirectory,
Logger logger,
String? filter,
List<String> allCompilationErrors) async {
Directory tempDir = systemTempDir.createTempSync('flutter_frontend_test');
try {
List<String> compilationErrors = await attemptStuff(
files,
tempDir,
flutterPlatformDirectory,
packageConfig,
testDir,
flutterDirectory,
logger,
filter);
if (compilationErrors.isNotEmpty) {
logger.notice("Notice that we had ${compilationErrors.length} "
"compilation errors for $testDir");
allCompilationErrors.addAll(compilationErrors);
}
} finally {
tempDir.deleteSync(recursive: true);
}
}
Future<List<String>> attemptStuff(
List<File> testFiles,
Directory tempDir,
Directory flutterPlatformDirectory,
File packageConfig,
Directory testDir,
Directory flutterDirectory,
Logger logger,
String? filter) async {
if (testFiles.isEmpty) return [];
File dillFile = new File('${tempDir.path}/dill.dill');
if (dillFile.existsSync()) {
throw "$dillFile already exists.";
}
Uint8List platformData = new File.fromUri(
flutterPlatformDirectory.uri.resolve("platform_strong.dill"))
.readAsBytesSync();
final String targetName = 'flutter';
final Target target = createFrontEndTarget(targetName)!;
final List<String> args = <String>[
'--sdk-root',
flutterPlatformDirectory.path,
'--incremental',
'--target=$targetName',
'--packages',
packageConfig.path,
'--output-dill=${dillFile.path}',
];
Stopwatch stopwatch = new Stopwatch()..start();
final StreamController<List<int>> inputStreamController =
new StreamController<List<int>>();
final StreamController<List<int>> stdoutStreamController =
new StreamController<List<int>>();
final IOSink ioSink = new IOSink(stdoutStreamController.sink);
StreamController<Result> receivedResults = new StreamController<Result>();
final OutputParser outputParser = new OutputParser(receivedResults);
stdoutStreamController.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(outputParser.listener);
Iterator<File> testFileIterator = testFiles.iterator;
testFileIterator.moveNext();
Zone parentZone = Zone.current;
Map<String, _MockFile> files = {};
/// Each test writes a complete (modulo platform) dill, often taking around
/// 40 MB. With 1,500+ tests thats easily 50+ GB data to write. We don't
/// really need it as an actual file though, we just need to get a hold on it
/// below so we can run verification on it. We thus use [IOOverrides] to
/// "catch" dill files (`new File("whatnot.dill")`) so we can write those to
/// memory instead of to actual files.
/// This is specialized for how it's actually written in the production code
/// (via `openWrite`) and will fail if that changes (at which point it will
/// have to be updated).
final Future<int> result = IOOverrides.runZoned(() {
return starter(args, input: inputStreamController.stream, output: ioSink);
}, createFile: (String path) {
if (files[path] != null) return files[path]!;
File f = parentZone.run(() => new File(path));
if (path.endsWith(".dill")) return files[path] = new _MockFile(f);
return f;
});
Set<Library> alreadyVerifiedLibraries = new HashSet.identity();
MultiBinaryLoader multiBinaryLoader = new MultiBinaryLoader();
String testName =
testFileIterator.current.path.substring(flutterDirectory.path.length);
logger.logTestStart(testName);
logger.notice(" => $testName");
Stopwatch stopwatch2 = new Stopwatch()..start();
inputStreamController
.add('compile ${testFileIterator.current.path}\n'.codeUnits);
int compilations = 0;
List<String> compilationErrors = [];
receivedResults.stream.listen((Result compiledResult) {
logger.notice(" --- done in ${stopwatch2.elapsedMilliseconds} ms\n");
stopwatch2.reset();
bool error = false;
try {
compiledResult.expectNoErrors();
} catch (e) {
logger.log("Got errors. Compiler output for this compile:");
outputParser.allReceived.forEach(logger.log);
compilationErrors.add(testFileIterator.current.path);
error = true;
}
if (!error) {
try {
_MockIOSink sink = files[dillFile.path]!.writeSink!;
Uint8List resultBytes = sink.bb.takeBytes();
List<List<int>> originalDataChunks = sink.originalDataChunks;
// The frontend-server serializes with the incremental serializer which
// works by outputting previously serialized component bytes, i.e. a
// single dill actually contains several components, and many of these
// components will byte-for-byte be the same between compiles.
// Furthermore what's added to the sink will be the *identical*
// Uint8List for the same bytes.
// We utilize this here by a) loading with the (same) MultiBinaryLoader
// which generally (with no additional help) figure out if a
// sub-component was previously loaded from the exact same bytes;
// b) additionally help it by returning the *identical* chunk
// representing a sub components bytes if available, which will allow it
// to not having to compare bytes, but simple use an identity hash on
// the list.
Component component = multiBinaryLoader.load(
[platformData, resultBytes], (Uint8List data, int from, int to) {
if (!identical(data, resultBytes)) return null;
// We're asked if we have an alternative to the bytes in [data]
// between [from] and [to]: We search the chunks used, and return the
// matching one if any. This will generally be *identical* to any
// previous one which allows us to not compare bytes.
int at = 0;
for (List<int> chunk in originalDataChunks) {
if (from == at && chunk is Uint8List) {
int length = to - from;
if (chunk.length == length) {
return chunk;
}
}
if (at > from) return null;
at += chunk.length;
}
return null;
});
verifyComponent(
target,
VerificationStage.afterModularTransformations,
component,
skipPlatform: true,
librarySkipFilter: (library) =>
!alreadyVerifiedLibraries.add(library),
);
logger.log(
" => verified in ${stopwatch2.elapsedMilliseconds} ms.");
} catch (e, st) {
logger.log("Crash when trying to verify:");
logger.log("Error: $e");
logger.log("Stack trace: $st");
error = true;
// If there's been a crash we might have left the AST in a bad state.
// To avoid any potential issues caused by this we reset the loader etc.
alreadyVerifiedLibraries = new HashSet.identity();
multiBinaryLoader = new MultiBinaryLoader();
}
}
if (!error) {
logger.logExpectedResult(testName);
} else {
logger.logUnexpectedResult(testName);
}
files.clear();
stopwatch2.reset();
inputStreamController.add('accept\n'.codeUnits);
inputStreamController.add('reset\n'.codeUnits);
compilations++;
outputParser.allReceived.clear();
if (!testFileIterator.moveNext()) {
inputStreamController.add('quit\n'.codeUnits);
return;
}
testName =
testFileIterator.current.path.substring(flutterDirectory.path.length);
logger.logTestStart(testName);
logger.notice(" => $testName");
inputStreamController.add('recompile ${testFileIterator.current.path} abc\n'
'${testFileIterator.current.uri}\n'
'abc\n'
.codeUnits);
});
int resultDone = await result;
if (resultDone != 0) {
throw "Got $resultDone, expected 0";
}
await inputStreamController.close();
logger.log("Did $compilations compilations and verifications in "
"${stopwatch.elapsedMilliseconds} ms.");
return compilationErrors;
}
// The below is copied from incremental_compiler_test.dart,
// but with expect stuff replaced with ifs and throws
// (expect can only be used in tests via the test framework).
class OutputParser {
OutputParser(this._receivedResults);
bool expectSources = true;
final StreamController<Result> _receivedResults;
List<String>? _receivedSources;
String? _boundaryKey;
bool _readingSources = false;
List<String> allReceived = <String>[];
void listener(String s) {
allReceived.add(s);
if (_boundaryKey == null) {
const String resultOutputSpace = 'result ';
if (s.startsWith(resultOutputSpace)) {
_boundaryKey = s.substring(resultOutputSpace.length);
}
_readingSources = false;
_receivedSources?.clear();
return;
}
if (s.startsWith(_boundaryKey!)) {
// First boundaryKey separates compiler output from list of sources
// (if we expect list of sources, which is indicated by receivedSources
// being not null)
if (expectSources && !_readingSources) {
_readingSources = true;
return;
}
// Second boundaryKey indicates end of frontend server response
expectSources = true;
_receivedResults.add(new Result(
s.length > _boundaryKey!.length
? s.substring(_boundaryKey!.length + 1)
: null,
_receivedSources));
_boundaryKey = null;
} else {
if (_readingSources) {
(_receivedSources ??= <String>[]).add(s);
}
}
}
}
class Result {
String? status;
List<String>? sources;
Result(this.status, this.sources);
void expectNoErrors({String? filename}) {
CompilationResult result = new CompilationResult.parse(status!);
if (result.errorsCount != 0) {
throw "Got ${result.errorsCount} errors. Expected 0.";
}
if (filename != null) {
if (result.filename != filename) {
throw "Got ${result.filename} errors. Expected $filename.";
}
}
}
}
class CompilationResult {
late String filename;
late int errorsCount;
CompilationResult.parse(String? filenameAndErrorCount) {
if (filenameAndErrorCount == null) {
return;
}
int delim = filenameAndErrorCount.lastIndexOf(' ');
if (delim <= 0) {
throw "Expected $delim > 0...";
}
filename = filenameAndErrorCount.substring(0, delim);
errorsCount = int.parse(filenameAndErrorCount.substring(delim + 1).trim());
}
}
abstract class Logger {
void logTestStart(String testName);
void logExpectedResult(String testName);
void logUnexpectedResult(String testName);
void log(String s);
void notice(String s);
}
class StdoutLogger extends Logger {
final List<String> _log = <String>[];
@override
void logExpectedResult(String testName) {
print("$testName: OK.");
for (String s in _log) {
print(s);
}
}
@override
void logTestStart(String testName) {
_log.clear();
}
@override
void logUnexpectedResult(String testName) {
print("$testName: Fail.");
for (String s in _log) {
print(s);
}
}
@override
void log(String s) {
_log.add(s);
}
@override
void notice(String s) {
print(s);
}
}
class _MockFile implements File {
final File _f;
_MockIOSink? writeSink;
_MockFile(this._f);
@override
bool existsSync() {
return _f.existsSync();
}
@override
Uint8List readAsBytesSync() {
return _f.readAsBytesSync();
}
@override
IOSink openWrite({FileMode mode = FileMode.write, Encoding encoding = utf8}) {
return writeSink = new _MockIOSink();
}
@override
dynamic noSuchMethod(Invocation invocation) {
return super.noSuchMethod(invocation);
}
}
class _MockIOSink implements IOSink {
BytesBuilder bb = new BytesBuilder();
List<List<int>> originalDataChunks = [];
bool _closed = false;
@override
void add(List<int> data) {
if (_closed) throw "Adding to closed";
bb.add(data);
originalDataChunks.add(data);
}
@override
Future close() {
_closed = true;
return new Future.value();
}
@override
dynamic noSuchMethod(Invocation invocation) {
return super.noSuchMethod(invocation);
}
}