Adds a Dart library for loading and parsing build configs (flutter/engine#45390)

Also adds a test that the build configs in the repo are valid json that
matches the spec.

Fleshed out @christopherfujino's code from here
https://github.com/christopherfujino/flutter-engine-runner/blob/main/main.dart
This commit is contained in:
Zachary Anderson 2023-09-05 10:00:44 -07:00 committed by GitHub
parent 89fb5205ea
commit 81534e8ace
13 changed files with 1633 additions and 1 deletions

View File

@ -69,6 +69,10 @@
"language": "dart",
"name": "test: Lint android host",
"script": "flutter/tools/android_lint/bin/main.dart"
},
{
"name": "Check build configs",
"script": "flutter/ci/check_build_configs.sh"
}
]
},

View File

@ -239,6 +239,6 @@
}
}
],
"generators": [],
"generators": {},
"archives": []
}

View File

@ -0,0 +1,41 @@
#!/bin/bash
#
# 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.
set -e
# Needed because if it is set, cd may print the path it changed to.
unset CDPATH
# On Mac OS, readlink -f doesn't work, so follow_links traverses the path one
# link at a time, and then cds into the link destination and find out where it
# ends up.
#
# The function is enclosed in a subshell to avoid changing the working directory
# of the caller.
function follow_links() (
cd -P "$(dirname -- "$1")"
file="$PWD/$(basename -- "$1")"
while [[ -h "$file" ]]; do
cd -P "$(dirname -- "$file")"
file="$(readlink -- "$file")"
cd -P "$(dirname -- "$file")"
file="$PWD/$(basename -- "$file")"
done
echo "$file"
)
SCRIPT_DIR=$(follow_links "$(dirname -- "${BASH_SOURCE[0]}")")
SRC_DIR="$(cd "$SCRIPT_DIR/../.."; pwd -P)"
FLUTTER_DIR="$(cd "$SCRIPT_DIR/.."; pwd -P)"
DART_BIN="${SRC_DIR}/third_party/dart/tools/sdks/dart-sdk/bin"
DART="${DART_BIN}/dart"
cd "$SCRIPT_DIR"
"$DART" \
--disable-dart-dev \
"$SRC_DIR/flutter/tools/pkg/engine_build_configs/bin/check.dart" \
"$SRC_DIR"

View File

