diff --git a/.ci/scripts/analyze_flutter_goldens.sh b/.ci/scripts/analyze_flutter_goldens.sh new file mode 100755 index 000000000000..d36446ec0b68 --- /dev/null +++ b/.ci/scripts/analyze_flutter_goldens.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +set -e + +cd script/flutter_goldens +flutter analyze --fatal-infos diff --git a/.ci/scripts/flutter_goldens_format.sh b/.ci/scripts/flutter_goldens_format.sh new file mode 100755 index 000000000000..6a92ca0fa057 --- /dev/null +++ b/.ci/scripts/flutter_goldens_format.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +set -e + +cd script/flutter_goldens +dart format --set-exit-if-changed . diff --git a/.ci/scripts/flutter_goldens_tests.sh b/.ci/scripts/flutter_goldens_tests.sh new file mode 100755 index 000000000000..51ebc3b727e0 --- /dev/null +++ b/.ci/scripts/flutter_goldens_tests.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +set -e + +cd script/flutter_goldens +flutter test diff --git a/.ci/scripts/prepare_tool.sh b/.ci/scripts/prepare_tool.sh index 33646d56e7cd..29f2b38ce325 100755 --- a/.ci/scripts/prepare_tool.sh +++ b/.ci/scripts/prepare_tool.sh @@ -10,3 +10,6 @@ git branch main origin/main cd script/tool dart pub get + +cd ../flutter_goldens +flutter pub get diff --git a/.ci/targets/analyze.yaml b/.ci/targets/analyze.yaml index 92f5ebec7b28..3d4f4716cff3 100644 --- a/.ci/targets/analyze.yaml +++ b/.ci/targets/analyze.yaml @@ -4,6 +4,8 @@ tasks: infra_step: true # Note infra steps failing prevents "always" from running. - name: analyze repo tools script: .ci/scripts/analyze_repo_tools.sh + - name: analyze flutter_goldens + script: .ci/scripts/analyze_flutter_goldens.sh - name: download Dart deps script: .ci/scripts/tool_runner.sh args: ["fetch-deps"] diff --git a/.ci/targets/repo_checks.yaml b/.ci/targets/repo_checks.yaml index 924468351134..be7cd9204c72 100644 --- a/.ci/targets/repo_checks.yaml +++ b/.ci/targets/repo_checks.yaml @@ -13,6 +13,10 @@ tasks: script: .ci/scripts/plugin_tools_tests.sh - name: tool format script: .ci/scripts/plugin_tools_format.sh + - name: flutter_goldens unit tests + script: .ci/scripts/flutter_goldens_tests.sh + - name: flutter_goldens format + script: .ci/scripts/flutter_goldens_format.sh - name: format script: .ci/scripts/tool_runner.sh # Skip Swift formatting on Linux builders. diff --git a/.ci/targets/repo_tools_tests.yaml b/.ci/targets/repo_tools_tests.yaml index bd80daeb3fbc..d484e268e97a 100644 --- a/.ci/targets/repo_tools_tests.yaml +++ b/.ci/targets/repo_tools_tests.yaml @@ -4,3 +4,5 @@ tasks: infra_step: true # Note infra steps failing prevents "always" from running. - name: tool unit tests script: .ci/scripts/plugin_tools_tests.sh + - name: flutter_goldens unit tests + script: .ci/scripts/flutter_goldens_tests.sh diff --git a/packages/cupertino_ui/pubspec.yaml b/packages/cupertino_ui/pubspec.yaml index df913bbfc049..fd5575ddb15c 100644 --- a/packages/cupertino_ui/pubspec.yaml +++ b/packages/cupertino_ui/pubspec.yaml @@ -1,6 +1,7 @@ name: cupertino_ui description: The official Flutter Cupertino Design Library, implementing the iOS design system. -version: 0.0.1 +version: 0.0.2 +publish_to: none repository: https://github.com/flutter/packages/tree/main/packages/cupertino_ui issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A%20cupertino%22 @@ -13,6 +14,8 @@ dependencies: sdk: flutter dev_dependencies: + flutter_goldens: + path: ../../script/flutter_goldens flutter_test: sdk: flutter diff --git a/packages/cupertino_ui/test/flutter_test_config.dart b/packages/cupertino_ui/test/flutter_test_config.dart new file mode 100644 index 000000000000..89496e15a775 --- /dev/null +++ b/packages/cupertino_ui/test/flutter_test_config.dart @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'goldens_io.dart' + if (dart.library.js_interop) 'goldens_web.dart' + as flutter_goldens; + +Future testExecutable(FutureOr Function() testMain) { + // Enable golden file testing using Skia Gold. + return flutter_goldens.testExecutable(testMain); +} diff --git a/packages/cupertino_ui/test/goldens/goldens_test.dart b/packages/cupertino_ui/test/goldens/goldens_test.dart new file mode 100644 index 000000000000..d099e7ac14bc --- /dev/null +++ b/packages/cupertino_ui/test/goldens/goldens_test.dart @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Inconsequential golden test', (WidgetTester tester) async { + // The test validates the Flutter Gold integration. Any changes to the + // golden file can be approved at any time. + await tester.pumpWidget( + const CupertinoApp(home: Center(child: Text('Cupertino Goldens'))), + ); + + await tester.pumpAndSettle(); + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('inconsequential_golden_file.png'), + ); + }, skip: kIsWeb); +} diff --git a/packages/cupertino_ui/test/goldens_io.dart b/packages/cupertino_ui/test/goldens_io.dart new file mode 100644 index 000000000000..001d31494718 --- /dev/null +++ b/packages/cupertino_ui/test/goldens_io.dart @@ -0,0 +1,5 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'package:flutter_goldens/flutter_goldens.dart' show testExecutable; diff --git a/packages/cupertino_ui/test/goldens_web.dart b/packages/cupertino_ui/test/goldens_web.dart new file mode 100644 index 000000000000..80945b782d2e --- /dev/null +++ b/packages/cupertino_ui/test/goldens_web.dart @@ -0,0 +1,11 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +// package:flutter_goldens is not used as part of the test process for web. +Future testExecutable( + FutureOr Function() testMain, { + String? namePrefix, +}) async => testMain(); diff --git a/packages/material_ui/pubspec.yaml b/packages/material_ui/pubspec.yaml index 1700a3878450..4626a6fd00f1 100644 --- a/packages/material_ui/pubspec.yaml +++ b/packages/material_ui/pubspec.yaml @@ -1,6 +1,7 @@ name: material_ui description: The official Flutter Material UI Library, implementing Google's Material Design design system. -version: 0.0.1 +version: 0.0.2 +publish_to: none repository: https://github.com/flutter/packages/tree/main/packages/material_ui issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A%20material%20design%22 @@ -13,6 +14,8 @@ dependencies: sdk: flutter dev_dependencies: + flutter_goldens: + path: ../../script/flutter_goldens flutter_test: sdk: flutter diff --git a/packages/material_ui/test/flutter_test_config.dart b/packages/material_ui/test/flutter_test_config.dart new file mode 100644 index 000000000000..89496e15a775 --- /dev/null +++ b/packages/material_ui/test/flutter_test_config.dart @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'goldens_io.dart' + if (dart.library.js_interop) 'goldens_web.dart' + as flutter_goldens; + +Future testExecutable(FutureOr Function() testMain) { + // Enable golden file testing using Skia Gold. + return flutter_goldens.testExecutable(testMain); +} diff --git a/packages/material_ui/test/goldens/goldens_test.dart b/packages/material_ui/test/goldens/goldens_test.dart new file mode 100644 index 000000000000..c4270a026784 --- /dev/null +++ b/packages/material_ui/test/goldens/goldens_test.dart @@ -0,0 +1,24 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + testWidgets('Inconsequential golden test', (WidgetTester tester) async { + // The test validates the Flutter Gold integration. Any changes to the + // golden file can be approved at any time. + await tester.pumpWidget( + RepaintBoundary(child: Container(color: const Color(0xAFF61145))), + ); + + await tester.pumpAndSettle(); + await expectLater( + find.byType(RepaintBoundary), + matchesGoldenFile('inconsequential_golden_file.png'), + ); + }, skip: kIsWeb); +} diff --git a/packages/material_ui/test/goldens_io.dart b/packages/material_ui/test/goldens_io.dart new file mode 100644 index 000000000000..001d31494718 --- /dev/null +++ b/packages/material_ui/test/goldens_io.dart @@ -0,0 +1,5 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'package:flutter_goldens/flutter_goldens.dart' show testExecutable; diff --git a/packages/material_ui/test/goldens_web.dart b/packages/material_ui/test/goldens_web.dart new file mode 100644 index 000000000000..80945b782d2e --- /dev/null +++ b/packages/material_ui/test/goldens_web.dart @@ -0,0 +1,11 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +// package:flutter_goldens is not used as part of the test process for web. +Future testExecutable( + FutureOr Function() testMain, { + String? namePrefix, +}) async => testMain(); diff --git a/script/flutter_goldens/README.md b/script/flutter_goldens/README.md new file mode 100644 index 000000000000..24b37a4d51ea --- /dev/null +++ b/script/flutter_goldens/README.md @@ -0,0 +1,11 @@ +This package is an internal implementation detail for our testing +infrastructure. It enables packages to use the Skia Gold +infrastructure for tracking golden image tests. + +See also: + + * https://skia.org/docs/dev/testing/skiagold/ + * https://flutter-packages-gold.skia.org/ + * [Writing a golden file test for package flutter] + +[Writing a golden file test for package flutter]: https://github.com/flutter/flutter/blob/master/docs/contributing/testing/Writing-a-golden-file-test-for-package-flutter.md diff --git a/script/flutter_goldens/lib/flutter_goldens.dart b/script/flutter_goldens/lib/flutter_goldens.dart new file mode 100644 index 000000000000..fbb02bfb7355 --- /dev/null +++ b/script/flutter_goldens/lib/flutter_goldens.dart @@ -0,0 +1,181 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'dart:io'; +library; + +import 'dart:async' show FutureOr; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; +import 'package:yaml/yaml.dart'; + +/// Main method that can be used in a `flutter_test_config.dart` file to set +/// [goldenFileComparator] to an instance of [FlutterGoldenFileComparator] that +/// works for the current test. _Which_ [FlutterGoldenFileComparator] is +/// instantiated is based on the current testing environment. +/// +/// When set, the `namePrefix` is prepended to the names of all gold images. +/// +/// This function assumes the [goldenFileComparator] has been set to a +/// [LocalFileComparator], which happens in the bootstrap code used when running +/// tests using `flutter test`. This should not be called when running a test +/// using `flutter run`, as in that environment, the [goldenFileComparator] is a +/// [TrivialComparator]. +Future testExecutable( + FutureOr Function() testMain, { + String? namePrefix, +}) async { + assert( + goldenFileComparator is LocalFileComparator, + 'The flutter_goldens package should be used from a flutter_test_config.dart ' + 'file, which is only invoked when using "flutter test". The "flutter test" ' + 'bootstrap logic sets "goldenFileComparator" to a LocalFileComparator. It ' + 'appears in this instance however that the "goldenFileComparator" is a ' + '${goldenFileComparator.runtimeType}.\n' + 'See also: https://flutter.dev/to/flutter-test-docs', + ); + const FileSystem fs = LocalFileSystem(); + + namePrefix ??= FlutterGoldenFileComparator.getPackageName(fs); + + goldenFileComparator = FlutterSkippingFileComparator.fromLocalFileComparator( + localFileComparator: goldenFileComparator as LocalFileComparator, + 'Golden file testing is currently skipped.', + namePrefix: namePrefix, + fs: fs, + ); + await testMain(); +} + +/// Abstract base class golden file comparator specific to the `flutter/packages` +/// repository. +/// +/// The [FlutterSkippingFileComparator] is utilized to skip tests outside +/// of the appropriate environments. Currently, some packages or environments +/// do not execute golden file testing, and as such do not require a +/// comparator. This comparator is also used when an internet connection is unavailable. +abstract class FlutterGoldenFileComparator extends GoldenFileComparator { + /// Creates a [FlutterGoldenFileComparator] that will resolve golden file + /// URIs relative to the specified [basedir]. When testing locally, the + /// [basedir] will also contain any diffs from failed tests, or goldens + /// generated from newly introduced tests. + @visibleForTesting + FlutterGoldenFileComparator( + this.basedir, { + required this.fs, + this.namePrefix, + }); + + /// The directory to which golden file URIs will be resolved in [compare] and + /// [update]. + final Uri basedir; + + /// The file system used to perform file access. + final FileSystem fs; + + /// The prefix that is added to all golden names. + final String? namePrefix; + + @override + Future update(Uri golden, Uint8List imageBytes) async { + final File goldenFile = getGoldenFile(golden); + await goldenFile.parent.create(recursive: true); + await goldenFile.writeAsBytes(imageBytes, flush: true); + } + + @override + Uri getTestUri(Uri key, int? version) => key; + + /// Calculate the appropriate basedir for the current test context. + @protected + @visibleForTesting + static Directory getBaseDirectory( + LocalFileComparator defaultComparator, { + String? suffix, + required FileSystem fs, + }) { + final Directory comparisonRoot = switch (suffix) { + null => + fs + .directory(path.fromUri(defaultComparator.basedir)) + .childDirectory('skia_goldens'), + _ => fs.systemTempDirectory.createTempSync(suffix), + }; + return comparisonRoot; + } + + /// Returns the golden [File] identified by the given [Uri]. + @protected + File getGoldenFile(Uri uri) { + final File goldenFile = fs + .directory(path.fromUri(basedir)) + .childFile(path.fromUri(uri)); + + return goldenFile; + } + + /// Extracts the package name from the nearest `pubspec.yaml` file. + @visibleForTesting + static String? getPackageName(FileSystem fs) { + Directory current = fs.currentDirectory; + while (current.path != current.parent.path) { + final File pubspec = current.childFile('pubspec.yaml'); + if (pubspec.existsSync()) { + try { + final Object? yaml = loadYaml(pubspec.readAsStringSync()); + if (yaml is YamlMap) { + return yaml['name'] as String?; + } + } catch (e) { + // Ignore parsing errors and keep looking + } + } + current = current.parent; + } + return null; + } +} + +/// A [FlutterGoldenFileComparator] for testing conditions that do not execute +/// golden file tests. +class FlutterSkippingFileComparator extends FlutterGoldenFileComparator { + /// Creates a [FlutterSkippingFileComparator] that will skip tests that + /// are not in the right environment for golden file testing. + FlutterSkippingFileComparator( + super.basedir, + this.reason, { + super.namePrefix, + required super.fs, + }); + + /// Describes the reason for using the [FlutterSkippingFileComparator]. + final String reason; + + /// Creates a new [FlutterSkippingFileComparator] that mirrors the + /// relative path resolution of the given [localFileComparator]. + static FlutterSkippingFileComparator fromLocalFileComparator( + String reason, { + required LocalFileComparator localFileComparator, + String? namePrefix, + required FileSystem fs, + }) { + final Uri basedir = localFileComparator.basedir; + return FlutterSkippingFileComparator( + basedir, + reason, + namePrefix: namePrefix, + fs: fs, + ); + } + + @override + Future compare(Uint8List imageBytes, Uri golden) async => true; + + @override + Future update(Uri golden, Uint8List imageBytes) async {} +} diff --git a/script/flutter_goldens/pubspec.yaml b/script/flutter_goldens/pubspec.yaml new file mode 100644 index 000000000000..6ac1f887f6a0 --- /dev/null +++ b/script/flutter_goldens/pubspec.yaml @@ -0,0 +1,17 @@ +name: flutter_goldens +publish_to: none + +environment: + sdk: ^3.9.0-0 + +dependencies: + crypto: any + file: any + flutter: + sdk: flutter + flutter_test: + sdk: flutter + path: any + platform: any + process: any + yaml: any diff --git a/script/flutter_goldens/test/flutter_goldens_test.dart b/script/flutter_goldens/test/flutter_goldens_test.dart new file mode 100644 index 000000000000..9a8f329a78dd --- /dev/null +++ b/script/flutter_goldens/test/flutter_goldens_test.dart @@ -0,0 +1,110 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// See also dev/automated_tests/flutter_test/flutter_gold_test.dart + +import 'dart:typed_data'; + +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_goldens/flutter_goldens.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const String _kFlutterRoot = '/flutter'; + +void main() { + group('FlutterGoldenFileComparator', () { + test( + 'calculates the basedir correctly from defaultComparator for local testing', + () async { + final fs = MemoryFileSystem(); + fs.directory(_kFlutterRoot).createSync(recursive: true); + final defaultComparator = FakeLocalFileComparator(); + final Directory root = fs.directory('/')..createSync(recursive: true); + defaultComparator.basedir = root.childDirectory('baz').uri; + final Directory basedir = FlutterGoldenFileComparator.getBaseDirectory( + defaultComparator, + fs: fs, + ); + expect(basedir.uri, fs.directory('/baz/skia_goldens').uri); + }, + ); + + group('_getPackageName', () { + test('extracts name from pubspec.yaml', () { + final fs = MemoryFileSystem(); + final Directory packageDir = fs.directory('/my_package') + ..createSync(recursive: true); + packageDir + .childFile('pubspec.yaml') + .writeAsStringSync('name: my_package_name\n'); + fs.currentDirectory = packageDir; + + expect( + FlutterGoldenFileComparator.getPackageName(fs), + 'my_package_name', + ); + }); + + test('traverses upwards to find pubspec.yaml', () { + final fs = MemoryFileSystem(); + final Directory packageDir = fs.directory('/my_package') + ..createSync(recursive: true); + packageDir + .childFile('pubspec.yaml') + .writeAsStringSync('name: my_package_name\n'); + final Directory testDir = packageDir.childDirectory('test') + ..createSync(recursive: true); + fs.currentDirectory = testDir; + + expect( + FlutterGoldenFileComparator.getPackageName(fs), + 'my_package_name', + ); + }); + + test('returns null if no pubspec.yaml is found', () { + final fs = MemoryFileSystem(); + final Directory someDir = fs.directory('/some/dir') + ..createSync(recursive: true); + fs.currentDirectory = someDir; + + expect(FlutterGoldenFileComparator.getPackageName(fs), isNull); + }); + + test('handles invalid yaml gracefully', () { + final fs = MemoryFileSystem(); + final Directory packageDir = fs.directory('/my_package') + ..createSync(recursive: true); + packageDir + .childFile('pubspec.yaml') + .writeAsStringSync('invalid: yaml: : :'); + fs.currentDirectory = packageDir; + + expect(FlutterGoldenFileComparator.getPackageName(fs), isNull); + }); + }); + }); + + group('FlutterSkippingFileComparator', () { + test('compare returns true', () async { + final fs = MemoryFileSystem(); + final comparator = FlutterSkippingFileComparator( + Uri.parse('/basedir'), + 'reason', + fs: fs, + ); + final bool result = await comparator.compare( + Uint8List.fromList([1, 2, 3]), + Uri.parse('golden.png'), + ); + expect(result, isTrue); + }); + }); +} + +class FakeLocalFileComparator extends Fake implements LocalFileComparator { + @override + late Uri basedir; +}