utils.dart 24.8 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7
// 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';
8
import 'dart:math' as math;
9

10
import 'package:flutter_devicelab/common.dart';
11
import 'package:flutter_devicelab/framework/devices.dart';
12
import 'package:path/path.dart' as path;
13
import 'package:process/process.dart';
14 15
import 'package:stack_trace/stack_trace.dart';

16
import 'host_agent.dart';
17
import 'task_result.dart';
18

19 20 21
/// Virtual current working directory, which affect functions, such as [exec].
String cwd = Directory.current.path;

22
/// The local engine to use for [flutter] and [evalFlutter], if any.
23
String? get localEngine {
24 25 26 27 28 29 30
  // Use two distinct `defaultValue`s to determine whether a 'localEngine'
  // declaration exists in the environment.
  const bool isDefined =
      String.fromEnvironment('localEngine', defaultValue: 'a') ==
          String.fromEnvironment('localEngine', defaultValue: 'b');
  return isDefined ? const String.fromEnvironment('localEngine') : null;
}
31 32 33

/// The local engine source path to use if a local engine is used for [flutter]
/// and [evalFlutter].
34
String? get localEngineSrcPath {
35 36 37 38 39 40 41
  // Use two distinct `defaultValue`s to determine whether a
  // 'localEngineSrcPath' declaration exists in the environment.
  const bool isDefined =
      String.fromEnvironment('localEngineSrcPath', defaultValue: 'a') ==
          String.fromEnvironment('localEngineSrcPath', defaultValue: 'b');
  return isDefined ? const String.fromEnvironment('localEngineSrcPath') : null;
}
42

43
List<ProcessInfo> _runningProcesses = <ProcessInfo>[];
44
ProcessManager _processManager = const LocalProcessManager();
45 46 47 48

class ProcessInfo {
  ProcessInfo(this.command, this.process);

49
  final DateTime startTime = DateTime.now();
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
  final String command;
  final Process process;

  @override
  String toString() {
    return '''
  command : $command
  started : $startTime
  pid     : ${process.pid}
'''
        .trim();
  }
}

/// Result of a health check for a specific parameter.
class HealthCheckResult {
  HealthCheckResult.success([this.details]) : succeeded = true;
  HealthCheckResult.failure(this.details) : succeeded = false;
  HealthCheckResult.error(dynamic error, dynamic stackTrace)
      : succeeded = false,
70
        details = 'ERROR: $error${stackTrace != null ? '\n$stackTrace' : ''}';
71 72

  final bool succeeded;
73
  final String? details;
74 75 76

  @override
  String toString() {
77
    final StringBuffer buf = StringBuffer(succeeded ? 'succeeded' : 'failed');
78
    if (details != null && details!.trim().isNotEmpty) {
79 80
      buf.writeln();
      // Indent details by 4 spaces
81
      for (final String line in details!.trim().split('\n')) {
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98
        buf.writeln('    $line');
      }
    }
    return '$buf';
  }
}

class BuildFailedError extends Error {
  BuildFailedError(this.message);

  final String message;

  @override
  String toString() => message;
}

void fail(String message) {
99
  throw BuildFailedError(message);
100 101
}

102 103 104 105 106 107 108 109 110 111 112 113
// Remove the given file or directory.
void rm(FileSystemEntity entity, { bool recursive = false}) {
  if (entity.existsSync()) {
    // This should not be necessary, but it turns out that
    // on Windows it's common for deletions to fail due to
    // bogus (we think) "access denied" errors.
    try {
      entity.deleteSync(recursive: recursive);
    } on FileSystemException catch (error) {
      print('Failed to delete ${entity.path}: $error');
    }
  }
114 115 116 117
}

/// Remove recursively.
void rmTree(FileSystemEntity entity) {
118
  rm(entity, recursive: true);
119 120 121 122
}

List<FileSystemEntity> ls(Directory directory) => directory.listSync();

123
Directory dir(String path) => Directory(path);
124

125
File file(String path) => File(path);
126