@ -972,6 +972,25 @@ def gather_build_bucket_golden_scraper_tests(build_dir):
)
def gather_engine_build_configs_tests(build_dir):
test_dir = os.path.join(
BUILDROOT_DIR, 'flutter', 'tools', 'pkg', 'engine_build_configs'
)
dart_tests = glob.glob('%s/*_test.dart' % test_dir)
for dart_test_file in dart_tests:
opts = [
'--disable-dart-dev',
dart_test_file,
]
yield EngineExecutableTask(
build_dir,
os.path.join('dart-sdk', 'bin', 'dart'),
None,
flags=opts,
cwd=test_dir
)
def gather_engine_repo_tools_tests(build_dir):
test_dir = os.path.join(
BUILDROOT_DIR, 'flutter', 'tools', 'pkg', 'engine_repo_tools'
@ -1269,6 +1288,7 @@ Flutter Wiki page on the subject: https://github.com/flutter/flutter/wiki/Testin
tasks += list(gather_githooks_tests(build_dir))
tasks += list(gather_clang_tidy_tests(build_dir))
tasks += list(gather_build_bucket_golden_scraper_tests(build_dir))
tasks += list(gather_engine_build_configs_tests(build_dir))
tasks += list(gather_engine_repo_tools_tests(build_dir))
tasks += list(gather_api_consistency_tests(build_dir))
tasks += list(gather_path_ops_tests(build_dir))

View File

@ -0,0 +1,5 @@
include: ../../../analysis_options.yaml
linter:
rules:
public_member_api_docs: false

View File

@ -0,0 +1,67 @@
// 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:engine_build_configs/engine_build_configs.dart';
import 'package:engine_repo_tools/engine_repo_tools.dart';
import 'package:path/path.dart' as p;
// Usage:
// $ dart bin/check.dart [/path/to/engine/src]
void main(List<String> args) {
final String? engineSrcPath;
if (args.isNotEmpty) {
engineSrcPath = args[0];
} else {
engineSrcPath = null;
}
// Find the engine repo.
final Engine engine;
try {
engine = Engine.findWithin(engineSrcPath);
} catch (e) {
io.stderr.writeln(e);
io.exitCode = 1;
return;
}
// Find and parse the engine build configs.
final io.Directory buildConfigsDir = io.Directory(p.join(
engine.flutterDir.path, 'ci', 'builders',
));
final BuildConfigLoader loader = BuildConfigLoader(
buildConfigsDir: buildConfigsDir,
);
// Treat it as an error if no build configs were found. The caller likely
// expected to find some.
final Map<String, BuildConfig> configs = loader.configs;
if (configs.isEmpty) {
io.stderr.writeln(
'Error: No build configs found under ${buildConfigsDir.path}',
);
io.exitCode = 1;
return;
}
if (loader.errors.isNotEmpty) {
loader.errors.forEach(io.stderr.writeln);
io.exitCode = 1;
}
// Check the parsed build configs for validity.
for (final String name in configs.keys) {
final BuildConfig buildConfig = configs[name]!;
final List<String> buildConfigErrors = buildConfig.check(name);
if (buildConfigErrors.isNotEmpty) {
io.stderr.writeln('Errors in ${buildConfig.path}:');
io.exitCode = 1;
}
for (final String error in buildConfigErrors) {
io.stderr.writeln(' $error');
}
}
}

View File

@ -0,0 +1,24 @@
// 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.
/// This is a library for parsing the Engine CI configurations that live under
/// flutter/ci/builders. They describe how CI builds, tests, archives, and
/// uploads the engine to cloud storage. The documentation and spec for the
/// format is at:
///
/// https://github.com/flutter/engine/blob/main/ci/builders/README.md
///
/// The code in this library is *not* used by CI to run these configurations.
/// Rather, that code executes these configs on CI is part of the "engine_v2"
/// recipes at:
///
/// https://cs.opensource.google/flutter/recipes/+/main:recipes/engine_v2
///
/// This library exposes two main classes, [BuildConfigLoader], which reads and
/// loads all build configurations under a directory, and [BuildConfig], which
/// is the Dart representation of a single build configuration.
library;
export 'src/build_config.dart';
export 'src/build_config_loader.dart';

View File

@ -0,0 +1,843 @@
// 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 'package:meta/meta.dart';
// This library parses Engine build config data out of the "Engine v2" build
// config JSON files with the format described at:
// https://github.com/flutter/engine/blob/main/ci/builders/README.md
/// Base class for all nodes in the build config.
sealed class BuildConfigBase {
BuildConfigBase(this.errors);
/// Accumulated errors. Non-null and non-empty when a node is invalid.
final List<String>? errors;
/// Whether there were errors when loading the data for this node.
late final bool valid = errors == null;
/// Returns an empty list when the object is valid, and errors when it is not.
/// Subclasses with more data to check for validity should override this
/// method and add `super.check(path)` to the returned list.
@mustCallSuper
List<String> check(String path) {
if (valid) {
return <String>[];
}
return errors!.map((String s) => '$path: $s').toList();
}
}
/// The build configuration is a json file containing a list of builds, tests,
/// generators and archives.
///
/// Each build config file contains a top-level json map with the following
/// fields:
/// {
/// "builds": [],
/// "tests": [],
/// "generators": {
/// "tasks": []
/// },
/// "archives": []
/// }
final class BuildConfig extends BuildConfigBase {
/// Load build configuration data into an instance of this class.
///
/// [path] should be the file system path to the file that the JSON data comes
/// from. [map] must be the JSON data returned by e.g. `JsonDecoder.convert`.
factory BuildConfig.fromJson({
required String path,
required Map<String, Object?> map,
}) {
final List<String> errors = <String>[];
// Parse the "builds" field.
final List<GlobalBuild>? builds = objListOfJson<GlobalBuild>(
map, 'builds', errors, GlobalBuild.fromJson,
);
// Parse the "tests" field.
final List<GlobalTest>? tests = objListOfJson<GlobalTest>(
map, 'tests', errors, GlobalTest.fromJson,
);
// Parse the "generators" field.
final List<TestTask>? generators;
if (map['generators'] == null) {
generators = <TestTask>[];
} else if (map['generators'] is! Map<String, Object?>) {
appendTypeError(map, 'generators', 'map', errors);
generators = null;
} else {
generators = objListOfJson(
map['generators']! as Map<String, Object?>,
'tasks',
errors,
TestTask.fromJson,
);
}
// Parse the "archives" field.
final List<GlobalArchive>? archives = objListOfJson<GlobalArchive>(
map, 'archives', errors, GlobalArchive.fromJson,
);
if (builds == null ||
tests == null ||
generators == null ||
archives == null) {
return BuildConfig._invalid(path, errors);
}
return BuildConfig._(path, builds, tests, generators, archives);
}
BuildConfig._(
this.path,
this.builds,
this.tests,
this.generators,
this.archives,
) : super(null);
BuildConfig._invalid(this.path, super.errors) :
builds = <GlobalBuild>[],
tests = <GlobalTest>[],
generators = <TestTask>[],
archives = <GlobalArchive>[];
/// The path to the JSON file.
final String path;
/// A list of independent builds that have no dependencies among them. They
/// can run in parallel if need be.
final List<GlobalBuild> builds;
/// A list of tests. The tests may have dependencies on one or more of the
/// builds.
final List<GlobalTest> tests;
/// A list of generator tasks that produce additional artifacts, which may
/// depend on the output of one or more builds.
final List<TestTask> generators;
/// A description of the upload instructions for the artifacts produced by
/// the global generators.
final List<GlobalArchive> archives;
@override
List<String> check(String path) {
final List<String> errors = <String>[];
errors.addAll(super.check(path));
for (int i = 0; i < builds.length; i++) {
final GlobalBuild build = builds[i];
errors.addAll(build.check('$path/builds[$i]'));
}
for (int i = 0; i < tests.length; i++) {
final GlobalTest test = tests[i];
errors.addAll(test.check('$path/tests[$i]'));
}
for (int i = 0; i < generators.length; i++) {
final TestTask task = generators[i];
errors.addAll(task.check('$path/generators/tasks[$i]'));
}
for (int i = 0; i < archives.length; i++) {
final GlobalArchive archive = archives[i];
errors.addAll(archive.check('$path/archives[$i]'));
}
return errors;
}
}
/// A "build" is a dictionary with a gn command, a ninja command, zero or more
/// generator commands, zero or more local tests, zero or more local generators
/// and zero or more output artifacts.
///
/// "builds" contains a list of maps with fields like:
/// {
/// "name": "",
/// "gn": [""],
/// "ninja": {},
/// "tests": [],
/// "generators": {
/// "tasks": []
/// }, (optional)
/// "archives": [],
/// "drone_dimensions": [""],
/// "gclient_variables": {}
/// }
final class GlobalBuild extends BuildConfigBase {
factory GlobalBuild.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final List<String>? gn = stringListOfJson(map, 'gn', errors);
final List<BuildTest>? tests = objListOfJson(
map, 'tests', errors, BuildTest.fromJson,
);
final List<BuildArchive>? archives = objListOfJson(
map, 'archives', errors, BuildArchive.fromJson,
);
final List<String>? droneDimensions = stringListOfJson(
map, 'drone_dimensions', errors,
);
final BuildNinja? ninja;
if (map['ninja'] == null) {
ninja = BuildNinja.nop();
} else if (map['ninja'] is! Map<String, Object?>) {
ninja = null;
} else {
ninja = BuildNinja.fromJson(map['ninja']! as Map<String, Object?>);
}
if (ninja == null) {
appendTypeError(map, 'ninja', 'map', errors);
}
final List<BuildTask>? generators;
if (map['generators'] == null) {
generators = <BuildTask>[];
} else if (map['generators'] is! Map<String, Object?>) {
appendTypeError(map, 'generators', 'map', errors);
generators = null;
} else {
generators = objListOfJson(
map['generators']! as Map<String, Object?>,
'tasks',
errors,
BuildTask.fromJson,
);
}
final Map<String, Object?>? gclientVariables;
if (map['gclient_variables'] == null) {
gclientVariables = <String, Object?>{};
} else if (map['gclient_variables'] is! Map<String, Object?>) {
gclientVariables = null;
} else {
gclientVariables = map['gclient_variables']! as Map<String, Object?>;
}
if (gclientVariables == null) {
appendTypeError(map, 'gclient_variables', 'map', errors);
}
if (name == null ||
gn == null ||
ninja == null ||
archives == null ||
tests == null ||
generators == null ||
droneDimensions == null ||
gclientVariables == null) {
return GlobalBuild._invalid(errors);
}
return GlobalBuild._(
name, gn, ninja, tests, generators, archives, droneDimensions,
gclientVariables,
);
}
GlobalBuild._(
this.name,
this.gn,
this.ninja,
this.tests,
this.generators,
this.archives,
this.droneDimensions,
this.gclientVariables,
) : super(null);
GlobalBuild._invalid(super.errors) :
name = '',
gn = <String>[],
ninja = BuildNinja.nop(),
tests = <BuildTest>[],
generators = <BuildTask>[],
archives = <BuildArchive>[],
droneDimensions = <String>[],
gclientVariables = <String, Object?>{};
/// The name of the build which may also be used to reference it as a
/// depdendency of a global test.
final String name;
/// The parameters to pass to `flutter/tools/gn` to configure the build.
final List<String> gn;
/// The data to form the ninja command to perform the build.
final BuildNinja ninja;
/// The list of tests that can be run after the ninja build is finished.
final List<BuildTest> tests;
/// A list of other tasks that may generate new artifacts after the ninja
/// build is finished.
final List<BuildTask> generators;
/// Upload instructions for the artifacts produced by the build.
final List<BuildArchive> archives;
/// A list 'key=value' strings that are used to select the bot where this
/// build will be running.
final List<String> droneDimensions;
/// A dictionary with variables included in the `custom_vars` section of the
/// .gclient file before `gclient sync` is run.
final Map<String, Object?> gclientVariables;
@override
List<String> check(String path) {
final List<String> errors = <String>[];
errors.addAll(super.check(path));
errors.addAll(ninja.check('$path/ninja'));
for (int i = 0; i < tests.length; i++) {
final BuildTest test = tests[i];
errors.addAll(test.check('$path/tests[$i]'));
}
for (int i = 0; i < generators.length; i++) {
final BuildTask task = generators[i];
errors.addAll(task.check('$path/generators/tasks[$i]'));
}
for (int i = 0; i < archives.length; i++) {
final BuildArchive archive = archives[i];
errors.addAll(archive.check('$path/archives[$i]'));
}
return errors;
}
}
/// "builds" -> "ninja" contains a map with fields like:
/// {
/// "config": "",
/// "targets": [""]
/// },
final class BuildNinja extends BuildConfigBase {
factory BuildNinja.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? config = stringOfJson(map, 'config', errors);
final List<String>? targets = stringListOfJson(map, 'targets', errors);
if (config == null || targets == null) {
return BuildNinja._invalid(errors);
}
return BuildNinja._(config, targets);
}
BuildNinja._(this.config, this.targets) : super(null);
BuildNinja._invalid(super.errors) :
config = '',
targets = <String>[];
BuildNinja.nop() :
config = '',
targets = <String>[],
super(null);
/// The name of the configuration created by gn.
///
/// This is also the subdirectory of the `out/` directory where the build
/// output will go.
final String config;
/// The ninja targets to build.
final List<String> targets;
}
/// "builds" -> "tests" contains a list of maps with fields like:
/// {
/// "language": "",
/// "name": "",
/// "parameters": [""],
/// "script": "",
/// "contexts": [""]
/// }
final class BuildTest extends BuildConfigBase {
factory BuildTest.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? language = stringOfJson(map, 'language', errors);
final String? script = stringOfJson(map, 'script', errors);
final List<String>? parameters = stringListOfJson(
map, 'parameters', errors,
);
final List<String>? contexts = stringListOfJson(
map, 'contexts', errors,
);
if (name == null ||
language == null ||
script == null ||
parameters == null ||
contexts == null) {
return BuildTest._invalid(errors);
}
return BuildTest._(name, language, script, parameters, contexts);
}
BuildTest._(
this.name,
this.language,
this.script,
this.parameters,
this.contexts,
) : super(null);
BuildTest._invalid(super.errors) :
name = '',
language = '',
script = '',
parameters = <String>[],
contexts = <String>[];
/// The human readable description of the test.
final String name;
/// The executable used to run the script.
final String language;
/// The path to the script to execute relative to the checkout directory.
final String script;
/// Flags or parameters passed to the script.
///
/// Parameters accept magic environment variables (placeholders replaced
/// before executing the test). Magic environment variables have the following
/// limitations: only ${FLUTTER_LOGS_DIR} is currently supported and it needs
/// to be used alone within the parameter string(e.g. ["${FLUTTER_LOGS_DIR}"]
/// is OK but ["path=${FLUTTER_LOGS_DIR}"] is not).
final List<String> parameters;
/// A list of available contexts to add to the text execution step.
///
/// Two contexts are supported: "android_virtual_device" and
/// "metric_center_token".
final List<String> contexts;
}
/// "builds" -> "generators" is a map containing a single property "tasks",
/// which is a list of maps with fields like:
/// {
/// "name": "",
/// "parameters": [""],
/// "scripts": [""],
/// "language": ""
/// }
///
/// The semantics of this task are that each script in the list of scripts is
/// run in sequence by appending the same parameter list to each one.
final class BuildTask extends BuildConfigBase {
factory BuildTask.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? language = stringOfJson(map, 'language', errors);
final List<String>? scripts = stringListOfJson(map, 'scripts', errors);
final List<String>? parameters = stringListOfJson(
map, 'parameters', errors,
);
if (name == null ||
language == null ||
scripts == null ||
parameters == null) {
return BuildTask._invalid(errors);
}
return BuildTask._(name, language, scripts, parameters);
}
BuildTask._invalid(super.errors) :
name = '',
language = '',
scripts = <String>[],
parameters = <String>[];
BuildTask._(this.name, this.language, this.scripts, this.parameters) :
super(null);
/// The human readable name of the step running the script.
final String name;
/// The script language executable to run the script. If empty it is assumed
/// to be bash.
final String language;
/// A list of paths of scripts relative to the checkout directory. Each
/// script is run in turn by appending the list of parameters to it.
final List<String> scripts;
/// The flags passed to the script. Paths referenced in the list are relative
/// to the checkout directory.
final List<String> parameters;
}
/// "builds" -> "archives" contains a list of maps with fields like:
/// {
/// "name": "",
/// "base_path": "",
/// "type": "",
/// "include_paths": [""],
/// "realm": ""
/// }
final class BuildArchive extends BuildConfigBase {
factory BuildArchive.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? type = stringOfJson(map, 'type', errors);
final String? basePath = stringOfJson(map, 'base_path', errors);
final String? realm = stringOfJson(map, 'realm', errors);
final List<String>? includePaths = stringListOfJson(
map, 'include_paths', errors,
);
if (name == null ||
type == null ||
basePath == null ||
realm == null ||
includePaths == null) {
return BuildArchive._invalid(errors);
}
return BuildArchive._(name, type, basePath, realm, includePaths);
}
BuildArchive._invalid(super.error) :
name = '',
type = '',
basePath = '',
realm = '',
includePaths = <String>[];
BuildArchive._(
this.name,
this.type,
this.basePath,
this.realm,
this.includePaths,
) : super(null);
/// The name which may be referenced later as a dependency of global tests.
final String name;
/// The type of storage to use. Currently only gcs and cas are supported.
final String type;
/// The portion of the path to remove from the full path before uploading
final String basePath;
/// Either "production" or "experimental".
final String realm;
/// A list of strings with the paths to be uploaded to a given destination.
final List<String> includePaths;
}
/// Global "tests" is a list of maps containing fields like:
/// {
/// "name": "",
/// "recipe": "",
/// "drone_dimensions": [""],
/// "dependencies": [""],
/// "tasks": [] (same format as above)
/// }
final class GlobalTest extends BuildConfigBase {
factory GlobalTest.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? recipe = stringOfJson(map, 'recipe', errors);
final List<String>? droneDimensions = stringListOfJson(
map, 'drone_dimensions', errors,
);
final List<String>? dependencies = stringListOfJson(
map, 'dependencies', errors,
);
final List<TestDependency>? testDependencies = objListOfJson(
map, 'test_dependencies', errors, TestDependency.fromJson,
);
final List<TestTask>? tasks = objListOfJson(
map, 'tasks', errors, TestTask.fromJson,
);
if (name == null ||
recipe == null ||
droneDimensions == null ||
dependencies == null ||
testDependencies == null ||
tasks == null) {
return GlobalTest._invalid(errors);
}
return GlobalTest._(
name, recipe, droneDimensions, dependencies, testDependencies, tasks);
}
GlobalTest._invalid(super.errors) :
name = '',
recipe = '',
droneDimensions = <String>[],
dependencies = <String>[],
testDependencies = <TestDependency>[],
tasks = <TestTask>[];
GlobalTest._(
this.name,
this.recipe,
this.droneDimensions,
this.dependencies,
this.testDependencies,
this.tasks,
) : super(null);
/// The name that will be assigned to the sub-build.
final String name;
/// The recipe name to use if different than tester.
final String recipe;
/// A list of strings with key values to select the bot where the test will
/// run.
final List<String> droneDimensions;
/// A list of build outputs required by the test.
final List<String> dependencies;
/// A list of dependencies required for the test to run.
final List<TestDependency> testDependencies;
/// A list of dictionaries representing scripts and parameters to run them
final List<TestTask> tasks;
@override
List<String> check(String path) {
final List<String> errors = <String>[];
errors.addAll(super.check(path));
for (int i = 0; i < testDependencies.length; i++) {
final TestDependency testDependency = testDependencies[i];
errors.addAll(testDependency.check('$path/test_dependencies[$i]'));
}
for (int i = 0; i < tasks.length; i++) {
final TestTask task = tasks[i];
errors.addAll(task.check('$path/tasks[$i]'));
}
return errors;
}
}
/// A test dependency for a global test has fields like:
/// {
/// "dependency": "",
/// "version": ""
/// }
final class TestDependency extends BuildConfigBase {
factory TestDependency.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? dependency = stringOfJson(map, 'dependency', errors);
final String? version = stringOfJson(map, 'version', errors);
if (dependency == null || version == null) {
return TestDependency._invalid(errors);
}
return TestDependency._(dependency, version);
}
TestDependency._invalid(super.error) : dependency = '', version = '';
TestDependency._(this.dependency, this.version) : super(null);
/// A dependency from the list at:
/// https://flutter.googlesource.com/recipes/+/refs/heads/main/recipe_modules/flutter_deps/api.py#75
final String dependency;
/// The CIPD version string of the dependency.
final String version;
}
/// Task for a global generator and a global test.
/// {
/// "name": "",
/// "parameters": [""],
/// "script": "",
/// "language": ""
/// }
final class TestTask extends BuildConfigBase {
factory TestTask.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? language = stringOfJson(map, 'language', errors);
final String? script = stringOfJson(map, 'script', errors);
final int? maxAttempts = intOfJson(map, 'max_attempts', fallback: 1, errors);
final List<String>? parameters = stringListOfJson(
map, 'parameters', errors,
);
if (name == null ||
language == null ||
script == null ||
maxAttempts == null ||
parameters == null) {
return TestTask._invalid(errors);
}
return TestTask._(name, language, script, maxAttempts, parameters);
}
TestTask._invalid(super.error) :
name = '',
language = '',
script = '',
maxAttempts = 0,
parameters = <String>[];
TestTask._(
this.name,
this.language,
this.script,
this.maxAttempts,
this.parameters,
) : super(null);
/// The human readable name of the step running the script.
final String name;
/// The script language executable to run the script. If empty it is assumed
/// to be bash.
final String language;
/// The script path relative to the checkout repository.
final String script;
/// The maximum number of failures to tolerate. The default is 1.
final int maxAttempts;
/// The flags passed to the script. Paths referenced in the list are relative
/// to the checkout directory.
final List<String> parameters;
}
/// The objects that populate the list of global archives have fields like:
/// {
/// "source": "out/debug/artifacts.zip",
/// "destination": "ios/artifacts.zip",
/// "realm": "production"
/// },
final class GlobalArchive extends BuildConfigBase {
factory GlobalArchive.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? source = stringOfJson(map, 'source', errors);
final String? destination = stringOfJson(map, 'destination', errors);
final String? realm = stringOfJson(map, 'realm', errors);
if (source == null ||
destination == null ||
realm == null) {
return GlobalArchive._invalid(errors);
}
return GlobalArchive._(source, destination, realm);
}
GlobalArchive._invalid(super.error) :
source = '',
destination = '',
realm = '';
GlobalArchive._(this.source, this.destination, this.realm) :
super(null);
/// The path of the artifact relative to the engine checkout.
final String source;
/// The destination folder in the storage bucket.
final String destination;
/// Which storage bucket the destination path is relative to.
/// Either "production" or "experimental".
final String realm;
}
void appendTypeError(
Map<String, Object?> map,
String field,
String expected,
List<String> errors, {
Object? element,
}) {
if (element == null) {
final Type actual = map[field]!.runtimeType;
errors.add(
'For field "$field", expected type: $expected, actual type: $actual.',
);
} else {
final Type actual = element.runtimeType;
errors.add(
'For element "$element" of "$field", '
'expected type: $expected, actual type: $actual',
);
}
}
List<T>? objListOfJson<T>(
Map<String, Object?> map,
String field,
List<String> errors,
T Function(Map<String, Object?>) fn,
) {
if (map[field] == null) {
return <T>[];
}
if (map[field]! is! List<Object?>) {
appendTypeError(map, field, 'list', errors);
return null;
}
for (final Object? obj in map[field]! as List<Object?>) {
if (obj is! Map<String, Object?>) {
appendTypeError(map, field, 'map', errors);
return null;
}
}
return (map[field]! as List<Object?>)
.cast<Map<String, Object?>>().map<T>(fn).toList();
}
List<String>? stringListOfJson(
Map<String, Object?> map,
String field,
List<String> errors,
) {
if (map[field] == null) {
return <String>[];
}
if (map[field]! is! List<Object?>) {
appendTypeError(map, field, 'list', errors);
return null;
}
for (final Object? obj in map[field]! as List<Object?>) {
if (obj is! String) {
appendTypeError(map, field, element: obj, 'string', errors);
return null;
}
}
return (map[field]! as List<Object?>).cast<String>();
}
String? stringOfJson(
Map<String, Object?> map,
String field,
List<String> errors,
) {
if (map[field] == null) {
return '<undef>';
}
if (map[field]! is! String) {
appendTypeError(map, field, 'string', errors);
return null;
}
return map[field]! as String;
}
int? intOfJson(
Map<String, Object?> map,
String field,
List<String> errors, {
int fallback = 0,
}) {
if (map[field] == null) {
return fallback;
}
if (map[field]! is! int) {
appendTypeError(map, field, 'int', errors);
return null;
}
return map[field]! as int;
}

