diff --git a/webdev/CHANGELOG.md b/webdev/CHANGELOG.md index 9a990a94e..10d45ce7f 100644 --- a/webdev/CHANGELOG.md +++ b/webdev/CHANGELOG.md @@ -1,3 +1,25 @@ +## 2.7.9-dev + +- Add an option to pass user data directory to chrome: `user-data-dir`. + Auto detect user data directory based on the current OS if `auto` is + given as a value. If `null` is given as a value (default), fall back + to the existing behavior (i.e. creating/reusing a temp directory). + + Note: not supported for Windows yet due to flakiness it introduces. + + Example using user-specified directory: + ``` + webdev serve \ + --debug --debug-extension \ + --user-data-dir='/Users//Library/Application Support/Google/Chrome' + ``` + Example using auto-detected directory: + ``` + webdev serve \ + --debug --debug-extension \ + --user-data-dir=auto + ``` + ## 2.7.8 - Update `vm_service` to `^8.1.0`. diff --git a/webdev/README.md b/webdev/README.md index 1fb0f45bd..92ef99543 100644 --- a/webdev/README.md +++ b/webdev/README.md @@ -72,6 +72,15 @@ Advanced: specify a specific port to attach to an already running chrome instance instead. + --user-data-dir Use with launch-in-chrome to specify + user data directory to pass to + chrome. Will start chrome window + logged into default profile with + enabled extensions. Use `auto` as a + value to infer the default directory + for the current OS. Note: only + supported for Mac OS X and linux + platforms. --log-requests Enables logging for each request to the server. --tls-cert-chain The file location to a TLS diff --git a/webdev/lib/src/command/configuration.dart b/webdev/lib/src/command/configuration.dart index a295145de..57a9485ac 100644 --- a/webdev/lib/src/command/configuration.dart +++ b/webdev/lib/src/command/configuration.dart @@ -23,6 +23,7 @@ const hotReloadFlag = 'hot-reload'; const hotRestartFlag = 'hot-restart'; const launchAppOption = 'launch-app'; const launchInChromeFlag = 'launch-in-chrome'; +const userDataDirFlag = 'user-data-dir'; const liveReloadFlag = 'live-reload'; const logRequestsFlag = 'log-requests'; const outputFlag = 'output'; @@ -92,6 +93,7 @@ class Configuration { final String _tlsCertKey; final List _launchApps; final bool _launchInChrome; + final String _userDataDir; final bool _logRequests; final String _output; final String outputInput; @@ -115,6 +117,7 @@ class Configuration { String tlsCertKey, List launchApps, bool launchInChrome, + String userDataDir, bool logRequests, String output, this.outputInput, @@ -136,6 +139,7 @@ class Configuration { _tlsCertKey = tlsCertKey, _launchApps = launchApps, _launchInChrome = launchInChrome, + _userDataDir = userDataDir, _logRequests = logRequests, _output = output, _release = release, @@ -185,6 +189,11 @@ class Configuration { throw InvalidConfiguration( '--$launchAppOption can only be used with --$launchInChromeFlag'); } + + if (userDataDir != null && !launchInChrome) { + throw InvalidConfiguration( + '--$userDataDir can only be used with --$launchInChromeFlag'); + } } /// Creates a new [Configuration] with all non-null fields from @@ -201,6 +210,7 @@ class Configuration { tlsCertKey: other._tlsCertKey ?? _tlsCertKey, launchApps: other._launchApps ?? _launchApps, launchInChrome: other._launchInChrome ?? _launchInChrome, + userDataDir: other._userDataDir ?? _userDataDir, logRequests: other._logRequests ?? _logRequests, output: other._output ?? _output, release: other._release ?? _release, @@ -239,6 +249,8 @@ class Configuration { bool get launchInChrome => _launchInChrome ?? false; + String get userDataDir => _userDataDir; + bool get logRequests => _logRequests ?? false; String get output => _output ?? outputNone; @@ -320,6 +332,10 @@ class Configuration { ? true : defaultConfiguration.launchInChrome; + var userDataDir = argResults.options.contains(userDataDirFlag) + ? argResults[userDataDirFlag] as String + : defaultConfiguration.userDataDir; + var logRequests = argResults.options.contains(logRequestsFlag) ? argResults[logRequestsFlag] as bool : defaultConfiguration.logRequests; @@ -382,6 +398,7 @@ class Configuration { tlsCertKey: tlsCertKey, launchApps: launchApps, launchInChrome: launchInChrome, + userDataDir: userDataDir, logRequests: logRequests, output: output, outputInput: outputInput, diff --git a/webdev/lib/src/command/serve_command.dart b/webdev/lib/src/command/serve_command.dart index 329c5d4be..caefd754c 100644 --- a/webdev/lib/src/command/serve_command.dart +++ b/webdev/lib/src/command/serve_command.dart @@ -59,6 +59,13 @@ refresh: Performs a full page refresh. help: 'Automatically launches your application in Chrome with the ' 'debug port open. Use $chromeDebugPortFlag to specify a specific ' 'port to attach to an already running chrome instance instead.') + ..addOption(userDataDirFlag, + defaultsTo: null, + help: 'Use with $launchInChromeFlag to specify user data directory ' + 'to pass to chrome. Will start chrome window logged into default ' + 'profile with enabled extensions. Use `auto` as a value to infer ' + 'the default directory for the current OS. ' + 'Note: only supported for Mac OS X and linux platforms.') ..addFlag(liveReloadFlag, negatable: false, help: 'Automatically refreshes the page after each successful build.\n' diff --git a/webdev/lib/src/serve/chrome.dart b/webdev/lib/src/serve/chrome.dart index 983a4e5c6..df042ba33 100644 --- a/webdev/lib/src/serve/chrome.dart +++ b/webdev/lib/src/serve/chrome.dart @@ -8,9 +8,14 @@ import 'dart:async'; import 'dart:io'; import 'package:browser_launcher/browser_launcher.dart' as browser_launcher; +import 'package:logging/logging.dart'; import 'package:path/path.dart' as path; import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; +import '../command/configuration.dart'; +import 'utils.dart'; + +var _logger = Logger('ChromeLauncher'); var _currentCompleter = Completer(); /// A class for managing an instance of Chrome. @@ -38,17 +43,61 @@ class Chrome { /// Starts Chrome with the remote debug port enabled. /// /// Each url in [urls] will be loaded in a separate tab. - static Future start(List urls, {int port}) async { + /// Enables chrome devtools with port [port] if specified. + /// Uses a copy of [userDataDir] to sign into the default + /// user profile, or starts a new session without sign in, + /// if not specified. + static Future start(List urls, + {int port, String userDataDir}) async { + var signIn = false; String dir; // Re-using the directory causes flakiness on Windows, so on that platform // pass null to have it create a directory. + // Issue: https://github.com/dart-lang/webdev/issues/1545 if (!Platform.isWindows) { - dir = path.join(Directory.current.absolute.path, '.dart_tool', 'webdev', - 'chrome_user_data'); - Directory(dir).createSync(recursive: true); + var userDataTemp = path.join(Directory.current.absolute.path, + '.dart_tool', 'webdev', 'chrome_user_data'); + var userDataCopy = path.join(Directory.current.absolute.path, + '.dart_tool', 'webdev', 'chrome_user_data_copy'); + + if (userDataDir != null) { + signIn = true; + dir = userDataCopy; + var stopwatch = Stopwatch()..start(); + try { + _logger.info('Copying user data directory...'); + _logger.warning( + 'Copying user data directory might take >12s on the first ' + 'use of --$userDataDirFlag, and ~2-3s on subsequent runs. ' + 'Run without --$userDataDirFlag to improve performance.'); + + Directory(dir).createSync(recursive: true); + await updatePath( + path.join(userDataDir, 'Default'), path.join(dir, 'Default')); + + _logger.info( + 'Copied user data directory in ${stopwatch.elapsedMilliseconds} ms'); + } catch (e, s) { + dir = userDataTemp; + signIn = false; + if (Directory(dir).existsSync()) { + Directory(dir).deleteSync(recursive: true); + } + _logger.severe('Failed to copy user data directory', e, s); + _logger.severe('Launching with temp profile instead.'); + rethrow; + } + } + + if (!signIn) { + dir = userDataTemp; + Directory(dir).createSync(recursive: true); + } } + + _logger.info('Starting chrome with user data directory: $dir'); var chrome = await browser_launcher.Chrome.startWithDebugPort(urls, - debugPort: port, userDataDir: dir); + debugPort: port, userDataDir: dir, signIn: signIn); return _connect(Chrome._(chrome.debugPort, chrome)); } @@ -79,3 +128,29 @@ class ChromeError extends Error { return 'ChromeError: $details'; } } + +String autoDetectChromeUserDataDirectory() { + Directory directory; + if (Platform.isMacOS) { + var home = Platform.environment['HOME']; + directory = Directory( + path.join(home, 'Library', 'Application Support', 'Google', 'Chrome')); + } else if (Platform.isLinux) { + var home = Platform.environment['HOME']; + directory = Directory(path.join(home, '.config', 'google-chrome')); + } else { + _logger.warning('Auto detecting chrome user data directory option is not ' + 'supported for ${Platform.operatingSystem}'); + return null; + } + + if (directory.existsSync()) { + _logger.info('Auto detected chrome user data directory: ${directory.path}'); + return directory.path; + } + + _logger.warning('Cannot automatically detect chrome user data directory. ' + 'Directory does not exist: ${directory.path}'); + + return null; +} diff --git a/webdev/lib/src/serve/dev_workflow.dart b/webdev/lib/src/serve/dev_workflow.dart index 0ef222903..a50233b4c 100644 --- a/webdev/lib/src/serve/dev_workflow.dart +++ b/webdev/lib/src/serve/dev_workflow.dart @@ -72,7 +72,11 @@ Future _startChrome( ]; try { if (configuration.launchInChrome) { - return await Chrome.start(uris, port: configuration.chromeDebugPort); + var userDataDir = configuration.userDataDir == autoOption + ? autoDetectChromeUserDataDirectory() + : configuration.userDataDir; + return await Chrome.start(uris, + port: configuration.chromeDebugPort, userDataDir: userDataDir); } else if (configuration.chromeDebugPort != 0) { return await Chrome.fromExisting(configuration.chromeDebugPort); } diff --git a/webdev/lib/src/serve/utils.dart b/webdev/lib/src/serve/utils.dart index 24cf5acb9..ae958a38a 100644 --- a/webdev/lib/src/serve/utils.dart +++ b/webdev/lib/src/serve/utils.dart @@ -7,6 +7,8 @@ import 'dart:async'; import 'dart:io'; +import 'package:path/path.dart' as p; + /// Returns a port that is probably, but not definitely, not in use. /// /// This has a built-in race condition: another process may bind this port at @@ -23,3 +25,62 @@ Future findUnusedPort() async { await socket.close(); return port; } + +/// Copy directory [from] to [to]. +/// +/// Updates contents of [to] if already exists. +Future updatePath(String from, String to) async { + await _removeDeleted(from, to); + await _copyUpdated(from, to); +} + +// Update modified files. +Future _copyUpdated(String from, String to) async { + if (!Directory(from).existsSync()) return; + await Directory(to).create(recursive: true); + + await for (final file in Directory(from).list()) { + final copyTo = p.join(to, p.relative(file.path, from: from)); + if (file is Directory) { + await _copyUpdated(file.path, copyTo); + } else if (file is File) { + var copyToFile = File(copyTo); + if (!copyToFile.existsSync() || + copyToFile.statSync().modified.compareTo(file.statSync().modified) < + 0) { + await File(file.path).copy(copyTo); + } + } else if (file is Link) { + await Link(copyTo).create(await file.target(), recursive: true); + } + } +} + +// Remove deleted files. +Future _removeDeleted(String from, String to) async { + if (!Directory(from).existsSync()) { + if (Directory(to).existsSync()) { + await Directory(to).delete(recursive: true); + } + return; + } + + if (!Directory(to).existsSync()) return; + await for (final file in Directory(to).list()) { + final copyFrom = p.join(from, p.relative(file.path, from: to)); + if (file is File) { + var copyFromFile = File(copyFrom); + if (!copyFromFile.existsSync()) { + await File(file.path).delete(); + } + } else if (file is Directory) { + var copyFromDir = Directory(copyFrom); + await _removeDeleted(copyFromDir.path, file.path); + } else if (file is Link) { + var copyFromDir = Link(copyFrom); + if (!copyFromDir.existsSync()) { + await Link(file.path).delete(); + } + } + } +} diff --git a/webdev/pubspec.yaml b/webdev/pubspec.yaml index eaf6ba93a..1dfa03674 100644 --- a/webdev/pubspec.yaml +++ b/webdev/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: args: ^2.0.0 async: ^2.2.0 build_daemon: '>=2.0.0 <4.0.0' - browser_launcher: ^1.0.0 + browser_launcher: ^1.1.0 crypto: ^3.0.0 dds: ^2.2.0 dwds: ^12.0.0 diff --git a/webdev/test/chrome_test.dart b/webdev/test/chrome_test.dart index f21bee7d2..7acbda24f 100644 --- a/webdev/test/chrome_test.dart +++ b/webdev/test/chrome_test.dart @@ -4,48 +4,202 @@ // @dart = 2.9 +@Timeout(Duration(seconds: 60)) import 'dart:async'; +import 'dart:io'; +import 'package:logging/logging.dart'; import 'package:test/test.dart'; +import 'package:webdev/src/logging.dart'; import 'package:webdev/src/serve/chrome.dart'; import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; void main() { Chrome chrome; - Future launchChrome({int port}) async { - chrome = await Chrome.start([_googleUrl], port: port ?? 0); + Future launchChrome({int port, String userDataDir}) async { + chrome = await Chrome.start([_googleUrl], + port: port ?? 0, userDataDir: userDataDir); } - tearDown(() async { - var tabs = await chrome?.chromeConnection?.getTabs(); - if (tabs != null) { - for (var tab in tabs) { - await chrome.chromeConnection.getUrl('/json/close/${tab.id}'); + Future openTab(String url) => + chrome.chromeConnection.getUrl(_openTabUrl(url)); + + Future closeTab(ChromeTab tab) => + chrome.chromeConnection.getUrl(_closeTabUrl(tab.id)); + + Future connectToTab(String url) async { + var tab = await chrome.chromeConnection.getTab((t) => t.url.contains(url)); + expect(tab, isNotNull); + return tab.connect(); + } + + group('chrome with temp data dir', () { + tearDown(() async { + var tabs = await chrome?.chromeConnection?.getTabs(); + if (tabs != null) { + for (var tab in tabs) { + await closeTab(tab); + } } - } - await chrome?.close(); - chrome = null; - }); + await chrome?.close(); + chrome = null; + }); - test('can launch chrome', () async { - await launchChrome(); - expect(chrome, isNotNull); - }); + test('can launch chrome', () async { + await launchChrome(); + expect(chrome, isNotNull); + }); + + test('has a working debugger', () async { + await launchChrome(); + var tabs = await chrome.chromeConnection.getTabs(); + expect( + tabs, + contains(const TypeMatcher() + .having((t) => t.url, 'url', _googleUrl))); + }); + + test('uses open debug port if provided port is 0', () async { + await launchChrome(port: 0); + expect(chrome.debugPort, isNot(equals(0))); + }); + + test('has correct profile path', () async { + await launchChrome(); + await openTab(_chromeVersionUrl); - test('debugger is working', () async { - await launchChrome(); - var tabs = await chrome.chromeConnection.getTabs(); - expect( - tabs, - contains(const TypeMatcher() - .having((t) => t.url, 'url', _googleUrl))); + var wipConnection = await connectToTab(_chromeVersionUrl); + var result = await _evaluateExpression(wipConnection.page, + "document.getElementById('profile_path').textContent"); + + if (Platform.isWindows) { + // --user-data-dir is not supported on Windows yet + // Issue: https://github.com/dart-lang/webdev/issues/1545 + expect(result, isNot(contains('chrome_user_data'))); + } else { + expect(result, contains('chrome_user_data')); + } + }); }); - test('uses open debug port if provided port is 0', () async { - await launchChrome(port: 0); - expect(chrome.debugPort, isNot(equals(0))); + group('chrome with user data dir', () { + Directory dataDir; + StreamController logController; + Stream logStream; + + setUp(() { + logController = StreamController(); + logStream = logController.stream; + + void _logWriter(Level level, String message, + {String error, String loggerName, String stackTrace}) { + if (level >= Level.INFO) { + logController.add('[$level] $loggerName: $message'); + } + } + + configureLogWriter(true, customLogWriter: _logWriter); + dataDir = Directory.systemTemp.createTempSync(_userDataDirName); + }); + + tearDown(() async { + var tabs = await chrome?.chromeConnection?.getTabs(); + if (tabs != null) { + for (var tab in tabs) { + await closeTab(tab); + } + } + await chrome?.close(); + chrome = null; + + // Issue: https://github.com/dart-lang/webdev/issues/1545 + if (!Platform.isWindows) { + expect( + logStream, + emitsThrough(matches('Starting chrome with user data directory:' + '.*chrome_user_data_copy'))); + await logController.close(); + } + dataDir?.deleteSync(recursive: true); + }); + + test('works with debug port', () async { + await launchChrome(userDataDir: dataDir.path); + expect(chrome, isNotNull); + }); + + test('has a working debugger', () async { + await launchChrome(userDataDir: dataDir.path); + + var tabs = await chrome.chromeConnection.getTabs(); + expect( + tabs, + contains(const TypeMatcher() + .having((t) => t.url, 'url', _googleUrl))); + }); + + test('has correct profile path', () async { + await launchChrome(userDataDir: dataDir.path); + await openTab(_chromeVersionUrl); + + var wipConnection = await connectToTab(_chromeVersionUrl); + var result = await _evaluateExpression(wipConnection.page, + "document.getElementById('profile_path').textContent"); + + if (Platform.isWindows) { + // --user-data-dir is not supported on Windows yet + // Issue: https://github.com/dart-lang/webdev/issues/1545 + expect(result, isNot(contains('chrome_user_data_copy'))); + } else { + expect(result, contains('chrome_user_data_copy')); + } + }); + + test('can auto detect default chrome directory', () async { + var userDataDir = autoDetectChromeUserDataDirectory(); + expect(userDataDir, isNotNull); + + expect(Directory(userDataDir).existsSync(), isTrue); + + await launchChrome(userDataDir: userDataDir); + await openTab(_chromeVersionUrl); + + var wipConnection = await connectToTab(_chromeVersionUrl); + var result = await _evaluateExpression(wipConnection.page, + "document.getElementById('profile_path').textContent"); + + expect(result, contains('chrome_user_data_copy')); + }, onPlatform: { + 'windows': const Skip('https://github.com/dart-lang/webdev/issues/1545') + }); + + test('cannot auto detect default chrome directory on windows', () async { + var userDataDir = autoDetectChromeUserDataDirectory(); + expect(userDataDir, isNull); + }, onPlatform: { + 'linux': const Skip('https://github.com/dart-lang/webdev/issues/1545'), + 'mac-os': const Skip('https://github.com/dart-lang/webdev/issues/1545'), + }); }); } -const _googleUrl = 'http://www.google.com/'; +String _openTabUrl(String url) => '/json/new?$url'; +String _closeTabUrl(String id) => '/json/close/$id'; + +Future _evaluateExpression(WipPage page, String expression) async { + var result = ''; + while (result == null || result.isEmpty) { + await Future.delayed(const Duration(milliseconds: 100)); + var wipResponse = await page.sendCommand( + 'Runtime.evaluate', + params: {'expression': expression}, + ); + result = wipResponse.json['result']['result']['value'] as String; + } + return result; +} + +const _googleUrl = 'https://www.google.com/'; +const _chromeVersionUrl = 'chrome://version/'; +const _userDataDirName = 'data dir'; diff --git a/webdev/test/configuration_test.dart b/webdev/test/configuration_test.dart index df1337668..c308d5819 100644 --- a/webdev/test/configuration_test.dart +++ b/webdev/test/configuration_test.dart @@ -13,7 +13,9 @@ void main() { setUp(() { argParser = ArgParser() ..addFlag('release') - ..addOption(nullSafetyFlag, defaultsTo: nullSafetyAuto); + ..addFlag(launchInChromeFlag, defaultsTo: false) + ..addOption(nullSafetyFlag, defaultsTo: nullSafetyAuto) + ..addOption(userDataDirFlag, defaultsTo: null); }); test('default configuration is correctly applied', () { @@ -41,6 +43,36 @@ void main() { throwsA(isA())); }); + test('user data directory defaults to null ', () { + var argResults = argParser.parse(['']); + var defaultConfiguration = Configuration.fromArgs(argResults); + expect(defaultConfiguration.userDataDir, isNull); + }); + + test('can read user data dir from args ', () { + var argResults = + argParser.parse(['--launch-in-chrome', '--user-data-dir=tempdir']); + var configuration = Configuration.fromArgs(argResults); + expect(configuration.userDataDir, equals('tempdir')); + }); + + test('can set user data directory with launchInChrome ', () { + var configuration = + Configuration(launchInChrome: true, userDataDir: 'temp'); + expect(configuration.userDataDir, equals('temp')); + }); + + test('can set user data directory to auto with launchInChrome ', () { + var configuration = + Configuration(launchInChrome: true, userDataDir: 'auto'); + expect(configuration.userDataDir, equals('auto')); + }); + + test('must set launchInChrome is to true if using user data directory ', () { + expect(() => Configuration(launchInChrome: false, userDataDir: 'temp'), + throwsA(isA())); + }); + test('nullSafety defaults to auto', () { var argResults = argParser.parse(['']); var defaultConfiguration = Configuration.fromArgs(argResults); diff --git a/webdev/test/utils_test.dart b/webdev/test/utils_test.dart new file mode 100644 index 000000000..a29748beb --- /dev/null +++ b/webdev/test/utils_test.dart @@ -0,0 +1,210 @@ +// Copyright (c) 2019, 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. + +// @dart = 2.9 + +@Retry(0) + +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:webdev/src/serve/utils.dart'; + +void main() { + Directory from; + Directory to; + + setUp(() { + from = Directory.systemTemp.createTempSync('from'); + to = Directory.systemTemp.createTempSync('to'); + }); + + tearDown(() { + from?.deleteSync(recursive: true); + to?.deleteSync(recursive: true); + }); + + test('updatePath does nothing for non-existing directories', () async { + var subDirFrom = Directory(p.join(from.path, '1')); + var subDirTo = Directory(p.join(to.path, '2')); + + expect(subDirFrom.existsSync(), isFalse); + expect(subDirTo.existsSync(), isFalse); + + await updatePath(subDirFrom.path, subDirTo.path); + + expect(subDirFrom.existsSync(), isFalse); + expect(subDirTo.existsSync(), isFalse); + }); + + test('updatePath creates non-existing directories', () async { + var subDirFrom = Directory(p.join(from.path, '1')); + var subDirTo = Directory(p.join(to.path, '2')); + + subDirFrom.createSync(); + + expect(subDirFrom.existsSync(), isTrue); + expect(subDirTo.existsSync(), isFalse); + + await updatePath(subDirFrom.path, subDirTo.path); + + expect(subDirFrom.existsSync(), isTrue); + expect(subDirTo.existsSync(), isTrue); + }); + + test('updatePath removes stale directories', () async { + var subDirFrom = Directory(p.join(from.path, '1')); + var subDirTo = Directory(p.join(to.path, '2')); + + subDirTo.createSync(); + + expect(subDirFrom.existsSync(), isFalse); + expect(subDirTo.existsSync(), isTrue); + + await updatePath(from.path, to.path); + + expect(subDirFrom.existsSync(), isFalse); + expect(subDirTo.existsSync(), isFalse); + }); + + test('updatePath updates directories', () async { + var subDirFrom = Directory(p.join(from.path, '1')); + var subDirTo = Directory(p.join(to.path, '2')); + + subDirFrom.createSync(); + subDirTo.createSync(); + + var listFrom = + from.listSync().map((e) => p.relative(e.path, from: from.path)); + await updatePath(from.path, to.path); + + var listTo = to.listSync().map((e) => p.relative(e.path, from: to.path)); + expect(listFrom, listTo); + }); + + test('updatePath updates files', () async { + var fileFrom = File(p.join(from.path, '1')); + var fileTo = File(p.join(to.path, '2')); + + fileFrom.createSync(); + fileTo.createSync(); + + var listFrom = + from.listSync().map((e) => p.relative(e.path, from: from.path)); + await updatePath(from.path, to.path); + + var listTo = to.listSync().map((e) => p.relative(e.path, from: to.path)); + expect(listFrom, listTo); + }); + + test('updatePath updates files and directories', () async { + var subDirFrom = Directory(p.join(from.path, '1')); + var subDirTo1 = Directory(p.join(to.path, '1')); + var subDirTo2 = Directory(p.join(to.path, '2')); + + subDirFrom.createSync(); + subDirTo1.createSync(); + subDirTo2.createSync(); + + var fileFrom = File(p.join(subDirFrom.path, 'a')); + var fileTo1 = File(p.join(subDirTo1.path, 'b')); + var fileTo2 = File(p.join(subDirTo2.path, 'b')); + + fileFrom.createSync(); + fileTo1.createSync(); + fileTo2.createSync(); + + var listFrom = from + .listSync(recursive: true) + .map((e) => p.relative(e.path, from: from.path)); + await updatePath(from.path, to.path); + + var listTo = to + .listSync(recursive: true) + .map((e) => p.relative(e.path, from: to.path)); + expect(listFrom, listTo); + }); + + test('updatePath updates stale files', () async { + var fileFrom = File(p.join(from.path, '1')); + var fileTo = File(p.join(to.path, '1')); + + fileTo.writeAsStringSync('contentsTo'); + await Future.delayed(const Duration(seconds: 1)); + fileFrom.writeAsStringSync('contentsFrom'); + + var stats = fileFrom.statSync(); + expect(fileTo.statSync().modified, isNot(equals(stats.modified))); + + expect(fileTo.readAsStringSync(), equals('contentsTo')); + await updatePath(from.path, to.path); + expect(fileTo.readAsStringSync(), equals('contentsFrom')); + }); + + test('updatePath does not update newer files', () async { + var fileFrom = File(p.join(from.path, '1')); + var fileTo = File(p.join(to.path, '1')); + + fileFrom.writeAsStringSync('contentsFrom'); + await Future.delayed(const Duration(seconds: 1)); + fileTo.writeAsStringSync('contentsTo'); + + var stats = fileFrom.statSync(); + expect(fileTo.statSync().modified, isNot(equals(stats.modified))); + + await updatePath(from.path, to.path); + expect(fileTo.statSync().modified, isNot(equals(stats.modified))); + expect(fileTo.readAsStringSync(), equals('contentsTo')); + }); + + test('updatePath updates stale files and directories', () async { + var subDirFrom = Directory(p.join(from.path, '1')); + var subDirTo1 = Directory(p.join(to.path, '1')); + var subDirTo2 = Directory(p.join(to.path, '2')); + + subDirFrom.createSync(); + subDirTo1.createSync(); + subDirTo2.createSync(); + + var fileFrom = File(p.join(subDirFrom.path, 'a')); + var fileTo1 = File(p.join(subDirTo1.path, 'a')); + var fileTo2 = File(p.join(subDirTo2.path, 'b')); + + fileTo1.writeAsStringSync('contentsTo1'); + fileTo2.writeAsStringSync('contentsTo2'); + await Future.delayed(const Duration(seconds: 1)); + fileFrom.writeAsStringSync('contentsFrom'); + + await updatePath(from.path, to.path); + + expect(fileTo1.readAsStringSync(), equals('contentsFrom')); + expect(fileTo2.existsSync(), isFalse); + }); + + test('updatePath does not update newer files and directories', () async { + var subDirFrom = Directory(p.join(from.path, '1')); + var subDirTo1 = Directory(p.join(to.path, '1')); + var subDirTo2 = Directory(p.join(to.path, '2')); + + subDirFrom.createSync(); + subDirTo1.createSync(); + subDirTo2.createSync(); + + var fileFrom = File(p.join(subDirFrom.path, 'a')); + var fileTo1 = File(p.join(subDirTo1.path, 'a')); + var fileTo2 = File(p.join(subDirTo2.path, 'b')); + + fileFrom.writeAsStringSync('contentsFrom'); + await Future.delayed(const Duration(seconds: 1)); + fileTo1.writeAsStringSync('contentsTo1'); + fileTo2.writeAsStringSync('contentsTo2'); + + await updatePath(from.path, to.path); + + expect(fileTo1.readAsStringSync(), equals('contentsTo1')); + expect(fileTo2.existsSync(), isFalse); + }); +}