127
void copy(File sourceFile, Directory targetDirectory, {String? name}) {
128
  final File target = file(
129 130 131 132
      path.join(targetDirectory.path, name ?? path.basename(sourceFile.path)));
  target.writeAsBytesSync(sourceFile.readAsBytesSync());
}

133 134 135 136
void recursiveCopy(Directory source, Directory target) {
  if (!target.existsSync())
    target.createSync();

137
  for (final FileSystemEntity entity in source.listSync(followLinks: false)) {
138
    final String name = path.basename(entity.path);
139
    if (entity is Directory && !entity.path.contains('.dart_tool'))
140
      recursiveCopy(entity, Directory(path.join(target.path, name)));
141
    else if (entity is File) {
142
      final File dest = File(path.join(target.path, name));
143
      dest.writeAsBytesSync(entity.readAsBytesSync());
144 145 146 147 148
      // Preserve executable bit
      final String modes = entity.statSync().modeString();
      if (modes != null && modes.contains('x')) {
        makeExecutable(dest);
      }
149 150 151 152
    }
  }
}

153
FileSystemEntity move(FileSystemEntity whatToMove,
154
    {required Directory to, String? name}) {
155 156 157 158
  return whatToMove
      .renameSync(path.join(to.path, name ?? path.basename(whatToMove.path)));
}

159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
/// Equivalent of `chmod a+x file`
void makeExecutable(File file) {
  // Windows files do not have an executable bit
  if (Platform.isWindows) {
    return;
  }
  final ProcessResult result = _processManager.runSync(<String>[
    'chmod',
    'a+x',
    file.path,
  ]);

  if (result.exitCode != 0) {
    throw FileSystemException(
      'Error making ${file.path} executable.\n'
      '${result.stderr}',
      file.path,
    );
  }
}

180 181 182 183 184 185 186 187 188 189 190 191 192
/// Equivalent of `mkdir directory`.
void mkdir(Directory directory) {
  directory.createSync();
}

/// Equivalent of `mkdir -p directory`.
void mkdirs(Directory directory) {
  directory.createSync(recursive: true);
}

bool exists(FileSystemEntity entity) => entity.existsSync();

void section(String title) {
193 194 195 196 197 198 199 200 201 202 203 204
  String output;
  if (Platform.isWindows) {
    // Windows doesn't cope well with characters produced for *nix systems, so
    // just output the title with no decoration.
    output = title;
  } else {
    title = '╡ ••• $title ••• ╞';
    final String line = '═' * math.max((80 - title.length) ~/ 2, 2);
    output = '$line$title$line';
    if (output.length == 79)
      output += '═';
  }
205
  print('\n\n$output\n');
206 207 208 209
}

Future<String> getDartVersion() async {
  // The Dart VM returns the version text to stderr.
210
  final ProcessResult result = _processManager.runSync(<String>[dartBin, '--version']);
211
  String version = (result.stderr as String).trim();
212 213 214 215 216

  // Convert:
  //   Dart VM version: 1.17.0-dev.2.0 (Tue May  3 12:14:52 2016) on "macos_x64"
  // to:
  //   1.17.0-dev.2.0
217
  if (version.contains('('))
218
    version = version.substring(0, version.indexOf('(')).trim();
219
  if (version.contains(':'))
220 221 222 223 224
    version = version.substring(version.indexOf(':') + 1).trim();

  return version.replaceAll('"', "'");
}

225
Future<String?> getCurrentFlutterRepoCommit() {
226
  if (!dir('${flutterDirectory.path}/.git').existsSync()) {
227
    return Future<String?>.value(null);
228 229
  }

230
  return inDirectory<String>(flutterDirectory, () {
231 232 233 234 235 236
    return eval('git', <String>['rev-parse', 'HEAD']);
  });
}

Future<DateTime> getFlutterRepoCommitTimestamp(String commit) {
  // git show -s --format=%at 4b546df7f0b3858aaaa56c4079e5be1ba91fbb65
237
  return inDirectory<DateTime>(flutterDirectory, () async {
238
    final String unixTimestamp = await eval('git', <String>[
239 240 241 242 243
      'show',
      '-s',
      '--format=%at',
      commit,
    ]);
244
    final int secondsSinceEpoch = int.parse(unixTimestamp);
245
    return DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
246 247 248
  });
}