View File

@ -0,0 +1,70 @@
// 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:convert' as convert;
import 'dart:io' as io show Directory, File;
import 'package:path/path.dart' as p;
import 'build_config.dart';
/// This is a utility class for reading all of the build configurations from
/// a subdirectory of the engine repo. After building an instance of this class,
/// the build configurations can be accessed on the [configs] getter.
class BuildConfigLoader {
BuildConfigLoader({required this.buildConfigsDir});
/// Any errors encountered while parsing and loading the build config files
/// are accumulated in this list as strings. It should be checked for errors
/// after the first access to the [configs] getter.
final List<String> errors = <String>[];
/// The directory where the engine's build config .json files exist.
final io.Directory buildConfigsDir;
/// Walks [buildConfigsDir] looking for .json files, which it attempts to
/// parse as engine build configs. JSON parsing errors during this process
/// are added as strings to the [errors] list. That last should be checked
/// for errors after accessing this getter.
///
/// The [BuildConfig]s given by this getter should be further checked for
/// validity by calling `BuildConfig.check()` on each one. See
/// `bin/check.dart` for an example.
late final Map<String, BuildConfig> configs = (){
return _parseAllBuildConfigs(buildConfigsDir);
}();
Map<String, BuildConfig> _parseAllBuildConfigs(io.Directory dir) {
final Map<String, BuildConfig> result = <String, BuildConfig>{};
if (!dir.existsSync()) {
errors.add('${buildConfigsDir.path} does not exist.');
return result;
}
final List<io.File> jsonFiles = dir
.listSync(recursive: true)
.whereType<io.File>()
.where((io.File f) => f.path.endsWith('.json'))
.toList();
for (final io.File jsonFile in jsonFiles) {
final String basename = p.basename(jsonFile.path);
final String name = basename.substring(
0, basename.length - 5,
);
final String jsonData = jsonFile.readAsStringSync();
final dynamic maybeJson;
try {
maybeJson = convert.jsonDecode(jsonData);
} on FormatException catch (e) {
errors.add('While parsing ${jsonFile.path}:\n$e');
continue;
}
if (maybeJson is! Map<String, Object?>) {
errors.add('${jsonFile.path} did not contain a json map.');
continue;
}
result[name] = BuildConfig.fromJson(path: jsonFile.path, map: maybeJson);
}
return result;
}
}

