Refactor DevFS for kernel code (#7529)

Refactor DevFS so that it's easier to add new types of content such as kernel code
* add tests for DevFS package scanning
* add tests for DevFS over VMService protocol
* which covers _DevFSHttpWriter and ServiceProtocolDevFSOperations
* replace AssetBundleEntry and DevFSEntry with DevFSContent
* refactor to cleanup common code and replace some fields with locals
* rework .package file generation refactor away DevFSOperations.writeSource
* only update .package file if it has changed
* only write/delete/evict assets that have been changed/removed
diff --git a/packages/flutter_tools/test/asset_bundle_test.dart b/packages/flutter_tools/test/asset_bundle_test.dart
index be4d150..2e1f5f7 100644
--- a/packages/flutter_tools/test/asset_bundle_test.dart
+++ b/packages/flutter_tools/test/asset_bundle_test.dart
@@ -5,6 +5,7 @@
 import 'dart:convert';
 import 'package:flutter_tools/src/asset.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
+import 'package:flutter_tools/src/devfs.dart';
 import 'package:path/path.dart' as path;
 import 'package:test/test.dart';
 
@@ -37,29 +38,26 @@
       AssetBundle ab = new AssetBundle.fixed('', 'apple.txt');
       expect(ab.entries, isNotEmpty);
       expect(ab.entries.length, 1);
-      AssetBundleEntry entry = ab.entries.first;
-      expect(entry, isNotNull);
-      expect(entry.archivePath, 'apple.txt');
+      String archivePath = ab.entries.keys.first;
+      expect(archivePath, isNotNull);
+      expect(archivePath, 'apple.txt');
     });
     test('two entries', () async {
       AssetBundle ab = new AssetBundle.fixed('', 'apple.txt,packages/flutter_gallery_assets/shrine/products/heels.png');
       expect(ab.entries, isNotEmpty);
       expect(ab.entries.length, 2);
-      AssetBundleEntry firstEntry = ab.entries.first;
-      expect(firstEntry, isNotNull);
-      expect(firstEntry.archivePath, 'apple.txt');
-      AssetBundleEntry lastEntry = ab.entries.last;
-      expect(lastEntry, isNotNull);
-      expect(lastEntry.archivePath, 'packages/flutter_gallery_assets/shrine/products/heels.png');
+      List<String> archivePaths = ab.entries.keys.toList()..sort();
+      expect(archivePaths[0], 'apple.txt');
+      expect(archivePaths[1], 'packages/flutter_gallery_assets/shrine/products/heels.png');
     });
     test('file contents', () async {
       AssetBundle ab = new AssetBundle.fixed(projectRoot, assetPath);
       expect(ab.entries, isNotEmpty);
       expect(ab.entries.length, 1);
-      AssetBundleEntry entry = ab.entries.first;
-      expect(entry, isNotNull);
-      expect(entry.archivePath, assetPath);
-      expect(assetContents, UTF8.decode(entry.contentsAsBytes()));
+      String archivePath = ab.entries.keys.first;
+      DevFSContent content = ab.entries[archivePath];
+      expect(archivePath, assetPath);
+      expect(assetContents, UTF8.decode(await content.contentsAsBytes()));
     });
   });
 
diff --git a/packages/flutter_tools/test/devfs_test.dart b/packages/flutter_tools/test/devfs_test.dart
index 4211cc9..4a9b91f 100644
--- a/packages/flutter_tools/test/devfs_test.dart
+++ b/packages/flutter_tools/test/devfs_test.dart
@@ -2,10 +2,15 @@
 // 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 'package:flutter_tools/src/asset.dart';
+import 'package:flutter_tools/src/base/io.dart';
 import 'package:flutter_tools/src/build_info.dart';
 import 'package:flutter_tools/src/devfs.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
+import 'package:flutter_tools/src/vmservice.dart';
 import 'package:path/path.dart' as path;
 import 'package:test/test.dart';
 
