feat(mdtest run): initiate mdtest tool and add support for run command

Create mdtest tool for testing multi-device applications.  Currently
it supports only part of the function of 'mdtest run' command.  The
run command is able to detect connected device properties and print
them on the terminal.

Change-Id: I95d55319b7cf3873eafac825c24d9e039c5ebb76
diff --git a/.gitignore b/.gitignore
index 42d86f8..86a08e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,4 +10,9 @@
 generated-src
 *.iml
 .gradle
-local.properties
\ No newline at end of file
+local.properties
+
+# multi_app_driver
+*.lock
+*.snapshot
+*.stamp
\ No newline at end of file
diff --git a/mdtest/README.md b/mdtest/README.md
new file mode 100644
index 0000000..277d33a
--- /dev/null
+++ b/mdtest/README.md
@@ -0,0 +1,10 @@
+mdtest: Multi-Device Applicatoin Testing Framework
+
+Requires:
+    *dart
+    *pub
+    *flutter
+    *adb
+
+To build mdtest in a specific revision in the git history, simply checkout
+that revision and run mdtest.
diff --git a/mdtest/bin/mdtest b/mdtest/bin/mdtest
new file mode 100755
index 0000000..71a1489
--- /dev/null
+++ b/mdtest/bin/mdtest
@@ -0,0 +1,63 @@
+#!/bin/bash
+# Copyright 2016 The Vanadium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+
+set -e
+
+function follow_links() {
+  cd -P "${1%/*}"
+  file="$PWD/${1##*/}"
+  while [ -h "$file" ]; do
+    # On Mac OS, readlink -f doesn't work.
+    cd -P "${file%/*}"
+    file="$(readlink "$file")"
+    cd -P "${file%/*}"
+    file="$PWD/${file##*/}"
+  done
+  echo "$PWD/${file##*/}"
+}
+
+PROG_NAME="$(follow_links "$BASH_SOURCE")"
+BIN_DIR="$(cd "${PROG_NAME%/*}" ; pwd -P)"
+export MDTEST_TOOL="$(cd "${BIN_DIR}/.." ; pwd -P)"
+
+SNAPSHOT_PATH="$MDTEST_TOOL/bin/cache/mdtest.snapshot"
+STAMP_PATH="$MDTEST_TOOL/bin/cache/mdtest.stamp"
+SCRIPT_PATH="$MDTEST_TOOL/bin/mdtest.dart"
+DART="$(which dart)"
+PUB="$(which pub)"
+
+if [ "$DART" = "dart not found" ]; then
+  echo "dart is not detected.  Please install dart sdk from www.dartlang.org"
+  exit 0
+fi
+
+if [ "$PUB" = "pub not found" ]; then
+  echo "pub is not detected.  Please install dart sdk from www.dartlang.org"
+  exit 0
+fi
+
+REVISION=`(cd "$MDTEST_TOOL"; git rev-parse HEAD)`
+if [ ! -f "$SNAPSHOT_PATH" ] || [ ! -f "$STAMP_PATH" ] || [ `cat "$STAMP_PATH"` != "$REVISION" ] || [ "$MDTEST_TOOL/pubspec.yaml" -nt "$MDTEST_TOOL/pubspec.lock" ]; then
+  echo Building multi-drive tool...
+  (cd "$MDTEST_TOOL"; "$PUB" get --verbosity=warning)
+  "$DART" --snapshot="$SNAPSHOT_PATH" --packages="$MDTEST_TOOL/.packages" "$SCRIPT_PATH"
+  echo $REVISION > "$STAMP_PATH"
+fi
+
+if [ $MDTEST_DEV ]; then
+  "$DART" --packages="$MDTEST_TOOL/.packages" -c "$SCRIPT_PATH" "$@"
+else
+  set +e
+  "$DART" "$SNAPSHOT_PATH" "$@"
+
+  EXIT_CODE=$?
+  if [ $EXIT_CODE != 253 ]; then
+    exit $EXIT_CODE
+  fi
+
+  set -e
+  "$DART" --snapshot="$SNAPSHOT_PATH" --package="$MDTEST_TOOL/.package" "$SCRIPT_PATH"
+  "$DART" "$SNAPSHOT_PATH" "$@"
+fi
diff --git a/mdtest/bin/mdtest.dart b/mdtest/bin/mdtest.dart
new file mode 100644
index 0000000..ad43745
--- /dev/null
+++ b/mdtest/bin/mdtest.dart
@@ -0,0 +1,9 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+import 'package:mdtest/executable.dart' as executable;
+
+void main(List<String> args) {
+  executable.main(args);
+}
diff --git a/mdtest/lib/executable.dart b/mdtest/lib/executable.dart
new file mode 100644
index 0000000..47ef009
--- /dev/null
+++ b/mdtest/lib/executable.dart
@@ -0,0 +1,23 @@
+// Copyright 2016 The Vanadium Authors. 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:io';
+
+import 'package:stack_trace/stack_trace.dart';
+
+import 'src/commands/run.dart';
+import 'src/runner/mdtest_command_runner.dart';
+
+Future<Null> main(List<String> args) async {
+  MDTestCommandRunner runner = new MDTestCommandRunner()
+    ..addCommand(new RunCommand());
+
+    return Chain.capture(() async {
+      dynamic result = await runner.run(args);
+      exit(result is int ? result : 0);
+    }, onError: (dynamic error, Chain chain) {
+      print(error);
+    });
+}
diff --git a/mdtest/lib/src/base/logger.dart b/mdtest/lib/src/base/logger.dart
new file mode 100644
index 0000000..c3baacf
--- /dev/null
+++ b/mdtest/lib/src/base/logger.dart
@@ -0,0 +1,22 @@
+// Copyright 2016 The Vanadium Authors. 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:io';
+
+abstract class Logger {
+  void info(String message);
+  void error(String message);
+}
+
+class StdoutLogger extends Logger {
+  @override
+  void info(String message) {
+    print('[info] $message');
+  }
+
+  @override
+  void error(String message) {
+    stderr.writeln('[error] $message');
+  }
+}
diff --git a/mdtest/lib/src/commands/run.dart b/mdtest/lib/src/commands/run.dart
new file mode 100644
index 0000000..60da707
--- /dev/null
+++ b/mdtest/lib/src/commands/run.dart
@@ -0,0 +1,74 @@
+// Copyright 2016 The Vanadium Authors. 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:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as path;
+
+import '../mobile/device.dart';
+import '../mobile/device_util.dart';
+import '../globals.dart';
+import '../runner/mdtest_command.dart';
+
+class RunCommand extends MDTestCommand {
+
+  @override
+  final String name = 'run';
+
+  @override
+  final String description = 'Run multi-device driver tests';
+
+  dynamic _specs;
+
+  List<Device> _devices;
+
+  @override
+  Future<int> runCore() async {
+    print('Running "mdtest run command" ...');
+    this._specs = await loadSpecs(argResults['specs']);
+    print(_specs);
+    this._devices = await getDevices();
+    if (_devices.isEmpty) {
+      printError('No device found.');
+      return 1;
+    }
+    return 0;
+  }
+
+  RunCommand() {
+    usesSpecsOption();
+  }
+}
+
+Future<dynamic> loadSpecs(String specsPath) async {
+  try {
+    // Read specs file into json format
+    dynamic newSpecs = JSON.decode(await new File(specsPath).readAsString());
+    // Get the parent directory of the specs file
+    String rootPath = new File(specsPath).parent.absolute.path;
+    // Normalize the 'test-path' in the specs file
+    newSpecs['test-path'] = normalizePath(rootPath, newSpecs['test-path']);
+    // Normalize the 'app-path' in the specs file
+    newSpecs['devices'].forEach((String name, Map<String, String> map) {
+      map['app-path'] = normalizePath(rootPath, map['app-path']);
+      map['app-root'] = normalizePath(rootPath, map['app-root']);
+    });
+    return newSpecs;
+  } on FileSystemException {
+    printError('File $specsPath does not exist.');
+    exit(1);
+  } on FormatException {
+    printError('File $specsPath is not in JSON format.');
+    exit(1);
+  } catch (e) {
+    print('Unknown Exception details:\n $e');
+    exit(1);
+  }
+}
+
+String normalizePath(String rootPath, String relativePath) {
+  return path.normalize(path.join(rootPath, relativePath));
+}
diff --git a/mdtest/lib/src/globals.dart b/mdtest/lib/src/globals.dart
new file mode 100644
index 0000000..947bfc0
--- /dev/null
+++ b/mdtest/lib/src/globals.dart
@@ -0,0 +1,12 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+import 'base/logger.dart';
+
+final Logger defaultLogger = new StdoutLogger();
+Logger get logger => defaultLogger;
+
+void printInfo(String message) => logger.info(message);
+
+void printError(String message) => logger.error(message);
diff --git a/mdtest/lib/src/mobile/device.dart b/mdtest/lib/src/mobile/device.dart
new file mode 100644
index 0000000..1286580
--- /dev/null
+++ b/mdtest/lib/src/mobile/device.dart
@@ -0,0 +1,16 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+class Device {
+  Device({
+    this.id,
+    this.modelName
+  });
+
+  final String id;
+  final String modelName;
+
+  @override
+  String toString() => '<id: $id, model-name: $modelName>';
+}
diff --git a/mdtest/lib/src/mobile/device_util.dart b/mdtest/lib/src/mobile/device_util.dart
new file mode 100644
index 0000000..783e47c
--- /dev/null
+++ b/mdtest/lib/src/mobile/device_util.dart
@@ -0,0 +1,67 @@
+// Copyright 2016 The Vanadium Authors. 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:convert';
+import 'dart:io';
+
+import 'device.dart';
+
+Future<List<Device>> getDevices() async {
+  List<Device> devices = <Device>[];
+  await _getDeviceIDs().then((List<String> ids) async {
+    for(String id in ids) {
+      devices.add(await _collectDeviceProps(id));
+    }
+  });
+  return devices;
+}
+
+/// Run "flutter devices -v" to collect device ids from the output
+Future<List<String>> _getDeviceIDs() async {
+  List<String> deviceIDs = <String>[];
+  Process process = await Process.start('flutter', ['devices', '-v']);
+  Stream lineStream = process.stdout
+                             .transform(new Utf8Decoder())
+                             .transform(new LineSplitter());
+  bool startReading = false;
+  RegExp startPattern = new RegExp(r'List of devices attached');
+  RegExp deviceIDPattern = new RegExp(r'\s+(\w+)\s+.*');
+  RegExp stopPatternWithDevices = new RegExp(r'\d+ connected devices?');
+  RegExp stopPatternWithoutDevices = new RegExp(r'No devices detected');
+  await for (var line in lineStream) {
+    if (!startReading && startPattern.hasMatch(line.toString())) {
+      startReading = true;
+      continue;
+    }
+
+    if (stopPatternWithDevices.hasMatch(line.toString())
+        || stopPatternWithoutDevices.hasMatch(line.toString()))
+      break;
+
+    if (startReading) {
+      Match idMatch = deviceIDPattern.firstMatch(line.toString());
+      if (idMatch != null) {
+        String deviceID = idMatch.group(1);
+        deviceIDs.add(deviceID);
+      }
+    }
+  }
+
+  process.stderr.drain();
+
+  return deviceIDs;
+}
+
+Future<Device> _collectDeviceProps(String deviceID) async {
+  return new Device(
+    id: deviceID,
+    modelName: await _getProperty(deviceID, 'ro.product.model')
+  );
+}
+
+Future<String> _getProperty(String deviceID, String propName) async {
+  ProcessResult results = await Process.run('adb', ['-s', deviceID, 'shell', 'getprop', propName]);
+  return results.stdout.toString().trim();
+}
diff --git a/mdtest/lib/src/runner/mdtest_command.dart b/mdtest/lib/src/runner/mdtest_command.dart
new file mode 100644
index 0000000..7610572
--- /dev/null
+++ b/mdtest/lib/src/runner/mdtest_command.dart
@@ -0,0 +1,72 @@
+// Copyright 2016 The Vanadium Authors. 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:io';
+
+import 'package:args/command_runner.dart';
+
+import '../globals.dart';
+import 'mdtest_command_runner.dart';
+
+typedef bool Validator();
+
+abstract class MDTestCommand extends Command {
+
+  MDTestCommand() {
+    commandValidator = _commandValidator;
+  }
+
+  @override
+  MDTestCommandRunner get runner => super.runner;
+
+  bool _usesSpecsOption = false;
+
+  void usesSpecsOption() {
+    argParser.addOption(
+      'specs',
+      defaultsTo: null,
+      allowMultiple: false,
+      help:
+        'Path to the config file that specifies the devices, '
+        'apps and debug-ports for testing.'
+    );
+    _usesSpecsOption = true;
+  }
+
+  @override
+  Future<int> run() {
+    Stopwatch stopwatch = new Stopwatch()..start();
+    return _run().then((int exitCode) {
+      int ms = stopwatch.elapsedMilliseconds;
+      printInfo('"mdtest $name" took ${ms}ms; exiting with code $exitCode.');
+      return exitCode;
+    });
+  }
+
+  Future<int> _run() async {
+    if (!commandValidator())
+      return 1;
+    return await runCore();
+  }
+
+  Future<int> runCore();
+
+  Validator commandValidator;
+
+  bool _commandValidator() {
+    if (_usesSpecsOption) {
+      String specsPath = argResults['specs'];
+      if (specsPath == null) {
+        printError('Specs file is not set.');
+        return false;
+      }
+      if (!FileSystemEntity.isFileSync(specsPath)) {
+        printError('Specs file "$specsPath" not found.');
+        return false;
+      }
+    }
+    return true;
+  }
+}
diff --git a/mdtest/lib/src/runner/mdtest_command_runner.dart b/mdtest/lib/src/runner/mdtest_command_runner.dart
new file mode 100644
index 0000000..81a439a
--- /dev/null
+++ b/mdtest/lib/src/runner/mdtest_command_runner.dart
@@ -0,0 +1,20 @@
+// Copyright 2016 The Vanadium Authors. 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 'package:args/command_runner.dart';
+
+class MDTestCommandRunner extends CommandRunner {
+  MDTestCommandRunner() : super(
+    'mdtest',
+    'Launch multi-device apps and run test script'
+  );
+
+  @override
+  Future<dynamic> run(Iterable<String> args) {
+    return super.run(args).then((dynamic result) {
+      return result;
+    });
+  }
+}
diff --git a/mdtest/pubspec.yaml b/mdtest/pubspec.yaml
new file mode 100644
index 0000000..3099215
--- /dev/null
+++ b/mdtest/pubspec.yaml
@@ -0,0 +1,6 @@
+name: mdtest
+description: A multi-device app testing framework for Android and iOS
+
+dependencies:
+  args: ^0.13.4
+  stack_trace: ^1.4.0