View File

@ -0,0 +1,61 @@
# 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.
name: engine_build_configs
publish_to: none
environment:
sdk: '>=3.1.0-0 <4.0.0'
# Do not add any dependencies that require more than what is provided in
# //third_party/pkg, //third_party/dart/pkg, or
# //third_party/dart/third_party/pkg. In particular, package:test is not usable
# here.
# If you do add packages here, make sure you can run `pub get --offline`, and
# check the .packages and .package_config to make sure all the paths are
# relative to this directory into //third_party/dart
dependencies:
args: any
engine_repo_tools:
path: ../engine_repo_tools
file: any
meta: any
path: any
platform: any
process_runner: any
dev_dependencies:
async_helper: any
expect: any
litetest: any
smith: any
dependency_overrides:
args:
path: ../../../../third_party/dart/third_party/pkg/args
async:
path: ../../../../third_party/dart/third_party/pkg/async
async_helper:
path: ../../../../third_party/dart/pkg/async_helper
collection:
path: ../../../../third_party/dart/third_party/pkg/collection
expect:
path: ../../../../third_party/dart/pkg/expect
file:
path: ../../../../third_party/pkg/file/packages/file
litetest:
path: ../../../testing/litetest
meta:
path: ../../../../third_party/dart/pkg/meta
path:
path: ../../../../third_party/dart/third_party/pkg/path
platform:
path: ../../../../third_party/pkg/platform
process:
path: ../../../../third_party/pkg/process
process_runner:
path: ../../../../third_party/pkg/process_runner
smith:
path: ../../../../third_party/dart/pkg/smith

