| // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file |
| // for details. 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:collection'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:analysis_server/protocol/protocol_constants.dart'; |
| import 'package:analysis_server/protocol/protocol_generated.dart'; |
| import 'package:analysis_server/src/services/pub/pub_command.dart'; |
| import 'package:analyzer/src/test_utilities/package_config_file_builder.dart'; |
| import 'package:analyzer_plugin/protocol/protocol_common.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:test/test.dart'; |
| |
| import 'integration_test_methods.dart'; |
| import 'protocol_matchers.dart'; |
| |
| const Matcher isBool = TypeMatcher<bool>(); |
| |
| const Matcher isDouble = TypeMatcher<double>(); |
| |
| const Matcher isInt = TypeMatcher<int>(); |
| |
| const Matcher isNotification = MatchesJsonObject( |
| 'notification', {'event': isString}, |
| optionalFields: {'params': isMap}); |
| |
| const Matcher isString = TypeMatcher<String>(); |
| |
| final Matcher isResponse = MatchesJsonObject('response', {'id': isString}, |
| optionalFields: {'result': anything, 'error': isRequestError}); |
| |
| Matcher isListOf(Matcher elementMatcher) => _ListOf(elementMatcher); |
| |
| Matcher isMapOf(Matcher keyMatcher, Matcher valueMatcher) => |
| _MapOf(keyMatcher, valueMatcher); |
| |
| Matcher isOneOf(List<Matcher> choiceMatchers) => _OneOf(choiceMatchers); |
| |
| /// Assert that [actual] matches [matcher]. |
| void outOfTestExpect(Object? actual, Matcher matcher, |
| {String? reason, skip, bool verbose = false}) { |
| var matchState = {}; |
| try { |
| if (matcher.matches(actual, matchState)) return; |
| } catch (e, trace) { |
| reason ??= '${(e is String) ? e : e.toString()} at $trace'; |
| } |
| fail(_defaultFailFormatter(actual, matcher, reason, matchState, verbose)); |
| } |
| |
| String _defaultFailFormatter( |
| actual, Matcher matcher, String? reason, Map matchState, bool verbose) { |
| var description = StringDescription(); |
| description.add('Expected: ').addDescriptionOf(matcher).add('\n'); |
| description.add(' Actual: ').addDescriptionOf(actual).add('\n'); |
| |
| var mismatchDescription = StringDescription(); |
| matcher.describeMismatch(actual, mismatchDescription, matchState, verbose); |
| |
| if (mismatchDescription.length > 0) { |
| description.add(' Which: $mismatchDescription\n'); |
| } |
| if (reason != null) description.add(reason).add('\n'); |
| return description.toString(); |
| } |
| |
| /// Type of closures used by LazyMatcher. |
| typedef MatcherCreator = Matcher Function(); |
| |
| /// Type of closures used by MatchesJsonObject to record field mismatches. |
| typedef MismatchDescriber = Description Function( |
| Description mismatchDescription); |
| |
| /// Type of callbacks used to process notifications. |
| typedef NotificationProcessor = void Function(String event, Map params); |
| |
| /// Base class for analysis server integration tests. |
| abstract class AbstractAnalysisServerIntegrationTest |
| extends IntegrationTestMixin { |
| /// Amount of time to give the server to respond to a shutdown request before |
| /// forcibly terminating it. |
| static const Duration SHUTDOWN_TIMEOUT = Duration(seconds: 60); |
| |
| /// Connection to the analysis server. |
| @override |
| final Server server = Server(); |
| |
| /// Temporary directory in which source files can be stored. |
| late Directory sourceDirectory; |
| |
| /// Map from file path to the list of analysis errors which have most recently |
| /// been received for the file. |
| Map<String, List<AnalysisError>> currentAnalysisErrors = |
| HashMap<String, List<AnalysisError>>(); |
| |
| /// The last list of analyzed files received. |
| late List<String> lastAnalyzedFiles; |
| |
| /// True if the teardown process should skip sending a "server.shutdown" |
| /// request (e.g. because the server is known to have already shutdown). |
| bool skipShutdown = false; |
| |
| /// True if we are currently subscribed to [SERVER_NOTIFICATION_STATUS] |
| /// updates. |
| bool _subscribedToServerStatus = false; |
| |
| String dartSdkPath = path.dirname(path.dirname(Platform.resolvedExecutable)); |
| |
| AbstractAnalysisServerIntegrationTest() { |
| initializeInttestMixin(); |
| } |
| |
| /// Return a future which will complete when a 'server.status' notification is |
| /// received from the server with 'analyzing' set to false. |
| /// |
| /// The future will only be completed by 'server.status' notifications that |
| /// are received after this function call. So it is safe to use this getter |
| /// multiple times in one test; each time it is used it will wait afresh for |
| /// analysis to finish. |
| Future<ServerStatusParams> get analysisFinished { |
| var completer = Completer<ServerStatusParams>(); |
| late StreamSubscription<ServerStatusParams> subscription; |
| // This will only work if the caller has already subscribed to |
| // SERVER_STATUS (e.g. using sendServerSetSubscriptions(['STATUS'])) |
| outOfTestExpect(_subscribedToServerStatus, isTrue); |
| subscription = onServerStatus.listen((ServerStatusParams params) { |
| var analysisStatus = params.analysis; |
| if (analysisStatus != null && !analysisStatus.isAnalyzing) { |
| completer.complete(params); |
| subscription.cancel(); |
| } |
| }); |
| return completer.future; |
| } |
| |
| /// Print out any messages exchanged with the server. If some messages have |
| /// already been exchanged with the server, they are printed out immediately. |
| void debugStdio() { |
| server.debugStdio(); |
| } |
| |
| /// If there was a set of errors (might be empty) received for the file |
| /// with the given [path], return it. If no errors - fail. |
| List<AnalysisError> existingErrorsForFile(String path) { |
| var errors = currentAnalysisErrors[path]; |
| if (errors == null) { |
| fail('Expected errors for: $path'); |
| } |
| return errors; |
| } |
| |
| List<AnalysisError>? getErrors(String pathname) => |
| currentAnalysisErrors[pathname]; |
| |
| /// Read a source file with the given absolute [pathname]. |
| String readFile(String pathname) => File(pathname).readAsStringSync(); |
| |
| @override |
| Future sendServerSetSubscriptions(List<ServerService> subscriptions) { |
| _subscribedToServerStatus = subscriptions.contains(ServerService.STATUS); |
| return super.sendServerSetSubscriptions(subscriptions); |
| } |
| |
| /// The server is automatically started before every test, and a temporary |
| /// [sourceDirectory] is created. |
| Future setUp() async { |
| sourceDirectory = Directory(Directory.systemTemp |
| .createTempSync('analysisServer') |
| .resolveSymbolicLinksSync()); |
| writeTestPackageConfig(); |
| |
| onAnalysisErrors.listen((AnalysisErrorsParams params) { |
| currentAnalysisErrors[params.file] = params.errors; |
| }); |
| onAnalysisAnalyzedFiles.listen((AnalysisAnalyzedFilesParams params) { |
| lastAnalyzedFiles = params.directories; |
| }); |
| var serverConnected = Completer(); |
| onServerConnected.listen((_) { |
| outOfTestExpect(serverConnected.isCompleted, isFalse); |
| serverConnected.complete(); |
| }); |
| onServerError.listen((ServerErrorParams params) { |
| // A server error should never happen during an integration test. |
| fail('${params.message}\n${params.stackTrace}'); |
| }); |
| await startServer(); |
| server.listenToOutput(dispatchNotification); |
| server.exitCode.then((_) { |
| skipShutdown = true; |
| }); |
| return serverConnected.future; |
| } |
| |
| /// If [skipShutdown] is not set, shut down the server. |
| Future shutdownIfNeeded() { |
| if (skipShutdown) { |
| return Future.value(); |
| } |
| // Give the server a short time to comply with the shutdown request; if it |
| // doesn't exit, then forcibly terminate it. |
| sendServerShutdown(); |
| return server.exitCode.timeout(SHUTDOWN_TIMEOUT, onTimeout: () { |
| // The integer value of the exit code isn't used, but we have to return |
| // an integer to keep the typing correct. |
| return server.kill('server failed to exit').then((_) => -1); |
| }); |
| } |
| |
| /// Convert the given [relativePath] to an absolute path, by interpreting it |
| /// relative to [sourceDirectory]. On Windows any forward slashes in |
| /// [relativePath] are converted to backslashes. |
| String sourcePath(String relativePath) { |
| return path.join( |
| sourceDirectory.path, relativePath.replaceAll('/', path.separator)); |
| } |
| |
| /// Send the server an 'analysis.setAnalysisRoots' command directing it to |
| /// analyze [sourceDirectory]. If [subscribeStatus] is true (the default), |
| /// then also enable [SERVER_NOTIFICATION_STATUS] notifications so that |
| /// [analysisFinished] can be used. |
| Future standardAnalysisSetup({bool subscribeStatus = true}) { |
| var futures = <Future>[]; |
| if (subscribeStatus) { |
| futures.add(sendServerSetSubscriptions([ServerService.STATUS])); |
| } |
| futures.add(sendAnalysisSetAnalysisRoots([sourceDirectory.path], [])); |
| return Future.wait(futures); |
| } |
| |
| /// Start [server]. |
| Future startServer({ |
| int? diagnosticPort, |
| int? servicesPort, |
| }) { |
| return server.start( |
| dartSdkPath: dartSdkPath, |
| diagnosticPort: diagnosticPort, |
| servicesPort: servicesPort, |
| ); |
| } |
| |
| /// After every test, the server is stopped and [sourceDirectory] is deleted. |
| Future tearDown() { |
| return shutdownIfNeeded().then((_) { |
| sourceDirectory.deleteSync(recursive: true); |
| }); |
| } |
| |
| /// Write a source file with the given absolute [pathname] and [contents]. |
| /// |
| /// If the file didn't previously exist, it is created. If it did, it is |
| /// overwritten. |
| /// |
| /// Parent directories are created as necessary. |
| /// |
| /// Return a normalized path to the file (with symbolic links resolved). |
| String writeFile(String pathname, String contents) { |
| Directory(path.dirname(pathname)).createSync(recursive: true); |
| var file = File(pathname); |
| file.writeAsStringSync(contents); |
| return file.resolveSymbolicLinksSync(); |
| } |
| |
| void writePackageConfig( |
| String pathname, { |
| required PackageConfigFileBuilder config, |
| }) { |
| writeFile( |
| pathname, |
| config.toContent( |
| toUriStr: (p) => '${path.toUri(p)}', |
| ), |
| ); |
| } |
| |
| void writeTestPackageConfig({ |
| PackageConfigFileBuilder? config, |
| String? languageVersion, |
| }) { |
| if (config == null) { |
| config = PackageConfigFileBuilder(); |
| } else { |
| config = config.copy(); |
| } |
| |
| config.add( |
| name: 'test', |
| rootPath: sourceDirectory.path, |
| languageVersion: languageVersion, |
| ); |
| |
| writePackageConfig( |
| sourcePath('.dart_tool/package_config.json'), |
| config: config, |
| ); |
| } |
| } |
| |
| /// Wrapper class for Matcher which doesn't create the underlying Matcher object |
| /// until it is needed. This is necessary in order to create matchers that can |
| /// refer to themselves (so that recursive data structures can be represented). |
| class LazyMatcher implements Matcher { |
| /// Callback that will be used to create the matcher the first time it is |
| /// needed. |
| final MatcherCreator _creator; |
| |
| /// The matcher returned by [_creator], if it has already been called. |
| /// Otherwise null. |
| Matcher? _wrappedMatcher; |
| |
| LazyMatcher(this._creator); |
| |
| /// Create the wrapped matcher object, if it hasn't been created already. |
| Matcher get _matcher { |
| return _wrappedMatcher ??= _creator(); |
| } |
| |
| @override |
| Description describe(Description description) { |
| return _matcher.describe(description); |
| } |
| |
| @override |
| Description describeMismatch( |
| item, Description mismatchDescription, Map matchState, bool verbose) { |
| return _matcher.describeMismatch( |
| item, mismatchDescription, matchState, verbose); |
| } |
| |
| @override |
| bool matches(item, Map matchState) { |
| return _matcher.matches(item, matchState); |
| } |
| } |
| |
| /// Matcher that matches a String drawn from a limited set. |
| class MatchesEnum extends Matcher { |
| /// Short description of the expected type. |
| final String description; |
| |
| /// The set of enum values that are allowed. |
| final List<String> allowedValues; |
| |
| const MatchesEnum(this.description, this.allowedValues); |
| |
| @override |
| Description describe(Description description) => |
| description.add(this.description); |
| |
| @override |
| bool matches(item, Map matchState) { |
| return allowedValues.contains(item); |
| } |
| } |
| |
| /// Matcher that matches a JSON object, with a given set of required and |
| /// optional fields, and their associated types (expressed as [Matcher]s). |
| class MatchesJsonObject extends _RecursiveMatcher { |
| /// Short description of the expected type. |
| final String description; |
| |
| /// Fields that are required to be in the JSON object, and [Matcher]s |
| /// describing their expected types. |
| final Map<String, Matcher>? requiredFields; |
| |
| /// Fields that are optional in the JSON object, and [Matcher]s describing |
| /// their expected types. |
| final Map<String, Matcher>? optionalFields; |
| |
| const MatchesJsonObject(this.description, this.requiredFields, |
| {this.optionalFields}); |
| |
| @override |
| Description describe(Description description) => |
| description.add(this.description); |
| |
| @override |
| void populateMismatches(Object? item, List<MismatchDescriber> mismatches) { |
| if (item is! Map<String, Object?>) { |
| mismatches.add(simpleDescription('is not a map')); |
| return; |
| } |
| final requiredFields = this.requiredFields; |
| final optionalFields = this.optionalFields; |
| if (requiredFields != null) { |
| requiredFields.forEach((String key, Matcher valueMatcher) { |
| if (!item.containsKey(key)) { |
| mismatches.add((Description mismatchDescription) => |
| mismatchDescription |
| .add('is missing field ') |
| .addDescriptionOf(key) |
| .add(' (') |
| .addDescriptionOf(valueMatcher) |
| .add(')')); |
| } else { |
| _checkField(key, item[key], valueMatcher, mismatches); |
| } |
| }); |
| } |
| item.forEach((key, value) { |
| if (requiredFields != null && requiredFields.containsKey(key)) { |
| // Already checked this field |
| return; |
| } |
| if (optionalFields != null) { |
| var optionalValue = optionalFields[key]; |
| if (optionalValue != null) { |
| _checkField(key, value, optionalValue, mismatches); |
| return; |
| } |
| } |
| mismatches.add((Description mismatchDescription) => mismatchDescription |
| .add('has unexpected field ') |
| .addDescriptionOf(key)); |
| }); |
| } |
| |
| /// Check the type of a field called [key], having value [value], using |
| /// [valueMatcher]. If it doesn't match, record a closure in [mismatches] |
| /// which can describe the mismatch. |
| void _checkField(String key, value, Matcher valueMatcher, |
| List<MismatchDescriber> mismatches) { |
| checkSubstructure( |
| value, |
| valueMatcher, |
| mismatches, |
| (Description description) => |
| description.add('field ').addDescriptionOf(key)); |
| } |
| } |
| |
| /// Instances of the class [Server] manage a connection to a server process, and |
| /// facilitate communication to and from the server. |
| class Server { |
| /// Server process object, or null if server hasn't been started yet. |
| late final Process _process; |
| |
| /// Commands that have been sent to the server but not yet acknowledged, and |
| /// the [Completer] objects which should be completed when acknowledgement is |
| /// received. |
| final Map<String, Completer<Map<String, Object?>?>> _pendingCommands = {}; |
| |
| /// Number which should be used to compute the 'id' to send in the next |
| /// command sent to the server. |
| int _nextId = 0; |
| |
| /// Messages which have been exchanged with the server; we buffer these |
| /// up until the test finishes, so that they can be examined in the debugger |
| /// or printed out in response to a call to [debugStdio]. |
| final List<String> _recordedStdio = <String>[]; |
| |
| /// True if we are currently printing out messages exchanged with the server. |
| bool _debuggingStdio = false; |
| |
| /// True if we've received bad data from the server, and we are aborting the |
| /// test. |
| bool _receivedBadDataFromServer = false; |
| |
| /// Stopwatch that we use to generate timing information for debug output. |
| final Stopwatch _time = Stopwatch(); |
| |
| /// The [currentElapseTime] at which the last communication was received from |
| /// the server or `null` if no communication has been received. |
| double? lastCommunicationTime; |
| |
| /// The current elapse time (seconds) since the server was started. |
| double get currentElapseTime => _time.elapsedTicks / _time.frequency; |
| |
| /// Future that completes when the server process exits. |
| Future<int> get exitCode => _process.exitCode; |
| |
| /// Print out any messages exchanged with the server. If some messages have |
| /// already been exchanged with the server, they are printed out immediately. |
| void debugStdio() { |
| if (_debuggingStdio) { |
| return; |
| } |
| _debuggingStdio = true; |
| for (var line in _recordedStdio) { |
| print(line); |
| } |
| } |
| |
| /// Find the root directory of the analysis_server package by proceeding |
| /// upward to the 'test' dir, and then going up one more directory. |
| String findRoot(String pathname) { |
| while (!['benchmark', 'test'].contains(path.basename(pathname))) { |
| var parent = path.dirname(pathname); |
| if (parent.length >= pathname.length) { |
| throw Exception("Can't find root directory"); |
| } |
| pathname = parent; |
| } |
| return path.dirname(pathname); |
| } |
| |
| /// Return a future that will complete when all commands that have been sent |
| /// to the server so far have been flushed to the OS buffer. |
| Future flushCommands() { |
| return _process.stdin.flush(); |
| } |
| |
| /// Stop the server. |
| Future<int> kill(String reason) { |
| debugStdio(); |
| _recordStdio('FORCIBLY TERMINATING PROCESS: $reason'); |
| _process.kill(); |
| return _process.exitCode; |
| } |
| |
| /// Start listening to output from the server, and deliver notifications to |
| /// [notificationProcessor]. |
| void listenToOutput(NotificationProcessor notificationProcessor) { |
| _process.stdout |
| .transform(utf8.decoder) |
| .transform(LineSplitter()) |
| .listen((String line) { |
| lastCommunicationTime = currentElapseTime; |
| var trimmedLine = line.trim(); |
| |
| // Guard against lines like: |
| // {"event":"server.connected","params":{...}}The Dart VM service is listening on ... |
| var dartVMServiceMessage = 'The Dart VM service is listening on '; |
| if (trimmedLine.contains(dartVMServiceMessage)) { |
| trimmedLine = trimmedLine |
| .substring(0, trimmedLine.indexOf(dartVMServiceMessage)) |
| .trim(); |
| } |
| if (trimmedLine.isEmpty) { |
| return; |
| } |
| |
| _recordStdio('<== $trimmedLine'); |
| Map message; |
| try { |
| message = json.decoder.convert(trimmedLine) as Map<Object?, Object?>; |
| } catch (exception) { |
| _badDataFromServer('JSON decode failure: $exception'); |
| return; |
| } |
| outOfTestExpect(message, isMap); |
| if (message.containsKey('id')) { |
| outOfTestExpect(message['id'], isString); |
| var id = message['id'] as String; |
| var completer = _pendingCommands[id]; |
| if (completer == null) { |
| fail('Unexpected response from server: id=$id'); |
| } else { |
| _pendingCommands.remove(id); |
| } |
| if (message.containsKey('error')) { |
| completer.completeError(ServerErrorMessage(message)); |
| } else { |
| completer.complete(message['result'] as Map<String, Object?>?); |
| } |
| // Check that the message is well-formed. We do this after calling |
| // completer.complete() or completer.completeError() so that we don't |
| // stall the test in the event of an error. |
| outOfTestExpect(message, isResponse); |
| } else { |
| // Message is a notification. It should have an event and possibly |
| // params. |
| outOfTestExpect(message, contains('event')); |
| outOfTestExpect(message['event'], isString); |
| notificationProcessor(message['event'] as String, |
| message['params'] as Map<Object?, Object?>); |
| // Check that the message is well-formed. We do this after calling |
| // notificationController.add() so that we don't stall the test in the |
| // event of an error. |
| outOfTestExpect(message, isNotification); |
| } |
| }); |
| _process.stderr |
| .transform(Utf8Codec().decoder) |
| .transform(LineSplitter()) |
| .listen((String line) { |
| var trimmedLine = line.trim(); |
| _recordStdio('ERR: $trimmedLine'); |
| _badDataFromServer('Message received on stderr', silent: true); |
| }); |
| } |
| |
| /// Send a command to the server. An 'id' will be automatically assigned. |
| /// The returned [Future] will be completed when the server acknowledges the |
| /// command with a response. If the server acknowledges the command with a |
| /// normal (non-error) response, the future will be completed with the |
| /// 'result' field from the response. If the server acknowledges the command |
| /// with an error response, the future will be completed with an error. |
| Future<Map<String, Object?>?> send( |
| String method, Map<String, Object?>? params) { |
| var id = '${_nextId++}'; |
| var command = <String, Object?>{'id': id, 'method': method}; |
| if (params != null) { |
| command['params'] = params; |
| } |
| var completer = Completer<Map<String, Object?>?>(); |
| _pendingCommands[id] = completer; |
| var line = json.encode(command); |
| _recordStdio('==> $line'); |
| _process.stdin.add(utf8.encoder.convert('$line\n')); |
| return completer.future; |
| } |
| |
| /// Start the server. If [profileServer] is `true`, the server will be started |
| /// with "--observe" and "--pause-isolates-on-exit", allowing the observatory |
| /// to be used. |
| Future start({ |
| required String dartSdkPath, |
| int? diagnosticPort, |
| String? instrumentationLogFile, |
| String? packagesFile, |
| bool profileServer = false, |
| int? servicesPort, |
| bool useAnalysisHighlight2 = false, |
| }) async { |
| _time.start(); |
| |
| var dartBinary = path.join(dartSdkPath, 'bin', 'dart'); |
| |
| // Setting the `TEST_SERVER_SNAPSHOT` env var to 'false' will disable the |
| // snapshot and run from source. |
| var useSnapshot = Platform.environment['TEST_SERVER_SNAPSHOT'] != 'false'; |
| String serverPath; |
| |
| if (useSnapshot) { |
| serverPath = path.normalize(path.join( |
| dartSdkPath, 'bin', 'snapshots', 'analysis_server.dart.snapshot')); |
| } else { |
| var rootDir = |
| findRoot(Platform.script.toFilePath(windows: Platform.isWindows)); |
| serverPath = path.normalize(path.join(rootDir, 'bin', 'server.dart')); |
| } |
| |
| var arguments = <String>[ |
| '--disable-dart-dev', |
| ]; |
| // |
| // Add VM arguments. |
| // |
| if (profileServer) { |
| if (servicesPort == null) { |
| arguments.add('--observe'); |
| } else { |
| arguments.add('--observe=$servicesPort'); |
| } |
| arguments.add('--pause-isolates-on-exit'); |
| } else if (servicesPort != null) { |
| arguments.add('--enable-vm-service=$servicesPort'); |
| } |
| if (Platform.packageConfig != null) { |
| arguments.add('--packages=${Platform.packageConfig}'); |
| } |
| arguments.add('--disable-service-auth-codes'); |
| // |
| // Add the server executable. |
| // |
| arguments.add(serverPath); |
| // |
| // Add server arguments. |
| // |
| arguments.add('--suppress-analytics'); |
| arguments.add('--sdk=$dartSdkPath'); |
| if (diagnosticPort != null) { |
| arguments.add('--port'); |
| arguments.add(diagnosticPort.toString()); |
| } |
| if (instrumentationLogFile != null) { |
| arguments.add('--instrumentation-log-file=$instrumentationLogFile'); |
| } |
| if (packagesFile != null) { |
| arguments.add('--packages=$packagesFile'); |
| } |
| if (useAnalysisHighlight2) { |
| arguments.add('--useAnalysisHighlight2'); |
| } |
| _process = await Process.start( |
| dartBinary, |
| arguments, |
| environment: {PubCommand.disablePubCommandEnvironmentKey: 'true'}, |
| ); |
| _process.exitCode.then((int code) { |
| if (code != 0) { |
| _badDataFromServer('server terminated with exit code $code'); |
| } |
| }); |
| } |
| |
| /// Deal with bad data received from the server. |
| void _badDataFromServer(String details, {bool silent = false}) { |
| if (!silent) { |
| _recordStdio('BAD DATA FROM SERVER: $details'); |
| } |
| if (_receivedBadDataFromServer) { |
| // We're already dealing with it. |
| return; |
| } |
| _receivedBadDataFromServer = true; |
| debugStdio(); |
| // Give the server 1 second to continue outputting bad data before we kill |
| // the test. This is helpful if the server has had an unhandled exception |
| // and is outputting a stacktrace, because it ensures that we see the |
| // entire stacktrace. Use expectAsync() to prevent the test from |
| // ending during this 1 second. |
| Future.delayed(Duration(seconds: 1), expectAsync0(() { |
| fail('Bad data received from server: $details'); |
| })); |
| } |
| |
| /// Record a message that was exchanged with the server, and print it out if |
| /// [debugStdio] has been called. |
| void _recordStdio(String line) { |
| var elapsedTime = currentElapseTime; |
| line = '$elapsedTime: $line'; |
| if (_debuggingStdio) { |
| print(line); |
| } |
| _recordedStdio.add(line); |
| } |
| } |
| |
| /// An error result from a server request. |
| class ServerErrorMessage { |
| final Map message; |
| |
| ServerErrorMessage(this.message); |
| |
| dynamic get error => message['error']; |
| |
| @override |
| String toString() => message.toString(); |
| } |
| |
| /// Matcher that matches a list of objects, each of which satisfies the given |
| /// matcher. |
| class _ListOf extends Matcher { |
| /// Matcher which every element of the list must satisfy. |
| final Matcher elementMatcher; |
| |
| /// Iterable matcher which we use to test the contents of the list. |
| final Matcher iterableMatcher; |
| |
| _ListOf(this.elementMatcher) : iterableMatcher = everyElement(elementMatcher); |
| |
| @override |
| Description describe(Description description) => |
| description.add('List of ').addDescriptionOf(elementMatcher); |
| |
| @override |
| Description describeMismatch( |
| item, Description mismatchDescription, Map matchState, bool verbose) { |
| if (item is! List) { |
| return super |
| .describeMismatch(item, mismatchDescription, matchState, verbose); |
| } else { |
| return iterableMatcher.describeMismatch( |
| item, mismatchDescription, matchState, verbose); |
| } |
| } |
| |
| @override |
| bool matches(item, Map matchState) { |
| if (item is! List) { |
| return false; |
| } |
| return iterableMatcher.matches(item, matchState); |
| } |
| } |
| |
| /// Matcher that matches a map of objects, where each key/value pair in the |
| /// map satisfies the given key and value matchers. |
| class _MapOf extends _RecursiveMatcher { |
| /// Matcher which every key in the map must satisfy. |
| final Matcher keyMatcher; |
| |
| /// Matcher which every value in the map must satisfy. |
| final Matcher valueMatcher; |
| |
| _MapOf(this.keyMatcher, this.valueMatcher); |
| |
| @override |
| Description describe(Description description) => description |
| .add('Map from ') |
| .addDescriptionOf(keyMatcher) |
| .add(' to ') |
| .addDescriptionOf(valueMatcher); |
| |
| @override |
| void populateMismatches(item, List<MismatchDescriber> mismatches) { |
| if (item is! Map) { |
| mismatches.add(simpleDescription('is not a map')); |
| return; |
| } |
| item.forEach((key, value) { |
| checkSubstructure( |
| key, |
| keyMatcher, |
| mismatches, |
| (Description description) => |
| description.add('key ').addDescriptionOf(key)); |
| checkSubstructure( |
| value, |
| valueMatcher, |
| mismatches, |
| (Description description) => |
| description.add('field ').addDescriptionOf(key)); |
| }); |
| } |
| } |
| |
| /// Matcher that matches a union of different types, each of which is described |
| /// by a matcher. |
| class _OneOf extends Matcher { |
| /// Matchers for the individual choices. |
| final List<Matcher> choiceMatchers; |
| |
| _OneOf(this.choiceMatchers); |
| |
| @override |
| Description describe(Description description) { |
| for (var i = 0; i < choiceMatchers.length; i++) { |
| if (i != 0) { |
| if (choiceMatchers.length == 2) { |
| description = description.add(' or '); |
| } else { |
| description = description.add(', '); |
| if (i == choiceMatchers.length - 1) { |
| description = description.add('or '); |
| } |
| } |
| } |
| description = description.addDescriptionOf(choiceMatchers[i]); |
| } |
| return description; |
| } |
| |
| @override |
| bool matches(item, Map matchState) { |
| for (var choiceMatcher in choiceMatchers) { |
| var subState = {}; |
| if (choiceMatcher.matches(item, subState)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| } |
| |
| /// Base class for matchers that operate by recursing through the contents of |
| /// an object. |
| abstract class _RecursiveMatcher extends Matcher { |
| const _RecursiveMatcher(); |
| |
| /// Check the type of a substructure whose value is [item], using [matcher]. |
| /// If it doesn't match, record a closure in [mismatches] which can describe |
| /// the mismatch. [describeSubstructure] is used to describe which |
| /// substructure did not match. |
| void checkSubstructure( |
| item, |
| Matcher matcher, |
| List<MismatchDescriber> mismatches, |
| Description Function(Description) describeSubstructure) { |
| var subState = {}; |
| if (!matcher.matches(item, subState)) { |
| mismatches.add((Description mismatchDescription) { |
| mismatchDescription = mismatchDescription.add('contains malformed '); |
| mismatchDescription = describeSubstructure(mismatchDescription); |
| mismatchDescription = |
| mismatchDescription.add(' (should be ').addDescriptionOf(matcher); |
| var subDescription = matcher |
| .describeMismatch(item, StringDescription(), subState, false) |
| .toString(); |
| if (subDescription.isNotEmpty) { |
| mismatchDescription = |
| mismatchDescription.add('; ').add(subDescription); |
| } |
| return mismatchDescription.add(')'); |
| }); |
| } |
| } |
| |
| @override |
| Description describeMismatch( |
| item, Description mismatchDescription, Map matchState, bool verbose) { |
| var mismatches = matchState['mismatches'] as List<MismatchDescriber>?; |
| if (mismatches != null) { |
| for (var i = 0; i < mismatches.length; i++) { |
| var mismatch = mismatches[i]; |
| if (i > 0) { |
| if (mismatches.length == 2) { |
| mismatchDescription = mismatchDescription.add(' and '); |
| } else if (i == mismatches.length - 1) { |
| mismatchDescription = mismatchDescription.add(', and '); |
| } else { |
| mismatchDescription = mismatchDescription.add(', '); |
| } |
| } |
| mismatchDescription = mismatch(mismatchDescription); |
| } |
| return mismatchDescription; |
| } else { |
| return super |
| .describeMismatch(item, mismatchDescription, matchState, verbose); |
| } |
| } |
| |
| @override |
| bool matches(item, Map matchState) { |
| var mismatches = <MismatchDescriber>[]; |
| populateMismatches(item, mismatches); |
| if (mismatches.isEmpty) { |
| return true; |
| } else { |
| addStateInfo(matchState, {'mismatches': mismatches}); |
| return false; |
| } |
| } |
| |
| /// Populate [mismatches] with descriptions of all the ways in which [item] |
| /// does not match. |
| void populateMismatches(item, List<MismatchDescriber> mismatches); |
| |
| /// Create a [MismatchDescriber] describing a mismatch with a simple string. |
| MismatchDescriber simpleDescription(String description) => |
| (Description mismatchDescription) { |
| mismatchDescription.add(description); |
| return mismatchDescription; |
| }; |
| } |