utils.dart 26.2 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:path/path.dart' as path;
11
import 'package:process/process.dart';
12 13
import 'package:stack_trace/stack_trace.dart';

14
import 'devices.dart';
15
import 'host_agent.dart';
16
import 'task_result.dart';
17

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

21
/// The local engine to use for [flutter] and [evalFlutter], if any.
22 23 24
///
/// This is set as an environment variable when running the task, see runTask in runner.dart.
String? get localEngineFromEnv {
25
  const bool isDefined = bool.hasEnvironment('localEngine');
26 27
  return isDefined ? const String.fromEnvironment('localEngine') : null;
}
28 29 30

/// The local engine source path to use if a local engine is used for [flutter]
/// and [evalFlutter].
31 32 33
///
/// This is set as an environment variable when running the task, see runTask in runner.dart.
String? get localEngineSrcPathFromEnv {
34
  const bool isDefined = bool.hasEnvironment('localEngineSrcPath');
35 36
  return isDefined ? const String.fromEnvironment('localEngineSrcPath') : null;
}
37

38
List<ProcessInfo> _runningProcesses = <ProcessInfo>[];
39
ProcessManager _processManager = const LocalProcessManager();
40 41 42 43

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

44
  final DateTime startTime = DateTime.now();
45 46 47 48 49 50
  final String command;
  final Process process;

  @override
  String toString() {
    return '''
51 52 53
  command: $command
  started: $startTime
  pid    : ${process.pid}
54 55 56 57 58 59 60 61 62 63 64
'''
        .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,
65
        details = 'ERROR: $error${stackTrace != null ? '\n$stackTrace' : ''}';
66 67

  final bool succeeded;
68
  final String? details;
69 70 71

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

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

  final String message;

  @override
  String toString() => message;
}

void fail(String message) {
94
  throw BuildFailedError(message);
95 96
}

97 98 99 100 101 102 103 104 105 106 107 108
// 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');
    }
  }
109 110 111 112
}

/// Remove recursively.
void rmTree(FileSystemEntity entity) {
113
  rm(entity, recursive: true);
114 115 116 117
}

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

118
Directory dir(String path) => Directory(path);
119

120
File file(String path) => File(path);
121

122
void copy(File sourceFile, Directory targetDirectory, {String? name}) {
123
  final File target = file(
124 125 126 127
      path.join(targetDirectory.path, name ?? path.basename(sourceFile.path)));
  target.writeAsBytesSync(sourceFile.readAsBytesSync());
}

128
void recursiveCopy(Directory source, Directory target) {
129
  if (!target.existsSync()) {
130
    target.createSync();
131
  }
132

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

149
FileSystemEntity move(FileSystemEntity whatToMove,
150
    {required Directory to, String? name}) {
151 152 153 154
  return whatToMove
      .renameSync(path.join(to.path, name ?? path.basename(whatToMove.path)));
}

155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
/// 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,
    );
  }
}

176 177 178 179 180 181 182 183 184 185 186 187 188
/// 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) {
189 190 191 192 193 194 195 196 197
  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';
198
    if (output.length == 79) {
199
      output += '═';
200
    }
201
  }
202
  print('\n\n$output\n');
203 204 205 206
}

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

  // 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
214
  if (version.contains('(')) {
215
    version = version.substring(0, version.indexOf('(')).trim();
216 217
  }
  if (version.contains(':')) {
218
    version = version.substring(version.indexOf(':') + 1).trim();
219
  }
220 221 222 223

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

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

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

