[ Widget Preview ] Don't try to load previews with compile-time errors (#170262)

This change reworks how preview detection is performed to allow for
identifying libraries which contain compile-time errors or transitive
dependencies with compile-time errors.

Previously, introducing a compile-time error into a preview containing
library's dependency chain would result in the preview environment no
longer being updated due to a failed compilation during hot reload.
Users would first need to know to fix the compilation error before being
able to make updates to previews.

With this change, the preview detector builds and maintains a dependency
graph, propagating errors from libraries to their upstream dependencies.
If a file containing a preview would fail to compile, it is no longer
inserted into the widget preview environment. In its place, an error
message is displayed informing the user that there's a compilation error
in a library that needs to be fixed first.

This change needs some follow up work to create a proper UI for
displaying the compilation error messaging, batch processing of bulk
file system operations (e.g., directory deletion) to avoid performance
issues, and some refactoring.

Work towards #166430
This commit is contained in:
Ben Konyi 2025-06-11 13:57:15 -04:00 committed by GitHub
parent f59e8e5885
commit b9a99683ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 860 additions and 296 deletions

View File

@ -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();

View File

@ -14,7 +14,7 @@ import '../base/file_system.dart';
import '../project.dart';
import 'preview_detector.dart';
typedef _PreviewMappingEntry = MapEntry<PreviewPath, List<PreviewDetails>>;
typedef _PreviewMappingEntry = MapEntry<PreviewPath, PreviewDependencyNode>;
/// 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(<cb.Spec>[
@ -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<cb.Expression> previewExpressions = <cb.Expression>[];
@ -122,12 +123,17 @@ class PreviewCodeGenerator {
});
for (final _PreviewMappingEntry(
key: (path: String _, :Uri uri),
value: List<PreviewDetails> 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>[]);
cb.Expression previewWidget;
// TODO(bkonyi): clean up the error related code.
if (fileDetails.hasErrors) {
previewWidget = cb.refer('Text', 'package:flutter/material.dart').newInstance(<cb.Expression>[
cb.literalString('$uri has errors!'),
]);
} else if (fileDetails.dependencyHasErrors) {
previewWidget = cb.refer('Text', 'package:flutter/material.dart').newInstance(<cb.Expression>[
cb.literalString('Dependency of $uri has errors!'),
]);
} else {
previewWidget = cb.refer(preview.functionName, uri.toString()).call(<cb.Expression>[]);
if (preview.isBuilder) {
previewWidget = cb.refer(_kBuilderType, _kBuilderLibraryUri).newInstance(
<cb.Expression>[],
<String, cb.Expression>{_kBuilderProperty: previewWidget},
);
if (preview.isBuilder) {
previewWidget = cb.refer(_kBuilderType, _kBuilderLibraryUri).newInstance(
<cb.Expression>[],
<String, cb.Expression>{_kBuilderProperty: previewWidget},
);
}
if (preview.hasWrapper) {
previewWidget = _buildIdentifierReference(
preview.wrapper!,
).call(<cb.Expression>[previewWidget]);
}
}
if (preview.hasWrapper) {
previewWidget = _buildIdentifierReference(
preview.wrapper!,
).call(<cb.Expression>[previewWidget]);
}
previewWidget =
cb.Method((cb.MethodBuilder previewBuilder) {
previewBuilder.body = previewWidget.code;
}).closure;
return cb
.refer(_kWidgetPreviewClass, _kWidgetPreviewLibraryUri)
.newInstance(<cb.Expression>[], <String, cb.Expression>{
return cb.refer(_kWidgetPreviewClass, _kWidgetPreviewLibraryUri).newInstance(
<cb.Expression>[],
<String, cb.Expression>{
// TODO(bkonyi): try to display the preview name, even if the preview can't be displayed.
if (!fileDetails.dependencyHasErrors && !fileDetails.hasErrors) ...<String, cb.Expression>{
...?_generateCodeFromAnalyzerExpression(
allocator: allocator,
key: PreviewDetails.kName,
@ -204,8 +224,10 @@ class PreviewCodeGenerator {
expression: preview.localizations,
isCallback: true,
),
_kBuilderProperty: previewWidget,
});
},
_kBuilderProperty: previewWidget,
},
);
}
Map<String, cb.Expression>? _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<cb.Expression> {
@ -336,12 +358,12 @@ class AnalyzerAstToCodeBuilderVisitor extends analyzer.RecursiveAstVisitor<cb.Ex
@override
cb.Expression visitNamedType(analyzer.NamedType node) {
return cb.refer(node.name2.lexeme, _elementToLibraryIdentifier(node.element2!));
return cb.refer(node.name2.lexeme, _elementToLibraryIdentifier(node.element2));
}
@override
cb.Expression visitPrefixedIdentifier(analyzer.PrefixedIdentifier node) {
final String libraryUri = _elementToLibraryIdentifier(node.element!);
final String libraryUri = _elementToLibraryIdentifier(node.element)!;
// If the prefix is an enum, don't strip the prefix from the emitted code.
if (node.prefix.element! is analyzer.EnumElement2) {

View File

@ -11,13 +11,16 @@ import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/dart/element/element2.dart';
import 'package:analyzer/diagnostic/diagnostic.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/source/source.dart';
import 'package:watcher/watcher.dart';
import '../base/file_system.dart';
import '../base/logger.dart';
import '../base/utils.dart';
import 'preview_code_generator.dart';
/// A path / URI pair used to map previews to a file.
///
@ -26,8 +29,9 @@ import 'preview_code_generator.dart';
/// package URIs for preview imports.
typedef PreviewPath = ({String path, Uri uri});
/// Represents a set of previews for a given file.
typedef PreviewMapping = Map<PreviewPath, List<PreviewDetails>>;
/// A mapping of file / library paths to dependency graph nodes containing details related to
/// previews defined within the file / library.
typedef PreviewDependencyGraph = Map<PreviewPath, PreviewDependencyNode>;
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<PreviewDetails> filePreviews = <PreviewDetails>[];
/// Files that import this file.
final Set<PreviewDependencyNode> dependedOnBy = <PreviewDependencyNode>{};
/// Files this file imports.
final Set<PreviewDependencyNode> dependsOn = <PreviewDependencyNode>{};
/// `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<AnalysisError> errors = <AnalysisError>[];
/// Determines the set of errors found in this file.
///
/// Results in [errors] being populated with the latest set of errors for the file.
Future<void> 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<PreviewDependencyNode> updatedDependencies = <PreviewDependencyNode>{};
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<PreviewDependencyNode> 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<WatchEvent>? _fileWatcher;
final PreviewDetectorMutex _mutex = PreviewDetectorMutex();
late final PreviewMapping _pathToPreviews;
final PreviewDependencyGraph _dependencyGraph = PreviewDependencyGraph();
late final AnalysisContextCollection collection = AnalysisContextCollection(
includedPaths: <String>[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<PreviewMapping> initialize() async {
/// the initial [PreviewDependencyGraph] for the project.
Future<PreviewDependencyGraph> 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<PreviewPath, List<PreviewDetails>>(
key: PreviewPath location,
value: List<PreviewDetails> filePreviews,
) = filePreviewsMapping.entries.first;
logger.printStatus('Updated previews for ${location.uri}: $filePreviews');
if (filePreviews.isNotEmpty) {
final List<PreviewDetails>? 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<void> dispose() async {
@ -267,66 +351,200 @@ class PreviewDetector {
});
}
/// Search for functions annotated with `@Preview` in the current project.
Future<PreviewMapping> findPreviewFunctions(FileSystemEntity entity) async {
final PreviewMapping previews = PreviewMapping();
Future<void> _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<void> _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<PreviewPath, PreviewDependencyNode>(
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<PreviewDependencyGraph> _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<PreviewDetails> previewEntries =
previews[libUnit.toPreviewPath()] ?? <PreviewDetails>[];
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<int>(
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<void> _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<PreviewDependencyNode> visitedNodes = <PreviewDependencyNode>{};
Future<void> 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<int>(
0,
(int count, List<PreviewDetails> 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<void> {
final List<PreviewDetails> previewEntries = <PreviewDetails>[];

View File

@ -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<Locale>? locales, Iterable<Locale> supportedLocales) => null,
localeResolutionCallback: (Locale? locale, Iterable<Locale> 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 <PreviewPath, List<PreviewDetails>>{},
const <PreviewPath, PreviewDependencyNode>{},
);
expect(generatedPreviewFile, exists);

View File

@ -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<String> 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<String> 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<PreviewPath, PreviewDependencyNode> get nodesWithPreviews {
return Map<PreviewPath, PreviewDependencyNode>.fromEntries(
entries.where(
(MapEntry<PreviewPath, PreviewDependencyNode> element) =>
element.value.filePreviews.isNotEmpty,
),
);
}
}
PreviewPath addNonPreviewContainingFile(Directory projectRoot, List<String> 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<Locale>? locales, Iterable<Locale> supportedLocales) => null,
localeResolutionCallback: (Locale? locale, Iterable<Locale> 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<void> waitForChangeDetected({
required void Function(PreviewDependencyGraph) onChangeDetected,
required void Function() changeOperation,
}) async {
final Completer<void> completer = Completer<void>();
onChangeDetectedImpl = (PreviewDependencyGraph updated) {
if (completer.isCompleted) {
return;
}
onChangeDetected(updated);
completer.complete();
};
changeOperation();
await completer.future;
}
void expectPreviewDependencyGraphIsWellFormed(
PreviewDependencyGraph graph, {
Set<PreviewPath> expectedFilesWithErrors = const <PreviewPath>{},
}) {
final Set<PreviewDependencyNode> nodesWithErrors = <PreviewDependencyNode>{};
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<PreviewPath> filesWithTransitiveErrors = <PreviewPath>{};
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<void> expectHasErrors({
required void Function() changeOperation,
required Set<PreviewPath> filesWithErrors,
}) async {
await waitForChangeDetected(
onChangeDetected:
(PreviewDependencyGraph updated) => expectPreviewDependencyGraphIsWellFormed(
updated,
expectedFilesWithErrors: filesWithErrors,
),
changeOperation: changeOperation,
);
}
Future<void> expectHasNoErrors({required void Function() changeOperation}) async {
await expectHasErrors(
changeOperation: changeOperation,
filesWithErrors: const <PreviewPath>{},
);
}
void expectContainsPreviews(
Map<PreviewPath, PreviewDependencyNode> actual,
Map<PreviewPath, List<PreviewDetailsMatcher>> expected,
) {
for (final MapEntry<PreviewPath, List<PreviewDetailsMatcher>>(
key: PreviewPath previewPath,
value: List<PreviewDetailsMatcher> 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<PreviewPath> previewFiles = <PreviewPath>[
addPreviewContainingFile(projectRoot, <String>['foo.dart']),
addPreviewContainingFile(projectRoot, <String>['src', 'bar.dart']),
addPreviewContainingFile('foo.dart'),
addPreviewContainingFile(platformPath(<String>['src', 'bar.dart'])),
];
addNonPreviewContainingFile(projectRoot, <String>['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<PreviewPath, List<PreviewDetailsMatcher>> expectedInitialMapping =
<PreviewPath, List<PreviewDetailsMatcher>>{
addPreviewContainingFile(projectRoot, <String>['foo.dart']): expectedPreviewDetails,
addPreviewContainingFile(projectRoot, <String>['src', 'bar.dart']):
addPreviewContainingFile('foo.dart'): expectedPreviewDetails,
addPreviewContainingFile(platformPath(<String>['src', 'bar.dart'])):
expectedPreviewDetails,
};
final PreviewPath nonPreviewContainingFile = addNonPreviewContainingFile(
projectRoot,
<String>['baz.dart'],
);
final PreviewPath nonPreviewContainingFile = addNonPreviewContainingFile('baz.dart');
Completer<void> completer = Completer<void>();
onChangeDetected = (PreviewMapping updated) {
// The new preview in baz.dart should be included in the preview mapping.
expect(updated, <PreviewPath, List<PreviewDetailsMatcher>>{
...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, <String>['baz.dart']);
await completer.future;
completer = Completer<void>();
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, <PreviewPath, List<PreviewDetailsMatcher>>{
...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, <String>['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 = <PreviewPath, List<PreviewDetails>>{};
final PreviewDependencyGraph expectedInitialMapping = <PreviewPath, PreviewDependencyNode>{};
final Completer<void> completer = Completer<void>();
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, <PreviewPath, List<PreviewDetailsMatcher>>{
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, <String>['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, <PreviewPath, List<PreviewDetailsMatcher>>{
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<PreviewPath> projectFiles = <PreviewPath>{
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<PreviewPath> initialProjectFiles;
setUp(() {
initialProjectFiles = <PreviewPath>{
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(<String>['dir', 'c.dart']),
'void foo() {}',
);
final Set<PreviewPath> directoryFiles = <PreviewPath>{
addProjectFile(platformPath(<String>['dir', 'a.dart']), "import 'b.dart';"),
addProjectFile(platformPath(<String>['dir', 'b.dart']), "import 'c.dart';"),
c,
};
final PreviewDependencyGraph initialGraph = await previewDetector.initialize();
expect(
initialGraph.keys,
containsAll(<PreviewPath>{...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<void> completer = Completer<void>();
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: <PreviewPath>{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: <PreviewPath>{previewPathForFile(main.name), bazPath},
);
// Delete baz.dart. main.dart should continue to have an error.
await expectHasErrors(
changeOperation: () => removeProjectFile(baz.name),
filesWithErrors: <PreviewPath>{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: <PreviewPath>{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<Locale>? locales, Iterable<Locale> supportedLocales) => null,
localeResolutionCallback: (Locale? locale, Iterable<Locale> 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';
''';

View File

@ -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);