From d85ca536336a81404bfe6fd4accaaf2bfd16cd68 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Fri, 31 Jul 2026 14:57:53 +0200 Subject: [PATCH 1/7] feat: :sparkles: register a dependency font under its bare family name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A font bundled by a dependency is exposed by the font manifest as `packages/my_theme/Roboto`, and `loadFonts()` only registered it under that name. A widget styled with `TextStyle(fontFamily: 'Roboto', package: 'my_theme')` — or a theme defaulting to that family — asks for the bare `Roboto` instead, so no font was found and the text rendered as placeholder blocks in the goldens. Register both names. `loadFonts()` now returns the families it registered, which is also what lets the tests assert on it without shared state. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/test/src/fonts_test.dart | 47 +++++++++++++++++ example/multi_packages_app/theme/pubspec.yaml | 7 ++- lib/adaptive_test.dart | 1 + lib/src/helpers/font_registration.dart | 14 +++++ lib/src/helpers/fonts_loader.dart | 52 ++++++++++++------- test/helpers/font_registration_test.dart | 14 +++++ test/helpers/fonts_loader_test.dart | 30 +++++++++++ 7 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 example/multi_packages_app/app/test/src/fonts_test.dart create mode 100644 lib/src/helpers/font_registration.dart create mode 100644 test/helpers/font_registration_test.dart create mode 100644 test/helpers/fonts_loader_test.dart diff --git a/example/multi_packages_app/app/test/src/fonts_test.dart b/example/multi_packages_app/app/test/src/fonts_test.dart new file mode 100644 index 0000000..04057aa --- /dev/null +++ b/example/multi_packages_app/app/test/src/fonts_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Whether text laid out with [fontFamily] is rendered with a real font. +/// +/// The placeholder font `flutter_test` uses for unknown families gives every +/// glyph the same advance, so a narrow and a wide string of the same length +/// measure the same. Any real proportional font measures them differently. +bool _rendersWithARealFont(String fontFamily) { + double widthOf(String text) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle(fontFamily: fontFamily, fontSize: 40), + ), + textDirection: TextDirection.ltr, + )..layout(); + final width = painter.width; + painter.dispose(); + + return width; + } + + return widthOf('iiii') != widthOf('WWWW'); +} + +void main() { + group('loadFonts in an app whose fonts live in a dependency', () { + test('renders a font bundled by a dependency under its bare name', () { + // ThemeSans is bundled by the theme package this app depends on, so the + // manifest only exposes it as + // `packages/multi_packages_example_theme/ThemeSans` — but a widget or a + // theme defaulting to that family asks for the bare name. + expect(_rendersWithARealFont('ThemeSans'), isTrue); + }); + + test('keeps the family under its manifest name as well', () { + expect( + _rendersWithARealFont( + 'packages/multi_packages_example_theme/ThemeSans', + ), + isTrue, + ); + }); + + }); +} diff --git a/example/multi_packages_app/theme/pubspec.yaml b/example/multi_packages_app/theme/pubspec.yaml index 5c9c32b..4bae3c2 100644 --- a/example/multi_packages_app/theme/pubspec.yaml +++ b/example/multi_packages_app/theme/pubspec.yaml @@ -41,4 +41,9 @@ flutter: - asset: fonts/Roboto-MediumItalic.ttf - asset: fonts/Roboto-Regular.ttf - asset: fonts/Roboto-Thin.ttf - - asset: fonts/Roboto-ThinItalic.ttf \ No newline at end of file + - asset: fonts/Roboto-ThinItalic.ttf + # Declared from an existing asset, to cover a font family bundled by a + # dependency that does not collide with a platform default one. + - family: ThemeSans + fonts: + - asset: fonts/Roboto-Regular.ttf diff --git a/lib/adaptive_test.dart b/lib/adaptive_test.dart index 33a304e..50cbecd 100644 --- a/lib/adaptive_test.dart +++ b/lib/adaptive_test.dart @@ -5,6 +5,7 @@ export 'src/adaptive/window_configuration_tester.dart'; export 'src/adaptive/window_config.dart'; export 'src/configuration.dart'; export 'src/helpers/await_images.dart'; +export 'src/helpers/font_registration.dart'; export 'src/helpers/fonts_loader.dart'; export 'src/helpers/goldens_difference.dart'; export 'src/helpers/skip_test_extension.dart'; diff --git a/lib/src/helpers/font_registration.dart b/lib/src/helpers/font_registration.dart new file mode 100644 index 0000000..2af7325 --- /dev/null +++ b/lib/src/helpers/font_registration.dart @@ -0,0 +1,14 @@ +import 'package:flutter/services.dart'; + +/// Registers [assets] under [family] and returns that family name, so that +/// callers can collect what they loaded without going through shared state. +Future registerFontFamily( + String family, + Iterable> assets, +) async { + final loader = FontLoader(family); + assets.forEach(loader.addFont); + await loader.load(); + + return family; +} diff --git a/lib/src/helpers/fonts_loader.dart b/lib/src/helpers/fonts_loader.dart index a743bce..c2dd225 100644 --- a/lib/src/helpers/fonts_loader.dart +++ b/lib/src/helpers/fonts_loader.dart @@ -3,22 +3,26 @@ import 'dart:convert'; import 'dart:io'; +import 'package:adaptive_test/src/helpers/font_registration.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:package_config/package_config.dart'; -/// Loads fonts and icons to ensure they appear in golden tests. +/// Loads the fonts and icons the goldens need, and returns the font families +/// that were registered. /// /// Usage: /// 1. Create a flutter_test_config.dart file. /// 2. Add `await loadFonts();` in the `testExecutable` function. /// /// Note: Your package must include all used fonts as assets for this to work. -Future loadFonts() async { +Future> loadFonts() async { TestWidgetsFlutterBinding.ensureInitialized(); - final fontManifest = await _loadFontManifest(); - final packageName = await _getCurrentPackageName(); - await _loadFontsFromManifest(fontManifest, packageName); + + return _loadFontsFromManifest( + await _loadFontManifest(), + packageName: await _getCurrentPackageName(), + ); } Future<_FontManifest> _loadFontManifest() async { @@ -30,33 +34,41 @@ Future<_FontManifest> _loadFontManifest() async { return fontManifest.map((font) => _FontData.fromJson(font)).toList(); } -Future _loadFontsFromManifest( - _FontManifest fontManifest, - String? packageName, -) async { - final fontLoaders = fontManifest.expand((font) { - final regularFontLoader = _createFontLoader(font.family, font.fonts); +Future> _loadFontsFromManifest( + _FontManifest fontManifest, { + required String? packageName, +}) async { + final loadings = fontManifest.expand((font) { final fontFamilyStartsWithPackages = font.family.startsWith('packages/'); + // A font bundled by a dependency shows up as `packages//` in + // the manifest, but a widget styled with + // `TextStyle(fontFamily: 'Roboto', package: 'my_theme')` — or a theme + // defaulting to that family — asks for the bare name. + final bareFamily = + fontFamilyStartsWithPackages ? _bareFamilyName(font.family) : null; return [ - regularFontLoader, + _loadFontFamily(font.family, font.fonts), if (!fontFamilyStartsWithPackages && packageName != null) - _createFontLoader('packages/$packageName/${font.family}', font.fonts), + _loadFontFamily('packages/$packageName/${font.family}', font.fonts), + if (bareFamily != null) _loadFontFamily(bareFamily, font.fonts), ]; }).toList(); - await Future.wait(fontLoaders.map((loader) => loader.load())); + return (await Future.wait(loadings)).toSet(); } -FontLoader _createFontLoader(String fontFamily, List<_FontType> fontTypes) { - final fontLoader = FontLoader(fontFamily); - fontTypes.forEach( - (fontType) => fontLoader.addFont(rootBundle.load(fontType.asset)), +Future _loadFontFamily(String fontFamily, List<_FontType> fontTypes) { + return registerFontFamily( + fontFamily, + fontTypes.map((fontType) => rootBundle.load(fontType.asset)), ); - - return fontLoader; } +/// `packages/my_theme/Roboto` -> `Roboto`. +String _bareFamilyName(String manifestFamily) => + manifestFamily.split('/').skip(2).join('/'); + Future _getCurrentPackageName() async { final current = Directory.current; final packageConfig = await findPackageConfig(current); diff --git a/test/helpers/font_registration_test.dart b/test/helpers/font_registration_test.dart new file mode 100644 index 0000000..0e3c6dc --- /dev/null +++ b/test/helpers/font_registration_test.dart @@ -0,0 +1,14 @@ +import 'package:adaptive_test/adaptive_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + group('registerFontFamily', () { + test('returns the family it registered', () async { + final family = await registerFontFamily('AFamily', const []); + + expect(family, 'AFamily'); + }); + }); +} diff --git a/test/helpers/fonts_loader_test.dart b/test/helpers/fonts_loader_test.dart new file mode 100644 index 0000000..f1d55dd --- /dev/null +++ b/test/helpers/fonts_loader_test.dart @@ -0,0 +1,30 @@ +import 'package:adaptive_test/adaptive_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Set loadedFamilies; + + setUpAll(() async => loadedFamilies = await loadFonts()); + + group('loadFonts', () { + test('registers the families declared in the font manifest', () { + // Bundled by this package through `uses-material-design: true`. + expect(loadedFamilies, contains('MaterialIcons')); + }); + + test('registers a dependency font under its manifest name', () { + // Bundled by the cupertino_icons dependency. + expect( + loadedFamilies, + contains('packages/cupertino_icons/CupertinoIcons'), + ); + }); + + test('registers a dependency font under its bare family name too', () { + // A widget styled with + // `TextStyle(fontFamily: 'CupertinoIcons', package: 'cupertino_icons')` + // asks for the bare name, not the manifest one. + expect(loadedFamilies, contains('CupertinoIcons')); + }); + }); +} From 27a566536d4eb10f82ad0eb702883c37d51e9d71 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Fri, 31 Jul 2026 14:59:19 +0200 Subject: [PATCH 2/7] feat: :sparkles: load the font families the framework falls back to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The families a widget gets when it specifies no font — `Roboto` on Android, `CupertinoSystemText` on iOS, `Segoe UI` on Windows — are declared in no `pubspec.yaml`: they either ship with the Flutter SDK or belong to the host OS. `loadFonts()` could not find them in the font manifest, so a `DatePickerDialog` or a `CupertinoButton` rendered as placeholder blocks in the goldens. The list is gathered from `Typography` and `CupertinoTextThemeData` instead of being hardcoded, so it follows the SDK. The families the SDK ships are loaded from its font cache, found through `FLUTTER_ROOT` or by walking up from the test executable. The ones only the OS can provide are aliased to the typeface the SDK ships: the glyphs then differ from a real device, but the goldens stay readable. Opt out with `setLoadPlatformFallbackFonts(false)`. `FontLoadingPolicy` holds what is registered on top of the manifest, and with it the arbitration introduced here: a dependency font does not claim the bare name of a platform default the SDK ships a font for. It usually bundles a single weight of such a family while the SDK ships all of them, and on a device it would not shadow the platform font either. The SDK font files are sorted before being registered: a directory listing comes back in file system order, which differs between APFS, ext4 and NTFS, and goldens must not depend on the machine that generated them. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/test/src/fonts_test.dart | 21 ++ lib/src/configuration.dart | 15 ++ lib/src/helpers/font_loading_policy.dart | 47 ++++ lib/src/helpers/fonts_loader.dart | 23 +- lib/src/helpers/platform_fonts.dart | 203 ++++++++++++++++++ pubspec.yaml | 1 + test/helpers/font_loading_policy_test.dart | 47 ++++ test/helpers/font_registration_test.dart | 18 ++ test/helpers/font_test_helpers.dart | 36 ++++ test/helpers/fonts_loader_test.dart | 27 +++ test/helpers/platform_fonts_test.dart | 142 ++++++++++++ 11 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 lib/src/helpers/font_loading_policy.dart create mode 100644 lib/src/helpers/platform_fonts.dart create mode 100644 test/helpers/font_loading_policy_test.dart create mode 100644 test/helpers/font_test_helpers.dart create mode 100644 test/helpers/platform_fonts_test.dart diff --git a/example/multi_packages_app/app/test/src/fonts_test.dart b/example/multi_packages_app/app/test/src/fonts_test.dart index 04057aa..4d1b065 100644 --- a/example/multi_packages_app/app/test/src/fonts_test.dart +++ b/example/multi_packages_app/app/test/src/fonts_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -43,5 +44,25 @@ void main() { ); }); + test('renders the Material default typeface of the host platform', () { + final defaultFamily = Typography.material2021( + platform: TargetPlatform.iOS, + ).black.bodyMedium?.fontFamily; + + expect(defaultFamily, isNotNull); + // ignore: avoid-non-null-assertion, asserted right above + expect(_rendersWithARealFont(defaultFamily!), isTrue); + }); + + test('renders text with a family no font could ever provide', () { + // Apple's system typeface: it ships with iOS and cannot be bundled, so it + // is stood in for by the typeface the SDK ships. + const cupertinoTextTheme = CupertinoTextThemeData(); + final cupertinoFamily = cupertinoTextTheme.textStyle.fontFamily; + + expect(cupertinoFamily, isNotNull); + // ignore: avoid-non-null-assertion, asserted right above + expect(_rendersWithARealFont(cupertinoFamily!), isTrue); + }); }); } diff --git a/lib/src/configuration.dart b/lib/src/configuration.dart index 11b7f4d..eaabc8a 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -46,6 +46,21 @@ class AdaptiveTestConfiguration { _failTestOnWrongPlatform = failTestOnWrongPlatform; } + bool _loadPlatformFallbackFonts = true; + + bool get loadPlatformFallbackFonts => _loadPlatformFallbackFonts; + + /// Whether [loadFonts] also registers the font families the framework falls + /// back to on each platform, e.g. `Roboto` or `CupertinoSystemText`. They are + /// not declared in any `pubspec.yaml`, so without this the text using them + /// renders as placeholder blocks in goldens. + /// + /// Defaults to true. Set it to false to keep the previous behavior, for + /// instance if you would rather register those families yourself. + void setLoadPlatformFallbackFonts(bool loadPlatformFallbackFonts) { + _loadPlatformFallbackFonts = loadPlatformFallbackFonts; + } + WindowVariant? _deviceVariant; WindowVariant get deviceVariant { diff --git a/lib/src/helpers/font_loading_policy.dart b/lib/src/helpers/font_loading_policy.dart new file mode 100644 index 0000000..ddbb073 --- /dev/null +++ b/lib/src/helpers/font_loading_policy.dart @@ -0,0 +1,47 @@ +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; +import 'package:meta/meta.dart'; + +/// Which font families [loadFonts] provides beyond the ones the font manifest +/// declares, and under which names. +/// +/// [platformFamilies] are the families the framework falls back to when a +/// widget does not specify a font, [sdkFamilies] the ones the Flutter SDK ships +/// font files for. +@immutable +@internal +class FontLoadingPolicy { + const FontLoadingPolicy({ + required this.platformFamilies, + required this.sdkFamilies, + }); + + /// Provides the platform default families, gathered from the SDK. + factory FontLoadingPolicy.platformAware() => FontLoadingPolicy( + platformFamilies: platformDefaultFontFamilies(), + sdkFamilies: sdkFontFamilies(), + ); + + /// Registers nothing but what the font manifest declares. + const FontLoadingPolicy.manifestOnly() + : platformFamilies = const {}, + sdkFamilies = const {}; + + final Set platformFamilies; + final Set sdkFamilies; + + /// Whether a font bundled by a dependency, exposed by the manifest as + /// `packages/my_theme/Roboto`, is also registered under its bare [family] + /// name — the name a `TextStyle` or a `ThemeData` asks for. + /// + /// It is, unless it would shadow a platform default the SDK ships a font for. + /// A dependency usually bundles a single weight of such a family while the + /// SDK ships all of them, and on a device that bundled font would not shadow + /// the platform one either: it is only reachable through its `packages/` + /// name. + bool registersBareFamilyName(String family) => + !platformFamilies.contains(family) || !sdkFamilies.contains(family); + + /// The families that must be provided on top of the manifest, so that text + /// laid out with a platform default does not render as placeholder blocks. + Set get familiesToProvide => platformFamilies; +} diff --git a/lib/src/helpers/fonts_loader.dart b/lib/src/helpers/fonts_loader.dart index c2dd225..2918948 100644 --- a/lib/src/helpers/fonts_loader.dart +++ b/lib/src/helpers/fonts_loader.dart @@ -3,7 +3,10 @@ import 'dart:convert'; import 'dart:io'; +import 'package:adaptive_test/src/configuration.dart'; +import 'package:adaptive_test/src/helpers/font_loading_policy.dart'; import 'package:adaptive_test/src/helpers/font_registration.dart'; +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:package_config/package_config.dart'; @@ -16,13 +19,27 @@ import 'package:package_config/package_config.dart'; /// 2. Add `await loadFonts();` in the `testExecutable` function. /// /// Note: Your package must include all used fonts as assets for this to work. +/// +/// The families the framework falls back to on each platform are loaded too. +/// Opt out with +/// `AdaptiveTestConfiguration.instance.setLoadPlatformFallbackFonts(false)`. Future> loadFonts() async { TestWidgetsFlutterBinding.ensureInitialized(); + final policy = AdaptiveTestConfiguration.instance.loadPlatformFallbackFonts + ? FontLoadingPolicy.platformAware() + : const FontLoadingPolicy.manifestOnly(); - return _loadFontsFromManifest( + final manifestFamilies = await _loadFontsFromManifest( await _loadFontManifest(), packageName: await _getCurrentPackageName(), + policy: policy, + ); + final providedFamilies = await loadPlatformFallbackFonts( + familiesToProvide: policy.familiesToProvide, + alreadyLoaded: manifestFamilies, ); + + return {...manifestFamilies, ...providedFamilies}; } Future<_FontManifest> _loadFontManifest() async { @@ -37,6 +54,7 @@ Future<_FontManifest> _loadFontManifest() async { Future> _loadFontsFromManifest( _FontManifest fontManifest, { required String? packageName, + required FontLoadingPolicy policy, }) async { final loadings = fontManifest.expand((font) { final fontFamilyStartsWithPackages = font.family.startsWith('packages/'); @@ -51,7 +69,8 @@ Future> _loadFontsFromManifest( _loadFontFamily(font.family, font.fonts), if (!fontFamilyStartsWithPackages && packageName != null) _loadFontFamily('packages/$packageName/${font.family}', font.fonts), - if (bareFamily != null) _loadFontFamily(bareFamily, font.fonts), + if (bareFamily != null && policy.registersBareFamilyName(bareFamily)) + _loadFontFamily(bareFamily, font.fonts), ]; }).toList(); diff --git a/lib/src/helpers/platform_fonts.dart b/lib/src/helpers/platform_fonts.dart new file mode 100644 index 0000000..cb6405d --- /dev/null +++ b/lib/src/helpers/platform_fonts.dart @@ -0,0 +1,203 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:adaptive_test/src/helpers/font_registration.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; + +/// The font families the framework itself falls back to when a widget does not +/// specify one, gathered from the SDK rather than hardcoded so that the list +/// keeps up with Flutter (`Roboto`, `CupertinoSystemText`, `Segoe UI`, ...). +/// +/// None of those families are declared in a `pubspec.yaml`: they are provided +/// by the host OS or bundled with the engine, so [loadFonts] cannot find them +/// in the font manifest. +@internal +Set platformDefaultFontFamilies() { + const cupertinoTextTheme = CupertinoTextThemeData(); + + return { + ...TargetPlatform.values + .map( + (targetPlatform) => Typography.material2021(platform: targetPlatform), + ) + .expand(_textThemesOf) + .expand(_representativeStylesOf) + .expand(_familiesOf), + ...[ + cupertinoTextTheme.textStyle, + cupertinoTextTheme.navTitleTextStyle, + cupertinoTextTheme.navLargeTitleTextStyle, + cupertinoTextTheme.actionTextStyle, + cupertinoTextTheme.tabLabelTextStyle, + cupertinoTextTheme.pickerTextStyle, + ].expand(_familiesOf), + }; +} + +Iterable _textThemesOf(Typography typography) => [ + typography.black, + typography.white, + typography.englishLike, + typography.dense, + typography.tall, + ]; + +/// The families do not vary with the size within a text theme, so a few styles +/// are enough to collect them all. +Iterable _representativeStylesOf(TextTheme textTheme) => [ + textTheme.displayLarge, + textTheme.headlineMedium, + textTheme.titleMedium, + textTheme.bodyMedium, + textTheme.labelLarge, + ]; + +Iterable _familiesOf(TextStyle? style) => [ + if (style?.fontFamily case final family?) family, + ...?style?.fontFamilyFallback, + ]; + +/// Locates the fonts shipped with the Flutter SDK +/// (`$FLUTTER_ROOT/bin/cache/artifacts/material_fonts`). +/// +/// `FLUTTER_ROOT` is exported by `flutter test`, but the directory is also +/// resolved by walking up from the test executable, which lives inside the +/// same `bin/cache` tree, so this keeps working in environments where the +/// variable is not set. +Directory? _findMaterialFontsDirectory() { + const dirName = 'material_fonts'; + final flutterRoot = Platform.environment['FLUTTER_ROOT']; + + final candidatePaths = [ + if (flutterRoot != null) + path.join(flutterRoot, 'bin', 'cache', 'artifacts', dirName), + // e.g. $FLUTTER_ROOT/bin/cache/artifacts/engine/darwin-x64/flutter_tester + ..._ancestorsOf(File(Platform.resolvedExecutable).parent.path) + .map((ancestor) => path.join(ancestor, dirName)), + ]; + + return candidatePaths + .map(Directory.new) + .firstWhereOrNull((directory) => directory.existsSync()); +} + +/// The given directory and all its parents, closest first. +Iterable _ancestorsOf(String directoryPath) { + final segments = path.split(directoryPath); + + return List.generate( + segments.length, + (index) => path.joinAll(segments.take(segments.length - index)), + ); +} + +/// The font files the Flutter SDK ships, grouped by the family they belong to. +/// +/// Empty when the SDK font cache cannot be located. +@visibleForTesting +Map> sdkFontFilesByFamily() { + const fontExtensions = ['.ttf', '.otf']; + final fontsDirectory = _findMaterialFontsDirectory(); + if (fontsDirectory == null || !fontsDirectory.existsSync()) return const {}; + + final fontFiles = fontsDirectory + .listSync() + .whereType() + .where((file) => fontExtensions.contains(path.extension(file.path))) + // A directory listing comes back in file system order, which differs + // between APFS, ext4 and NTFS, and the fonts must be registered in the + // same order on every machine for the goldens to match. + .sortedBy((file) => file.path); + + return Map.fromEntries( + fontFiles.groupListsBy(_familyNameOf).entries.expand( + (entry) => [ + if (entry.key case final family?) MapEntry(family, entry.value), + ], + ), + ); +} + +/// The family a font file belongs to, e.g. `Roboto-BoldItalic.ttf` belongs to +/// `Roboto`. +/// +/// Null for a file name that carries no family at all, which the fonts shipped +/// by the SDK always do: such a file is left out rather than grouped under a +/// made up name. +String? _familyNameOf(File file) => + path.basenameWithoutExtension(file.path).split('-').firstOrNull; + +/// The font families the Flutter SDK ships font files for. +/// +/// Empty when the SDK font cache cannot be located. +@internal +Set sdkFontFamilies() => sdkFontFilesByFamily().keys.toSet(); + +/// Registers the [familiesToProvide] that [alreadyLoaded] does not cover, so +/// that text laid out with a platform default renders real glyphs instead of +/// placeholder blocks, and returns the families it registered. +/// +/// Families that the SDK actually ships (`Roboto`) are loaded from their own +/// files. The remaining ones are provided by the host OS and cannot be loaded +/// at all — Apple's `CupertinoSystemText` for instance — so they are aliased +/// to the face the SDK ships. Glyphs then differ from a real device, but the +/// goldens stay readable and stable. +@internal +Future> loadPlatformFallbackFonts({ + required Set familiesToProvide, + required Set alreadyLoaded, +}) async { + final missingFamilies = familiesToProvide.difference(alreadyLoaded); + if (missingFamilies.isEmpty) return const {}; + + final availableFamilies = sdkFontFilesByFamily(); + final frameworkFallbackFamily = _frameworkFallbackFamily( + availableFamilies.keys, + familiesToProvide, + ); + if (frameworkFallbackFamily == null) return const {}; + + // Only the files that are about to be registered are read, and each one only + // once: the same bytes are reused by every family aliased to them. + final ownAssets = Map>.fromEntries( + availableFamilies.entries + .where((entry) => missingFamilies.contains(entry.key)) + .map((entry) => MapEntry(entry.key, _readAll(entry.value))), + ); + final frameworkFallbackAssets = ownAssets[frameworkFallbackFamily] ?? + _readAll(availableFamilies[frameworkFallbackFamily] ?? const []); + + final registeredFamilies = await Future.wait( + missingFamilies.map( + (family) => registerFontFamily( + family, + (ownAssets[family] ?? frameworkFallbackAssets) + .map(Future.value), + ), + ), + ); + + return registeredFamilies.toSet(); +} + +/// The family the framework falls back to that the SDK ships a font for, used +/// to stand in for the families only the host OS can provide. +/// +/// Derived from what the framework defaults to, so no typeface name has to be +/// hardcoded here. +String? _frameworkFallbackFamily( + Iterable availableFamilies, + Set platformFamilies, +) => + availableFamilies + .toSet() + .intersection(platformFamilies) + .sorted((a, b) => a.compareTo(b)) + .firstOrNull; + +List _readAll(Iterable files) => + files.map((file) => ByteData.view(file.readAsBytesSync().buffer)).toList(); diff --git a/pubspec.yaml b/pubspec.yaml index 6fca261..88c0a1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ environment: flutter: '>=1.17.0' dependencies: + collection: ^1.18.0 cupertino_icons: ^1.0.5 equatable: ^2.0.5 file: ^7.0.0 diff --git a/test/helpers/font_loading_policy_test.dart b/test/helpers/font_loading_policy_test.dart new file mode 100644 index 0000000..fa93499 --- /dev/null +++ b/test/helpers/font_loading_policy_test.dart @@ -0,0 +1,47 @@ +import 'package:adaptive_test/src/helpers/font_loading_policy.dart'; +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + group('registersBareFamilyName', () { + const policy = FontLoadingPolicy( + platformFamilies: {'Roboto', 'CupertinoSystemText'}, + sdkFamilies: {'Roboto'}, + ); + + test('registers a family that is not a platform default', () { + expect(policy.registersBareFamilyName('ThemeSans'), isTrue); + }); + + test('registers a platform default the SDK ships no font for', () { + // Nothing else can provide it, a stand-in beats placeholder blocks. + expect(policy.registersBareFamilyName('CupertinoSystemText'), isTrue); + }); + + test('leaves a platform default the SDK ships a font for alone', () { + // A dependency usually bundles a single weight of it, the SDK ships all + // of them, so the SDK font stays closer to a real device. + expect(policy.registersBareFamilyName('Roboto'), isFalse); + }); + }); + + group('FontLoadingPolicy.platformAware', () { + test('provides the platform default families', () { + final policy = FontLoadingPolicy.platformAware(); + + expect(policy.familiesToProvide, equals(platformDefaultFontFamilies())); + expect(policy.sdkFamilies, equals(sdkFontFamilies())); + }); + }); + + group('FontLoadingPolicy.manifestOnly', () { + test('provides nothing on top of the manifest', () { + const policy = FontLoadingPolicy.manifestOnly(); + + expect(policy.familiesToProvide, isEmpty); + expect(policy.registersBareFamilyName('Roboto'), isTrue); + }); + }); +} diff --git a/test/helpers/font_registration_test.dart b/test/helpers/font_registration_test.dart index 0e3c6dc..8c7ea92 100644 --- a/test/helpers/font_registration_test.dart +++ b/test/helpers/font_registration_test.dart @@ -1,6 +1,9 @@ import 'package:adaptive_test/adaptive_test.dart'; +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'font_test_helpers.dart'; + void main() { setUpAll(TestWidgetsFlutterBinding.ensureInitialized); @@ -10,5 +13,20 @@ void main() { expect(family, 'AFamily'); }); + + test('registers the assets it is given', () async { + // A proportional face, so that the width heuristic can tell it apart + // from the placeholder font — an icon font would not do. + final proportionalFace = sdkFontFilesByFamily().entries.firstWhere( + (entry) => platformDefaultFontFamilies().contains(entry.key), + ); + + await registerFontFamily( + 'ARealFamily', + proportionalFace.value.toByteData(), + ); + + expect(rendersWithARealFont('ARealFamily'), isTrue); + }); }); } diff --git a/test/helpers/font_test_helpers.dart b/test/helpers/font_test_helpers.dart new file mode 100644 index 0000000..7d2aa46 --- /dev/null +++ b/test/helpers/font_test_helpers.dart @@ -0,0 +1,36 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +/// Whether text laid out with [fontFamily] is rendered with a real font. +/// +/// The placeholder font used by `flutter_test` when a family is unknown gives +/// every glyph the same advance, so a narrow and a wide string of the same +/// length measure exactly the same. Any real proportional font measures them +/// differently, which makes this a reliable way to assert a font was loaded +/// without comparing golden files. +bool rendersWithARealFont(String fontFamily) { + double widthOf(String text) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle(fontFamily: fontFamily, fontSize: 40), + ), + textDirection: TextDirection.ltr, + )..layout(); + final width = painter.width; + painter.dispose(); + + return width; + } + + return widthOf('iiii') != widthOf('WWWW'); +} + +extension FontFilesToAssets on Iterable { + /// The font files as the byte data a font loader expects. + Iterable> toByteData() => map( + (file) async => ByteData.view((await file.readAsBytes()).buffer), + ); +} diff --git a/test/helpers/fonts_loader_test.dart b/test/helpers/fonts_loader_test.dart index f1d55dd..1f9fa1a 100644 --- a/test/helpers/fonts_loader_test.dart +++ b/test/helpers/fonts_loader_test.dart @@ -1,6 +1,10 @@ import 'package:adaptive_test/adaptive_test.dart'; +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'font_test_helpers.dart'; + void main() { late Set loadedFamilies; @@ -26,5 +30,28 @@ void main() { // asks for the bare name, not the manifest one. expect(loadedFamilies, contains('CupertinoIcons')); }); + + test('registers every font family the framework falls back to', () { + expect( + platformDefaultFontFamilies().difference(loadedFamilies), + isEmpty, + ); + }); + + test('makes the platform default families render real glyphs', () { + final blockRendering = platformDefaultFontFamilies().whereNot( + rendersWithARealFont, + ); + + expect( + blockRendering, + isEmpty, + reason: 'those families render with the placeholder test font', + ); + }); + + test('leaves unknown families to the placeholder font', () { + expect(rendersWithARealFont('NotARegisteredFontFamily'), isFalse); + }); }); } diff --git a/test/helpers/platform_fonts_test.dart b/test/helpers/platform_fonts_test.dart new file mode 100644 index 0000000..a3c1f6d --- /dev/null +++ b/test/helpers/platform_fonts_test.dart @@ -0,0 +1,142 @@ +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + group('platformDefaultFontFamilies', () { + test('collects the families of every target platform typography', () { + final materialFamilies = TargetPlatform.values + .map( + (targetPlatform) => Typography.material2021( + platform: targetPlatform, + ).black.bodyMedium?.fontFamily, + ) + .nonNulls + .toSet(); + + expect(materialFamilies, isNotEmpty); + expect(platformDefaultFontFamilies(), containsAll(materialFamilies)); + }); + + test('collects the Cupertino default family', () { + const cupertinoTextTheme = CupertinoTextThemeData(); + final cupertinoFamily = cupertinoTextTheme.textStyle.fontFamily; + + expect(cupertinoFamily, isNotNull); + expect(platformDefaultFontFamilies(), contains(cupertinoFamily)); + }); + + test('collects the fallbacks declared by the styles', () { + final fallbacks = TargetPlatform.values + .expand( + (targetPlatform) => + Typography.material2021(platform: targetPlatform) + .black + .bodyMedium + ?.fontFamilyFallback ?? + const [], + ) + .toSet(); + + expect(platformDefaultFontFamilies(), containsAll(fallbacks)); + }); + }); + + group('determinism across machines', () { + test('registers the SDK font files in a file system agnostic order', () { + // A directory listing comes back in file system order, which differs + // between APFS, ext4 and NTFS. + final filesByFamily = sdkFontFilesByFamily(); + + final unsortedFamilies = filesByFamily.entries.where((entry) { + final paths = entry.value.map((file) => file.path).toList(); + + return !paths.equals(paths.sorted((a, b) => a.compareTo(b))); + }).map((entry) => entry.key); + + expect(filesByFamily, isNotEmpty); + expect(unsortedFamilies, isEmpty); + }); + + test('does not depend on the host platform', () { + // The families are enumerated for every TargetPlatform, not for the one + // the test happens to run on. + final families = platformDefaultFontFamilies(); + + final perHostPlatform = TargetPlatform.values.map((targetPlatform) { + debugDefaultTargetPlatformOverride = targetPlatform; + + return platformDefaultFontFamilies(); + }).toList(); + debugDefaultTargetPlatformOverride = null; + + expect( + perHostPlatform.where((collected) => !setEquals(collected, families)), + isEmpty, + ); + }); + }); + + group('sdkFontFamilies', () { + test('lists the families the SDK ships', () { + // Also covers locating the SDK font cache, which callers cannot do + // themselves anymore. + final families = sdkFontFamilies(); + + expect(families, isNotEmpty); + expect( + families.intersection(platformDefaultFontFamilies()), + isNotEmpty, + reason: 'the SDK ships at least one of the platform default families', + ); + }); + }); + + group('loadPlatformFallbackFonts', () { + test('registers every family it is asked to provide', () async { + final registeredFamilies = await loadPlatformFallbackFonts( + familiesToProvide: platformDefaultFontFamilies(), + alreadyLoaded: const {}, + ); + + expect(registeredFamilies, containsAll(platformDefaultFontFamilies())); + }); + + test('aliases the families the SDK does not ship any file for', () async { + final osProvidedFamilies = + platformDefaultFontFamilies().difference(sdkFontFamilies()); + + final registeredFamilies = await loadPlatformFallbackFonts( + familiesToProvide: platformDefaultFontFamilies(), + alreadyLoaded: const {}, + ); + + // e.g. Apple's CupertinoSystemText, which no font file can provide. + expect(osProvidedFamilies, isNotEmpty); + expect(registeredFamilies, containsAll(osProvidedFamilies)); + }); + + test('skips the families already loaded', () async { + final registeredFamilies = await loadPlatformFallbackFonts( + familiesToProvide: platformDefaultFontFamilies(), + alreadyLoaded: platformDefaultFontFamilies(), + ); + + expect(registeredFamilies, isEmpty); + }); + + test('registers nothing when there is nothing to provide', () async { + final registeredFamilies = await loadPlatformFallbackFonts( + familiesToProvide: const {}, + alreadyLoaded: const {}, + ); + + expect(registeredFamilies, isEmpty); + }); + }); +} From 48406f60694b25f46d8547b4eb0a529850f2c805 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Fri, 31 Jul 2026 14:59:54 +0200 Subject: [PATCH 3/7] feat: :sparkles: warn when a golden asks for an unregistered font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A missing font is invisible until someone looks at the golden and notices the blocks, which is exactly what a reviewer skims past. Before taking the snapshot, `expectGolden` now collects the families the tree asks for, compares them against the ones `loadFonts()` registered, and reports the difference. A family is only reported when none of the fallbacks of the style is covered either, since the engine would have a real font to use in that case. Warnings are on by default and emitted once per family. Turn them into failures — recommended on CI once the suite is clean — or silence them with `setMissingFontsBehavior`. `findUnregisteredFontFamilies` and `reportUnregisteredFontFamilies` take what they need and return what they found, so the state that has to survive between `flutter_test_config.dart` and a test file lives in the configuration the package already exposes rather than in globals of their own. Co-Authored-By: Claude Opus 5 (1M context) --- lib/adaptive_test.dart | 1 + lib/src/adaptive/adaptive_test.dart | 32 ++++- lib/src/configuration.dart | 36 ++++++ lib/src/helpers/fonts_loader.dart | 9 +- lib/src/helpers/missing_fonts.dart | 99 ++++++++++++++ test/helpers/fonts_loader_test.dart | 8 ++ test/helpers/missing_fonts_test.dart | 184 +++++++++++++++++++++++++++ 7 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 lib/src/helpers/missing_fonts.dart create mode 100644 test/helpers/missing_fonts_test.dart diff --git a/lib/adaptive_test.dart b/lib/adaptive_test.dart index 50cbecd..3670a43 100644 --- a/lib/adaptive_test.dart +++ b/lib/adaptive_test.dart @@ -8,6 +8,7 @@ export 'src/helpers/await_images.dart'; export 'src/helpers/font_registration.dart'; export 'src/helpers/fonts_loader.dart'; export 'src/helpers/goldens_difference.dart'; +export 'src/helpers/missing_fonts.dart'; export 'src/helpers/skip_test_extension.dart'; export 'src/adaptive/window_config_data/window_config_data.dart'; export 'src/adaptive/window_config_data/system_nav_bar_data.dart' diff --git a/lib/src/adaptive/adaptive_test.dart b/lib/src/adaptive/adaptive_test.dart index 47f6f38..f087ed9 100644 --- a/lib/src/adaptive/adaptive_test.dart +++ b/lib/src/adaptive/adaptive_test.dart @@ -5,6 +5,7 @@ import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data import 'package:adaptive_test/src/adaptive/window_configuration_tester.dart'; import 'package:adaptive_test/src/configuration.dart'; import 'package:adaptive_test/src/helpers/await_images.dart'; +import 'package:adaptive_test/src/helpers/missing_fonts.dart'; import 'package:adaptive_test/src/helpers/skip_test_extension.dart'; import 'package:adaptive_test/src/helpers/target_platform_extension.dart'; import 'package:flutter/foundation.dart'; @@ -120,12 +121,39 @@ extension Adaptive on WidgetTester { await awaitImages(); } + // Find by its type except if the widget's unique key was given. + final finder = + byKey != null ? find.byKey(byKey) : find.byType(AdaptiveWrapper); + + _reportMissingFonts(finder); + final key = path ?? 'preview/${windowConfig.name}-${name.snakeCase}$localSuffix.png'; await expectLater( - // Find by its type except if the widget's unique key was given. - byKey != null ? find.byKey(byKey) : find.byType(AdaptiveWrapper), + finder, matchesGoldenFile(key, version: version), ); } + + /// Warns about the font families the snapshotted tree asks for while no font + /// is registered for them: they render as placeholder blocks in the golden. + void _reportMissingFonts(Finder finder) { + final configuration = AdaptiveTestConfiguration.instance; + if (configuration.missingFontsBehavior == MissingFontsBehavior.ignore) { + return; + } + + final renderObject = finder.evaluate().firstOrNull?.renderObject; + if (renderObject == null) return; + + final warnedFamilies = reportUnregisteredFontFamilies( + findUnregisteredFontFamilies( + renderObject, + loadedFamilies: configuration.loadedFontFamilies, + ), + configuration.missingFontsBehavior, + alreadyWarned: configuration.warnedFontFamilies, + ); + configuration.addWarnedFontFamilies(warnedFamilies); + } } diff --git a/lib/src/configuration.dart b/lib/src/configuration.dart index eaabc8a..e8002d2 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -1,5 +1,6 @@ import 'package:adaptive_test/src/adaptive/window_config.dart'; import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data.dart'; +import 'package:adaptive_test/src/helpers/missing_fonts.dart'; import 'package:flutter/material.dart'; /// Singleton class that configures global variables for the test. @@ -61,6 +62,41 @@ class AdaptiveTestConfiguration { _loadPlatformFallbackFonts = loadPlatformFallbackFonts; } + final Set _loadedFontFamilies = {}; + + /// The font families [loadFonts] registered in this test isolate. + Set get loadedFontFamilies => Set.unmodifiable(_loadedFontFamilies); + + /// Records the families [loadFonts] registered, so that [expectGolden] can + /// tell whether the text it snapshots will render real glyphs. + void addLoadedFontFamilies(Iterable families) { + _loadedFontFamilies.addAll(families); + } + + final Set _warnedFontFamilies = {}; + + /// The missing font families [expectGolden] already warned about, see + /// [MissingFontsBehavior.warn]. + Set get warnedFontFamilies => Set.unmodifiable(_warnedFontFamilies); + + /// Records the families that were warned about, so that a gap shared by many + /// goldens is only reported once. + void addWarnedFontFamilies(Iterable families) { + _warnedFontFamilies.addAll(families); + } + + MissingFontsBehavior _missingFontsBehavior = MissingFontsBehavior.warn; + + MissingFontsBehavior get missingFontsBehavior => _missingFontsBehavior; + + /// What [expectGolden] does when the widget tree asks for a font family that + /// no font has been registered for, see [MissingFontsBehavior]. + /// + /// Defaults to [MissingFontsBehavior.warn]. + void setMissingFontsBehavior(MissingFontsBehavior missingFontsBehavior) { + _missingFontsBehavior = missingFontsBehavior; + } + WindowVariant? _deviceVariant; WindowVariant get deviceVariant { diff --git a/lib/src/helpers/fonts_loader.dart b/lib/src/helpers/fonts_loader.dart index 2918948..4e10dff 100644 --- a/lib/src/helpers/fonts_loader.dart +++ b/lib/src/helpers/fonts_loader.dart @@ -23,6 +23,10 @@ import 'package:package_config/package_config.dart'; /// The families the framework falls back to on each platform are loaded too. /// Opt out with /// `AdaptiveTestConfiguration.instance.setLoadPlatformFallbackFonts(false)`. +/// +/// The registered families are also recorded in the +/// [AdaptiveTestConfiguration], which is how [expectGolden] knows whether the +/// text it snapshots will render real glyphs. Future> loadFonts() async { TestWidgetsFlutterBinding.ensureInitialized(); final policy = AdaptiveTestConfiguration.instance.loadPlatformFallbackFonts @@ -39,7 +43,10 @@ Future> loadFonts() async { alreadyLoaded: manifestFamilies, ); - return {...manifestFamilies, ...providedFamilies}; + final loadedFamilies = {...manifestFamilies, ...providedFamilies}; + AdaptiveTestConfiguration.instance.addLoadedFontFamilies(loadedFamilies); + + return loadedFamilies; } Future<_FontManifest> _loadFontManifest() async { diff --git a/lib/src/helpers/missing_fonts.dart b/lib/src/helpers/missing_fonts.dart new file mode 100644 index 0000000..07b3704 --- /dev/null +++ b/lib/src/helpers/missing_fonts.dart @@ -0,0 +1,99 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// What to do when a widget tree asks for a font family that no font has been +/// registered for. Such text renders as placeholder blocks in goldens, which is +/// easy to miss when reviewing them. +enum MissingFontsBehavior { + /// Do not check for missing font families. + ignore, + + /// Print a warning listing the missing families. This is the default. + warn, + + /// Fail the test. Recommended on CI once the suite is clean. + fail, +} + +/// The font families the rendered text asks for while [loadedFamilies], as +/// returned by [loadFonts], does not cover them. +/// +/// A family is only reported when none of the fallbacks of the style is covered +/// either, since the engine would then have a real font to use. +Set findUnregisteredFontFamilies( + RenderObject root, { + required Set loadedFamilies, +}) { + final unregisteredFamilies = {}; + + void visitStyle(TextStyle? style) { + final family = style?.fontFamily; + if (style == null || family == null) return; + + final candidates = [family, ...?style.fontFamilyFallback]; + if (candidates.any(loadedFamilies.contains)) return; + + unregisteredFamilies.add(family); + } + + void visitSpan(InlineSpan span) { + if (span is TextSpan) visitStyle(span.style); + span.visitChildren((child) { + if (child != span) visitSpan(child); + + return true; + }); + } + + void visitRenderObject(RenderObject renderObject) { + switch (renderObject) { + case final RenderParagraph paragraph: + visitSpan(paragraph.text); + case final RenderEditable editable: + if (editable.text case final text?) visitSpan(text); + default: + break; + } + renderObject.visitChildren(visitRenderObject); + } + + visitRenderObject(root); + + return unregisteredFamilies; +} + +/// Reports the [families] no font is registered for, according to [behavior], +/// and returns the ones it warned about. +/// +/// The families of [alreadyWarned] are skipped, so that the output stays +/// readable when many goldens share the same gap. +Set reportUnregisteredFontFamilies( + Set families, + MissingFontsBehavior behavior, { + Set alreadyWarned = const {}, +}) { + if (behavior == MissingFontsBehavior.ignore || families.isEmpty) { + return const {}; + } + + if (behavior == MissingFontsBehavior.fail) { + throw TestFailure(_message(families)); + } + + final familiesToWarnAbout = families.difference(alreadyWarned); + if (familiesToWarnAbout.isEmpty) return const {}; + + debugPrint(_message(familiesToWarnAbout)); + + return familiesToWarnAbout; +} + +String _message(Set families) { + final sortedFamilies = families.toList()..sort(); + + return ''' +adaptive_test: no font is registered for ${sortedFamilies.map((family) => "'$family'").join(', ')}. +The text using ${sortedFamilies.length > 1 ? 'those families' : 'that family'} renders as placeholder blocks in the golden. +Declare the font in your pubspec.yaml so that loadFonts() picks it up, or, if it +is provided by the host OS, register a stand-in with FontLoader before the test.'''; +} diff --git a/test/helpers/fonts_loader_test.dart b/test/helpers/fonts_loader_test.dart index 1f9fa1a..bacefe3 100644 --- a/test/helpers/fonts_loader_test.dart +++ b/test/helpers/fonts_loader_test.dart @@ -50,6 +50,14 @@ void main() { ); }); + test('records the loaded families in the configuration', () { + // This is how expectGolden knows what will render real glyphs. + expect( + AdaptiveTestConfiguration.instance.loadedFontFamilies, + containsAll(loadedFamilies), + ); + }); + test('leaves unknown families to the placeholder font', () { expect(rendersWithARealFont('NotARegisteredFontFamily'), isFalse); }); diff --git a/test/helpers/missing_fonts_test.dart b/test/helpers/missing_fonts_test.dart new file mode 100644 index 0000000..0452704 --- /dev/null +++ b/test/helpers/missing_fonts_test.dart @@ -0,0 +1,184 @@ +import 'package:adaptive_test/adaptive_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _registeredFamily = 'ARegisteredFamily'; +const _unregisteredFamily = 'AnUnregisteredFamily'; + +void main() { + late Set loadedFamilies; + + setUpAll(() async { + // The app fonts and the platform defaults are loaded, so that only the + // families these tests introduce are reported as missing. + loadedFamilies = { + ...await loadFonts(), + await registerFontFamily(_registeredFamily, const []), + }; + }); + + group('findUnregisteredFontFamilies', () { + Future> familiesOf(WidgetTester tester, Widget child) async { + // Wrapped in a Scaffold: a bare MaterialApp home inherits the debug + // error text style, which brings its own font family along. + await tester.pumpWidget(MaterialApp(home: Scaffold(body: child))); + + // ignore: avoid-non-null-assertion, the tree has just been pumped + return findUnregisteredFontFamilies( + tester.element(find.byWidget(child)).renderObject!, + loadedFamilies: loadedFamilies, + ); + } + + testWidgets('reports a family no font is registered for', (tester) async { + final families = await familiesOf( + tester, + const Text( + 'Hello', + style: TextStyle(fontFamily: _unregisteredFamily), + ), + ); + + expect(families, {_unregisteredFamily}); + }); + + testWidgets('ignores a registered family', (tester) async { + final families = await familiesOf( + tester, + const Text('Hello', style: TextStyle(fontFamily: _registeredFamily)), + ); + + expect(families, isEmpty); + }); + + testWidgets('ignores a family with a registered fallback', (tester) async { + final families = await familiesOf( + tester, + const Text( + 'Hello', + style: TextStyle( + fontFamily: _unregisteredFamily, + fontFamilyFallback: [_registeredFamily], + ), + ), + ); + + expect(families, isEmpty); + }); + + testWidgets('reports the families of nested spans', (tester) async { + final families = await familiesOf( + tester, + const Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Hello', + style: TextStyle(fontFamily: _registeredFamily), + ), + TextSpan( + text: 'World', + style: TextStyle(fontFamily: _unregisteredFamily), + ), + ], + ), + ), + ); + + expect(families, {_unregisteredFamily}); + }); + + testWidgets('reports the family of an editable text', (tester) async { + final families = await familiesOf( + tester, + TextField( + controller: TextEditingController(text: 'Hello'), + style: const TextStyle(fontFamily: _unregisteredFamily), + ), + ); + + expect(families, contains(_unregisteredFamily)); + }); + }); + + group('reportUnregisteredFontFamilies', () { + List capturePrints(void Function() body) { + final logs = []; + final previous = debugPrint; + debugPrint = (message, {wrapWidth}) => logs.add(message ?? ''); + try { + body(); + } finally { + debugPrint = previous; + } + + return logs; + } + + test('warns about the families it is given', () { + late Set warnedFamilies; + final logs = capturePrints(() { + warnedFamilies = reportUnregisteredFontFamilies( + {_unregisteredFamily}, + MissingFontsBehavior.warn, + ); + }); + + expect(warnedFamilies, {_unregisteredFamily}); + expect(logs, hasLength(1)); + expect(logs.single, contains(_unregisteredFamily)); + }); + + test('stays silent about a family already warned about', () { + late Set warnedFamilies; + final logs = capturePrints(() { + warnedFamilies = reportUnregisteredFontFamilies( + {_unregisteredFamily}, + MissingFontsBehavior.warn, + alreadyWarned: {_unregisteredFamily}, + ); + }); + + expect(warnedFamilies, isEmpty); + expect(logs, isEmpty); + }); + + test('stays silent when there is nothing to report', () { + final logs = capturePrints( + () => reportUnregisteredFontFamilies( + const {}, + MissingFontsBehavior.warn, + ), + ); + + expect(logs, isEmpty); + }); + + test('stays silent when ignoring', () { + final logs = capturePrints( + () => reportUnregisteredFontFamilies( + {_unregisteredFamily}, + MissingFontsBehavior.ignore, + ), + ); + + expect(logs, isEmpty); + }); + + test('fails the test when configured to', () { + expect( + () => reportUnregisteredFontFamilies( + {_unregisteredFamily}, + MissingFontsBehavior.fail, + ), + throwsA( + isA().having( + (failure) => failure.message, + 'message', + contains(_unregisteredFamily), + ), + ), + ); + }); + }); +} From e6907fc5b58b92a90ca02f01533b97656575e1c9 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Fri, 31 Jul 2026 15:00:04 +0200 Subject: [PATCH 4/7] ci: :construction_worker: run the package unit tests The matrix only covered the three example apps, so nothing under `test/` ever ran on CI. Format and analyze are left out of this job for now: the package sources still carry pre-existing analyzer infos. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-flutter.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-flutter.yaml b/.github/workflows/test-flutter.yaml index 363c041..23120cd 100644 --- a/.github/workflows/test-flutter.yaml +++ b/.github/workflows/test-flutter.yaml @@ -18,6 +18,8 @@ jobs: name: multi_packages_app_app - dir: example/multi_packages_app/theme name: multi_packages_app_theme + - dir: . + name: adaptive_test defaults: run: working-directory: ${{ matrix.dir }} From 3b647af0d45a6e4fa5bbfdff84f24093b3168f40 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Fri, 31 Jul 2026 15:00:04 +0200 Subject: [PATCH 5/7] docs: :memo: document font loading and missing font detection Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 091716d..0ce8b62 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,41 @@ Future testExecutable(FutureOr Function() testMain) async { > ℹ️ `loadFonts()` loads fonts from `pubspec.yaml` and from every separate package dependency as well. +A font bundled by a dependency is registered under both its manifest name +(`packages/my_theme/Roboto`) and its bare family name (`Roboto`), since that is +the name a `TextStyle` or a `ThemeData` asks for. + +`loadFonts()` also registers the families the framework itself falls back to on +each platform — `Roboto` on Android, `CupertinoSystemText` on iOS, `Segoe UI` on +Windows, ... They are declared in no `pubspec.yaml`: they either ship with the +Flutter SDK or belong to the host OS. Without them, any widget that does not +specify a font — a `DatePickerDialog`, a `CupertinoButton` — renders as +placeholder blocks in your goldens. + +Opt out to register nothing but what the manifest declares: + +```dart +AdaptiveTestConfiguration.instance.setLoadPlatformFallbackFonts(false); +``` + +#### Catching a missing font + +When a golden is taken, `expectGolden` checks the font families the widget tree +asks for against the ones that were loaded, and warns about the text that will +render as blocks: + +``` +adaptive_test: no font is registered for 'Poppins'. +The text using that family renders as placeholder blocks in the golden. +``` + +Make it a failure — recommended on CI once your suite is clean — or silence it: + +```dart +AdaptiveTestConfiguration.instance + .setMissingFontsBehavior(MissingFontsBehavior.fail); +``` + ### Setting Up Test Devices 1. Define a set of device variants: From 97a23a1588e581da9b3b05f93f091f4de36a9045 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Fri, 31 Jul 2026 15:00:04 +0200 Subject: [PATCH 6/7] chore: :bookmark: bump package to 0.11.0 Goldens that contained placeholder blocks change with this version, hence the minor bump rather than a patch. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 16 ++++++++++++++++ example/multi_packages_app/app/pubspec.lock | 2 +- example/multi_packages_app/theme/pubspec.lock | 2 +- example/simple_app/pubspec.lock | 2 +- pubspec.yaml | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a56c17..1150c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 0.11.0 + +- fix: **BREAKING CHANGE** — `loadFonts()` now also loads the system fonts, on + top of the ones the font manifest declares: the families the framework falls + back to on each platform (`Roboto`, `CupertinoSystemText`, `Segoe UI`, ...), + and a dependency's font under its bare family name (`Roboto`) as well as its + manifest name (`packages/my_theme/Roboto`). Widgets that do not specify a font + — a `DatePickerDialog` for instance — used to render as placeholder blocks in + goldens. Opt out with + `AdaptiveTestConfiguration.instance.setLoadPlatformFallbackFonts(false)`. +- feat: `expectGolden` warns when the snapshotted tree asks for a font family no + font is registered for. Configure it with + `AdaptiveTestConfiguration.instance.setMissingFontsBehavior(...)`. +- **Goldens that contained placeholder blocks change with this version**, run + `flutter test --update-goldens` to regenerate them. + ## 0.10.4 - fix: catch flaky offstage widget finder errors in awaitImages diff --git a/example/multi_packages_app/app/pubspec.lock b/example/multi_packages_app/app/pubspec.lock index f5c0d3e..8620f96 100644 --- a/example/multi_packages_app/app/pubspec.lock +++ b/example/multi_packages_app/app/pubspec.lock @@ -7,7 +7,7 @@ packages: path: "../../.." relative: true source: path - version: "0.10.3" + version: "0.11.0" async: dependency: transitive description: diff --git a/example/multi_packages_app/theme/pubspec.lock b/example/multi_packages_app/theme/pubspec.lock index 2eb15f1..4621e83 100644 --- a/example/multi_packages_app/theme/pubspec.lock +++ b/example/multi_packages_app/theme/pubspec.lock @@ -7,7 +7,7 @@ packages: path: "../../.." relative: true source: path - version: "0.10.3" + version: "0.11.0" async: dependency: transitive description: diff --git a/example/simple_app/pubspec.lock b/example/simple_app/pubspec.lock index aa29345..cc6bc5b 100644 --- a/example/simple_app/pubspec.lock +++ b/example/simple_app/pubspec.lock @@ -7,7 +7,7 @@ packages: path: "../.." relative: true source: path - version: "0.10.3" + version: "0.11.0" async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 88c0a1f..97a5769 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: adaptive_test description: >- A Flutter package to generate adaptive golden files during widget tests. -version: 0.10.4 +version: 0.11.0 homepage: https://github.com/bamlab/adaptive_test repository: https://github.com/bamlab/adaptive_test From 6e1a4eab1d8c37f20c728011454915105987eeb8 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Mon, 3 Aug 2026 10:37:08 +0200 Subject: [PATCH 7/7] fix: :rotating_light: linter warnings in adaptive test --- example/simple_app/pubspec.lock | 22 +++++++++---------- lib/adaptive_test.dart | 12 +++++----- lib/src/adaptive/devices_data.dart | 2 ++ .../adaptive/widgets/adaptive_wrapper.dart | 2 +- .../widgets/layers/keyboard_layer.dart | 2 +- .../three_button_system_nav_bar_layer.dart | 2 +- .../window_config_data.dart | 3 ++- .../adaptive/window_configuration_tester.dart | 6 +++-- lib/src/configuration.dart | 5 ++++- lib/src/helpers/goldens_difference.dart | 5 ++++- lib/src/helpers/skip_test_extension.dart | 4 ++-- .../helpers/target_platform_extension.dart | 5 +++-- 12 files changed, 41 insertions(+), 29 deletions(-) diff --git a/example/simple_app/pubspec.lock b/example/simple_app/pubspec.lock index cc6bc5b..e8285a6 100644 --- a/example/simple_app/pubspec.lock +++ b/example/simple_app/pubspec.lock @@ -28,10 +28,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -118,18 +118,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -142,10 +142,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" package_config: dependency: transitive description: @@ -227,10 +227,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.6" theodo_analysis: dependency: "direct dev" description: @@ -256,5 +256,5 @@ packages: source: hosted version: "14.2.5" sdks: - dart: ">=3.9.0-0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/adaptive_test.dart b/lib/adaptive_test.dart index 3670a43..0ae0e50 100644 --- a/lib/adaptive_test.dart +++ b/lib/adaptive_test.dart @@ -1,8 +1,13 @@ export 'src/adaptive/adaptive_test.dart'; export 'src/adaptive/devices_data.dart'; export 'src/adaptive/widgets/adaptive_wrapper.dart'; -export 'src/adaptive/window_configuration_tester.dart'; export 'src/adaptive/window_config.dart'; +export 'src/adaptive/window_config_data/dynamic_island_data.dart'; +export 'src/adaptive/window_config_data/punch_hole_data.dart'; +export 'src/adaptive/window_config_data/system_nav_bar_data.dart' + show SystemNavBarData; +export 'src/adaptive/window_config_data/window_config_data.dart'; +export 'src/adaptive/window_configuration_tester.dart'; export 'src/configuration.dart'; export 'src/helpers/await_images.dart'; export 'src/helpers/font_registration.dart'; @@ -10,8 +15,3 @@ export 'src/helpers/fonts_loader.dart'; export 'src/helpers/goldens_difference.dart'; export 'src/helpers/missing_fonts.dart'; export 'src/helpers/skip_test_extension.dart'; -export 'src/adaptive/window_config_data/window_config_data.dart'; -export 'src/adaptive/window_config_data/system_nav_bar_data.dart' - show SystemNavBarData; -export 'src/adaptive/window_config_data/punch_hole_data.dart'; -export 'src/adaptive/window_config_data/dynamic_island_data.dart'; diff --git a/lib/src/adaptive/devices_data.dart b/lib/src/adaptive/devices_data.dart index 74b3b44..559b819 100644 --- a/lib/src/adaptive/devices_data.dart +++ b/lib/src/adaptive/devices_data.dart @@ -1,3 +1,5 @@ +// ignore_for_file: constant_identifier_names + import 'package:adaptive_test/src/adaptive/window_config_data/dynamic_island_data.dart'; import 'package:adaptive_test/src/adaptive/window_config_data/punch_hole_data.dart'; import 'package:adaptive_test/src/adaptive/window_config_data/system_nav_bar_data.dart'; diff --git a/lib/src/adaptive/widgets/adaptive_wrapper.dart b/lib/src/adaptive/widgets/adaptive_wrapper.dart index a259f81..22c6658 100644 --- a/lib/src/adaptive/widgets/adaptive_wrapper.dart +++ b/lib/src/adaptive/widgets/adaptive_wrapper.dart @@ -1,8 +1,8 @@ import 'package:adaptive_test/src/adaptive/widgets/layers/hardware_layer.dart'; import 'package:adaptive_test/src/adaptive/widgets/layers/keyboard_layer.dart'; import 'package:adaptive_test/src/adaptive/widgets/layers/system_nav_bar_layer.dart'; -import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data.dart'; import 'package:adaptive_test/src/adaptive/window_config.dart'; +import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/lib/src/adaptive/widgets/layers/keyboard_layer.dart b/lib/src/adaptive/widgets/layers/keyboard_layer.dart index b5d832e..71c9305 100644 --- a/lib/src/adaptive/widgets/layers/keyboard_layer.dart +++ b/lib/src/adaptive/widgets/layers/keyboard_layer.dart @@ -1,5 +1,5 @@ -import 'package:adaptive_test/src/adaptive/window_configuration_tester.dart'; import 'package:adaptive_test/src/adaptive/window_config.dart'; +import 'package:adaptive_test/src/adaptive/window_configuration_tester.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/lib/src/adaptive/widgets/layers/three_button_system_nav_bar_layer.dart b/lib/src/adaptive/widgets/layers/three_button_system_nav_bar_layer.dart index 53c2134..cbb7bae 100644 --- a/lib/src/adaptive/widgets/layers/three_button_system_nav_bar_layer.dart +++ b/lib/src/adaptive/widgets/layers/three_button_system_nav_bar_layer.dart @@ -32,7 +32,7 @@ class ThreeButtonSystemNavBarLayer extends StatelessWidget { children: const [ Icons.arrow_back_ios_rounded, Icons.circle, - Icons.square_rounded + Icons.square_rounded, ] .map( (iconData) => Icon( diff --git a/lib/src/adaptive/window_config_data/window_config_data.dart b/lib/src/adaptive/window_config_data/window_config_data.dart index 02253c1..7e94153 100644 --- a/lib/src/adaptive/window_config_data/window_config_data.dart +++ b/lib/src/adaptive/window_config_data/window_config_data.dart @@ -53,7 +53,8 @@ class WindowConfigData extends Equatable { /// This is null when the device has no notch. final Size? notchSize; - /// Describe the size of the device physical screen top dynamic island in `dp`. + /// Describe the size of the device physical screen top dynamic island in + /// `dp`. /// /// This is null when the device has no dynamic island. final DynamicIslandData? dynamicIsland; diff --git a/lib/src/adaptive/window_configuration_tester.dart b/lib/src/adaptive/window_configuration_tester.dart index eaba821..5267e83 100644 --- a/lib/src/adaptive/window_configuration_tester.dart +++ b/lib/src/adaptive/window_configuration_tester.dart @@ -19,13 +19,15 @@ extension WidgetTesterWithConfigurableWindow on WidgetTester { addTearDown(view.resetViewInsets); } - /// Configure the tester window to represent an opened keyboard on the given device variant. + /// Configure the tester window to represent an opened keyboard on the given + /// device variant. void configureOpenedKeyboardWindow(WindowConfigData windowConfig) { view.viewInsets = windowConfig.viewInsets; view.padding = windowConfig.padding.copyWith(bottom: 0); } - /// Configure the tester window to represent a closed keyboard on the given device variant. + /// Configure the tester window to represent a closed keyboard on the given + /// device variant. void configureClosedKeyboardWindow(WindowConfigData windowConfig) { view.resetViewInsets(); view.padding = windowConfig.padding; diff --git a/lib/src/configuration.dart b/lib/src/configuration.dart index e8002d2..a7f0ec6 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use_setters_to_change_properties + import 'package:adaptive_test/src/adaptive/window_config.dart'; import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data.dart'; import 'package:adaptive_test/src/helpers/missing_fonts.dart'; @@ -116,7 +118,8 @@ See: https://api.flutter.dev/flutter/flutter_test/flutter_test-library.html /// Set the devices variant on which you want your test to run. /// - /// Eg [iPhone8], [iPhone13], [iPhone16],[iPhone16Dark], [iPadPro], [desktop], [pixel5], [pixel9]. + /// Eg [iPhone8], [iPhone13], [iPhone16],[iPhone16Dark], [iPadPro], + /// [desktop], [pixel5], [pixel9]. void setDeviceVariants(Set deviceConfigs) { _deviceVariant = WindowVariant(deviceConfigs); } diff --git a/lib/src/helpers/goldens_difference.dart b/lib/src/helpers/goldens_difference.dart index 8b5f500..3fa72f6 100644 --- a/lib/src/helpers/goldens_difference.dart +++ b/lib/src/helpers/goldens_difference.dart @@ -36,7 +36,10 @@ void setupFileComparatorWithThreshold([ /// exceeded, marks the test as a failure. class LocalFileComparatorWithThreshold extends LocalFileComparator { LocalFileComparatorWithThreshold(super.testFile, this.threshold) - : assert(threshold >= 0 && threshold <= 1); + : assert( + threshold >= 0 && threshold <= 1, + 'The threshold must be between 0 and 1 inclusive', + ); /// Threshold above which tests will be marked as failing. /// Ranges from 0 to 1, both inclusive. diff --git a/lib/src/helpers/skip_test_extension.dart b/lib/src/helpers/skip_test_extension.dart index 6deb121..e12deb9 100644 --- a/lib/src/helpers/skip_test_extension.dart +++ b/lib/src/helpers/skip_test_extension.dart @@ -9,8 +9,8 @@ extension ShouldSkipAdaptiveTest on AdaptiveTestConfiguration { /// runtime platform does not match the enforced platform, the test will be /// skipped if [AdaptiveTestConfiguration.failTestOnWrongPlatform] is false. /// - /// This extension is used to determine if a test should be skipped based on the - /// [AdaptiveTestConfiguration.enforcedTestPlatform] and + /// This extension is used to determine if a test should be skipped based on + /// the [AdaptiveTestConfiguration.enforcedTestPlatform] and /// [AdaptiveTestConfiguration.failTestOnWrongPlatform] values. bool get shouldSkipTest { final configuration = AdaptiveTestConfiguration.instance; diff --git a/lib/src/helpers/target_platform_extension.dart b/lib/src/helpers/target_platform_extension.dart index fbc72d9..236bcf5 100644 --- a/lib/src/helpers/target_platform_extension.dart +++ b/lib/src/helpers/target_platform_extension.dart @@ -2,7 +2,6 @@ import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:meta/meta.dart'; @internal extension IsRuntimePlatform on TargetPlatform { @@ -13,7 +12,9 @@ extension IsRuntimePlatform on TargetPlatform { TargetPlatform.windows: return; - default: + case TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia: log('Tests are intended to be runned on linux, macOS or windows' ' platform. But you are running them on $name'); }