diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/README.md b/engine/src/flutter/tools/pkg/engine_build_configs/README.md new file mode 100644 index 0000000000..74298c6e09 --- /dev/null +++ b/engine/src/flutter/tools/pkg/engine_build_configs/README.md @@ -0,0 +1,47 @@ +## Overview + +This package contains libraries and example code for working with the engine_v2 +build config json files that live under `flutter/ci/builders`. + +* `lib/src/build_config.dart`: Contains the Dart object representations of the + build config json files. +* `lib/src/build_config_loader.dart`: Contains a helper class for loading all + of the build configuration json files in a directory tree into the Dart + objects. +* `lib/src/build_config_runner.dart`: Contains classes that run a loaded build + config on the local machine. + +There is some example code using these APIs under the `bin/` directory. + +* `bin/check.dart`: Checks the validity of the build config json files. This + runs on CI in pre and post submit in `ci/check_build_configs.sh` through + `ci/builders/linux_unopt.json`. +* `bin/run.dart`: Runs one build from a build configuration on the local + machine. It doesn't run generators or tests, and it isn't run on CI. + +## Usage + +### `run.dart` usage: + + +``` +$ dart bin/run.dart [build config name] [build name] +``` + +For example: + +``` +$ dart bin/run.dart mac_unopt host_debug_unopt +``` + +The build config names are the names of the json files under ci/builders. +The build names are the "name" fields of the maps in the list of "builds". + +### `check.dart` usage: + +``` +$ dart bin/check.dart [/path/to/engine/src] +``` + +The path to the engine source is optional when the current working directory is +inside of an engine checkout. diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/bin/run.dart b/engine/src/flutter/tools/pkg/engine_build_configs/bin/run.dart new file mode 100644 index 0000000000..a06fd1b6fc --- /dev/null +++ b/engine/src/flutter/tools/pkg/engine_build_configs/bin/run.dart @@ -0,0 +1,147 @@ +// 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; +import 'package:platform/platform.dart'; +import 'package:process_runner/process_runner.dart'; + +// This is an example of how to use the APIs of this library to parse and +// execute the build configurations json files under ci/builders. +// +// Usage: +// $ dart bin/run.dart [build config name] [build name] +// For example: +// $ dart bin/run.dart mac_unopt host_debug_unopt +// +// The build config names are the names of the json files under ci/builders +// The build names are the "name" fields of the maps in the list of "builds". + +void main(List args) async { + final String? configName; + final String? buildName; + if (args.length >= 2) { + configName = args[0]; + buildName = args[1]; + } else { + io.stderr.writeln(r''' +Usage: +$ dart bin/run.dart [build config name] [build name] + +For example: + +$ dart bin/run.dart mac_unopt host_debug_unopt + +The build config names are the names of the json files under ci/builders. +The build names are the "name" fields of the maps in the list of "builds". +'''); + io.exitCode = 1; + return; + } + + // Find the engine repo. + final Engine engine; + try { + engine = Engine.findWithin(); + } 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 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. + final BuildConfig? targetConfig = configs[configName]; + if (targetConfig == null) { + io.stderr.writeln('Build config "$configName" not found.'); + io.exitCode = 1; + return; + } + final List buildConfigErrors = targetConfig.check(configName); + if (buildConfigErrors.isNotEmpty) { + io.stderr.writeln('Errors in "$configName":'); + for (final String error in buildConfigErrors) { + io.stderr.writeln(' $error'); + } + io.exitCode = 1; + return; + } + + GlobalBuild? targetBuild; + for (int i = 0; i < targetConfig.builds.length; i++) { + final GlobalBuild build = targetConfig.builds[i]; + if (build.name == buildName) { + targetBuild = build; + } + } + if (targetBuild == null) { + io.stderr.writeln( + 'Target build not found. No build called $buildName in $configName', + ); + io.exitCode = 1; + return; + } + + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: const LocalPlatform(), + processRunner: ProcessRunner(), + engineSrcDir: engine.srcDir, + build: targetBuild, + runGenerators: false, + runTests: false, + ); + void handler(RunnerEvent event) { + switch (event) { + case RunnerStart(): + io.stdout.writeln('$event: ${event.command.join(' ')}'); + case RunnerProgress(done: true): + io.stdout.writeln(event); + case RunnerProgress(done: false): { + final int width = io.stdout.terminalColumns; + final String percent = '${event.percent.toStringAsFixed(1)}%'; + final String fraction = '(${event.completed}/${event.total})'; + final String prefix = '[${event.name}] $percent $fraction '; + final int remainingSpace = width - prefix.length; + final String what; + if (remainingSpace >= event.what.length) { + what = event.what; + } else { + what = event.what.substring(event.what.length - remainingSpace + 1); + } + final String spaces = ' ' * width; + io.stdout.write('$spaces\r'); // Erase the old line. + io.stdout.write('$prefix$what\r'); // Print the new line. + } + default: + io.stdout.writeln(event); + } + } + final bool buildResult = await buildRunner.run(handler); + io.exitCode = buildResult ? 0 : 1; +} diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/lib/engine_build_configs.dart b/engine/src/flutter/tools/pkg/engine_build_configs/lib/engine_build_configs.dart index e0ebcb581e..c6e4f8feb9 100644 --- a/engine/src/flutter/tools/pkg/engine_build_configs/lib/engine_build_configs.dart +++ b/engine/src/flutter/tools/pkg/engine_build_configs/lib/engine_build_configs.dart @@ -22,3 +22,4 @@ library; export 'src/build_config.dart'; export 'src/build_config_loader.dart'; +export 'src/build_config_runner.dart'; diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config.dart b/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config.dart index 935e3ac90b..1cbdeeb95e 100644 --- a/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config.dart +++ b/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:meta/meta.dart'; +import 'package:platform/platform.dart'; // This library parses Engine build config data out of the "Engine v2" build // config JSON files with the format described at: @@ -287,6 +288,10 @@ final class GlobalBuild extends BuildConfigBase { /// .gclient file before `gclient sync` is run. final Map gclientVariables; + /// Returns true if platform is capable of executing this build and false + /// otherwise. + bool canRunOn(Platform platform) => _canRunOn(droneDimensions, platform); + @override List check(String path) { final List errors = []; @@ -601,6 +606,10 @@ final class GlobalTest extends BuildConfigBase { /// A list of dictionaries representing scripts and parameters to run them final List tasks; + /// Returns true if platform is capable of executing this build and false + /// otherwise. + bool canRunOn(Platform platform) => _canRunOn(droneDimensions, platform); + @override List check(String path) { final List errors = []; @@ -744,6 +753,18 @@ final class GlobalArchive extends BuildConfigBase { final String realm; } +bool _canRunOn(List droneDimensions, Platform platform) { + String? os; + for (final String dimension in droneDimensions) { + os ??= switch (dimension.split('=')) { + ['os', 'Linux'] => Platform.linux, + ['os', final String win] when win.startsWith('Windows') => Platform.windows, + ['os', final String mac] when mac.startsWith('Mac') => Platform.macOS, + _ => null, + }; + } + return os == platform.operatingSystem; +} void appendTypeError( Map map, @@ -766,7 +787,6 @@ void appendTypeError( } } - List? objListOfJson( Map map, String field, diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config_runner.dart b/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config_runner.dart new file mode 100644 index 0000000000..3c64e1b4af --- /dev/null +++ b/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config_runner.dart @@ -0,0 +1,560 @@ +// 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:async'; +import 'dart:convert'; +import 'dart:io' as io show Directory, Process; + +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; +import 'package:process_runner/process_runner.dart'; + +import 'build_config.dart'; + +/// The base clase for events generated by a command. +sealed class RunnerEvent { + RunnerEvent(this.name, this.command, this.timestamp); + + /// The name of the task or command. + final String name; + + /// The command and its arguments. + final List command; + + /// When the event happened. + final DateTime timestamp; +} + +/// A [RunnerEvent] representing the start of a command. +final class RunnerStart extends RunnerEvent { + RunnerStart(super.name, super.command, super.timestamp); + + @override + String toString() { + return '[${_timestamp(timestamp)}][$name]: STARTING'; + } +} + +/// A [RunnerEvent] representing the progress of a started command. +final class RunnerProgress extends RunnerEvent { + RunnerProgress( + super.name, super.command, super.timestamp, + this.what, this.completed, this.total, this.done, + ) : percent = (completed * 1000) / total; + + /// What a command is currently working on, for example a build target or + /// the name of a test. + final String what; + + /// The number of steps completed. + final int completed; + + /// The total number of steps in the task. + final int total; + + /// How close is the task to being completed, for example the proportion of + /// build targets that have finished building. + final double percent; + + /// Whether the command is finished and this is the final progress event. + final bool done; + + @override + String toString() { + final String ts = '[${_timestamp(timestamp)}]'; + final String pct = '${percent.toStringAsFixed(1)}%'; + return '$ts[$name]: $pct ($completed/$total) $what'; + } +} + +/// A [RunnerEvent] representing the result of a command. +final class RunnerResult extends RunnerEvent { + RunnerResult(super.name, super.command, super.timestamp, this.result); + + /// The resuilt of running the command. + final ProcessRunnerResult result; + + /// Whether the command was successful. + late final bool ok = result.exitCode == 0; + + @override + String toString() { + if (ok) { + return '[${_timestamp(timestamp)}][$name]: OK'; + } + final StringBuffer buffer = StringBuffer(); + buffer.writeln('[$timestamp][$name]: FAILED'); + buffer.writeln('COMMAND:\n${command.join(' ')}'); + buffer.writeln('STDOUT:\n${result.stdout}'); + buffer.writeln('STDERR:\n${result.stderr}'); + return buffer.toString(); + } +} + +final class RunnerError extends RunnerEvent { + RunnerError(super.name, super.command, super.timestamp, this.error); + + /// An error message. + final String error; + + @override + String toString() { + return '[${_timestamp(timestamp)}][$name]: ERROR: $error'; + } +} + +/// The type of a callback that handles [RunnerEvent]s while a [Runner] +/// is executing its `run()` method. +typedef RunnerEventHandler = void Function(RunnerEvent); + +/// An abstract base clase for running the various tasks that a build config +/// specifies. Derived classes implement the `run()` method. +sealed class Runner { + Runner(this.platform, this.processRunner, this.engineSrcDir, this.dryRun); + + /// Information about the platform that hosts the runner. + final Platform platform; + + /// Runs the subprocesses required to run the element of the build config. + final ProcessRunner processRunner; + + /// The src/ directory of the engine checkout. + final io.Directory engineSrcDir; + + /// Whether only a dry run is required. Subprocesses will not be spawned. + final bool dryRun; + + /// Uses the [processRunner] to run the commands specified by the build + /// config. + Future run(RunnerEventHandler eventHandler); + + String _interpreter(String language) { + // Force python to be python3. + if (language.startsWith('python')) { + return 'python3'; + } + + // If the language is 'dart', return the Dart binary that is running this + // program. + if (language == 'dart') { + return platform.executable; + } + + // Otherwise use the language verbatim as the interpreter. + return language; + } +} + +final ProcessRunnerResult _dryRunResult = ProcessRunnerResult( + 0, // exit code. + [], // stdout. + [], // stderr. + [], // combined, + pid: 0, // pid, +); + +/// The [Runner] for a [GlobalBuild]. +/// +/// Runs the specified `gn` and `ninja` commands, followed by generator tasks, +/// and finally tests. +final class GlobalBuildRunner extends Runner { + GlobalBuildRunner({ + Platform? platform, + ProcessRunner? processRunner, + required io.Directory engineSrcDir, + required this.build, + this.extraGnArgs = const [], + this.extraNinjaArgs = const [], + this.extraTestArgs = const [], + this.runGn = true, + this.runNinja = true, + this.runGenerators = true, + this.runTests = true, + bool dryRun = false, + }) : super( + platform ?? const LocalPlatform(), + processRunner ?? ProcessRunner(), + engineSrcDir, + dryRun, + ); + + /// The [GlobalBuild] to run. + final GlobalBuild build; + + /// Extra arguments to append to the `gn` command. + final List extraGnArgs; + + /// Extra arguments to append to the `ninja` command. + final List extraNinjaArgs; + + /// Extra arguments to append to *all* test commands. + final List extraTestArgs; + + /// Whether to run the GN step. Defaults to true. + final bool runGn; + + /// Whether to run the ninja step. Defaults to true. + final bool runNinja; + + /// Whether to run the generators. Defaults to true. + final bool runGenerators; + + /// Whether to run the test step. Defaults to true. + final bool runTests; + + @override + Future run(RunnerEventHandler eventHandler) async { + if (!build.canRunOn(platform)) { + eventHandler(RunnerError( + build.name, [], DateTime.now(), + 'Build with drone_dimensions "{${build.droneDimensions.join(',')}}" ' + 'cannot run on platform ${platform.operatingSystem}', + )); + return false; + } + + if (runGn) { + if (!await _runGn(eventHandler)) { + return false; + } + } + + if (runNinja) { + if (!await _runNinja(eventHandler)) { + return false; + } + } + + if (runGenerators) { + if (!await _runGenerators(eventHandler)) { + return false; + } + } + + if (runTests) { + if (!await _runTests(eventHandler)) { + return false; + } + } + + return true; + } + + // GN arguments from the build config that can be overridden by extraGnArgs. + static const List<(String, String)> _overridableArgs = <(String, String)>[ + ('--lto', '--no-lto'), + ('--rbe', '--no-rbe'), + ('--goma', '--no-goma'), + ]; + + // extraGnArgs overrides the build config args. + late final Set _mergedGnArgs = () { + // Put the union of the build config args and extraGnArgs in gnArgs. + final Set gnArgs = Set.of(build.gn); + gnArgs.addAll(extraGnArgs); + + // If extraGnArgs contains an arg, remove its opposite from gnArgs. + for (final (String, String) arg in _overridableArgs) { + if (extraGnArgs.contains(arg.$1)) { + gnArgs.remove(arg.$2); + } + if (extraGnArgs.contains(arg.$2)) { + gnArgs.remove(arg.$1); + } + } + + return gnArgs; + }(); + + Future _runGn(RunnerEventHandler eventHandler) async { + final String gnPath = p.join(engineSrcDir.path, 'flutter', 'tools', 'gn'); + final Set gnArgs = _mergedGnArgs; + final List command = [gnPath, ...gnArgs]; + eventHandler(RunnerStart('${build.name}: GN', command, DateTime.now())); + final ProcessRunnerResult processResult; + if (dryRun) { + processResult = _dryRunResult; + } else { + processResult = await processRunner.runProcess( + command, + workingDirectory: engineSrcDir, + failOk: true, + ); + } + final RunnerResult result = RunnerResult( + '${build.name}: GN', command, DateTime.now(), processResult, + ); + eventHandler(result); + return result.ok; + } + + // TODO(zanderso): This should start and stop RBE when it is an --rbe build. + Future _runNinja(RunnerEventHandler eventHandler) async { + final String ninjaPath = p.join( + engineSrcDir.path, 'flutter', 'third_party', 'ninja', 'ninja', + ); + final String outDir = p.join(engineSrcDir.path, 'out', build.ninja.config); + final List command = [ + ninjaPath, + '-C', outDir, + if (_isGomaOrRbe) ...['-j', '200'], + ...extraNinjaArgs, + ...build.ninja.targets, + ]; + eventHandler(RunnerStart('${build.name}: ninja', command, DateTime.now())); + + final ProcessRunnerResult processResult; + if (dryRun) { + processResult = _dryRunResult; + } else { + final io.Process process = await processRunner.processManager.start( + command, + workingDirectory: engineSrcDir.path, + ); + final List stderrOutput = []; + final Completer stdoutComplete = Completer(); + final Completer stderrComplete = Completer(); + + process.stdout + .transform(const Utf8Decoder()) + .transform(const LineSplitter()) + .listen( + (String line) { + _ninjaProgress(eventHandler, command, line); + }, + onDone: () async => stdoutComplete.complete(), + ); + + process.stderr.listen( + stderrOutput.addAll, + onDone: () async => stderrComplete.complete(), + ); + + await Future.wait(>[ + stdoutComplete.future, stderrComplete.future, + ]); + final int exitCode = await process.exitCode; + + processResult = ProcessRunnerResult( + exitCode, + [], // stdout. + stderrOutput, // stderr. + stderrOutput, // combined, + pid: process.pid, // pid, + ); + } + + final RunnerResult result = RunnerResult( + '${build.name}: ninja', command, DateTime.now(), processResult, + ); + eventHandler(result); + return result.ok; + } + + // Parse lines of the form '[6232/6269] LINK ./accessibility_unittests'. + void _ninjaProgress( + RunnerEventHandler eventHandler, + List command, + String line, + ) { + // Grab the '[6232/6269]' part. + final String maybeProgress = line.split(' ')[0]; + if (maybeProgress.length < 3 || + maybeProgress[0] != '[' || + maybeProgress[maybeProgress.length - 1] != ']') { + return; + } + // Extract the two numbers by stripping the '[' and ']' and splitting on + // the '/'. + final List progress = maybeProgress + .substring(1, maybeProgress.length - 1) + .split('/'); + if (progress.length < 2) { + return; + } + final int? completed = int.tryParse(progress[0]); + final int? total = int.tryParse(progress[1]); + if (completed == null || total == null) { + return; + } + eventHandler(RunnerProgress( + '${build.name}: ninja', + command, + DateTime.now(), + line.replaceFirst(maybeProgress, '').trim(), + completed, + total, + completed == total, // True when done. + )); + } + + late final bool _isGoma = build.gn.contains('--goma') || + extraGnArgs.contains('--goma'); + late final bool _isRbe = build.gn.contains('--rbe') || + extraGnArgs.contains('--rbe'); + late final bool _isGomaOrRbe = _isGoma || _isRbe; + + Future _runGenerators(RunnerEventHandler eventHandler) async { + for (final BuildTask task in build.generators) { + final BuildTaskRunner runner = BuildTaskRunner( + processRunner: processRunner, + platform: platform, + engineSrcDir: engineSrcDir, + task: task, + dryRun: dryRun, + ); + if (!await runner.run(eventHandler)) { + return false; + } + } + return true; + } + + Future _runTests(RunnerEventHandler eventHandler) async { + for (final BuildTest test in build.tests) { + final BuildTestRunner runner = BuildTestRunner( + processRunner: processRunner, + platform: platform, + engineSrcDir: engineSrcDir, + test: test, + extraTestArgs: extraTestArgs, + dryRun: dryRun, + ); + if (!await runner.run(eventHandler)) { + return false; + } + } + return true; + } +} + +/// The [Runner] for a [BuildTask] of a generator of a [GlobalBuild]. +final class BuildTaskRunner extends Runner { + BuildTaskRunner({ + Platform? platform, + ProcessRunner? processRunner, + required io.Directory engineSrcDir, + required this.task, + bool dryRun = false, + }) : super( + platform ?? const LocalPlatform(), + processRunner ?? ProcessRunner(), + engineSrcDir, + dryRun, + ); + + /// The task to run. + final BuildTask task; + + @override + Future run(RunnerEventHandler eventHandler) async { + final String interpreter = _interpreter(task.language); + for (final String script in task.scripts) { + final List command = [ + if (interpreter.isNotEmpty) interpreter, + script, + ...task.parameters, + ]; + eventHandler(RunnerStart(task.name, command, DateTime.now())); + final ProcessRunnerResult processResult; + if (dryRun) { + processResult = _dryRunResult; + } else { + processResult = await processRunner.runProcess( + command, + workingDirectory: engineSrcDir, + failOk: true, + ); + } + final RunnerResult result = RunnerResult( + task.name, command, DateTime.now(), processResult, + ); + eventHandler(result); + if (!result.ok) { + return false; + } + } + return true; + } +} + +/// The [Runner] for a [BuildTest] of a [GlobalBuild]. +final class BuildTestRunner extends Runner { + BuildTestRunner({ + Platform? platform, + ProcessRunner? processRunner, + required io.Directory engineSrcDir, + required this.test, + this.extraTestArgs = const [], + bool dryRun = false, + }) : super( + platform ?? const LocalPlatform(), + processRunner ?? ProcessRunner(), + engineSrcDir, + dryRun, + ); + + /// The test to run. + final BuildTest test; + + /// Extra arguments to append to the test command. + final List extraTestArgs; + + @override + Future run(RunnerEventHandler eventHandler) async { + final String interpreter = _interpreter(test.language); + final List command = [ + if (interpreter.isNotEmpty) interpreter, + test.script, + ...test.parameters, + ...extraTestArgs, + ]; + eventHandler(RunnerStart(test.name, command, DateTime.now())); + final ProcessRunnerResult processResult; + if (dryRun) { + processResult = _dryRunResult; + } else { + // TODO(zanderso): We could detect here that we're running e.g. C++ unit + // tests via run_tests.py, and parse the stdout to generate RunnerProgress + // events. + processResult = await processRunner.runProcess( + command, + workingDirectory: engineSrcDir, + failOk: true, + printOutput: true, + ); + } + final RunnerResult result = RunnerResult( + test.name, command, DateTime.now(), processResult, + ); + eventHandler(result); + return result.ok; + } +} + +String _timestamp(DateTime time) { + String threeDigits(int n) { + return switch (n) { + >= 100 => '$n', + >= 10 => '0$n', + _ => '00$n', + }; + } + + String twoDigits(int n) { + return switch (n) { + >= 10 => '$n', + _ => '0$n', + }; + } + + final String y = time.year.toString(); + final String m = twoDigits(time.month); + final String d = twoDigits(time.day); + final String hh = twoDigits(time.hour); + final String mm = twoDigits(time.minute); + final String ss = twoDigits(time.second); + final String ms = threeDigits(time.millisecond); + return '$y-$m-${d}T$hh:$mm:$ss.$ms${time.isUtc ? 'Z' : ''}'; +} diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/pubspec.yaml b/engine/src/flutter/tools/pkg/engine_build_configs/pubspec.yaml index 95020f0d59..e357389521 100644 --- a/engine/src/flutter/tools/pkg/engine_build_configs/pubspec.yaml +++ b/engine/src/flutter/tools/pkg/engine_build_configs/pubspec.yaml @@ -30,6 +30,8 @@ dev_dependencies: async_helper: any expect: any litetest: any + process_fakes: + path: ../process_fakes smith: any dependency_overrides: diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_loader_test.dart b/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_loader_test.dart index 80e32bcbd0..c2841d7f6c 100644 --- a/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_loader_test.dart +++ b/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_loader_test.dart @@ -8,85 +8,7 @@ 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" - } - ] - } - ] -} -'''; +import 'fixtures.dart' as fixtures; int main() { test('BuildConfigLoader can load a build config', () { @@ -97,7 +19,7 @@ int main() { 'linux_test_build.json', ); buildConfigFile.create(recursive: true); - buildConfigFile.writeAsStringSync(buildConfigJson); + buildConfigFile.writeAsStringSync(fixtures.buildConfigJson); final BuildConfigLoader loader = BuildConfigLoader( buildConfigsDir: buildConfigsDir, diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_runner_test.dart b/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_runner_test.dart new file mode 100644 index 0000000000..04f400d05c --- /dev/null +++ b/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_runner_test.dart @@ -0,0 +1,447 @@ +// 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; + +import 'package:engine_build_configs/src/build_config.dart'; +import 'package:engine_build_configs/src/build_config_runner.dart'; +import 'package:engine_repo_tools/engine_repo_tools.dart'; +import 'package:litetest/litetest.dart'; +import 'package:platform/platform.dart'; +import 'package:process_fakes/process_fakes.dart'; +import 'package:process_runner/process_runner.dart'; + +import 'fixtures.dart' as fixtures; + +void main() { + // Find the engine repo. + final Engine engine; + try { + engine = Engine.findWithin(); + } catch (e) { + io.stderr.writeln(e); + io.exitCode = 1; + return; + } + + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(fixtures.buildConfigJson) as Map, + ); + + test('BuildTaskRunner runs the right commands', () async { + final BuildTask generator = buildConfig.builds[0].generators[0]; + final BuildTaskRunner taskRunner = BuildTaskRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + task: generator, + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await taskRunner.run(handler); + + expect(runResult, isTrue); + expect(events[0] is RunnerStart, isTrue); + expect(events[0].name, equals('generator_task')); + expect(events[0].command[0], contains('python3')); + expect(events[0].command[1], contains('gen/script.py')); + expect(events[0].command[2], contains('--gen-param')); + expect(events[1] is RunnerResult, isTrue); + expect(events[1].name, equals('generator_task')); + }); + + test('BuildTestRunner runs the right commands', () async { + final BuildTest test = buildConfig.builds[0].tests[0]; + final BuildTestRunner testRunner = BuildTestRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + test: test, + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await testRunner.run(handler); + + expect(runResult, isTrue); + + // Check that the events for the tests are correct. + expect(events[0] is RunnerStart, isTrue); + expect(events[0].name, equals('build_name tests')); + expect(events[0].command[0], contains('python3')); + expect(events[0].command[1], contains('test/script.py')); + expect(events[0].command[2], contains('--test-params')); + expect(events[1] is RunnerResult, isTrue); + expect(events[1].name, equals('build_name tests')); + }); + + test('GlobalBuildRunner runs the right commands', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + final String buildName = targetBuild.name; + + expect(runResult, isTrue); + + // Check that the events for the GN command are correct. + expect(events[0] is RunnerStart, isTrue); + expect(events[0].name, equals('$buildName: GN')); + expect(events[0].command[0], contains('flutter/tools/gn')); + for (final String gnArg in targetBuild.gn) { + expect(events[0].command.contains(gnArg), isTrue); + } + expect(events[1] is RunnerResult, isTrue); + expect(events[1].name, equals('$buildName: GN')); + + // Check that the events for the Ninja command are correct. + expect(events[2] is RunnerStart, isTrue); + expect(events[2].name, equals('$buildName: ninja')); + expect(events[2].command[0], contains('ninja')); + final String configPath = '${engine.srcDir.path}/out/${targetBuild.ninja.config}'; + expect(events[2].command.contains(configPath), isTrue); + for (final String target in targetBuild.ninja.targets) { + expect(events[2].command.contains(target), isTrue); + } + expect(events[3] is RunnerResult, isTrue); + expect(events[3].name, equals('$buildName: ninja')); + + // Check that the events for generators are correct. + expect(events[4] is RunnerStart, isTrue); + expect(events[4].name, equals('generator_task')); + expect(events[4].command[0], contains('python3')); + expect(events[4].command[1], contains('gen/script.py')); + expect(events[4].command[2], contains('--gen-param')); + expect(events[5] is RunnerResult, isTrue); + expect(events[5].name, equals('generator_task')); + + // Check that the events for the tests are correct. + expect(events[6] is RunnerStart, isTrue); + expect(events[6].name, equals('$buildName tests')); + expect(events[6].command[0], contains('python3')); + expect(events[6].command[1], contains('test/script.py')); + expect(events[6].command[2], contains('--test-params')); + expect(events[7] is RunnerResult, isTrue); + expect(events[7].name, equals('$buildName tests')); + }); + + test('GlobalBuildRunner extra args are propagated correctly', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + extraGnArgs: ['--extra-gn-arg'], + extraNinjaArgs: ['--extra-ninja-arg'], + extraTestArgs: ['--extra-test-arg'], + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + final String buildName = targetBuild.name; + + expect(runResult, isTrue); + + // Check that the events for the GN command are correct. + expect(events[0] is RunnerStart, isTrue); + expect(events[0].name, equals('$buildName: GN')); + expect(events[0].command.contains('--extra-gn-arg'), isTrue); + + // Check that the events for the Ninja command are correct. + expect(events[2] is RunnerStart, isTrue); + expect(events[2].name, equals('$buildName: ninja')); + expect(events[2].command.contains('--extra-ninja-arg'), isTrue); + + // Check that the events for the tests are correct. + expect(events[6] is RunnerStart, isTrue); + expect(events[6].name, equals('$buildName tests')); + expect(events[6].command.contains('--extra-test-arg'), isTrue); + }); + + test('GlobalBuildRunner passes large -j for a goma build', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + extraGnArgs: ['--goma'], + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + final String buildName = targetBuild.name; + + expect(runResult, isTrue); + + // Check that the events for the Ninja command are correct. + expect(events[2] is RunnerStart, isTrue); + expect(events[2].name, equals('$buildName: ninja')); + expect(events[2].command.contains('-j'), isTrue); + expect(events[2].command.contains('200'), isTrue); + }); + + test('GlobalBuildRunner passes large -j for an rbe build', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + extraGnArgs: ['--rbe'], + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + final String buildName = targetBuild.name; + + expect(runResult, isTrue); + + // Check that the events for the Ninja command are correct. + expect(events[2] is RunnerStart, isTrue); + expect(events[2].name, equals('$buildName: ninja')); + expect(events[2].command.contains('-j'), isTrue); + expect(events[2].command.contains('200'), isTrue); + }); + + test('GlobalBuildRunner skips GN when runGn is false', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + runGn: false, + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + final String buildName = targetBuild.name; + + expect(runResult, isTrue); + + // Check that the events for the Ninja command are correct. + expect(events[0] is RunnerStart, isTrue); + expect(events[0].name, equals('$buildName: ninja')); + expect(events[0].command[0], contains('ninja')); + final String configPath = '${engine.srcDir.path}/out/${targetBuild.ninja.config}'; + expect(events[0].command.contains(configPath), isTrue); + for (final String target in targetBuild.ninja.targets) { + expect(events[0].command.contains(target), isTrue); + } + expect(events[1] is RunnerResult, isTrue); + expect(events[1].name, equals('$buildName: ninja')); + }); + + test('GlobalBuildRunner skips Ninja when runNinja is false', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + runNinja: false, + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + final String buildName = targetBuild.name; + + expect(runResult, isTrue); + + // Check that the events for the GN command are correct. + expect(events[0] is RunnerStart, isTrue); + expect(events[0].name, equals('$buildName: GN')); + expect(events[0].command[0], contains('flutter/tools/gn')); + for (final String gnArg in targetBuild.gn) { + expect(events[0].command.contains(gnArg), isTrue); + } + expect(events[1] is RunnerResult, isTrue); + expect(events[1].name, equals('$buildName: GN')); + + // Check that the events for generators are correct. + expect(events[2] is RunnerStart, isTrue); + expect(events[2].name, equals('generator_task')); + expect(events[2].command[0], contains('python3')); + expect(events[2].command[1], contains('gen/script.py')); + expect(events[2].command[2], contains('--gen-param')); + expect(events[3] is RunnerResult, isTrue); + expect(events[3].name, equals('generator_task')); + }); + + test('GlobalBuildRunner skips generators when runGenerators is false', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + runGenerators: false, + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + final String buildName = targetBuild.name; + + expect(runResult, isTrue); + + // Check that the events for the Ninja command are correct. + expect(events[2] is RunnerStart, isTrue); + expect(events[2].name, equals('$buildName: ninja')); + expect(events[2].command[0], contains('ninja')); + final String configPath = '${engine.srcDir.path}/out/${targetBuild.ninja.config}'; + expect(events[2].command.contains(configPath), isTrue); + for (final String target in targetBuild.ninja.targets) { + expect(events[2].command.contains(target), isTrue); + } + expect(events[3] is RunnerResult, isTrue); + expect(events[3].name, equals('$buildName: ninja')); + + // Check that the events for the tests are correct. + expect(events[4] is RunnerStart, isTrue); + expect(events[4].name, equals('$buildName tests')); + expect(events[4].command[0], contains('python3')); + expect(events[4].command[1], contains('test/script.py')); + expect(events[4].command[2], contains('--test-params')); + expect(events[5] is RunnerResult, isTrue); + expect(events[5].name, equals('$buildName tests')); + }); + + test('GlobalBuildRunner skips tests when runTests is false', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + runTests: false, + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + expect(runResult, isTrue); + + // Check that the events for generators are correct. + expect(events[4] is RunnerStart, isTrue); + expect(events[4].name, equals('generator_task')); + expect(events[4].command[0], contains('python3')); + expect(events[4].command[1], contains('gen/script.py')); + expect(events[4].command[2], contains('--gen-param')); + expect(events[5] is RunnerResult, isTrue); + expect(events[5].name, equals('generator_task')); + + expect(events.length, equals(6)); + }); + + test('GlobalBuildRunner extraGnArgs overrides build config args', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.linux), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + extraGnArgs: ['--no-lto', '--no-goma', '--rbe'], + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + final String buildName = targetBuild.name; + + expect(runResult, isTrue); + + // Check that the events for the GN command are correct. + expect(events[0] is RunnerStart, isTrue); + expect(events[0].name, equals('$buildName: GN')); + expect(events[0].command[0], contains('flutter/tools/gn')); + expect(events[0].command.contains('--no-lto'), isTrue); + expect(events[0].command.contains('--no-goma'), isTrue); + expect(events[0].command.contains('--rbe'), isTrue); + expect(events[0].command.contains('--lto'), isFalse); + expect(events[0].command.contains('--goma'), isFalse); + expect(events[0].command.contains('--no-rbe'), isFalse); + expect(events[1] is RunnerResult, isTrue); + expect(events[1].name, equals('$buildName: GN')); + }); + + test('GlobalBuildRunner canRun returns false on OS mismatch', () async { + final GlobalBuild targetBuild = buildConfig.builds[0]; + final GlobalBuildRunner buildRunner = GlobalBuildRunner( + platform: FakePlatform(operatingSystem: Platform.macOS), + processRunner: ProcessRunner( + // dryRun should not try to spawn any processes. + processManager: FakeProcessManager(), + ), + engineSrcDir: engine.srcDir, + build: targetBuild, + dryRun: true, + ); + final List events = []; + void handler(RunnerEvent event) => events.add(event); + final bool runResult = await buildRunner.run(handler); + + expect(runResult, isFalse); + expect(events[0] is RunnerError, isTrue); + }); +} diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_test.dart b/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_test.dart index 20220aad98..a4047ab601 100644 --- a/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_test.dart +++ b/engine/src/flutter/tools/pkg/engine_build_configs/test/build_config_test.dart @@ -6,92 +6,15 @@ import 'dart:convert' as convert; import 'package:engine_build_configs/src/build_config.dart'; import 'package:litetest/litetest.dart'; +import 'package:platform/platform.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" - } - ] - } - ] -} -'''; +import 'fixtures.dart' as fixtures; int main() { test('BuildConfig parser works', () { final BuildConfig buildConfig = BuildConfig.fromJson( path: 'linux_test_config', - map: convert.jsonDecode(buildConfigJson) as Map, + map: convert.jsonDecode(fixtures.buildConfigJson) as Map, ); expect(buildConfig.valid, isTrue); expect(buildConfig.errors, isNull); @@ -99,10 +22,18 @@ int main() { final GlobalBuild globalBuild = buildConfig.builds[0]; expect(globalBuild.name, equals('build_name')); - expect(globalBuild.gn.length, equals(1)); + expect(globalBuild.gn.length, equals(4)); expect(globalBuild.gn[0], equals('--gn-arg')); expect(globalBuild.droneDimensions.length, equals(1)); - expect(globalBuild.droneDimensions[0], equals('dimension')); + expect(globalBuild.droneDimensions[0], equals('os=Linux')); + expect( + globalBuild.canRunOn(FakePlatform(operatingSystem: Platform.linux)), + isTrue, + ); + expect( + globalBuild.canRunOn(FakePlatform(operatingSystem: Platform.macOS)), + isFalse, + ); final BuildNinja ninja = globalBuild.ninja; expect(ninja.config, equals('build_name')); @@ -148,7 +79,15 @@ int main() { 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.droneDimensions[0], equals('os=Linux')); + expect( + globalTest.canRunOn(FakePlatform(operatingSystem: Platform.linux)), + isTrue, + ); + expect( + globalTest.canRunOn(FakePlatform(operatingSystem: Platform.macOS)), + isFalse, + ); expect(globalTest.dependencies.length, equals(1)); expect(globalTest.dependencies[0], equals('dependency')); diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/test/fixtures.dart b/engine/src/flutter/tools/pkg/engine_build_configs/test/fixtures.dart new file mode 100644 index 0000000000..f168a401e0 --- /dev/null +++ b/engine/src/flutter/tools/pkg/engine_build_configs/test/fixtures.dart @@ -0,0 +1,88 @@ +// 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. + +const String buildConfigJson = ''' +{ + "builds": [ + { + "archives": [ + { + "name": "build_name", + "base_path": "base/path", + "type": "gcs", + "include_paths": ["include/path"], + "realm": "archive_realm" + } + ], + "drone_dimensions": [ + "os=Linux" + ], + "gclient_variables": { + "variable": false + }, + "gn": ["--gn-arg", "--lto", "--goma", "--no-rbe"], + "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", + "language": "python", + "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": [ + "os=Linux" + ], + "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" + } + ] + } + ] +} +''';