View File

@ -0,0 +1,128 @@
// 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 'package:engine_build_configs/src/build_config.dart';
import 'package:engine_build_configs/src/build_config_loader.dart';
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:litetest/litetest.dart';
const String buildConfigJson = '''
{
"builds": [
{
"archives": [
{
"name": "build_name",
"base_path": "base/path",
"type": "gcs",
"include_paths": ["include/path"],
"realm": "archive_realm"
}
],
"drone_dimensions": ["dimension"],
"gclient_variables": {
"variable": false
},
"gn": ["--gn-arg"],
"name": "build_name",
"ninja": {
"config": "build_name",
"targets": ["ninja_target"]
},
"tests": [
{
"language": "python3",
"name": "build_name tests",
"parameters": ["--test-params"],
"script": "test/script.py",
"contexts": ["context"]
}
],
"generators": {
"tasks": [
{
"name": "generator_task",
"parameters": ["--gen-param"],
"scripts": ["gen/script.py"]
}
]
}
}
],
"generators": {
"tasks": [
{
"name": "global generator task",
"parameters": ["--global-gen-param"],
"script": "global/gen_script.dart",
"language": "dart"
}
]
},
"tests": [
{
"name": "global test",
"recipe": "engine_v2/tester_engine",
"drone_dimensions": ["dimension"],
"gclient_variables": {
"variable": false
},
"dependencies": ["dependency"],
"test_dependencies": [
{
"dependency": "test_dependency",
"version": "git_revision:3a77d0b12c697a840ca0c7705208e8622dc94603"
}
],
"tasks": [
{
"name": "global test task",
"parameters": ["--test-parameter"],
"script": "global/test/script.py"
}
]
}
]
}
''';
int main() {
test('BuildConfigLoader can load a build config', () {
final FileSystem fs = MemoryFileSystem();
final String buildConfigPath = fs.path.join('flutter', 'ci', 'builders');
final Directory buildConfigsDir = fs.directory(buildConfigPath);
final File buildConfigFile = buildConfigsDir.childFile(
'linux_test_build.json',
);
buildConfigFile.create(recursive: true);
buildConfigFile.writeAsStringSync(buildConfigJson);
final BuildConfigLoader loader = BuildConfigLoader(
buildConfigsDir: buildConfigsDir,
);
expect(loader.configs, isNotNull);
expect(loader.errors, isEmpty);
expect(loader.configs['linux_test_build'], isNotNull);
});
test('BuildConfigLoader gives an empty config when no configs found', () {
final FileSystem fs = MemoryFileSystem();
final String buildConfigPath = fs.path.join(
'flutter', 'ci', 'builders', 'linux_test_build.json',
);
final Directory buildConfigsDir = fs.directory(buildConfigPath);
final BuildConfigLoader loader = BuildConfigLoader(
buildConfigsDir: buildConfigsDir,
);
expect(loader.configs, isNotNull);
expect(loader.errors[0], equals(
'flutter/ci/builders/linux_test_build.json does not exist.',
));
expect(loader.configs, equals(<String, BuildConfig>{}));
});
return 0;
}