249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272
/// Starts a subprocess.
///
/// The first argument is the full path to the executable to run.
///
/// The second argument is the list of arguments to provide on the command line.
/// This argument can be null, indicating no arguments (same as the empty list).
///
/// The `environment` argument can be provided to configure environment variables
/// that will be made available to the subprocess. The `BOT` environment variable
/// is always set and overrides any value provided in the `environment` argument.
/// The `isBot` argument controls the value of the `BOT` variable. It will either
/// be "true", if `isBot` is true (the default), or "false" if it is false.
///
/// The `BOT` variable is in particular used by the `flutter` tool to determine
/// how verbose to be and whether to enable analytics by default.
///
/// The working directory can be provided using the `workingDirectory` argument.
/// By default it will default to the current working directory (see [cwd]).
///
/// Information regarding the execution of the subprocess is printed to the
/// console.
///
/// The actual process executes asynchronously. A handle to the subprocess is
/// returned in the form of a [Future] that completes to a [Process] object.
273 274
Future<Process> startProcess(
  String executable,
275 276
  List<String>? arguments, {
  Map<String, String>? environment,
277
  bool isBot = true, // set to false to pretend not to be on a bot (e.g. to test user-facing outputs)
278
  String? workingDirectory,
279
}) async {
280
  assert(isBot != null);
281
  final String command = '$executable ${arguments?.join(" ") ?? ""}';
282
  final String finalWorkingDirectory = workingDirectory ?? cwd;
283 284
  final Map<String, String> newEnvironment = Map<String, String>.from(environment ?? <String, String>{});
  newEnvironment['BOT'] = isBot ? 'true' : 'false';
285 286
  newEnvironment['LANG'] = 'en_US.UTF-8';
  print('\nExecuting: $command in $finalWorkingDirectory with environment $newEnvironment');
287
  final Process process = await _processManager.start(
288
    <String>[executable, ...?arguments],
289
    environment: newEnvironment,
290
    workingDirectory: finalWorkingDirectory,
291
  );
292
  final ProcessInfo processInfo = ProcessInfo(command, process);
293 294
  _runningProcesses.add(processInfo);

295
  unawaited(process.exitCode.then<void>((int exitCode) {
296
    print('"$executable" exit code: $exitCode');
297
    _runningProcesses.remove(processInfo);
298
  }));
299

300
  return process;
301 302
}

303
Future<void> forceQuitRunningProcesses() async {
304 305 306 307
  if (_runningProcesses.isEmpty)
    return;

  // Give normally quitting processes a chance to report their exit code.
308
  await Future<void>.delayed(const Duration(seconds: 1));
309 310

  // Whatever's left, kill it.
311
  for (final ProcessInfo p in _runningProcesses) {
312
    print('Force-quitting process:\n$p');
313 314 315 316 317 318 319 320
    if (!p.process.kill()) {
      print('Failed to force quit process');
    }
  }
  _runningProcesses.clear();
}

/// Executes a command and returns its exit code.
321 322 323
Future<int> exec(
  String executable,
  List<String> arguments, {
324
  Map<String, String>? environment,
325
  bool canFail = false, // as in, whether failures are ok. False means that they are fatal.
326
  String? workingDirectory,
327
}) async {
328 329 330 331 332 333 334 335 336 337 338 339
  return _execute(
    executable,
    arguments,
    environment: environment,
    canFail : canFail,
    workingDirectory: workingDirectory,
  );
}

Future<int> _execute(
  String executable,
  List<String> arguments, {
340
  Map<String, String>? environment,
341
  bool canFail = false, // as in, whether failures are ok. False means that they are fatal.
342 343 344
  String? workingDirectory,
  StringBuffer? output, // if not null, the stdout will be written here
  StringBuffer? stderr, // if not null, the stderr will be written here
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360
  bool printStdout = true,
  bool printStderr = true,
}) async {
  final Process process = await startProcess(
    executable,
    arguments,
    environment: environment,
    workingDirectory: workingDirectory,
  );
  await forwardStandardStreams(
    process,
    output: output,
    stderr: stderr,
    printStdout: printStdout,
    printStderr: printStderr,
    );
361 362 363 364 365 366 367
  final int exitCode = await process.exitCode;

  if (exitCode != 0 && !canFail)
    fail('Executable "$executable" failed with exit code $exitCode.');

  return exitCode;
}
368

