blob: af863fd5152ed510c067c62195160584ed13235a [file] [log] [blame]
// 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.
library test.integration.analysis;
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:analysis_server/src/constants.dart';
import 'package:path/path.dart';
import 'package:unittest/unittest.dart';
/**
* Base class for analysis server integration tests.
*/
abstract class AbstractAnalysisServerIntegrationTest {
/**
* Connection to the analysis server.
*/
Server server;
/**
* Temporary directory in which source files can be stored.
*/
Directory sourceDirectory;
/**
* Map from file path to the list of analysis errors which have most recently
* been received for the file.
*/
HashMap<String, dynamic> currentAnalysisErrors = new HashMap<String, dynamic>(
);
/**
* Write a source file with the given contents. [relativePath]
* is relative to [sourceDirectory]; on Windows any forward slashes it
* contains are converted to backslashes.
*
* If the file didn't previously exist, it is created. If it did, it is
* overwritten.
*/
void writeFile(String relativePath, String contents) {
String absolutePath = normalizePath(relativePath);
new Directory(dirname(absolutePath)).createSync(recursive: true);
new File(absolutePath).writeAsStringSync(contents);
}
/**
* 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 normalizePath(String relativePath) {
return join(sourceDirectory.path, relativePath.replaceAll('/', separator));
}
/**
* Send the server an 'analysis.setAnalysisRoots' command.
*/
Future setAnalysisRoots(List<String> relativeRoots) {
return server.send(ANALYSIS_SET_ANALYSIS_ROOTS, {
'included': relativeRoots.map(normalizePath).toList(),
'excluded': []
});
}
/**
* Send the server a 'server.setSubscriptions' command.
*/
Future server_setSubscriptions(List<String> subscriptions) {
return server.send(SERVER_SET_SUBSCRIPTIONS, {
'subscriptions': subscriptions
});
}
/**
* 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 get analysisFinished {
Completer completer = new Completer();
StreamSubscription subscription;
subscription = server.onNotification(SERVER_STATUS).listen((params) {
bool analysisComplete = false;
try {
analysisComplete = !params['analysis']['analyzing'];
} catch (_) {
// Status message was mal-formed or missing optional parameters. That's
// fine, since we'll detect a mal-formed status message below.
}
if (analysisComplete) {
completer.complete(params);
subscription.cancel();
}
expect(params, isServerStatusParams);
});
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();
}
/**
* The server is automatically started before every test, and a temporary
* [sourceDirectory] is created.
*/
Future setUp() {
sourceDirectory = Directory.systemTemp.createTempSync('analysisServer');
return Server.start().then((Server server) {
this.server = server;
server.onNotification(ANALYSIS_ERRORS).listen((params) {
expect(params, isMap);
expect(params['file'], isString);
currentAnalysisErrors[params['file']] = params['errors'];
});
});
}
/**
* After every test, the server stopped and [sourceDirectory] is deleted.
*/
Future tearDown() {
return server.kill().then((_) {
sourceDirectory.deleteSync(recursive: true);
});
}
}
// Matchers for data types defined in the analysis server API
// ==========================================================
// TODO(paulberry): add more matchers.
// Matchers common to all domains
// ------------------------------
const Matcher isResponse = const MatchesJsonObject('response',
const {
'id': isString
}, optionalFields: const {
'result': anything,
'error': isError
});
const Matcher isError = const MatchesJsonObject('Error', const {
// TODO(paulberry): once we decide what the set of permitted error codes are,
// add validation for 'code'.
'code': anything,
'message': isString
}, optionalFields: const {
// TODO(paulberry): API spec says that 'data' is required, but sometimes we
// don't see it (example: error "Expected parameter subscriptions to be a
// string list map" in response to a malformed "analysis.setSubscriptions"
// command).
'data': anything
});
const Matcher isNotification = const MatchesJsonObject('notification', const {
'event': isString
}, optionalFields: const {
'params': isMap
});
// Matchers for specific responses and notifications
// -------------------------------------------------
// server.getVersion
const Matcher isServerGetVersionResult = const MatchesJsonObject(
'server.getVersion result', const {
'version': isString
});
// server.status
const Matcher isServerStatusParams = const MatchesJsonObject(
'server.status params', null, optionalFields: const {
'analysis': isAnalysisStatus
});
// analysis.getHover
final Matcher isAnalysisGetHoverResult = new MatchesJsonObject(
'analysis.getHover result', {
'hovers': isListOf(isHoverInformation)
});
// Matchers for data types used in responses and notifications
// -----------------------------------------------------------
const Matcher isString = const isInstanceOf<String>('String');
const Matcher isInt = const isInstanceOf<int>('int');
const Matcher isBool = const isInstanceOf<bool>('bool');
// AnalysisError
final Matcher isAnalysisError = new MatchesJsonObject('AnalysisError', {
'severity': isErrorSeverity,
'type': isErrorType,
'location': isLocation,
'message': isString,
}, optionalFields: {
'correction': isString
});
// AnalysisStatus
const Matcher isAnalysisStatus = const MatchesJsonObject('AnalysisStatus', const
{
'analyzing': isBool
}, optionalFields: const {
'analysisTarget': isString
});
// ErrorSeverity
final Matcher isErrorSeverity = isIn(['INFO', 'WARNING', 'ERROR']);
// ErrorType
final Matcher isErrorType = isIn(['COMPILE_TIME_ERROR', 'HINT',
'STATIC_TYPE_WARNING', 'STATIC_WARNING', 'SYNTACTIC_ERROR', 'TODO']);
// HoverInformation
const Matcher isHoverInformation = const MatchesJsonObject('HoverInformation',
const {
'offset': isInt,
'length': isInt
}, optionalFields: const {
'containingLibraryPath': isString,
'containingLibraryName': isString,
'dartdoc': isString,
'elementDescription': isString,
'elementKind': isString,
'parameter': isString,
'propagatedType': isString,
'staticType': isString
});
// Location
const Matcher isLocation = const MatchesJsonObject('Location', const {
'file': isString,
'offset': isInt,
'length': isInt,
'startLine': isInt,
'startColumn': isInt
});
/**
* Type of closures used by MatchesJsonObject to record field mismatches.
*/
typedef Description MismatchDescriber(Description mismatchDescription, bool
verbose);
/**
* 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 Matcher {
/**
* 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
bool matches(item, Map matchState) {
if (item is! Map) {
return false;
}
List<MismatchDescriber> mismatches = <MismatchDescriber>[];
if (requiredFields != null) {
requiredFields.forEach((String key, Matcher valueMatcher) {
if (!item.containsKey(key)) {
mismatches.add((Description mismatchDescription, bool verbose) =>
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
} else if (optionalFields != null && optionalFields.containsKey(key)) {
_checkField(key, value, optionalFields[key], mismatches);
} else {
mismatches.add((Description mismatchDescription, bool verbose) =>
mismatchDescription.add('has unexpected field ').addDescriptionOf(key));
}
});
if (mismatches.isEmpty) {
return true;
} else {
addStateInfo(matchState, {
'mismatches': mismatches
});
return false;
}
}
@override
Description describe(Description description) => description.add(
this.description);
@override
Description describeMismatch(item, Description mismatchDescription, Map
matchState, bool verbose) {
List<MismatchDescriber> mismatches = matchState['mismatches'];
if (mismatches != null) {
for (int i = 0; i < mismatches.length; i++) {
MismatchDescriber 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, verbose);
}
return mismatchDescription;
} else {
return super.describeMismatch(item, mismatchDescription, matchState,
verbose);
}
}
/**
* 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) {
Map subState = {};
if (!valueMatcher.matches(value, subState)) {
mismatches.add((Description mismatchDescription, bool verbose) {
mismatchDescription = mismatchDescription.add(
'contains malformed field ').addDescriptionOf(key).add(' (should be '
).addDescriptionOf(valueMatcher);
String subDescription = valueMatcher.describeMismatch(value,
new StringDescription(), subState, false).toString();
if (subDescription.isNotEmpty) {
mismatchDescription = mismatchDescription.add('; ').add(subDescription
);
}
return mismatchDescription.add(')');
});
}
}
}
/**
* 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(elementMatcher)
: elementMatcher = elementMatcher,
iterableMatcher = everyElement(elementMatcher);
@override
bool matches(item, Map matchState) {
if (item is! List) {
return false;
}
return iterableMatcher.matches(item, matchState);
}
@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);
}
}
}
Matcher isListOf(Matcher elementMatcher) => new _ListOf(elementMatcher);
/**
* 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.
*/
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 HashMap<String, Completer> _pendingCommands = <String, Completer> {};
/**
* Number which should be used to compute the 'id' to send in the next command
* sent to the server.
*/
int _nextId = 0;
/**
* [StreamController]s to which notifications should be sent, organized by
* event type.
*/
final HashMap<String, StreamController> _notificationControllers =
new HashMap<String, StreamController>();
/**
* [Stream]s associated with the controllers in [_notificationControllers],
* but converted to broadcast streams.
*/
final HashMap<String, Stream> _notificationStreams = new HashMap<String,
Stream>();
/**
* 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;
Server._(this._process);
/**
* Get a stream which will receive notifications of the given event type.
* The values delivered to the stream will be the contents of the 'params'
* field of the notification message.
*/
Stream onNotification(String event) {
Stream notificationStream = _notificationStreams[event];
if (notificationStream == null) {
StreamController notificationController = new StreamController();
_notificationControllers[event] = notificationController;
notificationStream = notificationController.stream.asBroadcastStream();
_notificationStreams[event] = notificationStream;
}
return notificationStream;
}
/**
* Start the server. If [debugServer] is true, the server will be started
* with "--debug", allowing a debugger to be attached.
*/
static Future<Server> start({bool debugServer: false}) {
// TODO(paulberry): move the logic for finding the script, the dart
// executable, and the package root into a shell script.
String dartBinary = Platform.executable;
String scriptDir = dirname(Platform.script.path);
String serverPath = normalize(join(scriptDir, '..',
'..', 'bin', 'server.dart'));
String repoPath = normalize(join(scriptDir, '..', '..', '..', '..'));
String buildDirName;
if (Platform.isWindows) {
buildDirName = 'build';
} else if (Platform.isMacOS){
buildDirName = 'xcodebuild';
} else {
buildDirName = 'out';
}
String dartConfiguration = 'ReleaseIA32'; // TODO(paulberry): this is a guess
String buildPath = join(repoPath, buildDirName, dartConfiguration);
String packageRoot = join(buildPath, 'packages');
List<String> arguments = [];
if (debugServer) {
arguments.add('--debug');
}
arguments.add('--package-root=$packageRoot');
arguments.add(serverPath);
return Process.start(dartBinary, arguments).then((Process process) {
Server server = new Server._(process);
process.stdout.transform((new Utf8Codec()).decoder).transform(
new LineSplitter()).listen((String line) {
String trimmedLine = line.trim();
server._recordStdio('RECV: $trimmedLine');
var message;
try {
message = JSON.decoder.convert(trimmedLine);
} catch (exception) {
server._badDataFromServer();
return;
}
expect(message, isMap);
Map messageAsMap = message;
if (messageAsMap.containsKey('id')) {
expect(messageAsMap['id'], isString);
String id = message['id'];
Completer completer = server._pendingCommands[id];
if (completer == null) {
fail('Unexpected response from server: id=$id');
} else {
server._pendingCommands.remove(id);
}
if (messageAsMap.containsKey('error')) {
// TODO(paulberry): propagate the error info to the completer.
completer.completeError(new UnimplementedError(
'Server responded with an error'));
} else {
completer.complete(messageAsMap['result']);
}
// 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.
expect(message, isResponse);
} else {
// Message is a notification. It should have an event and possibly
// params.
expect(messageAsMap, contains('event'));
expect(messageAsMap['event'], isString);
String event = messageAsMap['event'];
StreamController notificationController =
server._notificationControllers[event];
if (notificationController != null) {
notificationController.add(messageAsMap['params']);
}
// 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.
expect(message, isNotification);
}
});
process.stderr.transform((new Utf8Codec()).decoder).transform(
new LineSplitter()).listen((String line) {
String trimmedLine = line.trim();
server._recordStdio('ERR: $trimmedLine');
server._badDataFromServer();
});
return server;
});
}
/**
* Stop the server.
*/
Future kill() {
_process.kill();
return _process.exitCode;
}
/**
* 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 send(String method, Map<String, dynamic> params) {
String id = '${_nextId++}';
Map<String, dynamic> command = <String, dynamic> {
'id': id,
'method': method
};
if (params != null) {
command['params'] = params;
}
Completer completer = new Completer();
_pendingCommands[id] = completer;
String line = JSON.encode(command);
_recordStdio('SEND: $line');
_process.stdin.add(UTF8.encoder.convert("${line}\n"));
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() {
if (_debuggingStdio) {
return;
}
_debuggingStdio = true;
for (String line in _recordedStdio) {
print(line);
}
}
/**
* Deal with bad data received from the server.
*/
void _badDataFromServer() {
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.
new Future.delayed(new Duration(seconds: 1), expectAsync(() {
fail('Bad data received from server');
}));
}
/**
* Record a message that was exchanged with the server, and print it out if
* [debugStdio] has been called.
*/
void _recordStdio(String line) {
if (_debuggingStdio) {
print(line);
}
_recordedStdio.add(line);
}
}