View File

@ -0,0 +1,368 @@
// 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:convert' as convert;
import 'package:engine_build_configs/src/build_config.dart';
import 'package:litetest/litetest.dart';
const String buildConfigJson = '''
{
"builds": [
{
"archives": [
{
"name": "build_name",
"base_path": "base/path",
"type": "gcs",
"include_paths": ["include/path"],
"realm": "archive_realm"
}
],
"drone_dimensions": ["dimension"],
"gclient_variables": {
"variable": false
},
"gn": ["--gn-arg"],
"name": "build_name",
"ninja": {
"config": "build_name",
"targets": ["ninja_target"]
},
"tests": [
{
"language": "python3",
"name": "build_name tests",
"parameters": ["--test-params"],
"script": "test/script.py",
"contexts": ["context"]
}
],
"generators": {
"tasks": [
{
"name": "generator_task",
"parameters": ["--gen-param"],
"scripts": ["gen/script.py"]
}
]
}
}
],
"generators": {
"tasks": [
{
"name": "global generator task",
"parameters": ["--global-gen-param"],
"script": "global/gen_script.dart",
"language": "dart"
}
]
},
"tests": [
{
"name": "global test",
"recipe": "engine_v2/tester_engine",
"drone_dimensions": ["dimension"],
"gclient_variables": {
"variable": false
},
"dependencies": ["dependency"],
"test_dependencies": [
{
"dependency": "test_dependency",
"version": "git_revision:3a77d0b12c697a840ca0c7705208e8622dc94603"
}
],
"tasks": [
{
"name": "global test task",
"parameters": ["--test-parameter"],
"script": "global/test/script.py"
}
]
}
]
}
''';
int main() {
test('BuildConfig parser works', () {
final BuildConfig buildConfig = BuildConfig.fromJson(
path: 'linux_test_config',
map: convert.jsonDecode(buildConfigJson) as Map<String, Object?>,
);
expect(buildConfig.valid, isTrue);
expect(buildConfig.errors, isNull);
expect(buildConfig.builds.length, equals(1));
final GlobalBuild globalBuild = buildConfig.builds[0];
expect(globalBuild.name, equals('build_name'));
expect(globalBuild.gn.length, equals(1));
expect(globalBuild.gn[0], equals('--gn-arg'));
expect(globalBuild.droneDimensions.length, equals(1));
expect(globalBuild.droneDimensions[0], equals('dimension'));
final BuildNinja ninja = globalBuild.ninja;
expect(ninja.config, equals('build_name'));
expect(ninja.targets.length, equals(1));
expect(ninja.targets[0], equals('ninja_target'));
expect(globalBuild.archives.length, equals(1));
final BuildArchive buildArchive = globalBuild.archives[0];
expect(buildArchive.name, equals('build_name'));
expect(buildArchive.basePath, equals('base/path'));
expect(buildArchive.type, equals('gcs'));
expect(buildArchive.includePaths.length, equals(1));
expect(buildArchive.includePaths[0], equals('include/path'));
expect(globalBuild.tests.length, equals(1));
final BuildTest tst = globalBuild.tests[0];
expect(tst.name, equals('build_name tests'));
expect(tst.language, equals('python3'));
expect(tst.script, equals('test/script.py'));
expect(tst.parameters.length, equals(1));
expect(tst.parameters[0], equals('--test-params'));
expect(tst.contexts.length, equals(1));
expect(tst.contexts[0], equals('context'));
expect(globalBuild.generators.length, equals(1));
final BuildTask buildTask = globalBuild.generators[0];
expect(buildTask.name, equals('generator_task'));
expect(buildTask.scripts.length, equals(1));
expect(buildTask.scripts[0], equals('gen/script.py'));
expect(buildTask.parameters.length, equals(1));
expect(buildTask.parameters[0], equals('--gen-param'));
expect(buildConfig.generators.length, equals(1));
final TestTask testTask = buildConfig.generators[0];
expect(testTask.name, equals('global generator task'));
expect(testTask.language, equals('dart'));
expect(testTask.script, equals('global/gen_script.dart'));
expect(testTask.parameters.length, equals(1));
expect(testTask.parameters[0], equals('--global-gen-param'));
expect(buildConfig.tests.length, equals(1));
final GlobalTest globalTest = buildConfig.tests[0];
expect(globalTest.name, equals('global test'));
expect(globalTest.recipe, equals('engine_v2/tester_engine'));
expect(globalTest.droneDimensions.length, equals(1));
expect(globalTest.droneDimensions[0], equals('dimension'));
expect(globalTest.dependencies.length, equals(1));
expect(globalTest.dependencies[0], equals('dependency'));
expect(globalTest.tasks.length, equals(1));
final TestTask globalTestTask = globalTest.tasks[0];
expect(globalTestTask.name, equals('global test task'));
expect(globalTestTask.script, equals('global/test/script.py'));
expect(globalTestTask.language, equals('<undef>'));
});
test('BuildConfig flags invalid input', () {
const String invalidInput = '''
{
"builds": 5,
"generators": {},
"tests": []
}
''';
final BuildConfig buildConfig = BuildConfig.fromJson(
path: 'linux_test_config',
map: convert.jsonDecode(invalidInput) as Map<String, Object?>,
);
expect(buildConfig.valid, isFalse);
expect(buildConfig.errors![0], equals(
'For field "builds", expected type: list, actual type: int.',
));
});
test('GlobalBuild flags invalid input', () {
const String invalidInput = '''
{
"builds": [
{
"name": 5
}
],
"generators": {},
"tests": []
}
''';
final BuildConfig buildConfig = BuildConfig.fromJson(
path: 'linux_test_config',
map: convert.jsonDecode(invalidInput) as Map<String, Object?>,
);
expect(buildConfig.valid, isTrue);
expect(buildConfig.builds.length, equals(1));
expect(buildConfig.builds[0].valid, isFalse);
expect(buildConfig.builds[0].errors![0], equals(
'For field "name", expected type: string, actual type: int.',
));
});
test('BuildNinja flags invalid input', () {
const String invalidInput = '''
{
"builds": [
{
"ninja": {
"config": 5
}
}
],
"generators": {},
"tests": []
}
''';
final BuildConfig buildConfig = BuildConfig.fromJson(
path: 'linux_test_config',
map: convert.jsonDecode(invalidInput) as Map<String, Object?>,
);
expect(buildConfig.valid, isTrue);
expect(buildConfig.builds.length, equals(1));
expect(buildConfig.builds[0].valid, isTrue);
expect(buildConfig.builds[0].ninja.valid, isFalse);
expect(buildConfig.builds[0].ninja.errors![0], equals(
'For field "config", expected type: string, actual type: int.',
));
});
test('BuildTest flags invalid input', () {
const String invalidInput = '''
{
"builds": [
{
"tests": [
{
"language": 5
}
]
}
],
"generators": {},
"tests": []
}
''';
final BuildConfig buildConfig = BuildConfig.fromJson(
path: 'linux_test_config',
map: convert.jsonDecode(invalidInput) as Map<String, Object?>,
);
expect(buildConfig.valid, isTrue);
expect(buildConfig.builds.length, equals(1));
expect(buildConfig.builds[0].valid, isTrue);
expect(buildConfig.builds[0].tests[0].valid, isFalse);
expect(buildConfig.builds[0].tests[0].errors![0], equals(
'For field "language", expected type: string, actual type: int.',
));
});
test('BuildTask flags invalid input', () {
const String invalidInput = '''
{
"builds": [
{
"generators": {
"tasks": [
{
"name": 5
}
]
}
}
],
"generators": {},
"tests": []
}
''';
final BuildConfig buildConfig = BuildConfig.fromJson(
path: 'linux_test_config',
map: convert.jsonDecode(invalidInput) as Map<String, Object?>,
);
expect(buildConfig.valid, isTrue);
expect(buildConfig.builds.length, equals(1));
expect(buildConfig.builds[0].valid, isTrue);
expect(buildConfig.builds[0].generators[0].valid, isFalse);
expect(buildConfig.builds[0].generators[0].errors![0], equals(
'For field "name", expected type: string, actual type: int.',
));
});
test('BuildArchive flags invalid input', () {
const String invalidInput = '''
{
"builds": [
{
"archives": [
{
"name": 5
}
]
}
],
"generators": {},
"tests": []
}
''';
final BuildConfig buildConfig = BuildConfig.fromJson(
path: 'linux_test_config',
map: convert.jsonDecode(invalidInput) as Map<String, Object?>,
);
expect(buildConfig.valid, isTrue);
expect(buildConfig.builds.length, equals(1));
expect(buildConfig.builds[0].valid, isTrue);
expect(buildConfig.builds[0].archives[0].valid, isFalse);
expect(buildConfig.builds[0].archives[0].errors![0], equals(
'For field "name", expected type: string, actual type: int.',
));
});
test('GlobalTest flags invalid input', () {
const String invalidInput = '''
{
"tests": [
{
"name": 5
}
]
}
''';
final BuildConfig buildConfig = BuildConfig.fromJson(
path: 'linux_test_config',
map: convert.jsonDecode(invalidInput) as Map<String, Object?>,
);
expect(buildConfig.valid, isTrue);
expect(buildConfig.tests.length, equals(1));
expect(buildConfig.tests[0].valid, isFalse);
expect(buildConfig.tests[0].errors![0], equals(
'For field "name", expected type: string, actual type: int.',
));
});
test('TestTask flags invalid input', () {
const String invalidInput = '''
{
"tests": [
{
"tasks": [
{
"name": 5
}
]
}
]
}
''';
final BuildConfig buildConfig = BuildConfig.fromJson(
path: 'linux_test_config',
map: convert.jsonDecode(invalidInput) as Map<String, Object?>,
);
expect(buildConfig.valid, isTrue);
expect(buildConfig.tests.length, equals(1));
expect(buildConfig.tests[0].tasks[0].valid, isFalse);
expect(buildConfig.tests[0].tasks[0].errors![0], contains(
'For field "name", expected type: string, actual type: int.',
));
});
return 0;
}

View File

@ -42,6 +42,7 @@ ALL_PACKAGES = [
os.path.join(ENGINE_DIR, 'tools', 'githooks'),
os.path.join(ENGINE_DIR, 'tools', 'licenses'),
os.path.join(ENGINE_DIR, 'tools', 'path_ops', 'dart'),
os.path.join(ENGINE_DIR, 'tools', 'pkg', 'engine_build_configs'),
os.path.join(ENGINE_DIR, 'tools', 'pkg', 'engine_repo_tools'),
]