common.dart 14.4 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'dart:async';

7
import 'package:args/args.dart';
8
import 'package:args/command_runner.dart';
9
import 'package:flutter_tools/src/base/logger.dart';
10
import 'package:flutter_tools/src/base/platform.dart';
11
import 'package:flutter_tools/src/convert.dart';
12
import 'package:flutter_tools/src/doctor.dart';
13
import 'package:vm_service/vm_service.dart' as vm_service;
14
import 'package:path/path.dart' as path; // ignore: package_path_import
15

16
import 'package:flutter_tools/src/base/common.dart';
17
import 'package:flutter_tools/src/base/context.dart';
18
import 'package:flutter_tools/src/base/file_system.dart';
19
import 'package:flutter_tools/src/base/io.dart';
20
import 'package:flutter_tools/src/commands/create.dart';
21 22
import 'package:flutter_tools/src/runner/flutter_command.dart';
import 'package:flutter_tools/src/runner/flutter_command_runner.dart';
23
import 'package:flutter_tools/src/globals.dart' as globals;
24
import 'package:meta/meta.dart';
25
import 'package:fake_async/fake_async.dart';
26
import 'package:test_api/test_api.dart' as test_package show TypeMatcher, test; // ignore: deprecated_member_use
27 28
import 'package:test_api/test_api.dart' hide TypeMatcher, isInstanceOf; // ignore: deprecated_member_use
// ignore: deprecated_member_use
29
export 'package:test_core/test_core.dart' hide TypeMatcher, isInstanceOf, test; // Defines a 'package:test' shim.
30 31 32

/// A matcher that compares the type of the actual value to the type argument T.
// TODO(ianh): Remove this once https://github.com/dart-lang/matcher/issues/98 is fixed
Dan Field's avatar
Dan Field committed
33
test_package.TypeMatcher<T> isInstanceOf<T>() => isA<T>();
34

35 36 37 38 39
void tryToDelete(Directory directory) {
  // 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 {
40 41 42
    if (directory.existsSync()) {
      directory.deleteSync(recursive: true);
    }
43 44 45 46 47
  } on FileSystemException catch (error) {
    print('Failed to delete ${directory.path}: $error');
  }
}

48 49 50 51 52 53
/// Gets the path to the root of the Flutter repository.
///
/// This will first look for a `FLUTTER_ROOT` environment variable. If the
/// environment variable is set, it will be returned. Otherwise, this will
/// deduce the path from `platform.script`.
String getFlutterRoot() {
54 55 56
  const Platform platform = LocalPlatform();
  if (platform.environment.containsKey('FLUTTER_ROOT')) {
    return platform.environment['FLUTTER_ROOT'];
57
  }
58

59
  Error invalidScript() => StateError('Could not determine flutter_tools/ path from script URL (${globals.platform.script}); consider setting FLUTTER_ROOT explicitly.');
60 61

  Uri scriptUri;
62
  switch (platform.script.scheme) {
63
    case 'file':
64
      scriptUri = platform.script;
65 66
      break;
    case 'data':
67
      final RegExp flutterTools = RegExp(r'(file://[^"]*[/\\]flutter_tools[/\\][^"]+\.dart)', multiLine: true);
68
      final Match match = flutterTools.firstMatch(Uri.decodeFull(platform.script.path));
69
      if (match == null) {
70
        throw invalidScript();
71
      }
72 73 74 75 76 77
      scriptUri = Uri.parse(match.group(1));
      break;
    default:
      throw invalidScript();
  }

78
  final List<String> parts = path.split(globals.fs.path.fromUri(scriptUri));
79
  final int toolsIndex = parts.indexOf('flutter_tools');
80
  if (toolsIndex == -1) {
81
    throw invalidScript();
82
  }
83 84
  final String toolsPath = path.joinAll(parts.sublist(0, toolsIndex + 1));
  return path.normalize(path.join(toolsPath, '..', '..'));
85 86
}