Future<DateTime> getFlutterRepoCommitTimestamp(String commit) {
  // git show -s --format=%at 4b546df7f0b3858aaaa56c4079e5be1ba91fbb65
236
  return inDirectory<DateTime>(flutterDirectory, () async {
237
    final String unixTimestamp = await eval('git', <String>[
238 239 240 241 242
      'show',
      '-s',
      '--format=%at',
      commit,
    ]);
243
    final int secondsSinceEpoch = int.parse(unixTimestamp);
244
    return DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
245 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
/// 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.
272 273
Future<Process> startProcess(
  String executable,
274 275
  List<String>? arguments, {
  Map<String, String>? environment,
276
  bool isBot = true, // set to false to pretend not to be on a bot (e.g. to test user-facing outputs)
277
  String? workingDirectory,
278
}) async {
279
  assert(isBot != null);
280
  final String command = '$executable ${arguments?.join(" ") ?? ""}';
281
  final String finalWorkingDirectory = workingDirectory ?? cwd;
282 283
  final Map<String, String> newEnvironment = Map<String, String>.from(environment ?? <String, String>{});
  newEnvironment['BOT'] = isBot ? 'true' : 'false';
284
  newEnvironment['LANG'] = 'en_US.UTF-8';
285
  print('Executing "$command" in "$finalWorkingDirectory" with environment $newEnvironment');
286
  final Process process = await _processManager.start(
287
    <String>[executable, ...?arguments],
288
    environment: newEnvironment,
289
    workingDirectory: finalWorkingDirectory,
290
  );
291
  final ProcessInfo processInfo = ProcessInfo(command, process);
292 293
  _runningProcesses.add(processInfo);

294
  unawaited(process.exitCode.then<void>((int exitCode) {
295
    _runningProcesses.remove(processInfo);
296
  }));
297

298
  return process;
299 300
}

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

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

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

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

Future<int> _execute(
  String executable,
  List<String> arguments, {
339
  Map<String, String>? environment,
340
  bool canFail = false, // as in, whether failures are ok. False means that they are fatal.
341 342 343
  String? workingDirectory,
  StringBuffer? output, // if not null, the stdout will be written here
  StringBuffer? stderr, // if not null, the stderr will be written here
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358
  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,
359
  );
360 361
  final int exitCode = await process.exitCode;

362
  if (exitCode != 0 && !canFail) {
363
    fail('Executable "$executable" failed with exit code $exitCode.');
364
  }
365 366 367

  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 386 387 388 389 390 391
    .transform<String>(utf8.decoder)
    .transform<String>(const LineSplitter())
    .listen((String line) {
      if (printStdout) {
        print('stdout: $line');
      }
      output?.writeln(line);
    }, onDone: () { stdoutDone.complete(); });
392
  process.stderr
393 394 395 396 397 398 399 400
    .transform<String>(utf8.decoder)
    .transform<String>(const LineSplitter())
    .listen((String line) {
      if (printStderr) {
        print('stderr: $line');
      }
      stderr?.writeln(line);
    }, 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 448
  final String? localEngine = localEngineFromEnv;
  final String? localEngineSrcPath = localEngineSrcPathFromEnv;
449
  return <String>[
450
    command,
451 452 453
    if (deviceOperatingSystem == DeviceOperatingSystem.ios && supportedDeviceTimeoutCommands.contains(command))
      ...<String>[
        '--device-timeout',
454
        '5',
455
      ],
456 457 458

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

467 468
/// Runs the flutter `command`, and returns the exit code.
/// If `canFail` is `false`, the future completes with an error.
469 470 471
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.
472
  Map<String, String>? environment,
473 474
}) {
  final List<String> args = flutterCommandArgs(command, options);
475
  return exec(path.join(flutterDirectory.path, 'bin', 'flutter'), args,
476
    canFail: canFail, environment: environment);
477 478
}

479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500
/// Starts a Flutter subprocess.
///
/// The first argument is the flutter command 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 `isBot` argument controls whether the `BOT` environment variable is set
/// to `true` or `false` and is used by the `flutter` tool to determine how
/// verbose to be and whether to enable analytics by default.
///
/// 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.
501 502 503
Future<Process> startFlutter(String command, {
  List<String> options = const <String>[],
  Map<String, String> environment = const <String, String>{},
504
  bool isBot = true, // set to false to pretend not to be on a bot (e.g. to test user-facing outputs)
505
}) {
506
  assert(isBot != null);
507 508 509 510 511
  final List<String> args = flutterCommandArgs(command, options);
  return startProcess(
    path.join(flutterDirectory.path, 'bin', 'flutter'),
    args,
    environment: environment,
512
    isBot: isBot,
513 514 515
  );
}

516
/// Runs a `flutter` command and returns the standard output as a string.
517
Future<String> evalFlutter(String command, {
518
  List<String> options = const <String>[],
519
  bool canFail = false, // as in, whether failures are ok. False means that they are fatal.
520 521
  Map<String, String>? environment,
  StringBuffer? stderr, // if not null, the stderr will be written here.
522
}) {
523
  final List<String> args = flutterCommandArgs(command, options);
524
  return eval(path.join(flutterDirectory.path, 'bin', 'flutter'), args,
525
      canFail: canFail, environment: environment, stderr: stderr);
526 527
}

528 529 530 531 532 533 534 535 536 537
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,
  );
}

538 539 540
String get dartBin =>
    path.join(flutterDirectory.path, 'bin', 'cache', 'dart-sdk', 'bin', 'dart');

541 542 543
String get pubBin =>
    path.join(flutterDirectory.path, 'bin', 'cache', 'dart-sdk', 'bin', 'pub');

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

546 547
/// Returns a future that completes with a path suitable for JAVA_HOME
/// or with null, if Java cannot be found.
548
Future<String?> findJavaHome() async {
549 550 551 552 553
  if (_javaHome == null) {
    final Iterable<String> hits = grep(
      'Java binary at: ',
      from: await evalFlutter('doctor', options: <String>['-v']),
    );
554
    if (hits.isEmpty) {
555
      return null;
556
    }
557 558 559 560 561 562 563
    final String javaBinary = hits.first
        .split(': ')
        .last;
    // javaBinary == /some/path/to/java/home/bin/java
    _javaHome = path.dirname(path.dirname(javaBinary));
  }
  return _javaHome;
564
}
565
String? _javaHome;
566

567
Future<T> inDirectory<T>(dynamic directory, Future<T> Function() action) async {
568
  final String previousCwd = cwd;
569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585
  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 {
586
    throw FileSystemException('Unsupported directory type ${directory.runtimeType}', directory.toString());
587 588
  }

589
  if (!d.existsSync()) {
590
    throw FileSystemException('Cannot cd into directory that does not exist', d.toString());
591
  }
592 593
}

594
Directory get flutterDirectory => Directory.current.parent.parent;
595

596 597
Directory get openpayDirectory => Directory(requireEnvVar('OPENPAY_CHECKOUT_PATH'));

598
String requireEnvVar(String name) {
599
  final String? value = Platform.environment[name];
600

601
  if (value == null) {
602
    fail('$name environment variable is missing. Quitting.');
603
  }
604

605
  return value!;
606 607
}

608
T requireConfigProperty<T>(Map<String, dynamic> map, String propertyName) {
609
  if (!map.containsKey(propertyName)) {
610
    fail('Configuration property not found: $propertyName');
611
  }
612
  final T result = map[propertyName] as T;
613
  return result;
614 615 616
}

String jsonEncode(dynamic data) {
617 618
  final String jsonValue = const JsonEncoder.withIndent('  ').convert(data);
  return '$jsonValue\n';
619 620
}

621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636
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]);
  });
}

