diff --git a/pkg/analysis_server/lib/src/context_manager.dart b/pkg/analysis_server/lib/src/context_manager.dart index c37c64cd4dc..62cf7594bae 100644 --- a/pkg/analysis_server/lib/src/context_manager.dart +++ b/pkg/analysis_server/lib/src/context_manager.dart @@ -31,6 +31,7 @@ import 'package:analyzer/src/source/path_filter.dart'; import 'package:analyzer/src/task/options.dart'; import 'package:analyzer/src/util/glob.dart'; import 'package:analyzer/src/util/yaml.dart'; +import 'package:analyzer/src/workspace/bazel.dart'; import 'package:analyzer_plugin/protocol/protocol_common.dart' as protocol; import 'package:analyzer_plugin/utilities/analyzer_converter.dart'; import 'package:path/path.dart' as pathos; @@ -393,6 +394,11 @@ class ContextManagerImpl implements ContextManager { final Map> changeSubscriptions = >{}; + /// For each root directory stores subscriptions and watchers that we + /// established to detect changes to Bazel generated files. + final Map bazelSubscriptions = + {}; + ContextManagerImpl( this.resourceProvider, this.sdkManager, @@ -1016,6 +1022,7 @@ class ContextManagerImpl implements ContextManager { contextRoot.optionsFilePath = optionsFile.path; } info.analysisDriver = callbacks.addAnalysisDriver(folder, contextRoot); + _watchBazelFilesIfNeeded(folder, info.analysisDriver); if (optionsFile != null) { _analyzeAnalysisOptionsFile(info.analysisDriver, optionsFile.path); } @@ -1116,6 +1123,7 @@ class ContextManagerImpl implements ContextManager { /// Clean up and destroy the context associated with the given folder. void _destroyContext(ContextInfo info) { changeSubscriptions.remove(info.folder)?.cancel(); + bazelSubscriptions.remove(info.folder)?.cancel(); callbacks.removeContext(info.folder, _computeFlushedFiles(info)); var wasRemoved = info.parent.children.remove(info); assert(wasRemoved); @@ -1220,6 +1228,47 @@ class ContextManagerImpl implements ContextManager { return rootInfo; } + /// Establishes watch(es) for the Bazel generated files provided in + /// [notification]. + /// + /// Whenever the files change, we trigger re-analysis. This allows us to react + /// to creation/modification of files that were generated by Bazel. + void _handleBazelFileNotification( + Folder folder, BazelFileNotification notification) { + var fileSubscriptions = bazelSubscriptions[folder].fileSubscriptions; + if (fileSubscriptions.containsKey(notification.requested)) { + // We have already established a Watcher for this particular path. + return; + } + var watcher = notification.watcher( + pollingDelayShort: Duration(seconds: 10), + pollingDelayLong: Duration(seconds: 30)); + var subscription = watcher.events.listen(_handleBazelWatchEvent); + fileSubscriptions[notification.requested] = + _BazelFilesSubscription(watcher, subscription); + watcher.start(); + } + + /// Notifies the drivers that a generated Bazel file has changed. + void _handleBazelWatchEvent(WatchEvent event) { + if (event.type == ChangeType.ADD) { + for (var driver in driverMap.values) { + driver.addFile(event.path); + // Since the file has been created after we've searched for it, the + // URI resolution is likely wrong, so we need to reset it. + driver.resetUriResolution(); + } + } else if (event.type == ChangeType.MODIFY) { + for (var driver in driverMap.values) { + driver.changeFile(event.path); + } + } else if (event.type == ChangeType.REMOVE) { + for (var driver in driverMap.values) { + driver.removeFile(event.path); + } + } + } + void _handleWatchEvent(WatchEvent event) { callbacks.broadcastWatchEvent(event); _handleWatchEventImpl(event); @@ -1504,6 +1553,22 @@ class ContextManagerImpl implements ContextManager { driver.configure(sourceFactory: sourceFactory); } + /// Listens to files generated by Bazel that were found or searched for. + /// + /// This is handled specially because the files are outside the package + /// folder, but we still want to watch for changes to them. + /// + /// Does nothing if the [driver] is not in a Bazel workspace. + void _watchBazelFilesIfNeeded(Folder folder, AnalysisDriver analysisDriver) { + var workspace = analysisDriver.analysisContext.workspace; + if (workspace is BazelWorkspace && + !bazelSubscriptions.containsKey(folder)) { + var subscription = workspace.bazelCandidateFiles.listen( + (notification) => _handleBazelFileNotification(folder, notification)); + bazelSubscriptions[folder] = _BazelWorkspaceSubscription(subscription); + } + } + /// Create and return a source representing the given [file] within the given /// [driver]. static Source createSourceInContext(AnalysisDriver driver, File file) { @@ -1622,3 +1687,36 @@ class PackagesFileDisposition extends FolderDisposition { return _embedderLocator; } } + +/// A watcher with subscription used to detect changes to some file. +class _BazelFilesSubscription { + final BazelFileWatcher watcher; + final StreamSubscription subscription; + + _BazelFilesSubscription(this.watcher, this.subscription); + + void cancel() { + subscription.cancel(); + watcher.stop(); + } +} + +/// A subscription to notifications from a Bazel workspace. +class _BazelWorkspaceSubscription { + final StreamSubscription workspaceSubscription; + + /// For each absolute path that we searched for, provides the subscriptions + /// that we established to watch for changes. + /// + /// Note that the absolute path used when searching for a file is not + /// necessarily the actual path of the file (see [BazelWorkspace.findFile] for + /// details on how the files are searched). + final fileSubscriptions = {}; + + _BazelWorkspaceSubscription(this.workspaceSubscription); + + void cancel() { + workspaceSubscription.cancel(); + fileSubscriptions.values.forEach((sub) => sub.cancel()); + } +} diff --git a/pkg/analysis_server/test/analysis/bazel_changes_test.dart b/pkg/analysis_server/test/analysis/bazel_changes_test.dart new file mode 100644 index 00000000000..168f461f96f --- /dev/null +++ b/pkg/analysis_server/test/analysis/bazel_changes_test.dart @@ -0,0 +1,83 @@ +// Copyright (c) 2020, 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. +import 'dart:async'; + +import 'package:analysis_server/protocol/protocol.dart'; +import 'package:analysis_server/protocol/protocol_constants.dart'; +import 'package:analysis_server/protocol/protocol_generated.dart'; +import 'package:analyzer_plugin/protocol/protocol_common.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../analysis_abstract.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(BazelChangesTest); + }); +} + +@reflectiveTest +class BazelChangesTest extends AbstractAnalysisTest { + Map> filesErrors = {}; + Completer processedNotification; + + @override + void processNotification(Notification notification) { + if (notification.event == ANALYSIS_NOTIFICATION_ERRORS) { + var decoded = AnalysisErrorsParams.fromNotification(notification); + filesErrors[decoded.file] = decoded.errors; + processedNotification?.complete(); + } + } + + @override + void setUp() { + super.setUp(); + + projectPath = convertPath('/workspaceRoot/third_party/dart/project'); + testFile = + convertPath('/workspaceRoot/third_party/dart/project/lib/test.dart'); + newFile('/workspaceRoot/WORKSPACE'); + newFolder('/workspaceRoot/bazel-lib/project'); + newFolder('/workspaceRoot/bazel-genfiles/project'); + } + + @override + void tearDown() { + // Make sure to destroy all the contexts and cancel all subscriptions to + // file watchers. + server.contextManager.setRoots([], []); + + super.tearDown(); + } + + Future test_findingFileInGenfiles() async { + processedNotification = Completer(); + + newFile(testFile, content: r''' +import 'generated.dart'; +void main() { fun(); } +'''); + createProject(); + + // We should have some errors since the `generated.dart` is not there yet. + await processedNotification.future; + expect(filesErrors[testFile], isNotEmpty); + + // Clear errors, so that we'll notice new results. + filesErrors.clear(); + processedNotification = Completer(); + + // Simulate the creation of a generated file. + newFile( + '/workspaceRoot/bazel-genfiles/' + 'third_party/dart/project/lib/generated.dart', + content: 'fun() {}'); + + // No errors. + await processedNotification.future; + expect(filesErrors[testFile], isEmpty); + } +} diff --git a/pkg/analysis_server/test/analysis/test_all.dart b/pkg/analysis_server/test/analysis/test_all.dart index 11870654127..3329d7f57b5 100644 --- a/pkg/analysis_server/test/analysis/test_all.dart +++ b/pkg/analysis_server/test/analysis/test_all.dart @@ -4,6 +4,7 @@ import 'package:test_reflective_loader/test_reflective_loader.dart'; +import 'bazel_changes_test.dart' as bazel_changes; import 'get_errors_test.dart' as get_errors; import 'get_hover_test.dart' as get_hover; import 'get_navigation_test.dart' as get_navigation; @@ -26,6 +27,7 @@ import 'update_content_test.dart' as update_content; void main() { defineReflectiveSuite(() { + bazel_changes.main(); get_errors.main(); get_hover.main(); get_navigation.main(); diff --git a/pkg/analyzer/lib/src/context/builder.dart b/pkg/analyzer/lib/src/context/builder.dart index 7a43df74210..c91e372917c 100644 --- a/pkg/analyzer/lib/src/context/builder.dart +++ b/pkg/analyzer/lib/src/context/builder.dart @@ -114,7 +114,10 @@ class ContextBuilder { if (builderOptions.librarySummaryPaths != null) { summaryData = SummaryDataStore(builderOptions.librarySummaryPaths); } - final sf = createSourceFactory(path, summaryData: summaryData); + Workspace workspace = + ContextBuilder.createWorkspace(resourceProvider, path, this); + final sf = + createSourceFactoryFromWorkspace(workspace, summaryData: summaryData); AnalysisDriver driver = AnalysisDriver( analysisDriverScheduler, @@ -143,6 +146,7 @@ class ContextBuilder { resourceProvider, apiContextRoots.first, driver, + workspace: workspace, ), ); @@ -209,6 +213,15 @@ class ContextBuilder { return workspace.createSourceFactory(sdk, summaryData); } + SourceFactory createSourceFactoryFromWorkspace(Workspace workspace, + {SummaryDataStore summaryData}) { + DartSdk sdk = findSdk(workspace); + if (summaryData != null && sdk is SummaryBasedDartSdk) { + summaryData.addBundle(null, sdk.bundle); + } + return workspace.createSourceFactory(sdk, summaryData); + } + /// Add any [declaredVariables] to the list of declared variables used by the /// given analysis [driver]. void declareVariablesInDriver(AnalysisDriver driver) { diff --git a/pkg/analyzer/lib/src/dart/analysis/driver_based_analysis_context.dart b/pkg/analyzer/lib/src/dart/analysis/driver_based_analysis_context.dart index d02bc2c3dda..0b09d2e34ce 100644 --- a/pkg/analyzer/lib/src/dart/analysis/driver_based_analysis_context.dart +++ b/pkg/analyzer/lib/src/dart/analysis/driver_based_analysis_context.dart @@ -29,7 +29,9 @@ class DriverBasedAnalysisContext implements AnalysisContext { /// to access the file system and that is based on the given analysis /// [driver]. DriverBasedAnalysisContext( - this.resourceProvider, this.contextRoot, this.driver) { + this.resourceProvider, this.contextRoot, this.driver, + {Workspace workspace}) + : _workspace = workspace { driver.analysisContext = this; } diff --git a/pkg/analyzer/lib/src/workspace/bazel.dart b/pkg/analyzer/lib/src/workspace/bazel.dart index da3cafa6db7..d32be7a3e99 100644 --- a/pkg/analyzer/lib/src/workspace/bazel.dart +++ b/pkg/analyzer/lib/src/workspace/bazel.dart @@ -2,6 +2,7 @@ // 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. +import 'dart:async'; import 'dart:collection'; import 'dart:core'; @@ -14,8 +15,43 @@ import 'package:analyzer/src/generated/source_io.dart'; import 'package:analyzer/src/summary/package_bundle_reader.dart'; import 'package:analyzer/src/util/uri.dart'; import 'package:analyzer/src/workspace/workspace.dart'; +import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:pub_semver/pub_semver.dart'; +import 'package:watcher/watcher.dart'; + +/// Notification that we issue in [BazelWorkspace.findFile] when searching for +/// generated files. +/// +/// This allows clients to watch for changes to the generated files. +class BazelFileNotification { + /// Candidate paths that we searched. + /// + /// If it's a singleton, then the file was found/resolved. + final List candidates; + + /// Absolute path that we tried searching for. + /// + /// This is not necessarily the path of the actual file that will be used. See + /// [BazelWorkspace.findFile] for details. + final String requested; + + final ResourceProvider _provider; + + BazelFileNotification(this.requested, this.candidates, this._provider); + + BazelFileWatcher watcher( + {@required Duration pollingDelayShort, + @required Duration pollingDelayLong, + Timer Function(Duration, void Function(Timer)) timerFactory = + _defaultTimerFactory}) => + BazelFileWatcher(candidates, _provider, pollingDelayShort, + pollingDelayLong, timerFactory); + + static Timer _defaultTimerFactory( + Duration duration, void Function(Timer) callback) => + Timer.periodic(duration, callback); +} /// Instances of the class `BazelFileUriResolver` resolve `file` URI's by first /// resolving file uri's in the expected way, and then by looking in the @@ -41,6 +77,140 @@ class BazelFileUriResolver extends ResourceUriResolver { } } +/// Watches a list of files that should be generated by Bazel. +/// +/// If we didn't find the file initially, we'll have more that one potential +/// path. We'll poll them with a shorter delay waiting for at least one +/// of the paths to become valid (we assume that users will generate the rather +/// sooner than later since they will likely see errors due to unresolved +/// files). Once a file appears, we switch to a different mode, where we only +/// watch that particular file and poll it less frequently. If the file is +/// deleted we go back to the initial mode of eager polling for all paths. +class BazelFileWatcher { + /// The paths of files that we watch for. + /// + /// If it's a singleton, then the file was found/resolved by the + /// [BazelWorkspace]. + final List _candidates; + + final _eventsController = StreamController.broadcast(); + + /// The time of last modification of the file under [_validPath]. + int _lastModified; + + /// How often do we poll a file that we have found. + final Duration _pollingDelayLong; + + /// How often do we poll when none of potential files exist. + final Duration _pollingDelayShort; + + final ResourceProvider _provider; + + /// One of the [_candidates] that is valid, i.e., we found a file with that + /// path. + String _validPath; + + Timer _timer; + + /// Used to contruct a [Timer] for polling. + final Timer Function(Duration, void Function(Timer)) _timerFactory; + + BazelFileWatcher(this._candidates, this._provider, this._pollingDelayShort, + this._pollingDelayLong, this._timerFactory); + + Stream get events => _eventsController.stream; + + /// Starts watching the files. + /// + /// To avoid missing events, the clients should first start listening on + /// [events] and then call [start]. + void start() { + assert(_timer == null); + var info = _pollAll(); + if (info != null) { + _validPath = info.path; + _lastModified = info.modified; + _setPollingDelayToLong(); + } else { + _setPollingDelayToShort(); + } + } + + void stop() { + _timer.cancel(); + _eventsController.close(); + } + + void _poll() { + if (_eventsController.isClosed) return; + + int modified; + if (_validPath == null) { + var info = _pollAll(); + if (info != null) { + _validPath = info.path; + modified = info.modified; + } + } else { + modified = _pollOne(_validPath); + } + + // If there is no file, then we have nothing to do. + if (_validPath == null) return; + + if (modified == null && _lastModified != null) { + // The file is no longer there, so let's issue a REMOVE event, unset + // `_validPath` and set the timer to poll more frequently. + _eventsController.add(WatchEvent(ChangeType.REMOVE, _validPath)); + _validPath = null; + _setPollingDelayToShort(); + } else if (modified != null && _lastModified == null) { + _eventsController.add(WatchEvent(ChangeType.ADD, _validPath)); + _setPollingDelayToLong(); + } else if (_lastModified != null && modified != _lastModified) { + _eventsController.add(WatchEvent(ChangeType.MODIFY, _validPath)); + } + _lastModified = modified; + } + + /// Tries polling all the possible paths. + /// + /// Will set [_validPath] and return its modified time if a file is found. + /// Returns [null] if nothing is found. + FileInfo _pollAll() { + assert(_validPath == null); + for (var path in _candidates) { + var modified = _pollOne(path); + if (modified != null) { + return FileInfo(path, modified); + } + } + return null; + } + + /// Returns the modified time of the path or `null` if the file does not + /// exist. + int _pollOne(String path) { + try { + var file = _provider.getFile(path); + return file.modificationStamp; + } on FileSystemException catch (_) { + // File doesn't exist, so return null. + return null; + } + } + + void _setPollingDelayToLong() { + _timer?.cancel(); + _timer = _timerFactory(_pollingDelayLong, (_) => _poll()); + } + + void _setPollingDelayToShort() { + _timer?.cancel(); + _timer = _timerFactory(_pollingDelayShort, (_) => _poll()); + } +} + /// The [UriResolver] that can resolve `package` URIs in [BazelWorkspace]. class BazelPackageUriResolver extends UriResolver { final BazelWorkspace _workspace; @@ -189,9 +359,17 @@ class BazelWorkspace extends Workspace /// The absolute path to the `bazel-genfiles` folder. final String genfiles; + final _bazelCandidateFiles = + StreamController.broadcast(); + BazelWorkspace._( this.provider, this.root, this.readonly, this.binPaths, this.genfiles); + /// Stream of files that we tried to find along with their potential or actual + /// paths. + Stream get bazelCandidateFiles => + _bazelCandidateFiles.stream; + @override bool get isBazel => true; @@ -224,17 +402,16 @@ class BazelWorkspace extends Workspace if (relative == '.') { return null; } - // genfiles - if (genfiles != null) { - File file = provider.getFile(context.join(genfiles, relative)); - if (file.exists) { - return file; - } - } - // bin - for (String bin in binPaths) { - File file = provider.getFile(context.join(bin, relative)); + // First check genfiles and bin directories + var generatedCandidates = [ + if (genfiles != null) genfiles, + ...?binPaths + ].map((prefix) => context.join(prefix, relative)); + for (var path in generatedCandidates) { + File file = provider.getFile(path); if (file.exists) { + _bazelCandidateFiles.add(BazelFileNotification( + relative, generatedCandidates.toList(), provider)); return file; } } @@ -250,6 +427,10 @@ class BazelWorkspace extends Workspace return file; } } + // If we couldn't find the file, assume that it has not yet been + // generated, so send an event with all the paths that we tried. + _bazelCandidateFiles.add(BazelFileNotification( + relative, generatedCandidates.toList(), provider)); // Not generated, return the default one. return writableFile; } catch (_) { @@ -613,3 +794,9 @@ class BazelWorkspacePackage extends WorkspacePackage { } } } + +class FileInfo { + String path; + int modified; + FileInfo(this.path, this.modified); +} diff --git a/pkg/analyzer/pubspec.yaml b/pkg/analyzer/pubspec.yaml index c14bbd5ea12..7c09d2ce9da 100644 --- a/pkg/analyzer/pubspec.yaml +++ b/pkg/analyzer/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: dev_dependencies: analyzer_utilities: path: ../analyzer_utilities + async: ^2.0.0 linter: any matcher: ^0.12.3 pedantic: ^1.9.0 diff --git a/pkg/analyzer/test/src/workspace/bazel_test.dart b/pkg/analyzer/test/src/workspace/bazel_test.dart index 21297892105..f64cfc9d818 100644 --- a/pkg/analyzer/test/src/workspace/bazel_test.dart +++ b/pkg/analyzer/test/src/workspace/bazel_test.dart @@ -2,13 +2,17 @@ // 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. +import 'dart:async'; + import 'package:analyzer/src/generated/source.dart'; import 'package:analyzer/src/summary/package_bundle_reader.dart'; import 'package:analyzer/src/test_utilities/resource_provider_mixin.dart'; import 'package:analyzer/src/workspace/bazel.dart'; +import 'package:async/async.dart'; import 'package:meta/meta.dart'; import 'package:test/test.dart'; import 'package:test_reflective_loader/test_reflective_loader.dart'; +import 'package:watcher/watcher.dart'; import '../../generated/test_support.dart'; @@ -766,6 +770,152 @@ class BazelWorkspacePackageTest with ResourceProviderMixin { class BazelWorkspaceTest with ResourceProviderMixin { BazelWorkspace workspace; + void test_bazelFileWatcher() async { + _addResources([ + '/workspace/WORKSPACE', + ]); + _MockTimer timer; + var timerFactory = (Duration _, void Function(Timer) callback) { + timer = _MockTimer(callback); + return timer; + }; + var candidates = [ + convertPath('/workspace/bazel-bin/my/module/test1.dart'), + convertPath('/workspace/bazel-genfiles/my/module/test1.dart'), + ]; + var watcher = BazelFileWatcher(candidates, resourceProvider, Duration.zero, + Duration.zero, timerFactory); + var events = StreamQueue(watcher.events); + watcher.start(); + + // First do some tests with the first candidate path. + _addResources([candidates[0]]); + timer.triggerCallback(); + + var event = await events.next; + expect(event.type, ChangeType.ADD); + expect(event.path, candidates[0]); + + modifyFile(candidates[0], 'const foo = 42;'); + timer.triggerCallback(); + + event = await events.next; + expect(event.type, ChangeType.MODIFY); + expect(event.path, candidates[0]); + + _deleteResources([candidates[0]]); + timer.triggerCallback(); + + event = await events.next; + expect(event.type, ChangeType.REMOVE); + expect(event.path, candidates[0]); + + // Now check that if we add the *second* candidate, we'll get the + // notification for it. + _addResources([candidates[1]]); + timer.triggerCallback(); + + event = await events.next; + expect(event.type, ChangeType.ADD); + expect(event.path, candidates[1]); + + watcher.stop(); + expect(await events.rest.isEmpty, true); + } + + void test_bazelFileWatcher_existingFile() async { + _addResources([ + '/workspace/WORKSPACE', + '/workspace/bazel-bin/my/module/test1.dart', + ]); + BazelWorkspace workspace = BazelWorkspace.find( + resourceProvider, convertPath('/workspace/my/module')); + _MockTimer timer; + var timerFactory = (Duration _, void Function(Timer) callback) { + timer = _MockTimer(callback); + return timer; + }; + var watcherCompleter = Completer(); + workspace.bazelCandidateFiles.listen((notification) => + watcherCompleter.complete(notification.watcher( + pollingDelayLong: Duration.zero, + pollingDelayShort: Duration.zero, + timerFactory: timerFactory))); + + var file1 = + workspace.findFile(convertPath('/workspace/my/module/test1.dart')); + expect(file1.exists, true); + var watcher = await watcherCompleter.future; + var events = StreamQueue(watcher.events); + watcher.start(); + + // Make sure that triggering the callback, will not generate extra events. + timer.triggerCallback(); + + var convertedPath = + convertPath('/workspace/bazel-bin/my/module/test1.dart'); + + // Change the file -- we should get a single MODIFY event and not an ADD + // event, since the file already existed. + modifyFile(convertedPath, 'const foo = 42;'); + timer.triggerCallback(); + var event = await events.next; + + expect(event.type, ChangeType.MODIFY); + expect(event.path, convertedPath); + + // But if we delete the file and then re-create it, we should get an ADD + // event (after the REMOVE one). + deleteFile(convertedPath); + timer.triggerCallback(); + event = await events.next; + + expect(event.type, ChangeType.REMOVE); + expect(event.path, convertedPath); + + newFile(convertedPath); + timer.triggerCallback(); + event = await events.next; + + expect(event.type, ChangeType.ADD); + expect(event.path, convertedPath); + + watcher.stop(); + expect(await events.rest.isEmpty, true); + } + + void test_bazelNotifications() async { + _addResources([ + '/workspace/WORKSPACE', + '/workspace/bazel-bin/my/module/test1.dart', + ]); + BazelWorkspace workspace = BazelWorkspace.find( + resourceProvider, convertPath('/workspace/my/module')); + var notifications = StreamQueue(workspace.bazelCandidateFiles); + + var file1 = + workspace.findFile(convertPath('/workspace/my/module/test1.dart')); + expect(file1.exists, true); + var notification = await notifications.next; + expect(notification.requested, convertPath('my/module/test1.dart')); + expect( + notification.candidates, + containsAll( + [convertPath('/workspace/bazel-bin/my/module/test1.dart')])); + + var file2 = + workspace.findFile(convertPath('/workspace/my/module/test2.dart')); + expect(file2.exists, false); + notification = await notifications.next; + expect(notification.requested, convertPath('my/module/test2.dart')); + expect( + notification.candidates, + containsAll([ + convertPath('/workspace/bazel-bin/my/module/test2.dart'), + convertPath('/workspace/bazel-genfiles/my/module/test2.dart'), + ])); + } + void test_find_fail_notAbsolute() { expect( () => @@ -1015,6 +1165,17 @@ class BazelWorkspaceTest with ResourceProviderMixin { } } + /// Create new files and directories from [paths]. + void _deleteResources(List paths) { + for (String path in paths) { + if (path.endsWith('/')) { + deleteFolder(path.substring(0, path.length - 1)); + } else { + deleteFile(path); + } + } + } + /// Expect that [BazelWorkspace.findFile], given [path], returns [equals]. void _expectFindFile(String path, {@required String equals}) => expect(workspace.findFile(convertPath(path)).path, convertPath(equals)); @@ -1031,3 +1192,20 @@ class _MockSource implements Source { throw StateError('Unexpected invocation of ${invocation.memberName}'); } } + +class _MockTimer implements Timer { + final void Function(Timer) callback; + + @override + bool isActive = true; + + _MockTimer(this.callback); + + @override + int get tick => throw UnimplementedError(); + + @override + void cancel() => isActive = false; + + void triggerCallback() => callback(this); +}