369
/// Forwards standard out and standard error from [process] to this process'
370 371
/// respective outputs. Also writes stdout to [output] and stderr to [stderr]
/// if they are not null.
372 373
///
/// Returns a future that completes when both out and error streams a closed.
374 375
Future<void> forwardStandardStreams(
  Process process, {
376 377
  StringBuffer? output,
  StringBuffer? stderr,
378 379 380
  bool printStdout = true,
  bool printStderr = true,
  }) {
381 382
  final Completer<void> stdoutDone = Completer<void>();
  final Completer<void> stderrDone = Completer<void>();
383
  process.stdout
384 385
      .transform<String>(utf8.decoder)
      .transform<String>(const LineSplitter())
386
      .listen((String line) {
387 388 389 390
        if (printStdout) {
          print('stdout: $line');
        }
        output?.writeln(line);
391
      }, onDone: () { stdoutDone.complete(); });
392
  process.stderr
393 394
      .transform<String>(utf8.decoder)
      .transform<String>(const LineSplitter())
395
      .listen((String line) {
396 397 398 399
        if (printStderr) {
          print('stderr: $line');
        }
        stderr?.writeln(line);
400
      }, onDone: () { stderrDone.complete(); });
401

402 403 404 405
  return Future.wait<void>(<Future<void>>[
    stdoutDone.future,
    stderrDone.future,
  ]);
406 407 408 409
}

/// Executes a command and returns its standard output as a String.
///
410
/// For logging purposes, the command's output is also printed out by default.
411 412 413
Future<String> eval(
  String executable,
  List<String> arguments, {
414
  Map<String, String>? environment,
415
  bool canFail = false, // as in, whether failures are ok. False means that they are fatal.
416 417
  String? workingDirectory,
  StringBuffer? stderr, // if not null, the stderr will be written here
418 419
  bool printStdout = true,
  bool printStderr = true,
420
}) async {
421
  final StringBuffer output = StringBuffer();
422 423 424 425 426 427 428 429 430 431 432
  await _execute(
    executable,
    arguments,
    environment: environment,
    canFail: canFail,
    workingDirectory: workingDirectory,
    output: output,
    stderr: stderr,
    printStdout: printStdout,
    printStderr: printStderr,
  );
433
  return output.toString().trimRight();
434 435
}

436
List<String> flutterCommandArgs(String command, List<String> options) {
437
  // Commands support the --device-timeout flag.
438
  final Set<String> supportedDeviceTimeoutCommands = <String>{
439 440 441 442 443 444 445
    'attach',
    'devices',
    'drive',
    'install',
    'logs',
    'run',
    'screenshot',
446
  };
447
  return <String>[
448
    command,
449 450 451
    if (deviceOperatingSystem == DeviceOperatingSystem.ios && supportedDeviceTimeoutCommands.contains(command))
      ...<String>[
        '--device-timeout',
452
        '5',
453
      ],
454 455 456

    if (command == 'drive' && hostAgent.dumpDirectory != null) ...<String>[
      '--screenshot',
457
      hostAgent.dumpDirectory!.path,
458
    ],
459 460
    if (localEngine != null) ...<String>['--local-engine', localEngine!],
    if (localEngineSrcPath != null) ...<String>['--local-engine-src-path', localEngineSrcPath!],
461 462
    ...options,
  ];
463 464
}

465 466
/// Runs the flutter `command`, and returns the exit code.
/// If `canFail` is `false`, the future completes with an error.
467 468 469
Future<int> flutter(String command, {
  List<String> options = const <String>[],
  bool canFail = false, // as in, whether failures are ok. False means that they are fatal.
470
  Map<String, String>? environment,
471 472
}) {
  final List<String> args = flutterCommandArgs(command, options);
473
  return exec(path.join(flutterDirectory.path, 'bin', 'flutter'), args,
474
    canFail: canFail, environment: environment);
475 476
}