637 638 639 640 641 642 643 644 645 646
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]) {
647
  if (o1 == null) {
648
    throw 'o1 is null';
649 650
  }
  if (o2 == null) {
651
    throw 'o2 is null';
652 653
  }
  if (o3 == null) {
654
    throw 'o3 is null';
655 656
  }
  if (o4 == null) {
657
    throw 'o4 is null';
658 659
  }
  if (o5 == null) {
660
    throw 'o5 is null';
661 662
  }
  if (o6 == null) {
663
    throw 'o6 is null';
664 665
  }
  if (o7 == null) {
666
    throw 'o7 is null';
667 668
  }
  if (o8 == null) {
669
    throw 'o8 is null';
670 671
  }
  if (o9 == null) {
672
    throw 'o9 is null';
673 674
  }
  if (o10 == null) {
675
    throw 'o10 is null';
676
  }
677 678 679
}

/// Splits [from] into lines and selects those that contain [pattern].
680
Iterable<String> grep(Pattern pattern, {required String from}) {
681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697
  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) {
///
///     }
698
Future<void> runAndCaptureAsyncStacks(Future<void> Function() callback) {
699
  final Completer<void> completer = Completer<void>();
700 701 702
  Chain.capture(() async {
    await callback();
    completer.complete();
703
  }, onError: completer.completeError);
704 705
  return completer.future;
}
706

707
bool canRun(String path) => _processManager.canRun(path);
708

709 710
final RegExp _obsRegExp =
  RegExp('An Observatory debugger .* is available at: ');
711
final RegExp _obsPortRegExp = RegExp(r'(\S+:(\d+)/\S*)$');
712
final RegExp _obsUriRegExp = RegExp(r'((http|//)[a-zA-Z0-9:/=_\-\.\[\]]+)');
713

714 715 716
/// Tries to extract a port from the string.
///
/// The `prefix`, if specified, is a regular expression pattern and must not contain groups.
717
/// `prefix` defaults to the RegExp: `An Observatory debugger .* is available at: `.
718 719
int? parseServicePort(String line, {
  Pattern? prefix,
720
}) {
721
  prefix ??= _obsRegExp;
722 723
  final Iterable<Match> matchesIter = prefix.allMatches(line);
  if (matchesIter.isEmpty) {
724 725
    return null;
  }
726
  final Match prefixMatch = matchesIter.first;
727 728
  final List<Match> matches =
    _obsPortRegExp.allMatches(line, prefixMatch.end).toList();
729
  return matches.isEmpty ? null : int.parse(matches[0].group(2)!);
730 731
}

732
/// Tries to extract a URL from the string.
733 734 735
///
/// 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: `.
736 737
Uri? parseServiceUri(String line, {
  Pattern? prefix,
738 739
}) {
  prefix ??= _obsRegExp;
740 741
  final Iterable<Match> matchesIter = prefix.allMatches(line);
  if (matchesIter.isEmpty) {
742 743
    return null;
  }
744
  final Match prefixMatch = matchesIter.first;
745 746
  final List<Match> matches =
    _obsUriRegExp.allMatches(line, prefixMatch.end).toList();
747
  return matches.isEmpty ? null : Uri.parse(matches[0].group(0)!);
748
}
749

750 751 752
/// Checks that the file exists, otherwise throws a [FileSystemException].
void checkFileExists(String file) {
  if (!exists(File(file))) {
753
    throw FileSystemException('Expected file to exist.', file);
754 755
  }
}
756

xster's avatar
xster committed
757 758 759
/// Checks that the file does not exists, otherwise throws a [FileSystemException].
void checkFileNotExists(String file) {
  if (exists(File(file))) {
760
    throw FileSystemException('Expected file to not exist.', file);
xster's avatar
xster committed
761 762 763
  }
}

764 765 766 767 768 769 770
/// Checks that the directory exists, otherwise throws a [FileSystemException].
void checkDirectoryExists(String directory) {
  if (!exists(Directory(directory))) {
    throw FileSystemException('Expected directory to exist.', directory);
  }
}

771 772 773 774 775 776 777
/// 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);
  }
}

