diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index d83930dd85..692fe59aee 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -17,8 +17,11 @@ import '../flutter_manifest.dart'; import '../globals.dart' as globals; import '../project.dart'; import '../runner/flutter_command.dart'; +import '../widget_preview/preview_code_generator.dart'; +import '../widget_preview/preview_detector.dart'; import 'create_base.dart'; +// TODO(bkonyi): use dependency injection instead of global accessors throughout this file. class WidgetPreviewCommand extends FlutterCommand { WidgetPreviewCommand() { addSubcommand(WidgetPreviewStartCommand()); @@ -83,6 +86,13 @@ class WidgetPreviewStartCommand extends FlutterCommand @override String get name => 'start'; + late final PreviewDetector _previewDetector = PreviewDetector( + logger: globals.logger, + onChangeDetected: onChangeDetected, + ); + + late final PreviewCodeGenerator _previewCodeGenerator; + @override Future runCommand() async { final FlutterProject rootProject = getRootProject(); @@ -112,9 +122,26 @@ class WidgetPreviewStartCommand extends FlutterCommand ); await _populatePreviewPubspec(rootProject: rootProject); } + + // WARNING: this needs to happen after we generate the scaffold project as invoking the + // widgetPreviewScaffoldProject getter triggers lazy initialization of the preview scaffold's + // FlutterManifest before the scaffold project's pubspec has been generated. + _previewCodeGenerator = PreviewCodeGenerator( + widgetPreviewScaffoldProject: rootProject.widgetPreviewScaffoldProject, + fs: globals.fs, + ); + + final PreviewMapping initialPreviews = await _previewDetector.initialize(rootProject.directory); + _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(initialPreviews); + + await _previewDetector.dispose(); return FlutterCommandResult.success(); } + void onChangeDetected(PreviewMapping previews) { + // TODO(bkonyi): perform hot reload + } + @visibleForTesting static const Map flutterGenPackageConfigEntry = { 'name': 'flutter_gen', diff --git a/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart b/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart new file mode 100644 index 0000000000..18486394ea --- /dev/null +++ b/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart @@ -0,0 +1,76 @@ +// Copyright 2014 The Flutter 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:code_builder/code_builder.dart'; + +import '../base/file_system.dart'; +import '../project.dart'; +import 'preview_detector.dart'; + +/// Generates the Dart source responsible for importing widget previews from the developer's project +/// into the widget preview scaffold. +class PreviewCodeGenerator { + PreviewCodeGenerator({required this.widgetPreviewScaffoldProject, required this.fs}); + + final FileSystem fs; + + /// The project for the widget preview scaffold found under `.dart_tool/` in the developer's + /// project. + final FlutterProject widgetPreviewScaffoldProject; + + static const String generatedPreviewFilePath = 'lib/generated_preview.dart'; + + /// Generates code used by the widget preview scaffold based on the preview instances listed in + /// [previews]. + /// + /// The generated file will contain a single top level function named `previews()` which returns + /// a `List` that contains each widget preview defined in [previews]. + /// + /// An example of a formatted generated file containing previews from two files could be: + /// + /// ```dart + /// import 'package:foo/foo.dart' as _i1; + /// import 'package:foo/src/bar.dart' as _i2; + /// import 'package:widget_preview/widget_preview.dart'; + /// + /// List previews() => [ + /// _i1.fooPreview(), + /// _i2.barPreview1(), + /// _i3.barPreview2(), + /// ]; + /// ``` + void populatePreviewsInGeneratedPreviewScaffold(PreviewMapping previews) { + final Library lib = Library( + (LibraryBuilder b) => b.body.addAll([ + Directive.import( + // TODO(bkonyi): update with actual location in the framework + 'package:widget_preview/widget_preview.dart', + ), + Method( + (MethodBuilder b) => + b + ..body = + literalList([ + for (final MapEntry>( + key: String path, + value: List previewMethods, + ) + in previews.entries) ...[ + for (final String method in previewMethods) + refer(method, path).call([]), + ], + ]).code + ..name = 'previews' + ..returns = refer('List'), + ), + ]), + ); + final DartEmitter emitter = DartEmitter.scoped(useNullSafetySyntax: true); + final File generatedPreviewFile = fs.file( + widgetPreviewScaffoldProject.directory.uri.resolve(generatedPreviewFilePath), + ); + // TODO(bkonyi): do we want to bother with formatting this? + generatedPreviewFile.writeAsStringSync(lib.accept(emitter).toString()); + } +} diff --git a/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart b/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart new file mode 100644 index 0000000000..70d01df3ed --- /dev/null +++ b/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart @@ -0,0 +1,145 @@ +// Copyright 2014 The Flutter 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'; + +// ignore: implementation_imports +import 'package:_fe_analyzer_shared/src/base/syntactic_entity.dart'; +import 'package:analyzer/dart/analysis/analysis_context.dart'; +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/file_system/physical_file_system.dart'; +import 'package:watcher/watcher.dart'; + +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/utils.dart'; +import '../globals.dart' as globals; +import 'preview_code_generator.dart'; + +typedef PreviewMapping = Map>; + +class PreviewDetector { + PreviewDetector({required this.logger, required this.onChangeDetected}); + + final Logger logger; + final void Function(PreviewMapping) onChangeDetected; + StreamSubscription? _fileWatcher; + late final PreviewMapping _pathToPreviews; + + /// Starts listening for changes to Dart sources under [projectRoot] and returns + /// the initial [PreviewMapping] for the project. + Future initialize(Directory projectRoot) async { + // Find the initial set of previews. + _pathToPreviews = findPreviewFunctions(projectRoot); + + final Watcher watcher = Watcher(projectRoot.path); + // TODO(bkonyi): watch for changes to pubspec.yaml + _fileWatcher = watcher.events.listen((WatchEvent event) async { + final String eventPath = Uri.file(event.path).toString(); + // Only trigger a reload when changes to Dart sources are detected. We + // ignore the generated preview file to avoid getting stuck in a loop. + if (!eventPath.endsWith('.dart') || + eventPath.endsWith(PreviewCodeGenerator.generatedPreviewFilePath)) { + return; + } + logger.printStatus('Detected change in $eventPath.'); + final PreviewMapping filePreviewsMapping = findPreviewFunctions( + globals.fs.file(Uri.file(event.path)), + ); + if (filePreviewsMapping.isEmpty && !_pathToPreviews.containsKey(eventPath)) { + // No previews found or removed, nothing to do. + return; + } + if (filePreviewsMapping.length > 1) { + logger.printWarning('Previews from more than one file were detected!'); + logger.printWarning('Previews: $filePreviewsMapping'); + } + if (filePreviewsMapping.isNotEmpty) { + // The set of previews has changed, but there are still previews in the file. + final MapEntry>(key: String uri, value: List filePreviews) = + filePreviewsMapping.entries.first; + assert(uri == eventPath); + logger.printStatus('Updated previews for $eventPath: $filePreviews'); + if (filePreviews.isNotEmpty) { + final List? currentPreviewsForFile = _pathToPreviews[eventPath]; + if (filePreviews != currentPreviewsForFile) { + _pathToPreviews[eventPath] = filePreviews; + } + } + } else { + // The file previously had previews that were removed. + logger.printStatus('Previews removed from $eventPath'); + _pathToPreviews.remove(eventPath); + } + onChangeDetected(_pathToPreviews); + }); + // Wait for file watcher to finish initializing, otherwise we might miss changes and cause + // tests to flake. + await watcher.ready; + return _pathToPreviews; + } + + Future dispose() async { + await _fileWatcher?.cancel(); + } + + /// Search for functions annotated with `@Preview` in the current project. + PreviewMapping findPreviewFunctions(FileSystemEntity entity) { + final AnalysisContextCollection collection = AnalysisContextCollection( + includedPaths: [entity.absolute.path], + resourceProvider: PhysicalResourceProvider.INSTANCE, + ); + + final PreviewMapping previews = PreviewMapping(); + for (final AnalysisContext context in collection.contexts) { + logger.printStatus('Finding previews in ${context.contextRoot.root.path}...'); + + for (final String filePath in context.contextRoot.analyzedFiles()) { + logger.printTrace('Checking file: $filePath'); + if (!filePath.endsWith('.dart')) { + continue; + } + + final SomeParsedLibraryResult lib = context.currentSession.getParsedLibrary(filePath); + if (lib is ParsedLibraryResult) { + for (final ParsedUnitResult unit in lib.units) { + final List previewEntries = previews[unit.uri.toString()] ?? []; + for (final SyntacticEntity entity in unit.unit.childEntities) { + if (entity is FunctionDeclaration && !entity.name.toString().startsWith('_')) { + bool foundPreview = false; + for (final Annotation annotation in entity.metadata) { + if (annotation.name.name == 'Preview') { + // What happens if the annotation is applied multiple times? + foundPreview = true; + break; + } + } + if (foundPreview) { + logger.printStatus('Found preview at:'); + logger.printStatus('File path: ${unit.uri}'); + logger.printStatus('Preview function: ${entity.name}'); + logger.printStatus(''); + previewEntries.add(entity.name.toString()); + } + } + } + if (previewEntries.isNotEmpty) { + previews[unit.uri.toString()] = previewEntries; + } + } + } else { + logger.printWarning('Unknown library type at $filePath: $lib'); + } + } + } + final int previewCount = previews.values.fold( + 0, + (int count, List value) => count + value.length, + ); + logger.printStatus('Found $previewCount ${pluralize('preview', previewCount)}.'); + return previews; + } +} diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index 642eecc8ed..df806ee6d5 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: args: 2.6.0 dds: 5.0.0 dwds: 24.3.3 + code_builder: 4.10.1 completion: 1.0.1 coverage: 1.11.1 crypto: 3.0.6 @@ -121,4 +122,4 @@ dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: cf3c +# PUBSPEC CHECKSUM: a49f diff --git a/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart b/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart index 3e2c10d34b..87c4172f23 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart @@ -11,6 +11,7 @@ import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/commands/widget_preview.dart'; import 'package:flutter_tools/src/dart/pub.dart'; import 'package:flutter_tools/src/globals.dart' as globals; +import 'package:flutter_tools/src/widget_preview/preview_code_generator.dart'; import '../../src/common.dart'; import '../../src/context.dart'; @@ -33,8 +34,12 @@ void main() { tryToDelete(tempDir); }); - Future createRootProject() async { - return createProject(tempDir, arguments: ['--pub']); + Future createRootProject() async { + return globals.fs.directory(await createProject(tempDir, arguments: ['--pub'])); + } + + Directory widgetPreviewScaffoldFromRootProject({required Directory rootProject}) { + return rootProject.childDirectory('.dart_tool').childDirectory('widget_preview_scaffold'); } Future runWidgetPreviewCommand(List arguments) async { @@ -43,28 +48,29 @@ void main() { } Future startWidgetPreview({ - required String? rootProjectPath, + required Directory? rootProject, List? arguments, }) async { await runWidgetPreviewCommand([ 'start', ...?arguments, - if (rootProjectPath != null) rootProjectPath, + if (rootProject != null) rootProject.path, ]); + final Directory widgetPreviewScaffoldDir = widgetPreviewScaffoldFromRootProject( + rootProject: rootProject ?? globals.fs.currentDirectory, + ); + expect(widgetPreviewScaffoldDir, exists); expect( - globals.fs - .directory(rootProjectPath ?? globals.fs.currentDirectory.path) - .childDirectory('.dart_tool') - .childDirectory('widget_preview_scaffold'), + widgetPreviewScaffoldDir.childFile(PreviewCodeGenerator.generatedPreviewFilePath), exists, ); } - Future cleanWidgetPreview({required String rootProjectPath}) async { - await runWidgetPreviewCommand(['clean', rootProjectPath]); + Future cleanWidgetPreview({required Directory rootProject}) async { + await runWidgetPreviewCommand(['clean', rootProject.path]); expect( globals.fs - .directory(rootProjectPath) + .directory(rootProject) .childDirectory('.dart_tool') .childDirectory('widget_preview_scaffold'), isNot(exists), @@ -93,7 +99,7 @@ void main() { testUsingContext('run outside of a Flutter project directory', () async { try { - await startWidgetPreview(rootProjectPath: tempDir.path); + await startWidgetPreview(rootProject: tempDir); fail('Successfully executed outside of a Flutter project directory'); } on ToolExit catch (e) { expect(e.message, contains('${tempDir.path} is not a valid Flutter project.')); @@ -104,8 +110,8 @@ void main() { testUsingContext( 'start creates .dart_tool/widget_preview_scaffold', () async { - final String rootProjectPath = await createRootProject(); - await startWidgetPreview(rootProjectPath: rootProjectPath); + final Directory rootProject = await createRootProject(); + await startWidgetPreview(rootProject: rootProject); }, overrides: { Pub: @@ -123,11 +129,89 @@ void main() { testUsingContext( 'start creates .dart_tool/widget_preview_scaffold in the CWD', () async { - final String rootProjectPath = await createRootProject(); + final Directory rootProject = await createRootProject(); await io.IOOverrides.runZoned>(() async { // Try to execute using the CWD. - await startWidgetPreview(rootProjectPath: null); - }, getCurrentDirectory: () => globals.fs.directory(rootProjectPath)); + await startWidgetPreview(rootProject: null); + }, getCurrentDirectory: () => rootProject); + }, + overrides: { + Pub: + () => Pub.test( + fileSystem: globals.fs, + logger: globals.logger, + processManager: globals.processManager, + usage: globals.flutterUsage, + botDetector: globals.botDetector, + platform: globals.platform, + stdio: mockStdio, + ), + }, + ); + + const String samplePreviewFile = ''' +// This doesn't need to be valid code for testing as long as it has the @Preview() annotation +@Preview() +WidgetPreview preview() => WidgetPreview();'''; + + const String expectedGeneratedFileContents = ''' +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:flutter_project/foo.dart' as _i1;import 'package:widget_preview/widget_preview.dart';List previews() => [_i1.preview()];'''; + + testUsingContext( + 'start finds existing previews and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}', + () async { + final Directory rootProject = await createRootProject(); + final Directory widgetPreviewScaffoldDir = widgetPreviewScaffoldFromRootProject( + rootProject: rootProject, + ); + rootProject + .childDirectory('lib') + .childFile('foo.dart') + .writeAsStringSync(samplePreviewFile); + + final File generatedFile = widgetPreviewScaffoldDir.childFile( + PreviewCodeGenerator.generatedPreviewFilePath, + ); + + await startWidgetPreview(rootProject: rootProject); + expect(generatedFile.readAsStringSync(), expectedGeneratedFileContents); + }, + overrides: { + Pub: + () => Pub.test( + fileSystem: globals.fs, + logger: globals.logger, + processManager: globals.processManager, + usage: globals.flutterUsage, + botDetector: globals.botDetector, + platform: globals.platform, + stdio: mockStdio, + ), + }, + ); + + testUsingContext( + 'start finds existing previews in the CWD and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}', + () async { + final Directory rootProject = await createRootProject(); + final Directory widgetPreviewScaffoldDir = widgetPreviewScaffoldFromRootProject( + rootProject: rootProject, + ); + rootProject + .childDirectory('lib') + .childFile('foo.dart') + .writeAsStringSync(samplePreviewFile); + + final File generatedFile = widgetPreviewScaffoldDir.childFile( + PreviewCodeGenerator.generatedPreviewFilePath, + ); + + await io.IOOverrides.runZoned>(() async { + // Try to execute using the CWD. + await startWidgetPreview(rootProject: null); + expect(generatedFile.readAsStringSync(), expectedGeneratedFileContents); + }, getCurrentDirectory: () => globals.fs.directory(rootProject)); }, overrides: { Pub: @@ -145,9 +229,9 @@ void main() { testUsingContext( 'clean deletes .dart_tool/widget_preview_scaffold', () async { - final String rootProjectPath = await createRootProject(); - await startWidgetPreview(rootProjectPath: rootProjectPath); - await cleanWidgetPreview(rootProjectPath: rootProjectPath); + final Directory rootProject = await createRootProject(); + await startWidgetPreview(rootProject: rootProject); + await cleanWidgetPreview(rootProject: rootProject); }, overrides: { Pub: @@ -166,12 +250,12 @@ void main() { 'invokes pub in online and offline modes', () async { // Run pub online first in order to populate the pub cache. - final String rootProjectPath = await createRootProject(); + final Directory rootProject = await createRootProject(); loggingProcessManager.clear(); final RegExp dartCommand = RegExp(r'dart-sdk[\\/]bin[\\/]dart'); - await startWidgetPreview(rootProjectPath: rootProjectPath); + await startWidgetPreview(rootProject: rootProject); expect( loggingProcessManager.commands, contains( @@ -182,12 +266,12 @@ void main() { ), ); - await cleanWidgetPreview(rootProjectPath: rootProjectPath); + await cleanWidgetPreview(rootProject: rootProject); // Run pub offline. loggingProcessManager.clear(); await startWidgetPreview( - rootProjectPath: rootProjectPath, + rootProject: rootProject, arguments: ['--pub', '--offline'], ); diff --git a/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart b/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart new file mode 100644 index 0000000000..e5e12edd97 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart @@ -0,0 +1,74 @@ +// Copyright 2014 The Flutter 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:file/memory.dart'; +import 'package:file_testing/file_testing.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/flutter_manifest.dart'; +import 'package:flutter_tools/src/project.dart'; +import 'package:flutter_tools/src/widget_preview/preview_code_generator.dart'; +import 'package:test/test.dart'; + +import '../../src/context.dart'; + +void main() { + group('$PreviewCodeGenerator', () { + late PreviewCodeGenerator codeGenerator; + late FlutterProject project; + + setUp(() { + final FileSystem fs = MemoryFileSystem.test(); + final FlutterManifest manifest = FlutterManifest.empty(logger: BufferLogger.test()); + final Directory projectDir = + fs.currentDirectory.childDirectory('project') + ..createSync() + ..childDirectory('lib').createSync(); + project = FlutterProject(projectDir, manifest, manifest); + codeGenerator = PreviewCodeGenerator(widgetPreviewScaffoldProject: project, fs: fs); + }); + + testUsingContext( + 'correctly generates ${PreviewCodeGenerator.generatedPreviewFilePath}', + () async { + // Check that the generated preview file doesn't exist yet. + final File generatedPreviewFile = project.directory.childFile( + PreviewCodeGenerator.generatedPreviewFilePath, + ); + expect(generatedPreviewFile, isNot(exists)); + + // Populate the generated preview file. + codeGenerator.populatePreviewsInGeneratedPreviewScaffold(const >{ + 'foo.dart': ['preview'], + 'src/bar.dart': ['barPreview1', 'barPreview2'], + }); + expect(generatedPreviewFile, exists); + + // Check that the generated file contains: + // - An import of the widget preview library + // - Prefixed imports for both 'foo.dart' and 'src/bar.dart' + // - A top-level function 'List previews()' + // - A returned list containing function calls to 'preview()' from 'foo.dart' and 'barPreview1()' + // and 'barPreview2()' from 'src/bar.dart' + // + // The generated file is unfortunately unformatted. + const String expectedGeneratedPreviewFileContents = ''' +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'foo.dart' as _i1;import 'src/bar.dart' as _i2;import 'package:widget_preview/widget_preview.dart';List previews() => [_i1.preview(), _i2.barPreview1(), _i2.barPreview2(), ];'''; + expect(generatedPreviewFile.readAsStringSync(), expectedGeneratedPreviewFileContents); + + // Regenerate the generated file with no previews. + codeGenerator.populatePreviewsInGeneratedPreviewScaffold(const >{}); + expect(generatedPreviewFile, exists); + + // The generated file should only contain: + // - An import of the widget preview library + // - A top-level function 'List previews()' that returns an empty list. + const String emptyGeneratedPreviewFileContents = ''' +import 'package:widget_preview/widget_preview.dart';List previews() => [];'''; + expect(generatedPreviewFile.readAsStringSync(), emptyGeneratedPreviewFileContents); + }, + ); + }); +} diff --git a/packages/flutter_tools/test/general.shard/widget_preview/preview_detector_test.dart b/packages/flutter_tools/test/general.shard/widget_preview/preview_detector_test.dart new file mode 100644 index 0000000000..b00c70d07b --- /dev/null +++ b/packages/flutter_tools/test/general.shard/widget_preview/preview_detector_test.dart @@ -0,0 +1,119 @@ +// Copyright 2014 The Flutter 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:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/signals.dart'; +import 'package:flutter_tools/src/widget_preview/preview_detector.dart'; +import 'package:test/test.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; + +Directory createBasicProjectStructure(FileSystem fs) { + return fs.systemTempDirectory.createTempSync('root'); +} + +File addPreviewContainingFile(Directory projectRoot, String path) { + return projectRoot.childDirectory('lib').childFile(path) + ..createSync(recursive: true) + ..writeAsStringSync(previewContainingFileContents); +} + +File addNonPreviewContainingFile(Directory projectRoot, String path) { + return projectRoot.childDirectory('lib').childFile(path) + ..createSync(recursive: true) + ..writeAsStringSync(nonPreviewContainingFileContents); +} + +void main() { + group('$PreviewDetector', () { + // Note: we don't use a MemoryFileSystem since we don't have a way to + // provide it to package:analyzer APIs without writing a significant amount + // of wrapper logic. + late LocalFileSystem fs; + late Logger logger; + late PreviewDetector previewDetector; + late Directory projectRoot; + void Function(PreviewMapping)? onChangeDetected; + + void onChangeDetectedRoot(PreviewMapping mapping) { + onChangeDetected!(mapping); + } + + setUp(() { + fs = LocalFileSystem.test(signals: Signals.test()); + projectRoot = createBasicProjectStructure(fs); + logger = BufferLogger.test(); + previewDetector = PreviewDetector(logger: logger, onChangeDetected: onChangeDetectedRoot); + }); + + tearDown(() async { + await previewDetector.dispose(); + projectRoot.deleteSync(recursive: true); + onChangeDetected = null; + }); + + testUsingContext('can detect previews in existing files', () async { + final List previewFiles = [ + addPreviewContainingFile(projectRoot, 'foo.dart'), + addPreviewContainingFile(projectRoot, 'src/bar.dart'), + ]; + addNonPreviewContainingFile(projectRoot, 'baz.dart'); + final PreviewMapping mapping = previewDetector.findPreviewFunctions(projectRoot); + expect(mapping.keys.toSet(), previewFiles.map((File e) => e.uri.toString()).toSet()); + }); + + testUsingContext('can detect previews in updated files', () async { + // Create two files with existing previews and one without. + final PreviewMapping expectedInitialMapping = >{ + addPreviewContainingFile(projectRoot, 'foo.dart').uri.toString(): ['previews'], + addPreviewContainingFile(projectRoot, 'src/bar.dart').uri.toString(): ['previews'], + }; + final File nonPreviewContainingFile = addNonPreviewContainingFile(projectRoot, 'baz.dart'); + + Completer completer = Completer(); + onChangeDetected = (PreviewMapping updated) { + // The new preview in baz.dart should be included in the preview mapping. + expect(updated, >{ + ...expectedInitialMapping, + nonPreviewContainingFile.uri.toString(): ['previews'], + }); + completer.complete(); + }; + // Initialize the file watcher. + final PreviewMapping initialPreviews = await previewDetector.initialize(projectRoot); + expect(initialPreviews, expectedInitialMapping); + + // Update the file without an existing preview to include a preview and ensure it triggers + // the preview detector. + addPreviewContainingFile(projectRoot, 'baz.dart'); + await completer.future; + + completer = Completer(); + onChangeDetected = (PreviewMapping updated) { + // The removed preview in baz.dart should not longer be included in the preview mapping. + expect(updated, expectedInitialMapping); + completer.complete(); + }; + + // Update the file with an existing preview to remove the preview and ensure it triggers + // the preview detector. + addNonPreviewContainingFile(projectRoot, 'baz.dart'); + await completer.future; + }); + }); +} + +const String previewContainingFileContents = ''' +@Preview() +// This isn't necessarily valid code. We're just looking for the annotation +WidgetPreview previews() => WidgetPreview(); +'''; + +const String nonPreviewContainingFileContents = ''' +String foo() => 'bar'; +''';