477 478 479 480 481 482 483 484 485 486 487 488
Future<Process> startFlutter(String command, {
  List<String> options = const <String>[],
  Map<String, String> environment = const <String, String>{},
}) {
  final List<String> args = flutterCommandArgs(command, options);
  return startProcess(
    path.join(flutterDirectory.path, 'bin', 'flutter'),
    args,
    environment: environment,
  );
}

489
/// Runs a `flutter` command and returns the standard output as a string.
490
Future<String> evalFlutter(String command, {
491
  List<String> options = const <String>[],
492
  bool canFail = false, // as in, whether failures are ok. False means that they are fatal.
493 494
  Map<String, String>? environment,
  StringBuffer? stderr, // if not null, the stderr will be written here.
495
}) {
496
  final List<String> args = flutterCommandArgs(command, options);
497
  return eval(path.join(flutterDirectory.path, 'bin', 'flutter'), args,
498
      canFail: canFail, environment: environment, stderr: stderr);
499 500
}

501 502 503 504 505 506 507 508 509 510
Future<ProcessResult> executeFlutter(String command, {
  List<String> options = const <String>[],
}) async {
  final List<String> args = flutterCommandArgs(command, options);
  return _processManager.run(
    <String>[path.join(flutterDirectory.path, 'bin', 'flutter'), ...args],
    workingDirectory: cwd,
  );
}

511 512 513
String get dartBin =>
    path.join(flutterDirectory.path, 'bin', 'cache', 'dart-sdk', 'bin', 'dart');

514 515 516
String get pubBin =>
    path.join(flutterDirectory.path, 'bin', 'cache', 'dart-sdk', 'bin', 'pub');

517
Future<int> dart(List<String> args) => exec(dartBin, <String>['--disable-dart-dev', ...args]);
518

519 520
/// Returns a future that completes with a path suitable for JAVA_HOME
/// or with null, if Java cannot be found.
521
Future<String?> findJavaHome() async {
522 523 524 525 526 527 528 529 530 531 532 533 534 535
  if (_javaHome == null) {
    final Iterable<String> hits = grep(
      'Java binary at: ',
      from: await evalFlutter('doctor', options: <String>['-v']),
    );
    if (hits.isEmpty)
      return null;
    final String javaBinary = hits.first
        .split(': ')
        .last;
    // javaBinary == /some/path/to/java/home/bin/java
    _javaHome = path.dirname(path.dirname(javaBinary));
  }
  return _javaHome;
536
}
537
String? _javaHome;
538

539
Future<T> inDirectory<T>(dynamic directory, Future<T> Function() action) async {
540
  final String previousCwd = cwd;
541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557
  try {
    cd(directory);
    return await action();
  } finally {
    cd(previousCwd);
  }
}

void cd(dynamic directory) {
  Directory d;
  if (directory is String) {
    cwd = directory;
    d = dir(directory);
  } else if (directory is Directory) {
    cwd = directory.path;
    d = directory;
  } else {
558
    throw FileSystemException('Unsupported directory type ${directory.runtimeType}', directory.toString());
559 560 561
  }

  if (!d.existsSync())
562
    throw FileSystemException('Cannot cd into directory that does not exist', d.toString());
563 564
}

565
Directory get flutterDirectory => Directory.current.parent.parent;
566 567

String requireEnvVar(String name) {
568
  final String? value = Platform.environment[name];
569

570 571
  if (value == null)
    fail('$name environment variable is missing. Quitting.');
572

573
  return value!;
574 575
}

576
T requireConfigProperty<T>(Map<String, dynamic> map, String propertyName) {
577 578
  if (!map.containsKey(propertyName))
    fail('Configuration property not found: $propertyName');
579
  final T result = map[propertyName] as T;
580
  return result;
581 582 583
}

