Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(660)

Unified Diff: pkg/analysis_server/test/integration/integration_tests.dart

Issue 398693002: Start writing integration tests for the analysis server. (Closed) Base URL: https://dart.googlecode.com/svn/branches/bleeding_edge/dart
Patch Set: Created 6 years, 5 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
Index: pkg/analysis_server/test/integration/integration_tests.dart
diff --git a/pkg/analysis_server/test/integration/integration_tests.dart b/pkg/analysis_server/test/integration/integration_tests.dart
new file mode 100644
index 0000000000000000000000000000000000000000..df63d4accd034fd156371cff58f88cba850a1ace
--- /dev/null
+++ b/pkg/analysis_server/test/integration/integration_tests.dart
@@ -0,0 +1,490 @@
+// 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: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>(
+ );
+
+ /**
+ * Create a source file with the given contents. [relativePath]
+ * is relative to [sourceDirectory]; on Windows any forward slashes it
+ * contains are converted to backslashes.
+ */
+ void createFile(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.setAnalysisRoots', {
+ 'included': relativeRoots.map(normalizePath).toList(),
+ 'excluded': []
+ });
+ }
+
+ /**
+ * 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) {
+ if (!params['analysis']['analyzing']) {
+ 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();
+ }
+
+ /**
+ * 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.
+
+const Matcher isString = const isInstanceOf<String>('String');
+
+const Matcher isInt = const isInstanceOf<int>('Int');
+
+const Matcher isResultResponse = const MatchesJsonObject('result response', const {
+ 'id': isString
+}, optionalFields: const {
+ 'result': anything
+});
+
+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,
+ 'data': anything
+});
+
+const Matcher isErrorResponse = const MatchesJsonObject('error response', const {
+ 'id': isString,
+ 'error': isError
+});
+
+const Matcher isNotification = const MatchesJsonObject('notification', const {
+ 'event': isString
+}, optionalFields: const {
+ 'params': isMap
+});
+
+final Matcher isErrorSeverity = isIn(['INFO', 'WARNING', 'ERROR']);
+
+final Matcher isErrorType = isIn(['COMPILE_TIME_ERROR', 'HINT',
+ 'STATIC_TYPE_WARNING', 'STATIC_WARNING', 'SYNTACTIC_ERROR', 'TODO']);
+
+const Matcher isLocation = const MatchesJsonObject('Location', const {
+ 'file': isString,
+ 'offset': isInt,
+ 'length': isInt,
+ 'startLine': isInt,
+ 'startColumn': isInt
+});
+
+final Matcher isAnalysisError = new MatchesJsonObject('AnalysisError', {
+ 'severity': isErrorSeverity,
+ 'type': isErrorType,
+ 'location': isLocation,
+ 'message': isString,
+}, optionalFields: {
+ 'correction': isString,
+ // TODO(paulberry): remove 'errorCode' once server stops sending it
+ 'errorCode': anything
+});
+
+
+/**
+ * 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).add('; ');
+ mismatchDescription = valueMatcher.describeMismatch(value,
+ mismatchDescription, subState, verbose);
+ return mismatchDescription.add(')');
+ });
+ }
+ }
+}
+
+/**
+ * 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;
+
+ 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}) {
+ String dartBinary = Platform.executable;
+ String serverPath = normalize(join(dirname(Platform.script.path), '..',
+ '..', 'bin', 'server.dart'));
+ List<String> arguments = [];
+ if (debugServer) {
+ arguments.add('--debug');
+ }
+ 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 = JSON.decoder.convert(trimmedLine);
+ 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(null);
+ // Check that the message is well-formed. We do this after calling
+ // completer.completeError() so that we don't stall the test in the
+ // event of an error.
+ expect(message, isErrorResponse);
+ } else {
+ completer.complete(messageAsMap['result']);
+ // Check that the message is well-formed. We do this after calling
+ // completer.complete() so that we don't stall the test in the
+ // event of an error.
+ expect(message, isResultResponse);
+ }
+ } 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.listen((List<int> data) {
+ fail('Unexpected output from stderr');
+ });
+ 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);
+ }
+ }
+
+ /**
+ * 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);
+ }
+}

Powered by Google App Engine
This is Rietveld 408576698