@@ -14,72 +19,319 @@
 import 'src/mocks.dart';
 
 void main() {
-  String filePath = 'bar/foo.txt';
-  String filePath2 = 'foo/bar.txt';
+  final String filePath = 'bar/foo.txt';
+  final String filePath2 = 'foo/bar.txt';
   Directory tempDir;
   String basePath;
-  MockDevFSOperations devFSOperations = new MockDevFSOperations();
   DevFS devFS;
-  AssetBundle assetBundle = new AssetBundle();
-  assetBundle.entries.add(new AssetBundleEntry.fromString('a.txt', ''));
-  group('devfs', () {
-    testUsingContext('create local file system', () async {
-      tempDir = fs.systemTempDirectory.createTempSync();
+  final AssetBundle assetBundle = new AssetBundle();
+
+  group('DevFSContent', () {
+    test('bytes', () {
+      DevFSByteContent content = new DevFSByteContent(<int>[4, 5, 6]);
+      expect(content.bytes, orderedEquals(<int>[4, 5, 6]));
+      expect(content.isModified, isTrue);
+      expect(content.isModified, isFalse);
+      content.bytes = <int>[7, 8, 9, 2];
+      expect(content.bytes, orderedEquals(<int>[7, 8, 9, 2]));
+      expect(content.isModified, isTrue);
+      expect(content.isModified, isFalse);
+    });
+    test('string', () {
+      DevFSStringContent content = new DevFSStringContent('some string');
+      expect(content.string, 'some string');
+      expect(content.bytes, orderedEquals(UTF8.encode('some string')));
+      expect(content.isModified, isTrue);
+      expect(content.isModified, isFalse);
+      content.string = 'another string';
+      expect(content.string, 'another string');
+      expect(content.bytes, orderedEquals(UTF8.encode('another string')));
+      expect(content.isModified, isTrue);
+      expect(content.isModified, isFalse);
+      content.bytes = UTF8.encode('foo bar');
+      expect(content.string, 'foo bar');
+      expect(content.bytes, orderedEquals(UTF8.encode('foo bar')));
+      expect(content.isModified, isTrue);
+      expect(content.isModified, isFalse);
+    });
+  });
+
+  group('devfs local', () {
+    MockDevFSOperations devFSOperations = new MockDevFSOperations();
+
+    setUpAll(() {
+      tempDir = _newTempDir();
       basePath = tempDir.path;
+    });
+    tearDownAll(_cleanupTempDirs);
+
+    testUsingContext('create dev file system', () async {
+      // simulate workspace
       File file = fs.file(path.join(basePath, filePath));
       await file.parent.create(recursive: true);
       file.writeAsBytesSync(<int>[1, 2, 3]);
-    });
-    testUsingContext('create dev file system', () async {
+
+      // simulate package
+      await _createPackage('somepkg', 'somefile.txt');
+
       devFS = new DevFS.operations(devFSOperations, 'test', tempDir);
       await devFS.create();
-      expect(devFSOperations.contains('create test'), isTrue);
-    });
-    testUsingContext('populate dev file system', () async {
-      await devFS.update();
-      expect(devFSOperations.contains('writeFile test bar/foo.txt'), isTrue);
+      devFSOperations.expectMessages(<String>['create test']);
+      expect(devFS.assetPathsToEvict, isEmpty);
+
+      int bytes = await devFS.update();
+      devFSOperations.expectMessages(<String>[
+        'writeFile test .packages',
+        'writeFile test bar/foo.txt',
+        'writeFile test packages/somepkg/somefile.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, isEmpty);
+      expect(bytes, 31);
     });
     testUsingContext('add new file to local file system', () async {
       File file = fs.file(path.join(basePath, filePath2));
       await file.parent.create(recursive: true);
       file.writeAsBytesSync(<int>[1, 2, 3, 4, 5, 6, 7]);
-      await devFS.update();
-      expect(devFSOperations.contains('writeFile test foo/bar.txt'), isTrue);
+      int bytes = await devFS.update();
+      devFSOperations.expectMessages(<String>[
+        'writeFile test foo/bar.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, isEmpty);
+      expect(bytes, 7);
     });
     testUsingContext('modify existing file on local file system', () async {
       File file = fs.file(path.join(basePath, filePath));
       // Set the last modified time to 5 seconds in the past.
       updateFileModificationTime(file.path, new DateTime.now(), -5);
-      await devFS.update();
+      int bytes = await devFS.update();
+      devFSOperations.expectMessages(<String>[]);
+      expect(devFS.assetPathsToEvict, isEmpty);
+      expect(bytes, 0);
+
       await file.writeAsBytes(<int>[1, 2, 3, 4, 5, 6]);
-      await devFS.update();
-      expect(devFSOperations.contains('writeFile test bar/foo.txt'), isTrue);
+      bytes = await devFS.update();
+      devFSOperations.expectMessages(<String>[
+        'writeFile test bar/foo.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, isEmpty);
+      expect(bytes, 6);
     });
     testUsingContext('delete a file from the local file system', () async {
       File file = fs.file(path.join(basePath, filePath));
       await file.delete();
-      await devFS.update();
-      expect(devFSOperations.contains('deleteFile test bar/foo.txt'), isTrue);
+      int bytes = await devFS.update();
+      devFSOperations.expectMessages(<String>[
+        'deleteFile test bar/foo.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, isEmpty);
+      expect(bytes, 0);
     });
-    testUsingContext('add file in an asset bundle', () async {
-      await devFS.update(bundle: assetBundle, bundleDirty: true);
-      expect(devFSOperations.contains(
-          'writeFile test ${getAssetBuildDirectory()}/a.txt'), isTrue);
+    testUsingContext('add new package', () async {
+      await _createPackage('newpkg', 'anotherfile.txt');
+      int bytes = await devFS.update();
+      devFSOperations.expectMessages(<String>[
+        'writeFile test .packages',
+        'writeFile test packages/newpkg/anotherfile.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, isEmpty);
+      expect(bytes, 51);
+    });
+    testUsingContext('add an asset bundle', () async {
+      assetBundle.entries['a.txt'] = new DevFSStringContent('abc');
+      int bytes = await devFS.update(bundle: assetBundle, bundleDirty: true);
+      devFSOperations.expectMessages(<String>[
+        'writeFile test ${getAssetBuildDirectory()}/a.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, unorderedMatches(<String>['a.txt']));
+      devFS.assetPathsToEvict.clear();
+      expect(bytes, 3);
+    });
+    testUsingContext('add a file to the asset bundle - bundleDirty', () async {
+      assetBundle.entries['b.txt'] = new DevFSStringContent('abcd');
+      int bytes = await devFS.update(bundle: assetBundle, bundleDirty: true);
+      // Expect entire asset bundle written because bundleDirty is true
+      devFSOperations.expectMessages(<String>[
+        'writeFile test ${getAssetBuildDirectory()}/a.txt',
+        'writeFile test ${getAssetBuildDirectory()}/b.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, unorderedMatches(<String>[
+        'a.txt', 'b.txt']));
+      devFS.assetPathsToEvict.clear();
+      expect(bytes, 7);
     });
     testUsingContext('add a file to the asset bundle', () async {
-      assetBundle.entries.add(new AssetBundleEntry.fromString('b.txt', ''));
-      await devFS.update(bundle: assetBundle, bundleDirty: true);
-      expect(devFSOperations.contains(
-          'writeFile test ${getAssetBuildDirectory()}/b.txt'), isTrue);
+      assetBundle.entries['c.txt'] = new DevFSStringContent('12');
+      int bytes = await devFS.update(bundle: assetBundle);
+      devFSOperations.expectMessages(<String>[
+        'writeFile test ${getAssetBuildDirectory()}/c.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, unorderedMatches(<String>[
+        'c.txt']));
+      devFS.assetPathsToEvict.clear();
+      expect(bytes, 2);
     });
     testUsingContext('delete a file from the asset bundle', () async {
+      assetBundle.entries.remove('c.txt');
+      int bytes = await devFS.update(bundle: assetBundle);
+      devFSOperations.expectMessages(<String>[
+        'deleteFile test ${getAssetBuildDirectory()}/c.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, unorderedMatches(<String>['c.txt']));
+      devFS.assetPathsToEvict.clear();
+      expect(bytes, 0);
+    });
+    testUsingContext('delete all files from the asset bundle', () async {
       assetBundle.entries.clear();
-      await devFS.update(bundle: assetBundle, bundleDirty: true);
-      expect(devFSOperations.contains(
-          'deleteFile test ${getAssetBuildDirectory()}/b.txt'), isTrue);
+      int bytes = await devFS.update(bundle: assetBundle, bundleDirty: true);
+      devFSOperations.expectMessages(<String>[
+        'deleteFile test ${getAssetBuildDirectory()}/a.txt',
+        'deleteFile test ${getAssetBuildDirectory()}/b.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, unorderedMatches(<String>[
+        'a.txt', 'b.txt'
+      ]));
+      devFS.assetPathsToEvict.clear();
+      expect(bytes, 0);
     });
     testUsingContext('delete dev file system', () async {
       await devFS.destroy();
+      devFSOperations.expectMessages(<String>['destroy test']);
+      expect(devFS.assetPathsToEvict, isEmpty);
     });
   });
+
+  group('devfs remote', () {
+    MockVMService vmService;
+
+    setUpAll(() async {
+      tempDir = _newTempDir();
+      basePath = tempDir.path;
+      vmService = new MockVMService();
+      await vmService.setUp();
+    });
+    tearDownAll(() async {
+      await vmService.tearDown();
+      _cleanupTempDirs();
+    });
+
+    testUsingContext('create dev file system', () async {
+      // simulate workspace
+      File file = fs.file(path.join(basePath, filePath));
+      await file.parent.create(recursive: true);
+      file.writeAsBytesSync(<int>[1, 2, 3]);
+
+      // simulate package
+      await _createPackage('somepkg', 'somefile.txt');
+
+      devFS = new DevFS(vmService, 'test', tempDir);
+      await devFS.create();
+      vmService.expectMessages(<String>['create test']);
+      expect(devFS.assetPathsToEvict, isEmpty);
+
+      int bytes = await devFS.update();
+      vmService.expectMessages(<String>[
+        'writeFile test .packages',
+        'writeFile test bar/foo.txt',
+        'writeFile test packages/somepkg/somefile.txt',
+      ]);
+      expect(devFS.assetPathsToEvict, isEmpty);
+      expect(bytes, 31);
+    }, timeout: new Timeout(new Duration(seconds: 5)));
+
+    testUsingContext('delete dev file system', () async {
+      await devFS.destroy();
+      vmService.expectMessages(<String>['_deleteDevFS {fsName: test}']);
+      expect(devFS.assetPathsToEvict, isEmpty);
+    });
+  });
+}
+
+class MockVMService extends BasicMock implements VMService {
+  Uri _httpAddress;
+  HttpServer _server;
+  MockVM _vm;
+
+  MockVMService() {
+    _vm = new MockVM(this);
+  }
+
+  @override
+  Uri get httpAddress => _httpAddress;
+
+  @override
+  VM get vm => _vm;
+
+  Future<Null> setUp() async {
+    _server = await HttpServer.bind(InternetAddress.LOOPBACK_IP_V4, 0);
+    _httpAddress = Uri.parse('http://127.0.0.1:${_server.port}');
+    _server.listen((HttpRequest request) {
+      String fsName = request.headers.value('dev_fs_name');
+      String devicePath = UTF8.decode(BASE64.decode(request.headers.value('dev_fs_path_b64')));
+      messages.add('writeFile $fsName $devicePath');
+      request.drain().then((_) {
+        request.response
+          ..write('Got it')
+          ..close();
+      });
+    });
+  }
+
+  Future<Null> tearDown() async {
+    await _server.close();
+  }
+
+  @override
+  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
+}
+
+class MockVM implements VM {
+  final MockVMService _service;
+  final Uri _baseUri = Uri.parse('file:///tmp/devfs/test');
+
+  MockVM(this._service);
+
+  @override
+  Future<Map<String, dynamic>> createDevFS(String fsName) async {
+    _service.messages.add('create $fsName');
+    return <String, dynamic>{'uri': '$_baseUri'};
+  }
+
+  @override
+  Future<Map<String, dynamic>> invokeRpcRaw(
+      String method, [Map<String, dynamic> params, Duration timeout]) async {
+    _service.messages.add('$method $params');
+    return <String, dynamic>{'success': true};
+  }
+
+  @override
+  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
+}
+
+
+final List<Directory> _tempDirs = <Directory>[];
+final Map <String, Directory> _packages = <String, Directory>{};
+
+Directory _newTempDir() {
+  Directory tempDir = fs.systemTempDirectory.createTempSync('devfs${_tempDirs.length}');
+  _tempDirs.add(tempDir);
+  return tempDir;
+}
+
+void _cleanupTempDirs() {
+  while (_tempDirs.length > 0) {
+    _tempDirs.removeLast().deleteSync(recursive: true);
+  }
+}
+
+Future<Null> _createPackage(String pkgName, String pkgFileName) async {
+  final Directory pkgTempDir = _newTempDir();
+  File pkgFile = fs.file(path.join(pkgTempDir.path, pkgName, 'lib', pkgFileName));
+  await pkgFile.parent.create(recursive: true);
+  pkgFile.writeAsBytesSync(<int>[11, 12, 13]);
+  _packages[pkgName] = pkgTempDir;
+  StringBuffer sb = new StringBuffer();
+  _packages.forEach((String pkgName, Directory pkgTempDir) {
+    sb.writeln('$pkgName:${pkgTempDir.path}/$pkgName/lib');
+  });
+  fs.file(path.join(_tempDirs[0].path, '.packages')).writeAsStringSync(sb.toString());
 }
diff --git a/packages/flutter_tools/test/src/mocks.dart b/packages/flutter_tools/test/src/mocks.dart
index fb56b7c..706022b 100644
--- a/packages/flutter_tools/test/src/mocks.dart
+++ b/packages/flutter_tools/test/src/mocks.dart
@@ -13,6 +13,7 @@
 import 'package:flutter_tools/src/ios/simulators.dart';
 import 'package:flutter_tools/src/runner/flutter_command.dart';
 import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
 
 class MockApplicationPackageStore extends ApplicationPackageStore {
   MockApplicationPackageStore() : super(
@@ -74,9 +75,16 @@
     ..commandValidator = () => true;
 }
 
-class MockDevFSOperations implements DevFSOperations {
+/// Common functionality for tracking mock interaction
+class BasicMock {
   final List<String> messages = new List<String>();
 
+  void expectMessages(List<String> expectedMessages) {
+    List<String> actualMessages = new List<String>.from(messages);
+    messages.clear();
+    expect(actualMessages, unorderedEquals(expectedMessages));
+  }
+
   bool contains(String match) {
     print('Checking for `$match` in:');
     print(messages);
@@ -84,7 +92,9 @@
     messages.clear();
     return result;
   }
+}
 
+class MockDevFSOperations extends BasicMock implements DevFSOperations {
   @override
   Future<Uri> create(String fsName) async {
     messages.add('create $fsName');
@@ -97,19 +107,12 @@
   }
 
   @override
-  Future<dynamic> writeFile(String fsName, DevFSEntry entry) async {
-    messages.add('writeFile $fsName ${entry.devicePath}');
+  Future<dynamic> writeFile(String fsName, String devicePath, DevFSContent content) async {
+    messages.add('writeFile $fsName $devicePath');
   }
 
   @override
-  Future<dynamic> deleteFile(String fsName, DevFSEntry entry) async {
-    messages.add('deleteFile $fsName ${entry.devicePath}');
-  }
-
-  @override
-  Future<dynamic> writeSource(String fsName,
-                              String devicePath,
-                              String contents) async {
-    messages.add('writeSource $fsName $devicePath');
+  Future<dynamic> deleteFile(String fsName, String devicePath) async {
+    messages.add('deleteFile $fsName $devicePath');
   }
 }