diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index b5683a9ea28..a35045bf5fa 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -285,8 +285,8 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C await _populatePreviewPubspec(rootProject: rootProject); } - final PreviewMapping initialPreviews = await _previewDetector.initialize(); - _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(initialPreviews); + final PreviewDependencyGraph graph = await _previewDetector.initialize(); + _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(graph); if (boolArg(kLaunchPreviewer)) { shutdownHooks.addShutdownHook(() async { @@ -306,7 +306,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C return FlutterCommandResult.success(); } - void onChangeDetected(PreviewMapping previews) { + void onChangeDetected(PreviewDependencyGraph previews) { _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(previews); logger.printStatus('Triggering reload based on change to preview set: $previews'); _widgetPreviewApp?.restart(); 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 index 16a94c333f7..6c332ed6477 100644 --- a/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart +++ b/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart @@ -14,7 +14,7 @@ import '../base/file_system.dart'; import '../project.dart'; import 'preview_detector.dart'; -typedef _PreviewMappingEntry = MapEntry>; +typedef _PreviewMappingEntry = MapEntry; /// Generates the Dart source responsible for importing widget previews from the developer's project /// into the widget preview scaffold. @@ -27,8 +27,6 @@ class PreviewCodeGenerator { /// project. final FlutterProject widgetPreviewScaffoldProject; - static const String generatedPreviewFilePath = 'lib/src/generated_preview.dart'; - static const String _kBuilderType = 'Builder'; static const String _kBuilderLibraryUri = 'package:flutter/widgets.dart'; static const String _kBuilderProperty = 'builder'; @@ -37,6 +35,9 @@ class PreviewCodeGenerator { static const String _kWidgetPreviewClass = 'WidgetPreview'; static const String _kWidgetPreviewLibraryUri = 'widget_preview.dart'; + static String getGeneratedPreviewFilePath(FileSystem fs) => + fs.path.join('lib', 'src', 'generated_preview.dart'); + /// Generates code used by the widget preview scaffold based on the preview instances listed in /// [previews]. /// @@ -83,7 +84,7 @@ class PreviewCodeGenerator { /// ), /// ]; /// ``` - void populatePreviewsInGeneratedPreviewScaffold(PreviewMapping previews) { + void populatePreviewsInGeneratedPreviewScaffold(PreviewDependencyGraph previews) { final cb.DartEmitter emitter = cb.DartEmitter.scoped(useNullSafetySyntax: true); final cb.Library lib = cb.Library( (cb.LibraryBuilder b) => b.body.addAll([ @@ -97,7 +98,7 @@ class PreviewCodeGenerator { ]), ); final File generatedPreviewFile = fs.file( - widgetPreviewScaffoldProject.directory.uri.resolve(generatedPreviewFilePath), + widgetPreviewScaffoldProject.directory.uri.resolve(getGeneratedPreviewFilePath(fs)), ); generatedPreviewFile.writeAsStringSync( // Format the generated file for readability, particularly during feature development. @@ -108,8 +109,8 @@ class PreviewCodeGenerator { } void _buildGeneratedPreviewMethod({ + required PreviewDependencyGraph previews, required cb.Allocator allocator, - required PreviewMapping previews, required cb.MethodBuilder builder, }) { final List previewExpressions = []; @@ -122,12 +123,17 @@ class PreviewCodeGenerator { }); for (final _PreviewMappingEntry( key: (path: String _, :Uri uri), - value: List previewMethods, + value: PreviewDependencyNode fileDetails, ) in sortedPreviews) { - for (final PreviewDetails preview in previewMethods) { + for (final PreviewDetails preview in fileDetails.filePreviews) { previewExpressions.add( - _buildPreviewWidget(allocator: allocator, preview: preview, uri: uri), + _buildPreviewWidget( + allocator: allocator, + preview: preview, + uri: uri, + fileDetails: fileDetails, + ), ); } } @@ -147,31 +153,45 @@ class PreviewCodeGenerator { required cb.Allocator allocator, required PreviewDetails preview, required Uri uri, + required PreviewDependencyNode fileDetails, }) { - cb.Expression previewWidget = cb - .refer(preview.functionName, uri.toString()) - .call([]); + cb.Expression previewWidget; + // TODO(bkonyi): clean up the error related code. + if (fileDetails.hasErrors) { + previewWidget = cb.refer('Text', 'package:flutter/material.dart').newInstance([ + cb.literalString('$uri has errors!'), + ]); + } else if (fileDetails.dependencyHasErrors) { + previewWidget = cb.refer('Text', 'package:flutter/material.dart').newInstance([ + cb.literalString('Dependency of $uri has errors!'), + ]); + } else { + previewWidget = cb.refer(preview.functionName, uri.toString()).call([]); - if (preview.isBuilder) { - previewWidget = cb.refer(_kBuilderType, _kBuilderLibraryUri).newInstance( - [], - {_kBuilderProperty: previewWidget}, - ); + if (preview.isBuilder) { + previewWidget = cb.refer(_kBuilderType, _kBuilderLibraryUri).newInstance( + [], + {_kBuilderProperty: previewWidget}, + ); + } + + if (preview.hasWrapper) { + previewWidget = _buildIdentifierReference( + preview.wrapper!, + ).call([previewWidget]); + } } - if (preview.hasWrapper) { - previewWidget = _buildIdentifierReference( - preview.wrapper!, - ).call([previewWidget]); - } previewWidget = cb.Method((cb.MethodBuilder previewBuilder) { previewBuilder.body = previewWidget.code; }).closure; - return cb - .refer(_kWidgetPreviewClass, _kWidgetPreviewLibraryUri) - .newInstance([], { + return cb.refer(_kWidgetPreviewClass, _kWidgetPreviewLibraryUri).newInstance( + [], + { + // TODO(bkonyi): try to display the preview name, even if the preview can't be displayed. + if (!fileDetails.dependencyHasErrors && !fileDetails.hasErrors) ...{ ...?_generateCodeFromAnalyzerExpression( allocator: allocator, key: PreviewDetails.kName, @@ -204,8 +224,10 @@ class PreviewCodeGenerator { expression: preview.localizations, isCallback: true, ), - _kBuilderProperty: previewWidget, - }); + }, + _kBuilderProperty: previewWidget, + }, + ); } Map? _generateCodeFromAnalyzerExpression({ @@ -229,7 +251,7 @@ class PreviewCodeGenerator { } /// Returns the import URI for the [analyzer.LibraryElement2] containing [element]. -String _elementToLibraryIdentifier(analyzer.Element2 element) => element.library2!.identifier; +String? _elementToLibraryIdentifier(analyzer.Element2? element) => element?.library2!.identifier; cb.Reference _buildIdentifierReference(analyzer.Identifier identifier) { return switch (identifier) { @@ -240,7 +262,7 @@ cb.Reference _buildIdentifierReference(analyzer.Identifier identifier) { } cb.Reference _buildSimpleIdentifierReference(analyzer.SimpleIdentifier identifier) { - return cb.refer(identifier.name, _elementToLibraryIdentifier(identifier.element!)); + return cb.refer(identifier.name, _elementToLibraryIdentifier(identifier.element)); } class AnalyzerAstToCodeBuilderVisitor extends analyzer.RecursiveAstVisitor { @@ -336,12 +358,12 @@ class AnalyzerAstToCodeBuilderVisitor extends analyzer.RecursiveAstVisitor>; +/// A mapping of file / library paths to dependency graph nodes containing details related to +/// previews defined within the file / library. +typedef PreviewDependencyGraph = Map; extension on Token { /// Convenience getter to identify tokens for private fields and functions. @@ -47,7 +51,6 @@ extension on String { bool get isDartFile => endsWith('.dart'); bool get isPubspec => endsWith('pubspec.yaml'); bool get doesContainDartTool => contains('.dart_tool'); - bool get isGeneratedPreviewFile => endsWith(PreviewCodeGenerator.generatedPreviewFilePath); } extension on ParsedUnitResult { @@ -55,6 +58,123 @@ extension on ParsedUnitResult { PreviewPath toPreviewPath() => (path: path, uri: uri); } +extension on Source { + /// Convenience method to package [fullName] and [uri] into a [PreviewPath] + PreviewPath toPreviewPath() => (path: fullName, uri: uri); +} + +/// Contains all the information related to a file being watched by [PreviewDetector]. +final class PreviewDependencyNode { + PreviewDependencyNode({required this.previewPath, required this.logger}); + + final Logger logger; + + /// The path and URI pointing to the file. + final PreviewPath previewPath; + + /// The list of previews contained within the file. + final List filePreviews = []; + + /// Files that import this file. + final Set dependedOnBy = {}; + + /// Files this file imports. + final Set dependsOn = {}; + + /// `true` if a transitive dependency has compile time errors. + /// + /// IMPORTANT NOTE: this flag will not be set if there is a compile time error found in a + /// transitive dependency outside the previewed project (e.g., in a path or Git dependency, or + /// a modified package). + // TODO(bkonyi): determine how to best handle compile time errors in non-analyzed dependencies. + bool dependencyHasErrors = false; + + /// `true` if this file contains compile time errors. + bool get hasErrors => errors.isNotEmpty; + + /// The set of errors found in this file. + final List errors = []; + + /// Determines the set of errors found in this file. + /// + /// Results in [errors] being populated with the latest set of errors for the file. + Future populateErrors({required AnalysisContext context}) async { + errors + ..clear() + ..addAll( + ((await context.currentSession.getErrors(previewPath.path)) as ErrorsResult).errors + .where((AnalysisError error) => error.severity == Severity.error) + .toList(), + ); + } + + /// Finds all previews defined in [compilationUnit] and adds them to [filePreviews]. + void findPreviews({required CompilationUnit compilationUnit}) { + // Iterate over the compilation unit's AST to find previews. + final PreviewVisitor visitor = PreviewVisitor(); + compilationUnit.visitChildren(visitor); + filePreviews + ..clear() + ..addAll(visitor.previewEntries); + } + + /// Updates the dependency [graph] based on changes to a compilation [unit]. + /// + /// This method is responsible for: + /// - Inserting new nodes into the graph when new dependencies are introduced + /// - Computing the set of upstream and downstream dependencies of [unit] + void updateDependencyGraph({ + required PreviewDependencyGraph graph, + required ResolvedUnitResult unit, + }) { + final Set updatedDependencies = {}; + final LibraryFragment fragment = unit.libraryFragment; + for (final LibraryImport importedLib in fragment.libraryImports2) { + for (final LibraryFragment importedFragment in importedLib.importedLibrary2!.fragments) { + if (importedFragment == fragment) { + // Don't include the current file as its own dependency. + continue; + } + final PreviewDependencyNode result = graph.putIfAbsent( + importedFragment.source.toPreviewPath(), + () => PreviewDependencyNode( + previewPath: importedFragment.source.toPreviewPath(), + logger: logger, + ), + ); + updatedDependencies.add(result); + } + } + + final Set removedDependencies = dependsOn.difference( + updatedDependencies, + ); + for (final PreviewDependencyNode removedDependency in removedDependencies) { + removedDependency.dependedOnBy.remove(this); + } + + dependsOn + ..clear() + ..addAll(updatedDependencies); + + dependencyHasErrors = false; + for (final PreviewDependencyNode dependency in updatedDependencies) { + dependency.dependedOnBy.add(this); + if (dependency.dependencyHasErrors || dependency.errors.isNotEmpty) { + logger.printWarning('Dependency ${dependency.previewPath.uri} has errors'); + dependencyHasErrors = true; + } + } + } + + @override + String toString() { + return '(errorCount: ${errors.length} dependencyHasErrors: $dependencyHasErrors ' + 'previews: $filePreviews ' + 'dependedOnBy: ${dependedOnBy.length})'; + } +} + /// Contains details related to a single preview instance. final class PreviewDetails { PreviewDetails({required this.functionName, required this.isBuilder}); @@ -102,11 +222,17 @@ final class PreviewDetails { Identifier? get wrapper => _wrapper; Identifier? _wrapper; + /// Set to `true` if `wrapper` is set. bool get hasWrapper => _wrapper != null; + /// A callback to return Material and Cupertino theming data to be applied + /// to the previewed `Widget`. Identifier? get theme => _theme; Identifier? _theme; + /// Sets the initial theme brightness. + /// + /// If not provided, the current system default brightness will be used. Expression? get brightness => _brightness; Expression? _brightness; @@ -186,12 +312,12 @@ class PreviewDetector { final Directory projectRoot; final FileSystem fs; final Logger logger; - final void Function(PreviewMapping) onChangeDetected; + final void Function(PreviewDependencyGraph) onChangeDetected; final void Function() onPubspecChangeDetected; StreamSubscription? _fileWatcher; final PreviewDetectorMutex _mutex = PreviewDetectorMutex(); - late final PreviewMapping _pathToPreviews; + final PreviewDependencyGraph _dependencyGraph = PreviewDependencyGraph(); late final AnalysisContextCollection collection = AnalysisContextCollection( includedPaths: [projectRoot.absolute.path], @@ -199,63 +325,21 @@ class PreviewDetector { ); /// Starts listening for changes to Dart sources under [projectRoot] and returns - /// the initial [PreviewMapping] for the project. - Future initialize() async { + /// the initial [PreviewDependencyGraph] for the project. + Future initialize() async { // Find the initial set of previews. - _pathToPreviews = await findPreviewFunctions(projectRoot); + await _findPreviewFunctions(projectRoot); + + // Determine which files have transitive dependencies with compile time errors. + _propagateErrors(); final Watcher watcher = Watcher(projectRoot.path); - _fileWatcher = watcher.events.listen((WatchEvent event) async { - final String eventPath = event.path; - // If the pubspec has changed, new dependencies or assets could have been added, requiring - // the preview scaffold's pubspec to be updated. - if (eventPath.isPubspec && !eventPath.doesContainDartTool) { - onPubspecChangeDetected(); - return; - } - // 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.isDartFile || eventPath.isGeneratedPreviewFile) { - return; - } - logger.printStatus('Detected change in $eventPath.'); - final PreviewMapping filePreviewsMapping = await findPreviewFunctions( - fs.file(Uri.file(event.path)), - ); - final bool hasExistingPreviews = - _pathToPreviews.keys.where((PreviewPath e) => e.path == event.path).isNotEmpty; - if (filePreviewsMapping.isEmpty && !hasExistingPreviews) { - // 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: PreviewPath location, - value: List filePreviews, - ) = filePreviewsMapping.entries.first; - logger.printStatus('Updated previews for ${location.uri}: $filePreviews'); - if (filePreviews.isNotEmpty) { - final List? currentPreviewsForFile = _pathToPreviews[location]; - if (filePreviews != currentPreviewsForFile) { - _pathToPreviews[location] = filePreviews; - } - } - } else { - // The file previously had previews that were removed. - logger.printStatus('Previews removed from $eventPath'); - _pathToPreviews.removeWhere((PreviewPath e, _) => e.path == eventPath); - } - onChangeDetected(_pathToPreviews); - }); + _fileWatcher = watcher.events.listen(_onFileSystemEvent); + // Wait for file watcher to finish initializing, otherwise we might miss changes and cause // tests to flake. await watcher.ready; - return _pathToPreviews; + return _dependencyGraph; } Future dispose() async { @@ -267,66 +351,200 @@ class PreviewDetector { }); } - /// Search for functions annotated with `@Preview` in the current project. - Future findPreviewFunctions(FileSystemEntity entity) async { - final PreviewMapping previews = PreviewMapping(); + Future _onFileSystemEvent(WatchEvent event) async { // Only process one FileSystemEntity at a time so we don't invalidate an AnalysisSession that's // in use when we call context.changeFile(...). await _mutex.runGuarded(() async { - // TODO(bkonyi): this can probably be replaced by a call to collection.contextFor(...), - // but we need to figure out the right path format for Windows. - for (final AnalysisContext context in collection.contexts) { - logger.printStatus('Finding previews in ${entity.path}...'); + final String eventPath = event.path; + // If the pubspec has changed, new dependencies or assets could have been added, requiring + // the preview scaffold's pubspec to be updated. + if (eventPath.isPubspec && !eventPath.doesContainDartTool) { + onPubspecChangeDetected(); + return; + } + // 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.isDartFile || eventPath.doesContainDartTool) { + return; + } - // If we're processing a single file, it means the file watcher detected a - // change in a Dart source. We need to notify the analyzer that this file - // has changed so it can reanalyze the file. - if (entity is File) { - context.changeFile(entity.path); - await context.applyPendingFileChanges(); - } + // TODO(bkonyi): investigate batching change detection to handle cases where directories are + // deleted or moved. Currently, analysis, preview detection, and error propagation will be + // performed for each file contained in a modified directory (i.e., moved or deleted). This + // will likely cause performance issues when performing large directory operations, + // particularly for large projects. + // + // Unfortunately, package:watcher doesn't report changes to directories, only individual + // files. However, it does have a batching mechanism under the hood in the BatchEvents + // extension which may be worth using here. - for (final String filePath in context.contextRoot.analyzedFiles()) { - logger.printTrace('Checking file: $filePath'); - if (!filePath.isDartFile || !filePath.startsWith(entity.path)) { - logger.printTrace('Skipping $filePath'); - continue; - } - final SomeResolvedLibraryResult lib = await context.currentSession.getResolvedLibrary( - filePath, + // We need to notify the analyzer that this file has changed so it can reanalyze the file. + final AnalysisContext context = collection.contexts.single; + final File file = fs.file(eventPath); + context.changeFile(file.path); + await context.applyPendingFileChanges(); + + logger.printStatus('Detected change in $eventPath.'); + if (event.type == ChangeType.REMOVE) { + await _fileRemoved(context: context, eventPath: eventPath); + } else { + await _fileAddedOrUpdated(context: context, eventPath: eventPath); + } + // Determine which files have transitive dependencies with compile time errors. + _propagateErrors(); + onChangeDetected(_dependencyGraph); + }); + } + + Future _fileAddedOrUpdated({ + required AnalysisContext context, + required String eventPath, + }) async { + final PreviewDependencyGraph filePreviewsMapping = await _findPreviewFunctions( + fs.file(eventPath), + ); + 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: PreviewPath location, + value: PreviewDependencyNode fileDetails, + ) = filePreviewsMapping.entries.single; + logger.printStatus('Updated previews for ${location.uri}: ${fileDetails.filePreviews}'); + _dependencyGraph[location] = fileDetails; + } else { + // Why is this working with an empty file system on Linux? + final PreviewPath removedPath = _dependencyGraph.keys.firstWhere( + (PreviewPath element) => element.path == eventPath, + ); + // The file previously had previews that were removed. + logger.printStatus('Previews removed from $eventPath'); + _dependencyGraph.remove(removedPath); + } + } + + /// Search for functions annotated with `@Preview` in the current project. + Future _findPreviewFunctions(FileSystemEntity entity) async { + final PreviewDependencyGraph updatedPreviews = PreviewDependencyGraph(); + + final AnalysisContext context = collection.contexts.single; + logger.printStatus('Finding previews in ${entity.path}...'); + for (final String filePath in context.contextRoot.analyzedFiles()) { + logger.printTrace('Checking file: $filePath'); + if (!filePath.isDartFile || !filePath.startsWith(entity.path)) { + logger.printTrace('Skipping $filePath'); + continue; + } + final SomeResolvedLibraryResult lib = await context.currentSession.getResolvedLibrary( + filePath, + ); + // TODO(bkonyi): ensure this can handle part files. + if (lib is ResolvedLibraryResult) { + for (final ResolvedUnitResult libUnit in lib.units) { + final PreviewPath previewPath = libUnit.toPreviewPath(); + final PreviewDependencyNode previewForFile = _dependencyGraph.putIfAbsent( + previewPath, + () => PreviewDependencyNode(previewPath: previewPath, logger: logger), ); - // TODO(bkonyi): ensure this can handle part files. - if (lib is ResolvedLibraryResult) { - for (final ResolvedUnitResult libUnit in lib.units) { - final List previewEntries = - previews[libUnit.toPreviewPath()] ?? []; - final PreviewVisitor visitor = PreviewVisitor(); - libUnit.unit.visitChildren(visitor); - previewEntries.addAll(visitor.previewEntries); - if (previewEntries.isNotEmpty) { - previews[libUnit.toPreviewPath()] = previewEntries; - } - } - } else { - logger.printWarning('Unknown library type at $filePath: $lib'); - } + previewForFile.updateDependencyGraph(graph: _dependencyGraph, unit: libUnit); + updatedPreviews[previewPath] = previewForFile; + + // Check for errors in the compilation unit. + await previewForFile.populateErrors(context: context); + + // Iterate over the compilation unit's AST to find previews. + previewForFile.findPreviews(compilationUnit: libUnit.unit); + } + } else { + logger.printWarning('Unknown library type at $filePath: $lib'); + } + } + final int previewCount = updatedPreviews.values.fold( + 0, + (int count, PreviewDependencyNode value) => count + value.filePreviews.length, + ); + logger.printStatus('Found $previewCount ${pluralize('preview', previewCount)}.'); + return updatedPreviews; + } + + /// Handles the deletion of a file from the target project. + /// + /// This involves removing the relevant [PreviewDependencyNode] from the dependency graph as well + /// as checking for newly introduced errors in files which had a transitive dependency on the + /// removed file. + Future _fileRemoved({required AnalysisContext context, required String eventPath}) async { + final File file = fs.file(eventPath); + final PreviewPath previewPath = _dependencyGraph.keys.firstWhere( + (PreviewPath e) => e.path == file.path, + ); + + final Set visitedNodes = {}; + Future populateErrorsDownstream({required PreviewDependencyNode node}) async { + visitedNodes.add(node); + await node.populateErrors(context: context); + for (final PreviewDependencyNode downstream in node.dependedOnBy) { + if (!visitedNodes.contains(downstream)) { + await populateErrorsDownstream(node: downstream); } } - final int previewCount = previews.values.fold( - 0, - (int count, List value) => count + value.length, - ); - logger.printStatus('Found $previewCount ${pluralize('preview', previewCount)}.'); - }); - return previews; + } + + final PreviewDependencyNode node = _dependencyGraph.remove(previewPath)!; + + // Removing a file can cause errors to be introduced down the dependency chain, so all + // downstream dependencies need to be checked for errors. + for (final PreviewDependencyNode downstream in node.dependedOnBy) { + downstream.dependsOn.remove(node); + await populateErrorsDownstream(node: node); + } + for (final PreviewDependencyNode upstream in node.dependsOn) { + upstream.dependedOnBy.remove(node); + } + } + + /// Determines which files in the project have transitive dependencies containing compile time + /// errors, setting [PreviewDependencyNode.dependencyHasErrors] to true for files which + /// would cause errors if imported into the previewer. + // TODO(bkonyi): allow for processing a subset of files. + void _propagateErrors() { + final PreviewDependencyGraph previews = _dependencyGraph; + + // Reset the error state for all dependencies. + for (final PreviewDependencyNode fileDetails in previews.values) { + if (fileDetails.errors.isEmpty) { + fileDetails.dependencyHasErrors = false; + } + } + + void propagateErrorsHelper(PreviewDependencyNode errorContainingNode) { + for (final PreviewDependencyNode importer in errorContainingNode.dependedOnBy) { + if (importer.dependencyHasErrors) { + // This dependency path has already been processed. + continue; + } + logger.printWarning('Propagating errors to: ${importer.previewPath.path}'); + importer.dependencyHasErrors = true; + propagateErrorsHelper(importer); + } + } + + // Find the files that have errors and mark each of their downstream dependencies as having + // a dependency containing errors. + for (final PreviewDependencyNode nodeDetails in previews.values) { + if (nodeDetails.errors.isNotEmpty) { + logger.printWarning('${nodeDetails.previewPath.path} has errors.'); + propagateErrorsHelper(nodeDetails); + } + } } } /// Visitor which detects previews and extracts [PreviewDetails] for later code /// generation. -// TODO(bkonyi): this visitor needs better error detection to identify invalid -// previews and report them to the previewer without causing the entire -// environment to shutdown or fail to render valid previews. class PreviewVisitor extends RecursiveAstVisitor { final List previewEntries = []; diff --git a/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_code_generator_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_code_generator_test.dart index aab53c6e25f..bf6189e1a17 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_code_generator_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_code_generator_test.dart @@ -27,6 +27,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter flutter_test: sdk: flutter '''; @@ -90,7 +92,9 @@ Widget wrapper(Widget widget) { '''; const String kLocalizationsDart = ''' +import 'dart:ui'; import 'package:flutter/widget_previews.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; PreviewLocalizationsData myLocalizations() { return PreviewLocalizationsData( @@ -107,7 +111,26 @@ PreviewLocalizationsData myLocalizations() { localeListResolutionCallback: (List? locales, Iterable supportedLocales) => null, localeResolutionCallback: (Locale? locale, Iterable supportedLocales) => null, ); -} +}'''; + +const String kErrorContainingLibrary = ''' +invalid-symbol; + +import 'package:flutter/widgets.dart'; +import 'package:flutter/widget_previews.dart'; + +@Preview() +Widget preview() => Text('Error in library'); +'''; + +const String kTransitiveErrorLibrary = ''' +import 'error.dart'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter/widget_previews.dart'; + +@Preview() +Widget preview() => Text('Error in dependency'); '''; // Note: this test isn't under the general.shard since tests under that directory @@ -120,13 +143,16 @@ void main() { late PreviewCodeGenerator codeGenerator; late FlutterProject project; late PreviewDetector previewDetector; + // We perform this initialization just so we can build the generated file path for test + // descriptions. + LocalFileSystem fs = LocalFileSystem.test(signals: Signals.test()); setUp(() async { Cache.flutterRoot = getFlutterRoot(); // 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. - final FileSystem fs = LocalFileSystem.test(signals: Signals.test()); + fs = LocalFileSystem.test(signals: Signals.test()); final BufferLogger logger = BufferLogger.test(); FlutterManifest.empty(logger: logger); final Directory projectDir = @@ -138,7 +164,9 @@ void main() { ..childFile('lib/src/brightness.dart').writeAsStringSync(kBrightnessDart) ..childFile('lib/src/localizations.dart').writeAsStringSync(kLocalizationsDart) ..childFile('lib/src/wrapper.dart').writeAsStringSync(kWrapperDart) - ..childFile('lib/src/theme.dart').writeAsStringSync(kThemeDart); + ..childFile('lib/src/theme.dart').writeAsStringSync(kThemeDart) + ..childFile('lib/src/error.dart').writeAsStringSync(kErrorContainingLibrary) + ..childFile('lib/src/transitive_error.dart').writeAsStringSync(kTransitiveErrorLibrary); project = FlutterProject.fromDirectoryTest(projectDir); previewDetector = PreviewDetector( projectRoot: projectDir, @@ -166,16 +194,14 @@ void main() { }); testUsingContext( - 'correctly generates ${PreviewCodeGenerator.generatedPreviewFilePath}', + 'correctly generates ${PreviewCodeGenerator.getGeneratedPreviewFilePath(fs)}', () async { // Check that the generated preview file doesn't exist yet. final File generatedPreviewFile = project.directory.childFile( - PreviewCodeGenerator.generatedPreviewFilePath, + PreviewCodeGenerator.getGeneratedPreviewFilePath(fs), ); expect(generatedPreviewFile, isNot(exists)); - final PreviewMapping details = await previewDetector.findPreviewFunctions( - project.directory, - ); + final PreviewDependencyGraph details = await previewDetector.initialize(); // Populate the generated preview file. codeGenerator.populatePreviewsInGeneratedPreviewScaffold(details); @@ -199,6 +225,7 @@ import 'package:foo_project/src/theme.dart' as _i6; import 'package:foo_project/src/localizations.dart' as _i7; import 'package:foo_project/src/wrapper.dart' as _i8; import 'package:flutter/widgets.dart' as _i9; +import 'package:flutter/material.dart' as _i10; List<_i1.WidgetPreview> previews() => [ _i1.WidgetPreview(builder: () => _i2.preview()), @@ -219,13 +246,19 @@ List<_i1.WidgetPreview> previews() => [ localizations: _i7.myLocalizations(), builder: () => _i8.wrapper(_i9.Builder(builder: _i3.barPreview3())), ), + _i1.WidgetPreview( + builder: () => + _i10.Text('package:foo_project/src/error.dart has errors!')), + _i1.WidgetPreview( + builder: () => _i10.Text( + 'Dependency of package:foo_project/src/transitive_error.dart has errors!')), ]; '''; expect(generatedPreviewFile.readAsStringSync(), expectedGeneratedPreviewFileContents); // Regenerate the generated file with no previews. codeGenerator.populatePreviewsInGeneratedPreviewScaffold( - const >{}, + const {}, ); expect(generatedPreviewFile, exists); diff --git a/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_detector_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_detector_test.dart index 5b764fb6a2b..d1627d8445e 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_detector_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_detector_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:analyzer/dart/ast/ast.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/base/platform.dart'; @@ -24,28 +25,103 @@ Directory createBasicProjectStructure(FileSystem fs) { return fs.systemTempDirectory.createTempSync('root'); } +String platformPath(List pathSegments) => + pathSegments.join(const LocalPlatform().pathSeparator); + void populatePubspec(Directory projectRoot, String contents) { projectRoot.childFile('pubspec.yaml') ..createSync(recursive: true) ..writeAsStringSync(contents); } -PreviewPath addPreviewContainingFile(Directory projectRoot, List path) { - final File file = - projectRoot.childDirectory('lib').childFile(path.join(const LocalPlatform().pathSeparator)) - ..createSync(recursive: true) - ..writeAsStringSync(previewContainingFileContents); - return (path: file.path, uri: file.uri); +extension on PreviewDependencyGraph { + Map get nodesWithPreviews { + return Map.fromEntries( + entries.where( + (MapEntry element) => + element.value.filePreviews.isNotEmpty, + ), + ); + } } -PreviewPath addNonPreviewContainingFile(Directory projectRoot, List path) { - final File file = - projectRoot.childDirectory('lib').childFile(path.join(const LocalPlatform().pathSeparator)) - ..createSync(recursive: true) - ..writeAsStringSync(nonPreviewContainingFileContents); - return (path: file.path, uri: file.uri); +const String previewContainingFileContents = ''' +@Preview(name: 'Top-level preview') +Widget previews() => Text('Foo'); + +@Preview(name: 'Builder preview') +WidgetBuilder builderPreview() { + return (BuildContext context) { + return Text('Builder'); + }; } +Widget testWrapper(Widget child) { + return child; +} + +PreviewThemeData theming() => PreviewThemeData( + materialLight: ThemeData(colorScheme: ColorScheme.light(primary: Colors.red)), + materialDark: ThemeData(colorScheme: ColorScheme.dark(primary: Colors.blue)), + cupertinoLight: CupertinoThemeData(primaryColor: Colors.yellow), + cupertinoDark: CupertinoThemeData(primaryColor: Colors.purple), +); + +PreviewLocalizationsData localizations() { + return PreviewLocalizationsData( + locale: Locale('en'), + localizationsDelegates: [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: [ + Locale('en'), // English + Locale('es'), // Spanish + ], + localeListResolutionCallback: + (List? locales, Iterable supportedLocales) => null, + localeResolutionCallback: (Locale? locale, Iterable supportedLocales) => null, + ); +} + +const String kAttributesPreview = 'Attributes preview'; +@Preview( + name: kAttributesPreview, + size: Size(100.0, 100), + textScaleFactor: 2.0, + wrapper: testWrapper, + theme: theming, + brightness: Brightness.dark, + localizations: localizations, +) +Widget attributesPreview() { + return Text('Attributes'); +} + +class MyWidget extends StatelessWidget { + @Preview(name: 'Constructor preview') + MyWidget.preview(); + + @Preview(name: 'Factory constructor preview') + MyWidget.factoryPreview() => MyWidget.preview(); + + @Preview(name: 'Static preview') + static Widget previewStatic() => Text('Static'); + + @override + Widget build(BuildContext context) { + return Text('MyWidget'); + } +} +'''; + +const String nonPreviewContainingFileContents = ''' +String foo() => 'bar'; +'''; + +typedef TestSource = ({String name, String source}); + void main() { group('$PreviewDetector', () { // Note: we don't use a MemoryFileSystem since we don't have a way to @@ -55,17 +131,142 @@ void main() { late Logger logger; late PreviewDetector previewDetector; late Directory projectRoot; - void Function(PreviewMapping)? onChangeDetected; + void Function(PreviewDependencyGraph)? onChangeDetectedImpl; void Function()? onPubspecChangeDetected; - void onChangeDetectedRoot(PreviewMapping mapping) { - onChangeDetected!(mapping); + void onChangeDetectedRoot(PreviewDependencyGraph mapping) { + onChangeDetectedImpl!(mapping); } void onPubspecChangeDetectedRoot() { onPubspecChangeDetected!(); } + PreviewPath previewPathForFile(String path) { + final File file = projectRoot.childDirectory('lib').childFile(path); + return (path: file.path, uri: file.uri); + } + + PreviewPath addProjectFile(Object path, String contents) { + final PreviewPath previewPath = switch (path) { + final String previewPath => previewPathForFile(previewPath), + final PreviewPath previewPath => previewPath, + _ => throw StateError('path must be either PreviewPath or String: ${path.runtimeType}'), + }; + + fs.file(previewPath.path) + ..createSync(recursive: true) + ..writeAsStringSync(contents); + return previewPath; + } + + void removeProjectFile(Object path) { + final PreviewPath previewPath = switch (path) { + final String previewPath => previewPathForFile(previewPath), + final PreviewPath previewPath => previewPath, + _ => throw StateError('path must be either PreviewPath or String: ${path.runtimeType}'), + }; + + fs.file(previewPath.path).deleteSync(); + } + + void removeProjectDirectory(String path) { + fs.directory(path).deleteSync(recursive: true); + } + + PreviewPath addPreviewContainingFile(Object previewPath) => + addProjectFile(previewPath, previewContainingFileContents); + + PreviewPath addNonPreviewContainingFile(Object previewPath) => + addProjectFile(previewPath, nonPreviewContainingFileContents); + + Future waitForChangeDetected({ + required void Function(PreviewDependencyGraph) onChangeDetected, + required void Function() changeOperation, + }) async { + final Completer completer = Completer(); + onChangeDetectedImpl = (PreviewDependencyGraph updated) { + if (completer.isCompleted) { + return; + } + onChangeDetected(updated); + completer.complete(); + }; + changeOperation(); + await completer.future; + } + + void expectPreviewDependencyGraphIsWellFormed( + PreviewDependencyGraph graph, { + Set expectedFilesWithErrors = const {}, + }) { + final Set nodesWithErrors = {}; + for (final PreviewDependencyNode node in graph.values) { + expect(fs.file(node.previewPath.path), exists); + if (node.hasErrors) { + nodesWithErrors.add(node); + } + for (final PreviewDependencyNode upstream in node.dependedOnBy) { + expect(upstream.dependsOn, contains(node)); + } + for (final PreviewDependencyNode downstream in node.dependsOn) { + expect(downstream.dependedOnBy, contains(node)); + } + } + + // Validates that all upstream dependencies are marked as having a transitive dependency + // containing errors. + final Set filesWithTransitiveErrors = {}; + void dependencyHasErrorsValidator(PreviewDependencyNode node) { + filesWithTransitiveErrors.add(node.previewPath); + expect(node.dependencyHasErrors, true); + node.dependedOnBy.forEach(dependencyHasErrorsValidator); + } + + for (final PreviewDependencyNode node in nodesWithErrors) { + filesWithTransitiveErrors.add(node.previewPath); + node.dependedOnBy.forEach(dependencyHasErrorsValidator); + } + + // Verify we've found all the files expected to have transitive errors. + expect(filesWithTransitiveErrors, expectedFilesWithErrors); + } + + Future expectHasErrors({ + required void Function() changeOperation, + required Set filesWithErrors, + }) async { + await waitForChangeDetected( + onChangeDetected: + (PreviewDependencyGraph updated) => expectPreviewDependencyGraphIsWellFormed( + updated, + expectedFilesWithErrors: filesWithErrors, + ), + changeOperation: changeOperation, + ); + } + + Future expectHasNoErrors({required void Function() changeOperation}) async { + await expectHasErrors( + changeOperation: changeOperation, + filesWithErrors: const {}, + ); + } + + void expectContainsPreviews( + Map actual, + Map> expected, + ) { + for (final MapEntry>( + key: PreviewPath previewPath, + value: List filePreviews, + ) + in expected.entries) { + expect(actual.containsKey(previewPath), true); + expect(actual[previewPath]!.filePreviews, filePreviews); + } + } + setUp(() { fs = LocalFileSystem.test(signals: Signals.test()); projectRoot = createBasicProjectStructure(fs); @@ -82,17 +283,17 @@ void main() { tearDown(() async { await previewDetector.dispose(); projectRoot.deleteSync(recursive: true); - onChangeDetected = null; + onChangeDetectedImpl = null; }); testUsingContext('can detect previews in existing files', () async { final List previewFiles = [ - addPreviewContainingFile(projectRoot, ['foo.dart']), - addPreviewContainingFile(projectRoot, ['src', 'bar.dart']), + addPreviewContainingFile('foo.dart'), + addPreviewContainingFile(platformPath(['src', 'bar.dart'])), ]; - addNonPreviewContainingFile(projectRoot, ['baz.dart']); - final PreviewMapping mapping = await previewDetector.findPreviewFunctions(projectRoot); - expect(mapping.keys.toSet(), previewFiles.toSet()); + addNonPreviewContainingFile('baz.dart'); + final PreviewDependencyGraph mapping = await previewDetector.initialize(); + expect(mapping.nodesWithPreviews.keys, unorderedMatches(previewFiles)); }); testUsingContext('can detect previews in updated files', () async { @@ -138,44 +339,36 @@ void main() { // Create two files with existing previews and one without. final Map> expectedInitialMapping = >{ - addPreviewContainingFile(projectRoot, ['foo.dart']): expectedPreviewDetails, - addPreviewContainingFile(projectRoot, ['src', 'bar.dart']): + addPreviewContainingFile('foo.dart'): expectedPreviewDetails, + addPreviewContainingFile(platformPath(['src', 'bar.dart'])): expectedPreviewDetails, }; - final PreviewPath nonPreviewContainingFile = addNonPreviewContainingFile( - projectRoot, - ['baz.dart'], - ); + final PreviewPath nonPreviewContainingFile = addNonPreviewContainingFile('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: expectedPreviewDetails, - }); - completer.complete(); - }; // Initialize the file watcher. - final PreviewMapping initialPreviews = await previewDetector.initialize(); - expect(initialPreviews, expectedInitialMapping); + final PreviewDependencyGraph initialPreviews = await previewDetector.initialize(); + expectContainsPreviews(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(); - }; + await waitForChangeDetected( + onChangeDetected: (PreviewDependencyGraph updated) { + // The new preview in baz.dart should be included in the preview mapping. + expectContainsPreviews(updated, >{ + ...expectedInitialMapping, + nonPreviewContainingFile: expectedPreviewDetails, + }); + }, + changeOperation: () => addPreviewContainingFile('baz.dart'), + ); // 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; + await waitForChangeDetected( + onChangeDetected: (PreviewDependencyGraph updated) { + // The removed preview in baz.dart should not longer be included in the preview mapping. + expectContainsPreviews(updated, expectedInitialMapping); + }, + changeOperation: () => addNonPreviewContainingFile('baz.dart'), + ); }); testUsingContext('can detect previews in newly added files', () async { @@ -219,28 +412,24 @@ void main() { ]; // The initial mapping should be empty as there's no files containing previews. - final PreviewMapping expectedInitialMapping = >{}; + final PreviewDependencyGraph expectedInitialMapping = {}; - final Completer completer = Completer(); late final PreviewPath previewContainingFilePath; - onChangeDetected = (PreviewMapping updated) { - if (completer.isCompleted) { - return; - } - // The new previews in baz.dart should be included in the preview mapping. - expect(updated, >{ - previewContainingFilePath: expectedPreviewDetails, - }); - completer.complete(); - }; // Initialize the file watcher. - final PreviewMapping initialPreviews = await previewDetector.initialize(); + final PreviewDependencyGraph initialPreviews = await previewDetector.initialize(); expect(initialPreviews, expectedInitialMapping); - // Create baz.dart, which contains previews. - previewContainingFilePath = addPreviewContainingFile(projectRoot, ['baz.dart']); - await completer.future; + await waitForChangeDetected( + onChangeDetected: (PreviewDependencyGraph updated) { + // The new previews in baz.dart should be included in the preview mapping. + expectContainsPreviews(updated, >{ + previewContainingFilePath: expectedPreviewDetails, + }); + }, + // Create baz.dart, which contains previews. + changeOperation: () => previewContainingFilePath = addPreviewContainingFile('baz.dart'), + ); }); testUsingContext('can detect changes in the pubspec.yaml', () async { @@ -252,13 +441,185 @@ void main() { completer.complete(); }; // Initialize the file watcher. - final PreviewMapping initialPreviews = await previewDetector.initialize(); + final PreviewDependencyGraph initialPreviews = await previewDetector.initialize(); expect(initialPreviews, isEmpty); // Change the contents of the pubspec and verify the callback is invoked. populatePubspec(projectRoot, 'foo'); await completer.future; }); + + testUsingContext('dependency graph cycle smoke test', () async { + // Simple test to ensure graph cycles don't cause infinite recursion during traversal. + const TestSource a = (name: 'foo.dart', source: "import 'bar.dart';"); + const TestSource b = (name: 'bar.dart', source: "import 'foo.dart';"); + + final Set projectFiles = { + addProjectFile(a.name, a.source), + addProjectFile(b.name, b.source), + }; + final PreviewDependencyGraph graph = await previewDetector.initialize(); + expect(graph.keys, containsAll(projectFiles)); + expectPreviewDependencyGraphIsWellFormed(graph); + }); + + group('dependency errors', () { + const TestSource main = ( + name: 'main.dart', + source: ''' +import 'foo.dart'; +void main() => foo(); +''', + ); + const TestSource foo = ( + name: 'foo.dart', + source: ''' +import 'bar.dart'; +void foo() => bar(); +''', + ); + + const TestSource bar = ( + name: 'bar.dart', + source: ''' +void bar() => null; +''', + ); + + late Set initialProjectFiles; + + setUp(() { + initialProjectFiles = { + addProjectFile(main.name, main.source), + addProjectFile(foo.name, foo.source), + addProjectFile(bar.name, bar.source), + }; + }); + + testUsingContext('entire directory removed', () async { + final PreviewPath c = addProjectFile( + platformPath(['dir', 'c.dart']), + 'void foo() {}', + ); + final Set directoryFiles = { + addProjectFile(platformPath(['dir', 'a.dart']), "import 'b.dart';"), + addProjectFile(platformPath(['dir', 'b.dart']), "import 'c.dart';"), + c, + }; + + final PreviewDependencyGraph initialGraph = await previewDetector.initialize(); + expect( + initialGraph.keys, + containsAll({...initialProjectFiles, ...directoryFiles}), + ); + + // Validate the files in dir/ all have transistive errors. + await expectHasErrors( + changeOperation: () => addProjectFile(c, 'invalid-symbol'), + filesWithErrors: directoryFiles, + ); + + // Delete dir/. This will cause 3 change events to be reported, one for each file in the + // deleted directory. Until all 3 events have been processed, the dependency graph will not + // be consistent as the files have already been deleted on disk. + int changeCount = 0; + final Completer completer = Completer(); + onChangeDetectedImpl = (PreviewDependencyGraph _) { + changeCount++; + if (changeCount >= 3) { + completer.complete(); + } + }; + removeProjectDirectory(fs.path.dirname(directoryFiles.first.path)); + await completer.future; + + // Verify the graph is well formed once the deletion events have been processed. + expectPreviewDependencyGraphIsWellFormed(initialGraph); + }); + + testUsingContext('smoke test', () async { + final PreviewDependencyGraph initialGraph = await previewDetector.initialize(); + expect(initialGraph.keys, containsAll(initialProjectFiles)); + + // Verify there's no errors in the project. + for (final PreviewDependencyNode node in initialGraph.values) { + expect(node.dependencyHasErrors, false); + expect(node.hasErrors, false); + } + + // Introduce an error into bar.dart and verify files that have transitive dependencies on + // bar.dart are marked as having errors. + await expectHasErrors( + changeOperation: () => addProjectFile(bar.name, 'invalid-symbol'), + filesWithErrors: initialProjectFiles, + ); + + // Remove the error from bar.dart and ensure no files have errors. + await expectHasNoErrors(changeOperation: () => addProjectFile(bar.name, bar.source)); + }); + + testUsingContext('file with error added and removed', () async { + final PreviewDependencyGraph initialGraph = await previewDetector.initialize(); + expect(initialGraph.keys, containsAll(initialProjectFiles)); + + // Verify there's no errors in the project. + for (final PreviewDependencyNode node in initialGraph.values) { + expect(node.dependencyHasErrors, false); + expect(node.hasErrors, false); + } + + // Add baz.dart, which contains errors. Since no other files import baz.dart, it should be + // the only file with errors. + const TestSource baz = (name: 'baz.dart', source: 'invalid.symbol'); + final PreviewPath bazPath = previewPathForFile(baz.name); + await expectHasErrors( + changeOperation: () => addProjectFile(bazPath, baz.source), + filesWithErrors: {bazPath}, + ); + + // Update main.dart to import baz.dart. All files in the project should now have transitive + // errors. + await expectHasErrors( + changeOperation: () => addProjectFile(main.name, "import '${baz.name}';\n${main.source}"), + filesWithErrors: {previewPathForFile(main.name), bazPath}, + ); + + // Delete baz.dart. main.dart should continue to have an error. + await expectHasErrors( + changeOperation: () => removeProjectFile(baz.name), + filesWithErrors: {previewPathForFile(main.name)}, + ); + + // Restore main.dart to remove the baz.dart import and clear the errors. + await expectHasNoErrors(changeOperation: () => addProjectFile(main.name, main.source)); + }); + + testUsingContext( + 'error added into dependency in the middle of the graph and removed', + () async { + final PreviewDependencyGraph initialGraph = await previewDetector.initialize(); + expect(initialGraph.keys, containsAll(initialProjectFiles)); + + // Verify there's no errors in the project. + for (final PreviewDependencyNode node in initialGraph.values) { + expect(node.dependencyHasErrors, false); + expect(node.hasErrors, false); + } + + // Add baz.dart, which contains errors. Since no other files import baz.dart, it should be + // the only file with errors. + final PreviewPath fooPath = previewPathForFile(foo.name); + final PreviewPath mainPath = previewPathForFile(main.name); + await expectHasErrors( + changeOperation: () => addProjectFile(fooPath, 'invalid-symbol;${foo.source}'), + filesWithErrors: {fooPath, mainPath}, + ); + + // Delete baz.dart. main.dart should continue to have an error. + await expectHasNoErrors(changeOperation: () => addProjectFile(fooPath, foo.source)); + }, + ); + }); }); } @@ -370,78 +731,3 @@ class PreviewDetailsMatcher extends Matcher { return matches; } } - -const String previewContainingFileContents = ''' -@Preview(name: 'Top-level preview') -Widget previews() => Text('Foo'); - -@Preview(name: 'Builder preview') -WidgetBuilder builderPreview() { - return (BuildContext context) { - return Text('Builder'); - }; -} - -Widget testWrapper(Widget child) { - return child; -} - -PreviewThemeData theming() => PreviewThemeData( - materialLight: ThemeData(colorScheme: ColorScheme.light(primary: Colors.red)), - materialDark: ThemeData(colorScheme: ColorScheme.dark(primary: Colors.blue)), - cupertinoLight: CupertinoThemeData(primaryColor: Colors.yellow), - cupertinoDark: CupertinoThemeData(primaryColor: Colors.purple), -); - -PreviewLocalizationsData localizations() { - return PreviewLocalizationsData( - locale: Locale('en'), - localizationsDelegates: [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: [ - Locale('en'), // English - Locale('es'), // Spanish - ], - localeListResolutionCallback: - (List? locales, Iterable supportedLocales) => null, - localeResolutionCallback: (Locale? locale, Iterable supportedLocales) => null, - ); -} - -const String kAttributesPreview = 'Attributes preview'; -@Preview( - name: kAttributesPreview, - size: Size(100.0, 100), - textScaleFactor: 2.0, - wrapper: testWrapper, - theme: theming, - brightness: Brightness.dark, - localizations: localizations, -) -Widget attributesPreview() { - return Text('Attributes'); -} - -class MyWidget extends StatelessWidget { - @Preview(name: 'Constructor preview') - MyWidget.preview(); - - @Preview(name: 'Factory constructor preview') - MyWidget.factoryPreview() => MyWidget.preview(); - - @Preview(name: 'Static preview') - static Widget previewStatic() => Text('Static'); - - @override - Widget build(BuildContext context) { - return Text('MyWidget'); - } -} -'''; - -const String nonPreviewContainingFileContents = ''' -String foo() => 'bar'; -'''; 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 df33b7ef32c..208fb574791 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 @@ -32,7 +32,9 @@ void main() { late LoggingProcessManager loggingProcessManager; late FakeStdio mockStdio; late Logger logger; - late LocalFileSystem fs; + // We perform this initialization just so we can build the generated file path for test + // descriptions. + LocalFileSystem fs = LocalFileSystem.test(signals: Signals.test()); late BotDetector botDetector; late Platform platform; @@ -202,6 +204,9 @@ void main() { ); const String samplePreviewFile = ''' +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; + @Preview(name: 'preview') Widget preview() => Text('Foo');'''; @@ -219,7 +224,7 @@ List<_i1.WidgetPreview> previews() => [ '''; testUsingContext( - 'start finds existing previews and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}', + 'start finds existing previews and injects them into ${PreviewCodeGenerator.getGeneratedPreviewFilePath(fs)}', () async { final Directory rootProject = await createRootProject(); final Directory widgetPreviewScaffoldDir = widgetPreviewScaffoldFromRootProject( @@ -231,7 +236,7 @@ List<_i1.WidgetPreview> previews() => [ .writeAsStringSync(samplePreviewFile); final File generatedFile = widgetPreviewScaffoldDir.childFile( - PreviewCodeGenerator.generatedPreviewFilePath, + PreviewCodeGenerator.getGeneratedPreviewFilePath(fs), ); await startWidgetPreview(rootProject: rootProject); @@ -251,7 +256,7 @@ List<_i1.WidgetPreview> previews() => [ ); testUsingContext( - 'start finds existing previews in the CWD and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}', + 'start finds existing previews in the CWD and injects them into ${PreviewCodeGenerator.getGeneratedPreviewFilePath(fs)}', () async { final Directory rootProject = await createRootProject(); final Directory widgetPreviewScaffoldDir = widgetPreviewScaffoldFromRootProject( @@ -263,7 +268,7 @@ List<_i1.WidgetPreview> previews() => [ .writeAsStringSync(samplePreviewFile); final File generatedFile = widgetPreviewScaffoldDir.childFile( - PreviewCodeGenerator.generatedPreviewFilePath, + PreviewCodeGenerator.getGeneratedPreviewFilePath(fs), ); // Try to execute using the CWD. @@ -289,7 +294,7 @@ List<_i1.WidgetPreview> previews() => [ ); testUsingContext( - 'start finds existing previews in the provided directory and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}', + 'start finds existing previews in the provided directory and injects them into ${PreviewCodeGenerator.getGeneratedPreviewFilePath(fs)}', () async { final Directory rootProject = await createRootProject(); await startWidgetPreview(rootProject: rootProject);