778 779 780 781 782 783 784
/// Checks that the symlink exists, otherwise throws a [FileSystemException].
void checkSymlinkExists(String file) {
  if (!exists(Link(file))) {
    throw FileSystemException('Expected symlink to exist.', file);
  }
}

785 786
/// Check that `collection` contains all entries in `values`.
void checkCollectionContains<T>(Iterable<T> values, Iterable<T> collection) {
787
  for (final T value in values) {
788
    if (!collection.contains(value)) {
789
      throw TaskResult.failure('Expected to find `$value` in `$collection`.');
790
    }
791 792 793
  }
}

794 795
/// Check that `collection` does not contain any entries in `values`
void checkCollectionDoesNotContain<T>(Iterable<T> values, Iterable<T> collection) {
796
  for (final T value in values) {
797 798 799 800
    if (collection.contains(value)) {
      throw TaskResult.failure('Did not expect to find `$value` in `$collection`.');
    }
  }
801 802
}

803 804 805 806
/// 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();
807
  for (final Pattern pattern in patterns) {
808 809 810 811 812 813 814
    if (!fileContent.contains(pattern)) {
      throw TaskResult.failure(
        'Expected to find `$pattern` in `$filePath` '
        'instead it found:\n$fileContent'
      );
    }
  }
815
}
816 817 818 819 820

/// Clones a git repository.
///
/// Removes the directory [path], then clones the git repository
/// specified by [repo] to the directory [path].
821
Future<int> gitClone({required String path, required String repo}) async {
822 823 824 825
  rmTree(Directory(path));

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

826
  return inDirectory<int>(
827 828 829 830
    path,
        () => exec('git', <String>['clone', repo]),
  );
}
831 832 833 834 835 836 837 838 839 840 841

/// 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, {
842
  FutureOr<bool> Function(Exception)? retryIf,
843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861
  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);
  }
}