87
CommandRunner<void> createTestCommandRunner([ FlutterCommand command ]) {
88
  final FlutterCommandRunner runner = TestFlutterCommandRunner();
89
  if (command != null) {
90
    runner.addCommand(command);
91
  }
92
  return runner;
93
}
94

95 96 97 98 99 100 101 102 103 104 105 106
/// Capture console print events into a string buffer.
Future<StringBuffer> capturedConsolePrint(Future<void> Function() body) async {
  final StringBuffer buffer = StringBuffer();
  await runZoned<Future<void>>(() async {
    // Service the event loop.
    await body();
  }, zoneSpecification: ZoneSpecification(print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
    buffer.writeln(line);
  }));
  return buffer;
}

Dan Field's avatar
Dan Field committed
107 108 109
/// Matcher for functions that throw [AssertionError].
final Matcher throwsAssertionError = throwsA(isA<AssertionError>());

110
/// Matcher for functions that throw [ToolExit].
111
Matcher throwsToolExit({ int exitCode, Pattern message }) {
112
  Matcher matcher = isToolExit;
113
  if (exitCode != null) {
114
    matcher = allOf(matcher, (ToolExit e) => e.exitCode == exitCode);
115 116
  }
  if (message != null) {
117
    matcher = allOf(matcher, (ToolExit e) => e.message?.contains(message) ?? false);
118
  }
119
  return throwsA(matcher);
120 121 122
}

/// Matcher for [ToolExit]s.
Dan Field's avatar
Dan Field committed
123
final test_package.TypeMatcher<ToolExit> isToolExit = isA<ToolExit>();
124

125 126 127 128 129 130 131
/// Matcher for functions that throw [ProcessException].
Matcher throwsProcessException({ Pattern message }) {
  Matcher matcher = isProcessException;
  if (message != null) {
    matcher = allOf(matcher, (ProcessException e) => e.message?.contains(message));
  }
  return throwsA(matcher);
132 133
}

134 135
/// Matcher for [ProcessException]s.
final test_package.TypeMatcher<ProcessException> isProcessException = isA<ProcessException>();
136

137 138
/// Creates a flutter project in the [temp] directory using the
/// [arguments] list if specified, or `--no-pub` if not.
139
/// Returns the path to the flutter project.
140
Future<String> createProject(Directory temp, { List<String> arguments }) async {
141
  arguments ??= <String>['--no-pub'];
142
  final String projectPath = globals.fs.path.join(temp.path, 'flutter_project');
143
  final CreateCommand command = CreateCommand();
144
  final CommandRunner<void> runner = createTestCommandRunner(command);
145
  await runner.run(<String>['create', ...arguments, projectPath]);
146
  // Created `.packages` since it's not created when the flag `--no-pub` is passed.
147
  globals.fs.file(globals.fs.path.join(projectPath, '.packages')).createSync();
148
  return projectPath;
149 150
}

151 152 153 154 155 156
Future<void> expectToolExitLater(Future<dynamic> future, Matcher messageMatcher) async {
  try {
    await future;
    fail('ToolExit expected, but nothing thrown');
  } on ToolExit catch(e) {
    expect(e.message, messageMatcher);
157 158
  // Catch all exceptions to give a better test failure message.
  } catch (e, trace) { // ignore: avoid_catches_without_on_clauses
159 160 161
    fail('ToolExit expected, got $e\n$trace');
  }
}
162

kwkr's avatar
kwkr committed
163 164 165 166 167 168 169 170 171
Matcher containsIgnoringWhitespace(String toSearch) {
  return predicate(
    (String source) {
      return collapseWhitespace(source).contains(collapseWhitespace(toSearch));
    },
    'contains "$toSearch" ignoring whitespace.',
  );
}

172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
/// The tool overrides `test` to ensure that files created under the
/// system temporary directory are deleted after each test by calling
/// `LocalFileSystem.dispose()`.
@isTest
void test(String description, FutureOr<void> body(), {
  String testOn,
  Timeout timeout,
  dynamic skip,
  List<String> tags,
  Map<String, dynamic> onPlatform,
  int retry,
}) {
  test_package.test(
    description,
    () async {
      addTearDown(() async {
        await LocalFileSystem.dispose();
      });
      return body();
    },
    timeout: timeout,
    skip: skip,
    tags: tags,
    onPlatform: onPlatform,
    retry: retry,
    testOn: testOn,
  );
}