String jsonEncode(dynamic data) {
584 585
  final String jsonValue = const JsonEncoder.withIndent('  ').convert(data);
  return '$jsonValue\n';
586 587
}

588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603
Future<void> getNewGallery(String revision, Directory galleryDir) async {
  section('Get New Flutter Gallery!');

  if (exists(galleryDir)) {
    galleryDir.deleteSync(recursive: true);
  }

  await inDirectory<void>(galleryDir.parent, () async {
    await exec('git', <String>['clone', 'https://github.com/flutter/gallery.git']);
  });

  await inDirectory<void>(galleryDir, () async {
    await exec('git', <String>['checkout', revision]);
  });
}

604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636
void checkNotNull(Object o1,
    [Object o2 = 1,
    Object o3 = 1,
    Object o4 = 1,
    Object o5 = 1,
    Object o6 = 1,
    Object o7 = 1,
    Object o8 = 1,
    Object o9 = 1,
    Object o10 = 1]) {
  if (o1 == null)
    throw 'o1 is null';
  if (o2 == null)
    throw 'o2 is null';
  if (o3 == null)
    throw 'o3 is null';
  if (o4 == null)
    throw 'o4 is null';
  if (o5 == null)
    throw 'o5 is null';
  if (o6 == null)
    throw 'o6 is null';
  if (o7 == null)
    throw 'o7 is null';
  if (o8 == null)
    throw 'o8 is null';
  if (o9 == null)
    throw 'o9 is null';
  if (o10 == null)
    throw 'o10 is null';
}

/// Splits [from] into lines and selects those that contain [pattern].
637
Iterable<String> grep(Pattern pattern, {required String from}) {
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654
  return from.split('\n').where((String line) {
    return line.contains(pattern);
  });
}

/// Captures asynchronous stack traces thrown by [callback].
///
/// This is a convenience wrapper around [Chain] optimized for use with
/// `async`/`await`.
///
/// Example:
///
///     try {
///       await captureAsyncStacks(() { /* async things */ });
///     } catch (error, chain) {
///
///     }
655
Future<void> runAndCaptureAsyncStacks(Future<void> Function() callback) {
656
  final Completer<void> completer = Completer<void>();
657 658 659
  Chain.capture(() async {
    await callback();
    completer.complete();
660
  }, onError: completer.completeError);
661 662
  return completer.future;
}
663

664
bool canRun(String path) => _processManager.canRun(path);
665

666 667
final RegExp _obsRegExp =
  RegExp('An Observatory debugger .* is available at: ');
668
final RegExp _obsPortRegExp = RegExp(r'(\S+:(\d+)/\S*)$');
669
final RegExp _obsUriRegExp = RegExp(r'((http|//)[a-zA-Z0-9:/=_\-\.\[\]]+)');
670

671 672 673
/// Tries to extract a port from the string.
///
/// The `prefix`, if specified, is a regular expression pattern and must not contain groups.
674
/// `prefix` defaults to the RegExp: `An Observatory debugger .* is available at: `.
675 676
int? parseServicePort(String line, {
  Pattern? prefix,
677
}) {
678
  prefix ??= _obsRegExp;
679 680
  final Iterable<Match> matchesIter = prefix.allMatches(line);
  if (matchesIter.isEmpty) {
681 682
    return null;
  }
683
  final Match prefixMatch = matchesIter.first;
684 685
  final List<Match> matches =
    _obsPortRegExp.allMatches(line, prefixMatch.end).toList();
686
  return matches.isEmpty ? null : int.parse(matches[0].group(2)!);
687 688
}

689
/// Tries to extract a URL from the string.
690 691 692
///
/// The `prefix`, if specified, is a regular expression pattern and must not contain groups.
/// `prefix` defaults to the RegExp: `An Observatory debugger .* is available at: `.
693 694
Uri? parseServiceUri(String line, {
  Pattern? prefix,
695 696
}) {
  prefix ??= _obsRegExp;
697 698
  final Iterable<Match> matchesIter = prefix.allMatches(line);
  if (matchesIter.isEmpty) {
699 700
    return null;
  }
701
  final Match prefixMatch = matchesIter.first;
702 703
  final List<Match> matches =
    _obsUriRegExp.allMatches(line, prefixMatch.end).toList();
704
  return matches.isEmpty ? null : Uri.parse(matches[0].group(0)!);
705
}
706

707 708 709
/// Checks that the file exists, otherwise throws a [FileSystemException].
void checkFileExists(String file) {
  if (!exists(File(file))) {
710
    throw FileSystemException('Expected file to exist.', file);
711 712
  }
}
713

xster's avatar
xster committed
714 715 716
/// Checks that the file does not exists, otherwise throws a [FileSystemException].
void checkFileNotExists(String file) {
  if (exists(File(file))) {
717
    throw FileSystemException('Expected file to not exist.', file);
xster's avatar
xster committed
718 719 720
  }
}

721 722 723 724 725 726 727
/// Checks that the directory exists, otherwise throws a [FileSystemException].
void checkDirectoryExists(String directory) {
  if (!exists(Directory(directory))) {
    throw FileSystemException('Expected directory to exist.', directory);
  }
}

728 729 730 731 732 733 734
/// Checks that the directory does not exist, otherwise throws a [FileSystemException].
void checkDirectoryNotExists(String directory) {
  if (exists(Directory(directory))) {
    throw FileSystemException('Expected directory to not exist.', directory);
  }
}

735 736
/// Check that `collection` contains all entries in `values`.
void checkCollectionContains<T>(Iterable<T> values, Iterable<T> collection) {
737
  for (final T value in values) {
738 739 740
    if (!collection.contains(value)) {
      throw TaskResult.failure('Expected to find `$value` in `${collection.toString()}`.');
    }
741 742 743
  }
}

744 745
/// Check that `collection` does not contain any entries in `values`
void checkCollectionDoesNotContain<T>(Iterable<T> values, Iterable<T> collection) {
746
  for (final T value in values) {
747 748 749 750
    if (collection.contains(value)) {
      throw TaskResult.failure('Did not expect to find `$value` in `$collection`.');
    }
  }
751 752
}

753 754 755 756
/// Checks that the contents of a [File] at `filePath` contains the specified
/// [Pattern]s, otherwise throws a [TaskResult].
void checkFileContains(List<Pattern> patterns, String filePath) {
  final String fileContent = File(filePath).readAsStringSync();
757
  for (final Pattern pattern in patterns) {
758 759 760 761 762 763 764
    if (!fileContent.contains(pattern)) {
      throw TaskResult.failure(
        'Expected to find `$pattern` in `$filePath` '
        'instead it found:\n$fileContent'
      );
    }
  }
765
}
766 767 768 769 770

/// Clones a git repository.
///
/// Removes the directory [path], then clones the git repository
/// specified by [repo] to the directory [path].
771
Future<int> gitClone({required String path, required String repo}) async {
772 773 774 775
  rmTree(Directory(path));

  await Directory(path).create(recursive: true);

776
  return inDirectory<int>(
777 778 779 780
    path,
        () => exec('git', <String>['clone', repo]),
  );
}
781 782 783 784 785 786 787 788 789 790 791

/// Call [fn] retrying so long as [retryIf] return `true` for the exception
/// thrown and [maxAttempts] has not been reached.
///
/// If no [retryIf] function is given this will retry any for any [Exception]
/// thrown. To retry on an [Error], the error must be caught and _rethrown_
/// as an [Exception].
///
/// Waits a constant duration of [delayDuration] between every retry attempt.
Future<T> retry<T>(
  FutureOr<T> Function() fn, {
792
  FutureOr<bool> Function(Exception)? retryIf,
793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811
  int maxAttempts = 5,
  Duration delayDuration = const Duration(seconds: 3),
}) async {
  int attempt = 0;
  while (true) {
    attempt++; // first invocation is the first attempt
    try {
      return await fn();
    } on Exception catch (e) {
      if (attempt >= maxAttempts ||
          (retryIf != null && !(await retryIf(e)))) {
        rethrow;
      }
    }

    // Sleep for a delay
    await Future<void>.delayed(delayDuration);
  }
}