201 202 203 204 205 206 207 208 209 210 211 212
/// Executes a test body in zone that does not allow context-based injection.
///
/// For classes which have been refactored to excluded context-based injection
/// or globals like [fs] or [platform], prefer using this test method as it
/// will prevent accidentally including these context getters in future code
/// changes.
///
/// For more information, see https://github.com/flutter/flutter/issues/47161
@isTest
void testWithoutContext(String description, FutureOr<void> body(), {
  String testOn,
  Timeout timeout,
213
  dynamic skip,
214 215 216 217
  List<String> tags,
  Map<String, dynamic> onPlatform,
  int retry,
  }) {
218
  return test(
219 220 221 222 223 224 225 226 227 228 229 230 231 232
    description, () async {
      return runZoned(body, zoneValues: <Object, Object>{
        contextKey: const NoContext(),
      });
    },
    timeout: timeout,
    skip: skip,
    tags: tags,
    onPlatform: onPlatform,
    retry: retry,
    testOn: testOn,
  );
}

233 234 235 236 237 238 239 240 241 242 243 244
/// Runs a callback using FakeAsync.run while continually pumping the
/// microtask queue. This avoids a deadlock when tests `await` a Future
/// which queues a microtask that will not be processed unless the queue
/// is flushed.
Future<T> runFakeAsync<T>(Future<T> Function(FakeAsync time) f) async {
  return FakeAsync().run((FakeAsync time) async {
    bool pump = true;
    final Future<T> future = f(time).whenComplete(() => pump = false);
    while (pump) {
      time.flushMicrotasks();
    }
    return future;
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 272 273 274 275 276 277 278
/// An implementation of [AppContext] that throws if context.get is called in the test.
///
/// The intention of the class is to ensure we do not accidentally regress when
/// moving towards more explicit dependency injection by accidentally using
/// a Zone value in place of a constructor parameter.
class NoContext implements AppContext {
  const NoContext();

  @override
  T get<T>() {
    throw UnsupportedError(
      'context.get<$T> is not supported in test methods. '
      'Use Testbed or testUsingContext if accessing Zone injected '
      'values.'
    );
  }

  @override
  String get name => 'No Context';

  @override
  Future<V> run<V>({
    FutureOr<V> Function() body,
    String name,
    Map<Type, Generator> overrides,
    Map<Type, Generator> fallbacks,
    ZoneSpecification zoneSpecification,
  }) async {
    return body();
  }
}
279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300

/// A fake implementation of a vm_service that mocks the JSON-RPC request
/// and response structure.
class FakeVmServiceHost {
  FakeVmServiceHost({
    @required List<VmServiceExpectation> requests,
  }) : _requests = requests {
    _vmService = vm_service.VmService(
      _input.stream,
      _output.add,
    );
    _applyStreamListen();
    _output.stream.listen((String data) {
      final Map<String, Object> request = json.decode(data) as Map<String, Object>;
      if (_requests.isEmpty) {
        throw Exception('Unexpected request: $request');
      }
      final FakeVmServiceRequest fakeRequest = _requests.removeAt(0) as FakeVmServiceRequest;
      expect(request, isA<Map<String, Object>>()
        .having((Map<String, Object> request) => request['method'], 'method', fakeRequest.method)
        .having((Map<String, Object> request) => request['params'], 'args', fakeRequest.args)
      );
301 302 303 304 305
      if (fakeRequest.close) {
        _vmService.dispose();
        expect(_requests, isEmpty);
        return;
      }
306 307 308
      if (fakeRequest.errorCode == null) {
        _input.add(json.encode(<String, Object>{
          'jsonrpc': '2.0',
309
          'id': request['id'],
310 311 312 313 314
          'result': fakeRequest.jsonResponse ?? <String, Object>{'type': 'Success'},
        }));
      } else {
        _input.add(json.encode(<String, Object>{
          'jsonrpc': '2.0',
315
          'id': request['id'],
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357
          'error': <String, Object>{
            'code': fakeRequest.errorCode,
          }
        }));
      }
      _applyStreamListen();
    });
  }

  final List<VmServiceExpectation> _requests;
  final StreamController<String> _input = StreamController<String>();
  final StreamController<String> _output = StreamController<String>();

  vm_service.VmService get vmService => _vmService;
  vm_service.VmService _vmService;

  bool get hasRemainingExpectations => _requests.isNotEmpty;

  // remove FakeStreamResponse objects from _requests until it is empty
  // or until we hit a FakeRequest
  void _applyStreamListen() {
    while (_requests.isNotEmpty && !_requests.first.isRequest) {
      final FakeVmServiceStreamResponse response = _requests.removeAt(0) as FakeVmServiceStreamResponse;
      _input.add(json.encode(<String, Object>{
        'jsonrpc': '2.0',
        'method': 'streamNotify',
        'params': <String, Object>{
          'streamId': response.streamId,
          'event': response.event.toJson(),
        },
      }));
    }
  }
}

abstract class VmServiceExpectation {
  bool get isRequest;
}

class FakeVmServiceRequest implements VmServiceExpectation {
  const FakeVmServiceRequest({
    @required this.method,
358
    this.args = const <String, Object>{},
359 360
    this.jsonResponse,
    this.errorCode,
361
    this.close = false,
362 363 364 365
  });

  final String method;

366 367 368
  /// When true, the vm service is automatically closed.
  final bool close;

369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390
  /// If non-null, the error code for a [vm_service.RPCError] in place of a
  /// standard response.
  final int errorCode;
  final Map<String, Object> args;
  final Map<String, Object> jsonResponse;

  @override
  bool get isRequest => true;
}

class FakeVmServiceStreamResponse implements VmServiceExpectation {
  const FakeVmServiceStreamResponse({
    @required this.event,
    @required this.streamId,
  });

  final vm_service.Event event;
  final String streamId;

  @override
  bool get isRequest => false;
}
391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407

class TestFlutterCommandRunner extends FlutterCommandRunner {
  @override
  Future<void> runCommand(ArgResults topLevelResults) async {
    final Logger topLevelLogger = globals.logger;
    final Map<Type, dynamic> contextOverrides = <Type, dynamic>{
      if (topLevelResults['verbose'] as bool)
        Logger: VerboseLogger(topLevelLogger),
    };
    return context.run<void>(
      overrides: contextOverrides.map<Type, Generator>((Type type, dynamic value) {
        return MapEntry<Type, Generator>(type, () => value);
      }),
      body: () => super.runCommand(topLevelResults),
    );
  }
}
408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428

/// A file system that allows preconfiguring certain entities.
///
/// This is useful for inserting mocks/entities which throw errors or
/// have other behavior that is not easily configured through the
/// filesystem interface.
class ConfiguredFileSystem extends ForwardingFileSystem {
  ConfiguredFileSystem(FileSystem delegate, {@required this.entities}) : super(delegate);

  final Map<String, FileSystemEntity> entities;

  @override
  File file(dynamic path) {
    return (entities[path] as File) ?? super.file(path);
  }

  @override
  Directory directory(dynamic path) {
    return (entities[path] as Directory) ?? super.directory(path);
  }
}
429 430 431 432 433 434 435 436 437 438 439 440

/// Matches a doctor validation result.
Matcher matchDoctorValidation({
  ValidationType validationType,
  String statusInfo,
  dynamic messages
}) {
  return const test_package.TypeMatcher<ValidationResult>()
    .having((ValidationResult result) => result.type, 'type', validationType)
    .having((ValidationResult result) => result.statusInfo, 'statusInfo', statusInfo)
    .having((ValidationResult result) => result.messages, 'messages', messages);
}