// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // This file is run as part of a reduced test set in CI on Mac and Windows // machines. @Tags(['reduced-test-set']) library; import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; import '../image_data.dart'; import 'semantics_tester.dart'; void main() { late int originalCacheSize; late ui.Image image10x10; setUp(() async { originalCacheSize = imageCache.maximumSize; imageCache.clear(); imageCache.clearLiveImages(); image10x10 = await createTestImage(width: 10, height: 10); }); tearDown(() { imageCache.maximumSize = originalCacheSize; }); testWidgets('Verify Image does not use disposed handles', (WidgetTester tester) async { final ui.Image image100x100 = (await tester.runAsync( () async => createTestImage(width: 100, height: 100), ))!; final imageProvider1 = _TestImageProvider(); final imageProvider2 = _TestImageProvider(); final imageListenable = ValueNotifier<_TestImageProvider>(imageProvider1); addTearDown(imageListenable.dispose); final innerListenable = ValueNotifier(0); addTearDown(innerListenable.dispose); var imageLoaded = false; await tester.pumpWidget( ValueListenableBuilder<_TestImageProvider>( valueListenable: imageListenable, builder: (BuildContext context, _TestImageProvider image, Widget? child) => Image( image: image, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { if (frame == 0) { imageLoaded = true; } return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) => ValueListenableBuilder( valueListenable: innerListenable, builder: (BuildContext context, int value, Widget? valueListenableChild) => KeyedSubtree(key: UniqueKey(), child: child), ), ); }, ), ), ); imageLoaded = false; imageProvider1.complete(image10x10); await tester.idle(); await tester.pump(); expect(imageLoaded, true); imageLoaded = false; imageListenable.value = imageProvider2; innerListenable.value += 1; imageProvider2.complete(image100x100); await tester.idle(); await tester.pump(); expect(imageLoaded, true); }); testWidgets('Verify Image resets its RenderImage when changing providers', ( WidgetTester tester, ) async { final GlobalKey key = GlobalKey(); final imageProvider1 = _TestImageProvider(); await tester.pumpWidget( Container( key: key, child: Image(image: imageProvider1, excludeFromSemantics: true), ), phase: EnginePhase.layout, ); var renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNull); imageProvider1.complete(image10x10); await tester.idle(); // resolve the future from the image provider await tester.pump(null, EnginePhase.layout); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNotNull); final imageProvider2 = _TestImageProvider(); await tester.pumpWidget( Container( key: key, child: Image(image: imageProvider2, excludeFromSemantics: true), ), phase: EnginePhase.layout, ); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNull); }); testWidgets( "Verify Image doesn't reset its RenderImage when changing providers if it has gaplessPlayback set", (WidgetTester tester) async { final GlobalKey key = GlobalKey(); final imageProvider1 = _TestImageProvider(); await tester.pumpWidget( Container( key: key, child: Image(gaplessPlayback: true, image: imageProvider1, excludeFromSemantics: true), ), phase: EnginePhase.layout, ); var renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNull); imageProvider1.complete(image10x10); await tester.idle(); // resolve the future from the image provider await tester.pump(null, EnginePhase.layout); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNotNull); final imageProvider2 = _TestImageProvider(); await tester.pumpWidget( Container( key: key, child: Image(gaplessPlayback: true, image: imageProvider2, excludeFromSemantics: true), ), phase: EnginePhase.layout, ); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNotNull); }, ); testWidgets('Verify Image resets its RenderImage when changing providers if it has a key', ( WidgetTester tester, ) async { final GlobalKey key = GlobalKey(); final imageProvider1 = _TestImageProvider(); await tester.pumpWidget( Image(key: key, image: imageProvider1, excludeFromSemantics: true), phase: EnginePhase.layout, ); var renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNull); imageProvider1.complete(image10x10); await tester.idle(); // resolve the future from the image provider await tester.pump(null, EnginePhase.layout); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNotNull); final imageProvider2 = _TestImageProvider(); await tester.pumpWidget( Image(key: key, image: imageProvider2, excludeFromSemantics: true), phase: EnginePhase.layout, ); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNull); }); testWidgets( "Verify Image doesn't reset its RenderImage when changing providers if it has gaplessPlayback set", (WidgetTester tester) async { final GlobalKey key = GlobalKey(); final imageProvider1 = _TestImageProvider(); await tester.pumpWidget( Image(key: key, gaplessPlayback: true, image: imageProvider1, excludeFromSemantics: true), phase: EnginePhase.layout, ); var renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNull); imageProvider1.complete(image10x10); await tester.idle(); // resolve the future from the image provider await tester.pump(null, EnginePhase.layout); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNotNull); final imageProvider2 = _TestImageProvider(); await tester.pumpWidget( Image(key: key, gaplessPlayback: true, excludeFromSemantics: true, image: imageProvider2), phase: EnginePhase.layout, ); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNotNull); }, ); testWidgets('Verify ImageProvider configuration inheritance', (WidgetTester tester) async { final GlobalKey mediaQueryKey1 = GlobalKey(debugLabel: 'mediaQueryKey1'); final GlobalKey mediaQueryKey2 = GlobalKey(debugLabel: 'mediaQueryKey2'); final GlobalKey imageKey = GlobalKey(debugLabel: 'image'); final imageProvider = _ConfigurationKeyedTestImageProvider(); final seenKeys = {}; final debouncingProvider = _DebouncingImageProvider(imageProvider, seenKeys); // Of the two nested MediaQuery objects, the innermost one, // mediaQuery2, should define the configuration of the imageProvider. await tester.pumpWidget( MediaQuery( key: mediaQueryKey1, data: const MediaQueryData(devicePixelRatio: 10.0), child: MediaQuery( key: mediaQueryKey2, data: const MediaQueryData(devicePixelRatio: 5.0), child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider), ), ), ); expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0); // This is the same widget hierarchy as before except that the // two MediaQuery objects have exchanged places. The imageProvider // should be resolved again, with the new innermost MediaQuery. await tester.pumpWidget( MediaQuery( key: mediaQueryKey2, data: const MediaQueryData(devicePixelRatio: 5.0), child: MediaQuery( key: mediaQueryKey1, data: const MediaQueryData(devicePixelRatio: 10.0), child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider), ), ), ); expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 10.0); }); testWidgets('Verify ImageProvider configuration inheritance again', (WidgetTester tester) async { final GlobalKey mediaQueryKey1 = GlobalKey(debugLabel: 'mediaQueryKey1'); final GlobalKey mediaQueryKey2 = GlobalKey(debugLabel: 'mediaQueryKey2'); final GlobalKey imageKey = GlobalKey(debugLabel: 'image'); final imageProvider = _ConfigurationKeyedTestImageProvider(); final seenKeys = {}; final debouncingProvider = _DebouncingImageProvider(imageProvider, seenKeys); // This is just a variation on the previous test. In this version the location // of the Image changes and the MediaQuery widgets do not. await tester.pumpWidget( Row( textDirection: TextDirection.ltr, children: [ MediaQuery( key: mediaQueryKey2, data: const MediaQueryData(devicePixelRatio: 5.0), child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider), ), MediaQuery( key: mediaQueryKey1, data: const MediaQueryData(devicePixelRatio: 10.0), child: Container(width: 100.0), ), ], ), ); expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0); await tester.pumpWidget( Row( textDirection: TextDirection.ltr, children: [ MediaQuery( key: mediaQueryKey2, data: const MediaQueryData(devicePixelRatio: 5.0), child: Container(width: 100.0), ), MediaQuery( key: mediaQueryKey1, data: const MediaQueryData(devicePixelRatio: 10.0), child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider), ), ], ), ); expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 10.0); }); testWidgets('Verify ImageProvider does not inherit configuration when it does not key to it', ( WidgetTester tester, ) async { final GlobalKey mediaQueryKey1 = GlobalKey(debugLabel: 'mediaQueryKey1'); final GlobalKey mediaQueryKey2 = GlobalKey(debugLabel: 'mediaQueryKey2'); final GlobalKey imageKey = GlobalKey(debugLabel: 'image'); final imageProvider = _TestImageProvider(); final seenKeys = {}; final debouncingProvider = _DebouncingImageProvider(imageProvider, seenKeys); // Of the two nested MediaQuery objects, the innermost one, // mediaQuery2, should define the configuration of the imageProvider. await tester.pumpWidget( MediaQuery( key: mediaQueryKey1, data: const MediaQueryData(devicePixelRatio: 10.0), child: MediaQuery( key: mediaQueryKey2, data: const MediaQueryData(devicePixelRatio: 5.0), child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider), ), ), ); expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0); // This is the same widget hierarchy as before except that the // two MediaQuery objects have exchanged places. The imageProvider // should not be resolved again, because it does not key to configuration. await tester.pumpWidget( MediaQuery( key: mediaQueryKey2, data: const MediaQueryData(devicePixelRatio: 5.0), child: MediaQuery( key: mediaQueryKey1, data: const MediaQueryData(devicePixelRatio: 10.0), child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider), ), ), ); expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0); }); testWidgets( 'Verify ImageProvider does not inherit configuration when it does not key to it again', (WidgetTester tester) async { final GlobalKey mediaQueryKey1 = GlobalKey(debugLabel: 'mediaQueryKey1'); final GlobalKey mediaQueryKey2 = GlobalKey(debugLabel: 'mediaQueryKey2'); final GlobalKey imageKey = GlobalKey(debugLabel: 'image'); final imageProvider = _TestImageProvider(); final seenKeys = {}; final debouncingProvider = _DebouncingImageProvider(imageProvider, seenKeys); // This is just a variation on the previous test. In this version the location // of the Image changes and the MediaQuery widgets do not. await tester.pumpWidget( Row( textDirection: TextDirection.ltr, children: [ MediaQuery( key: mediaQueryKey2, data: const MediaQueryData(devicePixelRatio: 5.0), child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider), ), MediaQuery( key: mediaQueryKey1, data: const MediaQueryData(devicePixelRatio: 10.0), child: Container(width: 100.0), ), ], ), ); expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0); await tester.pumpWidget( Row( textDirection: TextDirection.ltr, children: [ MediaQuery( key: mediaQueryKey2, data: const MediaQueryData(devicePixelRatio: 5.0), child: Container(width: 100.0), ), MediaQuery( key: mediaQueryKey1, data: const MediaQueryData(devicePixelRatio: 10.0), child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider), ), ], ), ); expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0); }, ); testWidgets('Verify Image stops listening to ImageStream', (WidgetTester tester) async { final ui.Image image100x100 = (await tester.runAsync( () async => createTestImage(width: 100, height: 100), ))!; // Web does not override the toString, whereas VM does final imageString = image100x100.toString(); final imageProvider = _TestImageProvider(); await tester.pumpWidget(Image(image: imageProvider, excludeFromSemantics: true)); final State image = tester.state /*State*/ (find.byType(Image)); expect( image.toString(), equalsIgnoringHashCodes( '_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, unresolved, 2 listeners, 0 ephemeralErrorListeners), pixels: null, loadingProgress: null, frameNumber: null, wasSynchronouslyLoaded: false)', ), ); imageProvider.complete(image100x100); await tester.pump(); expect( image.toString(), equalsIgnoringHashCodes( '_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, $imageString @ 1.0x, 1 listener, 0 ephemeralErrorListeners), pixels: $imageString @ 1.0x, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)', ), ); await tester.pumpWidget(Container()); expect( image.toString(), equalsIgnoringHashCodes( '_ImageState#00000(lifecycle state: defunct, not mounted, stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, $imageString @ 1.0x, 0 listeners, 0 ephemeralErrorListeners), pixels: null, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)', ), ); }); testWidgets('Stream completer errors can be listened to by attaching before resolving', ( WidgetTester tester, ) async { dynamic capturedException; StackTrace? capturedStackTrace; ImageInfo? capturedImage; void errorListener(dynamic exception, StackTrace? stackTrace) { capturedException = exception; capturedStackTrace = stackTrace; } void listener(ImageInfo info, bool synchronous) { capturedImage = info; } final testException = Exception('cannot resolve host'); final StackTrace testStack = StackTrace.current; final imageProvider = _TestImageProvider(); imageProvider._streamCompleter.addListener( ImageStreamListener(listener, onError: errorListener), ); late ImageConfiguration configuration; await tester.pumpWidget( Builder( builder: (BuildContext context) { configuration = createLocalImageConfiguration(context); return Container(); }, ), ); imageProvider.resolve(configuration); imageProvider.fail(testException, testStack); expect(tester.binding.microtaskCount, 1); await tester.idle(); // Let the failed completer's future hit the stream completer. expect(tester.binding.microtaskCount, 0); expect(capturedImage, isNull); // The image stream listeners should never be called. // The image stream error handler should have the original exception. expect(capturedException, testException); expect(capturedStackTrace, testStack); // If there is an error listener, there should be no FlutterError reported. expect(tester.takeException(), isNull); }); testWidgets('Stream completer errors can be listened to by attaching after resolving', ( WidgetTester tester, ) async { dynamic capturedException; StackTrace? capturedStackTrace; dynamic reportedException; StackTrace? reportedStackTrace; ImageInfo? capturedImage; void errorListener(dynamic exception, StackTrace? stackTrace) { capturedException = exception; capturedStackTrace = stackTrace; } void listener(ImageInfo info, bool synchronous) { capturedImage = info; } FlutterError.onError = (FlutterErrorDetails flutterError) { reportedException = flutterError.exception; reportedStackTrace = flutterError.stack; }; final testException = Exception('cannot resolve host'); final StackTrace testStack = StackTrace.current; final imageProvider = _TestImageProvider(); late ImageConfiguration configuration; await tester.pumpWidget( Builder( builder: (BuildContext context) { configuration = createLocalImageConfiguration(context); return Container(); }, ), ); final ImageStream streamUnderTest = imageProvider.resolve(configuration); imageProvider.fail(testException, testStack); expect(tester.binding.microtaskCount, 1); await tester.idle(); // Let the failed completer's future hit the stream completer. expect(tester.binding.microtaskCount, 0); // Since there's no listeners attached yet, report error up via // FlutterError. expect(reportedException, testException); expect(reportedStackTrace, testStack); streamUnderTest.addListener(ImageStreamListener(listener, onError: errorListener)); expect(capturedImage, isNull); // The image stream listeners should never be called. // The image stream error handler should have the original exception. expect(capturedException, testException); expect(capturedStackTrace, testStack); }); testWidgets('Duplicate listener registration does not affect error listeners', ( WidgetTester tester, ) async { dynamic capturedException; StackTrace? capturedStackTrace; ImageInfo? capturedImage; void errorListener(dynamic exception, StackTrace? stackTrace) { capturedException = exception; capturedStackTrace = stackTrace; } void listener(ImageInfo info, bool synchronous) { capturedImage = info; } final testException = Exception('cannot resolve host'); final StackTrace testStack = StackTrace.current; final imageProvider = _TestImageProvider(); imageProvider._streamCompleter.addListener( ImageStreamListener(listener, onError: errorListener), ); // Add the exact same listener a second time without the errorListener. imageProvider._streamCompleter.addListener(ImageStreamListener(listener)); late ImageConfiguration configuration; await tester.pumpWidget( Builder( builder: (BuildContext context) { configuration = createLocalImageConfiguration(context); return Container(); }, ), ); imageProvider.resolve(configuration); imageProvider.fail(testException, testStack); expect(tester.binding.microtaskCount, 1); await tester.idle(); // Let the failed completer's future hit the stream completer. expect(tester.binding.microtaskCount, 0); expect(capturedImage, isNull); // The image stream listeners should never be called. // The image stream error handler should have the original exception. expect(capturedException, testException); expect(capturedStackTrace, testStack); // If there is an error listener, there should be no FlutterError reported. expect(tester.takeException(), isNull); }); testWidgets('Duplicate error listeners are all called', (WidgetTester tester) async { dynamic capturedException; StackTrace? capturedStackTrace; ImageInfo? capturedImage; var errorListenerCalled = 0; void errorListener(dynamic exception, StackTrace? stackTrace) { capturedException = exception; capturedStackTrace = stackTrace; errorListenerCalled++; } void listener(ImageInfo info, bool synchronous) { capturedImage = info; } final testException = Exception('cannot resolve host'); final StackTrace testStack = StackTrace.current; final imageProvider = _TestImageProvider(); imageProvider._streamCompleter.addListener( ImageStreamListener(listener, onError: errorListener), ); // Add the exact same errorListener a second time. imageProvider._streamCompleter.addListener( ImageStreamListener(listener, onError: errorListener), ); late ImageConfiguration configuration; await tester.pumpWidget( Builder( builder: (BuildContext context) { configuration = createLocalImageConfiguration(context); return Container(); }, ), ); imageProvider.resolve(configuration); imageProvider.fail(testException, testStack); expect(tester.binding.microtaskCount, 1); await tester.idle(); // Let the failed completer's future hit the stream completer. expect(tester.binding.microtaskCount, 0); expect(capturedImage, isNull); // The image stream listeners should never be called. // The image stream error handler should have the original exception. expect(capturedException, testException); expect(capturedStackTrace, testStack); expect(errorListenerCalled, 2); // If there is an error listener, there should be no FlutterError reported. expect(tester.takeException(), isNull); }); testWidgets('Listeners are only removed if callback tuple matches', (WidgetTester tester) async { var errorListenerCalled = false; dynamic reportedException; StackTrace? reportedStackTrace; ImageInfo? capturedImage; void errorListener(dynamic exception, StackTrace? stackTrace) { errorListenerCalled = true; reportedException = exception; reportedStackTrace = stackTrace; } void listener(ImageInfo info, bool synchronous) { capturedImage = info; } final testException = Exception('cannot resolve host'); final StackTrace testStack = StackTrace.current; final imageProvider = _TestImageProvider(); imageProvider._streamCompleter.addListener( ImageStreamListener(listener, onError: errorListener), ); // Now remove the listener the error listener is attached to. // Don't explicitly remove the error listener. imageProvider._streamCompleter.removeListener(ImageStreamListener(listener)); late ImageConfiguration configuration; await tester.pumpWidget( Builder( builder: (BuildContext context) { configuration = createLocalImageConfiguration(context); return Container(); }, ), ); imageProvider.resolve(configuration); imageProvider.fail(testException, testStack); expect(tester.binding.microtaskCount, 1); await tester.idle(); // Let the failed completer's future hit the stream completer. expect(tester.binding.microtaskCount, 0); expect(errorListenerCalled, true); expect(reportedException, testException); expect(reportedStackTrace, testStack); expect(capturedImage, isNull); // The image stream listeners should never be called. }); testWidgets('Removing listener removes one listener and error listener', ( WidgetTester tester, ) async { var errorListenerCalled = 0; ImageInfo? capturedImage; void errorListener(dynamic exception, StackTrace? stackTrace) { errorListenerCalled++; } void listener(ImageInfo info, bool synchronous) { capturedImage = info; } final testException = Exception('cannot resolve host'); final StackTrace testStack = StackTrace.current; final imageProvider = _TestImageProvider(); imageProvider._streamCompleter.addListener( ImageStreamListener(listener, onError: errorListener), ); // Duplicates the same set of listener and errorListener. imageProvider._streamCompleter.addListener( ImageStreamListener(listener, onError: errorListener), ); // Now remove one entry of the specified listener and associated error listener. // Don't explicitly remove the error listener. imageProvider._streamCompleter.removeListener( ImageStreamListener(listener, onError: errorListener), ); late ImageConfiguration configuration; await tester.pumpWidget( Builder( builder: (BuildContext context) { configuration = createLocalImageConfiguration(context); return Container(); }, ), ); imageProvider.resolve(configuration); imageProvider.fail(testException, testStack); expect(tester.binding.microtaskCount, 1); await tester.idle(); // Let the failed completer's future hit the stream completer. expect(tester.binding.microtaskCount, 0); expect(errorListenerCalled, 1); expect(capturedImage, isNull); // The image stream listeners should never be called. }); testWidgets('Image.memory control test', (WidgetTester tester) async { await tester.pumpWidget( Image.memory(Uint8List.fromList(kTransparentImage), excludeFromSemantics: true), ); }); testWidgets('Image color and colorBlend parameters', (WidgetTester tester) async { await tester.pumpWidget( Image( excludeFromSemantics: true, image: _TestImageProvider(), color: const Color(0xFF00FF00), colorBlendMode: BlendMode.clear, ), ); final RenderImage renderer = tester.renderObject(find.byType(Image)); expect(renderer.color, const Color(0xFF00FF00)); expect(renderer.colorBlendMode, BlendMode.clear); }); testWidgets('Image opacity parameter', (WidgetTester tester) async { const Animation opacity = AlwaysStoppedAnimation(0.5); await tester.pumpWidget( Image(excludeFromSemantics: true, image: _TestImageProvider(), opacity: opacity), ); final RenderImage renderer = tester.renderObject(find.byType(Image)); expect(renderer.opacity, opacity); }); testWidgets('Precache', (WidgetTester tester) async { final provider = _TestImageProvider(); late Future precache; await tester.pumpWidget( Builder( builder: (BuildContext context) { precache = precacheImage(provider, context); return Container(); }, ), ); provider.complete(image10x10); await precache; expect(provider._lastResolvedConfiguration, isNotNull); // Check that a second resolve of the same image is synchronous. final ImageStream stream = provider.resolve(provider._lastResolvedConfiguration); late bool isSync; stream.addListener( ImageStreamListener((ImageInfo image, bool sync) { image.dispose(); isSync = sync; }), ); expect(isSync, isTrue); }); testWidgets( 'Precache removes original listener immediately after future completes, does not crash on successive calls #25143', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final imageStreamCompleter = _TestImageStreamCompleter(); final provider = _TestImageProvider(streamCompleter: imageStreamCompleter); await tester.pumpWidget( Builder( builder: (BuildContext context) { precacheImage(provider, context); return Container(); }, ), ); // Two listeners - one is the listener added by precacheImage, the other by the ImageCache. final List listeners = imageStreamCompleter.listeners.toList(); expect(listeners.length, 2); // Make sure the first listener can be called re-entrantly final imageInfo = ImageInfo(image: image10x10); listeners[1].onImage(imageInfo.clone(), false); listeners[1].onImage(imageInfo.clone(), false); // Make sure the second listener can be called re-entrantly. listeners[0].onImage(imageInfo.clone(), false); listeners[0].onImage(imageInfo.clone(), false); imageInfo.dispose(); imageStreamCompleter.dispose(); imageCache.clear(); }, ); testWidgets('Precache completes with onError on error', (WidgetTester tester) async { dynamic capturedException; StackTrace? capturedStackTrace; void errorListener(dynamic exception, StackTrace? stackTrace) { capturedException = exception; capturedStackTrace = stackTrace; } final testException = Exception('cannot resolve host'); final StackTrace testStack = StackTrace.current; final imageProvider = _TestImageProvider(); late Future precache; await tester.pumpWidget( Builder( builder: (BuildContext context) { precache = precacheImage(imageProvider, context, onError: errorListener); return Container(); }, ), ); imageProvider.fail(testException, testStack); await precache; // The image stream error handler should have the original exception. expect(capturedException, testException); expect(capturedStackTrace, testStack); // If there is an error listener, there should be no FlutterError reported. expect(tester.takeException(), isNull); }); testWidgets( 'TickerMode controls stream registration', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final imageStreamCompleter = _TestImageStreamCompleter(); final image = Image( excludeFromSemantics: true, image: _TestImageProvider(streamCompleter: imageStreamCompleter), ); final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } expect(imageStreamCompleter.listeners.length, 0); await tester.pumpWidget(TickerMode(enabled: true, child: image)); expect(imageStreamCompleter.listeners.length, 2); await tester.pumpWidget(TickerMode(enabled: false, child: image)); // Despite being paused, the first frame hasn't come in yet, so it's still // listening. expect(imageStreamCompleter.listeners.length, 2); // Send the first frame and the listeners will be removed. imageStreamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(imageStreamCompleter.listeners.length, 0); }, ); testWidgets( 'MediaQuery.disableAnimations controls stream registration', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final imageStreamCompleter = _TestImageStreamCompleter(); final image = Image( excludeFromSemantics: true, image: _TestImageProvider(streamCompleter: imageStreamCompleter), ); final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } expect(imageStreamCompleter.listeners.length, 0); await tester.pumpWidget(image); expect(imageStreamCompleter.listeners.length, 2); await tester.pumpWidget( MediaQuery(data: const MediaQueryData(disableAnimations: true), child: image), ); // Despite being paused, the first frame hasn't come in yet, so it's still // listening. expect(imageStreamCompleter.listeners.length, 2); // Send the first frame and the listeners will be removed. imageStreamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(imageStreamCompleter.listeners.length, 0); }, ); testWidgets( 'Verify Image shows correct RenderImage when changing to an already completed provider', (WidgetTester tester) async { final GlobalKey key = GlobalKey(); final imageProvider1 = _TestImageProvider(); final imageProvider2 = _TestImageProvider(); final ui.Image image100x100 = (await tester.runAsync( () async => createTestImage(width: 100, height: 100), ))!; await tester.pumpWidget( Container( key: key, child: Image(excludeFromSemantics: true, image: imageProvider1), ), phase: EnginePhase.layout, ); var renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNull); imageProvider1.complete(image10x10); imageProvider2.complete(image100x100); await tester.idle(); // resolve the future from the image provider await tester.pump(null, EnginePhase.layout); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNotNull); final ui.Image oldImage = renderImage.image!; await tester.pumpWidget( Container( key: key, child: Image(excludeFromSemantics: true, image: imageProvider2), ), phase: EnginePhase.layout, ); renderImage = key.currentContext!.findRenderObject()! as RenderImage; expect(renderImage.image, isNotNull); expect(renderImage.image, isNot(equals(oldImage))); }, ); testWidgets('Image State can be reconfigured to use another image', (WidgetTester tester) async { final image1 = Image( image: _TestImageProvider()..complete(image10x10.clone()), width: 10.0, excludeFromSemantics: true, ); final image2 = Image( image: _TestImageProvider()..complete(image10x10.clone()), width: 20.0, excludeFromSemantics: true, ); final column = Column(children: [image1, image2]); await tester.pumpWidget(column, phase: EnginePhase.layout); final columnSwapped = Column(children: [image2, image1]); await tester.pumpWidget(columnSwapped, phase: EnginePhase.layout); final List renderObjects = tester .renderObjectList(find.byType(Image)) .toList(); expect(renderObjects, hasLength(2)); expect(renderObjects[0].image, isNotNull); expect(renderObjects[0].width, 20.0); expect(renderObjects[1].image, isNotNull); expect(renderObjects[1].width, 10.0); }); testWidgets('Image contributes semantics', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Row( children: [ Image(image: _TestImageProvider(), width: 100.0, height: 100.0, semanticLabel: 'test'), ], ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: [ TestSemantics.rootChild( id: 1, label: 'test', rect: const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0), textDirection: TextDirection.ltr, flags: [SemanticsFlag.isImage], ), ], ), ignoreTransform: true, ), ); semantics.dispose(); }); testWidgets('Image can exclude semantics', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Image( image: _TestImageProvider(), width: 100.0, height: 100.0, excludeFromSemantics: true, ), ), ); expect(semantics, hasSemantics(TestSemantics.root(children: []))); semantics.dispose(); }); testWidgets( 'Image invokes frameBuilder with correct frameNumber argument', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; await tester.pumpWidget( Image( image: imageProvider, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { lastFrame = frame; return Center(child: child); }, ), ); expect(lastFrame, isNull); expect(find.byType(Center), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(find.byType(Center), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 1); expect(find.byType(Center), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); }, ); testWidgets('Image invokes frameBuilder with correct wasSynchronouslyLoaded=false', ( WidgetTester tester, ) async { final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; late bool lastFrameWasSync; await tester.pumpWidget( Image( image: imageProvider, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { lastFrame = frame; lastFrameWasSync = wasSynchronouslyLoaded; return child; }, ), ); expect(lastFrame, isNull); expect(lastFrameWasSync, isFalse); expect(find.byType(RawImage), findsOneWidget); final info = ImageInfo(image: image10x10); addTearDown(info.dispose); streamCompleter.setData(imageInfo: info); await tester.pump(); expect(lastFrame, 0); expect(lastFrameWasSync, isFalse); }); testWidgets( 'Image invokes frameBuilder with correct wasSynchronouslyLoaded=true', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final streamCompleter = _TestImageStreamCompleter(ImageInfo(image: image10x10.clone())); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; late bool lastFrameWasSync; await tester.pumpWidget( Image( image: imageProvider, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { lastFrame = frame; lastFrameWasSync = wasSynchronouslyLoaded; return child; }, ), ); expect(lastFrame, 0); expect(lastFrameWasSync, isTrue); expect(find.byType(RawImage), findsOneWidget); streamCompleter.setData(imageInfo: ImageInfo(image: image10x10.clone())); await tester.pump(); expect(lastFrame, 1); expect(lastFrameWasSync, isTrue); }, ); testWidgets('Image state handles frameBuilder update', (WidgetTester tester) async { final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); await tester.pumpWidget( Image( image: imageProvider, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { return Center(child: child); }, ), ); expect(find.byType(Center), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); final State state = tester.state(find.byType(Image)); await tester.pumpWidget( Image( image: imageProvider, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { return Padding(padding: const EdgeInsets.all(1), child: child); }, ), ); expect(find.byType(Center), findsNothing); expect(find.byType(Padding), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); expect(tester.state(find.byType(Image)), same(state)); }); testWidgets( 'Image state handles enabling and disabling of tickers', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; var buildCount = 0; Widget buildFrame( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame = frame; buildCount++; return child; } await tester.pumpWidget( TickerMode( enabled: true, child: Image(image: imageProvider, frameBuilder: buildFrame), ), ); final State state = tester.state(find.byType(Image)); expect(lastFrame, isNull); expect(buildCount, 1); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); await tester.pumpWidget( TickerMode( enabled: false, child: Image(image: imageProvider, frameBuilder: buildFrame), ), ); expect(tester.state(find.byType(Image)), same(state)); expect(lastFrame, 0); expect(buildCount, 3); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 3); await tester.pumpWidget( TickerMode( enabled: true, child: Image(image: imageProvider, frameBuilder: buildFrame), ), ); expect(tester.state(find.byType(Image)), same(state)); expect(lastFrame, 1); // missed a frame because we weren't animating at the time expect(buildCount, 4); }, ); testWidgets( 'disableAnimations prevents the image from updating', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; var buildCount = 0; Widget buildFrame( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame = frame; buildCount++; return child; } var disableAnimations = false; late StateSetter setState; await tester.pumpWidget( StatefulBuilder( builder: (BuildContext context, StateSetter localSetState) { setState = localSetState; return MediaQuery( data: MediaQueryData(disableAnimations: disableAnimations), child: Image(image: imageProvider, frameBuilder: buildFrame), ); }, ), ); expect(lastFrame, isNull); expect(buildCount, 1); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame, isNull); expect(buildCount, 1); // When some data comes through for the image, it updates to show the image. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); // When another image frame comes, it updates again. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 1); expect(buildCount, 3); // Disable animations. A rebuild happens of the same frame. setState(() { disableAnimations = true; }); await tester.pump(); expect(lastFrame, 1); expect(buildCount, 4); // A new frame arriving does nothing because animations are disabled. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 1); expect(buildCount, 4); // Re-enable animations. The image updates to show the frame that was // received while disabled. setState(() { disableAnimations = false; }); await tester.pump(); expect(lastFrame, 2); expect(buildCount, 5); // Subsequent frames showing up update the image. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 3); expect(buildCount, 6); }, ); testWidgets( 'initial load', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; var buildCount = 0; Widget buildFrame( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame = frame; buildCount++; return child; } await tester.pumpWidget(Image(image: imageProvider, frameBuilder: buildFrame)); expect(lastFrame, isNull); expect(buildCount, 1); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame, isNull); expect(buildCount, 1); // When some image data comes through, it updates to show the image. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); }, ); testWidgets( 'initial load with existing image data', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; var buildCount = 0; Widget buildFrame( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame = frame; buildCount++; return child; } // Load a frame before even pumping the widget. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); expect(lastFrame, isNull); expect(buildCount, 0); await tester.pumpWidget(Image(image: imageProvider, frameBuilder: buildFrame)); // The first frame of the image is shown in the first frame of the app. expect(lastFrame, 0); expect(buildCount, 1); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame, 0); expect(buildCount, 1); }, ); for (final _DisableMethod disableMethod in _DisableMethod.values) { testWidgets( 'image source swapping with $disableMethod', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter1 = _TestImageStreamCompleter(); final imageProvider1 = _TestImageProvider(streamCompleter: streamCompleter1); final streamCompleter2 = _TestImageStreamCompleter(); final imageProvider2 = _TestImageProvider(streamCompleter: streamCompleter2); int? lastFrame1; int? lastFrame2; var buildCount = 0; var imageProvider = imageProvider1; Widget buildFrame1( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame1 = frame; buildCount++; return child; } Widget buildFrame2( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame2 = frame; buildCount++; return child; } var disableAnimations = false; late StateSetter setState; await tester.pumpWidget( StatefulBuilder( builder: (BuildContext context, StateSetter localSetState) { setState = localSetState; return switch (disableMethod) { _DisableMethod.tickerMode => TickerMode( enabled: !disableAnimations, child: Image( image: imageProvider, frameBuilder: imageProvider == imageProvider1 ? buildFrame1 : buildFrame2, ), ), _DisableMethod.mediaQuery => MediaQuery( data: MediaQueryData(disableAnimations: disableAnimations), child: Image( image: imageProvider, frameBuilder: imageProvider == imageProvider1 ? buildFrame1 : buildFrame2, ), ), }; }, ), ); expect(lastFrame1, isNull); expect(lastFrame2, isNull); expect(buildCount, 1); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame1, isNull); expect(lastFrame2, isNull); expect(buildCount, 1); // When some data comes through for image 1, it updates to show the image. streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, isNull); expect(buildCount, 2); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, isNull); expect(buildCount, 2); // Swap the image source and pump a frame. The second image hasn't // displayed because its first frame hasn't arrived yet. setState(() { imageProvider = imageProvider2; }); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, isNull); expect(buildCount, 3); // If another frame comes for image 1, nothing happens, because we have // swapped the image source to image 2. streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, isNull); expect(buildCount, 3); // When image 2's first frame comes, it updates. streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 4); // Disable animations. A rebuild happens of the same frame. setState(() { disableAnimations = true; }); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 5); // A new frame arriving for either image does nothing because animations // are disabled. streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 5); streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 5); // Swapping the image source back rebuilds to show the old image. setState(() { imageProvider = imageProvider1; }); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 6); // Re-enable animations. Subsequent frames showing up update the image. setState(() { disableAnimations = false; }); await tester.pump(); expect(lastFrame1, 1); expect(lastFrame2, 0); expect(buildCount, 7); streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 2); expect(lastFrame2, 0); expect(buildCount, 8); streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 3); expect(lastFrame2, 0); expect(buildCount, 9); }, ); testWidgets( 'image source swapping while paused with $disableMethod', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter1 = _TestImageStreamCompleter(); final imageProvider1 = _TestImageProvider(streamCompleter: streamCompleter1); final streamCompleter2 = _TestImageStreamCompleter(); final imageProvider2 = _TestImageProvider(streamCompleter: streamCompleter2); int? lastFrame1; int? lastFrame2; var buildCount = 0; var imageProvider = imageProvider1; Widget buildFrame1( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame1 = frame; buildCount++; return child; } Widget buildFrame2( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame2 = frame; buildCount++; return child; } late StateSetter setState; await tester.pumpWidget( StatefulBuilder( builder: (BuildContext context, StateSetter localSetState) { setState = localSetState; return switch (disableMethod) { _DisableMethod.tickerMode => TickerMode( enabled: false, child: Image( image: imageProvider, frameBuilder: imageProvider == imageProvider1 ? buildFrame1 : buildFrame2, ), ), _DisableMethod.mediaQuery => MediaQuery( data: const MediaQueryData(disableAnimations: true), child: Image( image: imageProvider, frameBuilder: imageProvider == imageProvider1 ? buildFrame1 : buildFrame2, ), ), }; }, ), ); expect(lastFrame1, isNull); expect(lastFrame2, isNull); expect(buildCount, 1); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame1, isNull); expect(lastFrame2, isNull); expect(buildCount, 1); // When some data comes through for image 1, it updates to show the // first frame. streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, isNull); expect(buildCount, 2); // When some data comes through for image 2, it doesn't update because // it's not showing that image. streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, isNull); expect(buildCount, 2); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, isNull); expect(buildCount, 2); // Subsequent frames do nothing because it's paused. streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, isNull); expect(buildCount, 2); streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, isNull); expect(buildCount, 2); // Swap the image source and pump a frame. The second image updates with // the frame that already came in. setState(() { imageProvider = imageProvider2; }); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 3); // Subsequently swapping the image source loads the new image but does // not advance the frame. setState(() { imageProvider = imageProvider1; }); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 4); setState(() { imageProvider = imageProvider2; }); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 5); // Even when new frames come in, they are not displayed. streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 5); streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 5); // Even when the source is swapped again, the new frames that previously // came in are not displayed. setState(() { imageProvider = imageProvider1; }); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 6); setState(() { imageProvider = imageProvider2; }); await tester.pump(); expect(lastFrame1, 0); expect(lastFrame2, 0); expect(buildCount, 7); }, ); testWidgets('image source swapping and image disposal with $disableMethod', ( WidgetTester tester, ) async { final ui.Image image1 = (await tester.runAsync(() => createTestImage(cache: false)))!; final imageProvider1 = _TestImageProvider( streamCompleter: OneFrameImageStreamCompleter( Future.value(ImageInfo(image: image1, debugLabel: '_TestImage1')), ), ); final ui.Image image2 = (await tester.runAsync(() => createTestImage(cache: false)))!; final imageProvider2 = _TestImageProvider( streamCompleter: OneFrameImageStreamCompleter( Future.value(ImageInfo(image: image2, debugLabel: '_TestImage2')), ), ); expect(image1.debugGetOpenHandleStackTraces()!.length, 1); expect(image2.debugGetOpenHandleStackTraces()!.length, 1); var imageProvider = imageProvider1; var disableAnimations = false; late StateSetter setState; await tester.pumpWidget( StatefulBuilder( builder: (BuildContext context, StateSetter localSetState) { setState = localSetState; return switch (disableMethod) { _DisableMethod.tickerMode => TickerMode( enabled: !disableAnimations, child: Image(image: imageProvider), ), _DisableMethod.mediaQuery => MediaQuery( data: MediaQueryData(disableAnimations: disableAnimations), child: Image(image: imageProvider), ), }; }, ), ); // Image widget + 1, render object + 1 for the active image. expect(image1.debugGetOpenHandleStackTraces()!.length, 3); expect(image2.debugGetOpenHandleStackTraces()!.length, 1); // Pumping another frame doesn't change anything. await tester.pump(); expect(image1.debugGetOpenHandleStackTraces()!.length, 3); expect(image2.debugGetOpenHandleStackTraces()!.length, 1); // Swap the image source and pump a frame. // Image widget + 1, render object + 1 for the active image. // Image widget - 1, render object - 1 for the inactive image. setState(() { imageProvider = imageProvider2; }); await tester.pump(); expect(image1.debugGetOpenHandleStackTraces()!.length, 1); expect(image2.debugGetOpenHandleStackTraces()!.length, 3); // Disable animations. setState(() { disableAnimations = true; }); await tester.pump(); expect(image1.debugGetOpenHandleStackTraces()!.length, 1); expect(image2.debugGetOpenHandleStackTraces()!.length, 3); // Swapping the image source back. // Image widget + 1, render object + 1 for the active image. // Image widget - 1, render object + 1 for the inactive image. setState(() { imageProvider = imageProvider1; }); await tester.pump(); expect(image1.debugGetOpenHandleStackTraces()!.length, 3); expect(image2.debugGetOpenHandleStackTraces()!.length, 1); // Re-enable animations. setState(() { disableAnimations = false; }); await tester.pump(); expect(image1.debugGetOpenHandleStackTraces()!.length, 3); expect(image2.debugGetOpenHandleStackTraces()!.length, 1); // Disposing disposes both the active and inactive images. await tester.pumpWidget(const SizedBox()); // Image widget and render object go away. expect(image1.debugGetOpenHandleStackTraces()!.length, 1); expect(image2.debugGetOpenHandleStackTraces()!.length, 1); await imageProvider1.evict(); await imageProvider2.evict(); tester.binding.scheduleFrame(); await tester.pump(); expect(image1.debugGetOpenHandleStackTraces()!.length, 0); expect(image2.debugGetOpenHandleStackTraces()!.length, 0); }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/87442 } testWidgets( 'the first frame is still loaded when disableAnimations is true on first load', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; var buildCount = 0; Widget buildFrame( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame = frame; buildCount++; return child; } await tester.pumpWidget( MediaQuery( data: const MediaQueryData(disableAnimations: true), child: Image(image: imageProvider, frameBuilder: buildFrame), ), ); expect(lastFrame, isNull); expect(buildCount, 1); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame, isNull); expect(buildCount, 1); // When some data comes through for the image, it updates to show the image, // even though disableAnimations is true. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); // Subsequent frames arriving don't do anything, because disableAnimations // is true. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); }, ); testWidgets( 'the first frame is still loaded when TickerMode is disabled on first load', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; var buildCount = 0; Widget buildFrame( BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded, ) { lastFrame = frame; buildCount++; return child; } await tester.pumpWidget( TickerMode( enabled: false, child: Image(image: imageProvider, frameBuilder: buildFrame), ), ); expect(lastFrame, isNull); expect(buildCount, 1); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame, isNull); expect(buildCount, 1); // When some data comes through for the image, it updates to show the image, // even though disableAnimations is true. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); // Pumping another frame doesn't do anything. await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); // Subsequent frames arriving don't do anything, because disableAnimations // is true. streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(buildCount, 2); }, ); testWidgets('Image invokes loadingBuilder on chunk event notification', ( WidgetTester tester, ) async { final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); final chunkEvents = []; await tester.pumpWidget( Image( image: imageProvider, loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { chunkEvents.add(loadingProgress); if (loadingProgress == null) { return child; } return Directionality( textDirection: TextDirection.ltr, child: Text( 'loading ${loadingProgress.cumulativeBytesLoaded} / ${loadingProgress.expectedTotalBytes}', ), ); }, ), ); expect(chunkEvents.length, 1); expect(chunkEvents.first, isNull); expect(tester.binding.hasScheduledFrame, isFalse); streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100), ); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pump(); expect(chunkEvents.length, 2); expect(find.text('loading 10 / 100'), findsOneWidget); expect(find.byType(RawImage), findsNothing); streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 30, expectedTotalBytes: 100), ); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pump(); expect(chunkEvents.length, 3); expect(find.text('loading 30 / 100'), findsOneWidget); expect(find.byType(RawImage), findsNothing); final info = ImageInfo(image: image10x10); addTearDown(info.dispose); streamCompleter.setData(imageInfo: info); await tester.pump(); expect(chunkEvents.length, 4); expect(find.byType(Text), findsNothing); expect(find.byType(RawImage), findsOneWidget); }); testWidgets("Image doesn't rebuild on chunk events if loadingBuilder is null", ( WidgetTester tester, ) async { final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); await tester.pumpWidget(Image(image: imageProvider, excludeFromSemantics: true)); expect(tester.binding.hasScheduledFrame, isFalse); streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100), ); expect(tester.binding.hasScheduledFrame, isFalse); final info = ImageInfo(image: image10x10); addTearDown(info.dispose); streamCompleter.setData(imageInfo: info); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pump(); streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100), ); expect(tester.binding.hasScheduledFrame, isFalse); expect(find.byType(RawImage), findsOneWidget); }); testWidgets('Image chains the results of frameBuilder and loadingBuilder', ( WidgetTester tester, ) async { final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); await tester.pumpWidget( Image( image: imageProvider, excludeFromSemantics: true, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { return Padding(padding: const EdgeInsets.all(1), child: child); }, loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { return Center(child: child); }, ), ); expect(find.byType(Center), findsOneWidget); expect(find.byType(Padding), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); expect(tester.widget(find.byType(Padding)).child, isA()); streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100), ); await tester.pump(); expect(find.byType(Center), findsOneWidget); expect(find.byType(Padding), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); expect(tester.widget
(find.byType(Center)).child, isA()); expect(tester.widget(find.byType(Padding)).child, isA()); }); testWidgets('Image state handles loadingBuilder update from null to non-null', ( WidgetTester tester, ) async { final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); await tester.pumpWidget(Image(image: imageProvider)); expect(find.byType(RawImage), findsOneWidget); streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100), ); expect(tester.binding.hasScheduledFrame, isFalse); final State state = tester.state(find.byType(Image)); await tester.pumpWidget( Image( image: imageProvider, loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { return Center(child: child); }, ), ); expect(find.byType(Center), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); expect(tester.state(find.byType(Image)), same(state)); streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100), ); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pump(); expect(find.byType(Center), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); }); testWidgets('Image state handles loadingBuilder update from non-null to null', ( WidgetTester tester, ) async { final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); await tester.pumpWidget( Image( image: imageProvider, loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { return Center(child: child); }, ), ); expect(find.byType(Center), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100), ); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pump(); expect(find.byType(Center), findsOneWidget); expect(find.byType(RawImage), findsOneWidget); final State state = tester.state(find.byType(Image)); await tester.pumpWidget(Image(image: imageProvider)); expect(find.byType(Center), findsNothing); expect(find.byType(RawImage), findsOneWidget); expect(tester.state(find.byType(Image)), same(state)); streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100), ); expect(tester.binding.hasScheduledFrame, isFalse); }); testWidgets('Verify Image resets its ImageListeners', (WidgetTester tester) async { final GlobalKey key = GlobalKey(); final imageStreamCompleter = _TestImageStreamCompleter(); final imageProvider1 = _TestImageProvider(streamCompleter: imageStreamCompleter); await tester.pumpWidget( Container( key: key, child: Image(image: imageProvider1), ), ); // listener from resolveStreamForKey is always added. expect(imageStreamCompleter.listeners.length, 2); final imageProvider2 = _TestImageProvider(); await tester.pumpWidget( Container( key: key, child: Image(image: imageProvider2, excludeFromSemantics: true), ), phase: EnginePhase.layout, ); // only listener from resolveStreamForKey is left. expect(imageStreamCompleter.listeners.length, 1); }); testWidgets('Verify Image resets its ErrorListeners', (WidgetTester tester) async { final GlobalKey key = GlobalKey(); final imageStreamCompleter = _TestImageStreamCompleter(); final imageProvider1 = _TestImageProvider(streamCompleter: imageStreamCompleter); await tester.pumpWidget( Container( key: key, child: Image(image: imageProvider1, errorBuilder: (_, _, _) => Container()), ), ); // listener from resolveStreamForKey is always added. expect(imageStreamCompleter.listeners.length, 2); final imageProvider2 = _TestImageProvider(); await tester.pumpWidget( Container( key: key, child: Image(image: imageProvider2, excludeFromSemantics: true), ), phase: EnginePhase.layout, ); // only listener from resolveStreamForKey is left. expect(imageStreamCompleter.listeners.length, 1); }); testWidgets('Image defers loading while fast scrolling', (WidgetTester tester) async { const gridCells = 1000; final imageProviders = <_TestImageProvider>[]; final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: GridView.builder( controller: controller, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3), itemCount: gridCells, itemBuilder: (_, int index) { final provider = _TestImageProvider(); imageProviders.add(provider); return SizedBox( height: 250, width: 250, child: Image(image: provider, semanticLabel: index.toString()), ); }, ), ), ); bool loadCalled(_TestImageProvider provider) => provider.loadCalled; bool loadNotCalled(_TestImageProvider provider) => !provider.loadCalled; expect(find.bySemanticsLabel('5'), findsOneWidget); expect(imageProviders.length, 12); expect(imageProviders.every(loadCalled), true); imageProviders.clear(); // Simulate a very fast fling. controller.animateTo(30000, duration: const Duration(seconds: 2), curve: Curves.linear); await tester.pumpAndSettle(); // The last 15 images on screen have loaded because the scrolling settled there. // The rest have not loaded. expect(imageProviders.length, 309); expect(imageProviders.skip(309 - 15).every(loadCalled), true); expect(imageProviders.take(309 - 15).every(loadNotCalled), true); }); testWidgets('Same image provider in multiple parts of the tree, no cache room left', ( WidgetTester tester, ) async { imageCache.maximumSize = 0; final provider1 = _TestImageProvider(); final provider2 = _TestImageProvider(); expect(provider1.loadCallCount, 0); expect(provider2.loadCallCount, 0); expect(imageCache.liveImageCount, 0); await tester.pumpWidget( Column( children: [ Image(image: provider1), Image(image: provider2), Image(image: provider1), Image(image: provider1), Image(image: provider2), ], ), ); expect(imageCache.liveImageCount, 2); expect(imageCache.statusForKey(provider1).live, true); expect(imageCache.statusForKey(provider1).pending, false); expect(imageCache.statusForKey(provider1).keepAlive, false); expect(imageCache.statusForKey(provider2).live, true); expect(imageCache.statusForKey(provider2).pending, false); expect(imageCache.statusForKey(provider2).keepAlive, false); expect(provider1.loadCallCount, 1); expect(provider2.loadCallCount, 1); provider1.complete(image10x10.clone()); await tester.idle(); provider2.complete(image10x10.clone()); await tester.idle(); expect(imageCache.liveImageCount, 2); expect(imageCache.currentSize, 0); await tester.pumpWidget(Image(image: provider2)); await tester.idle(); expect(imageCache.statusForKey(provider1).untracked, true); expect(imageCache.statusForKey(provider2).live, true); expect(imageCache.statusForKey(provider2).pending, false); expect(imageCache.statusForKey(provider2).keepAlive, false); expect(imageCache.liveImageCount, 1); await tester.pumpWidget(const SizedBox()); await tester.idle(); expect(provider1.loadCallCount, 1); expect(provider2.loadCallCount, 1); expect(imageCache.liveImageCount, 0); }); testWidgets('precacheImage does not hold weak ref for more than a frame', ( WidgetTester tester, ) async { imageCache.maximumSize = 0; final provider = _TestImageProvider(); late Future precache; await tester.pumpWidget( Builder( builder: (BuildContext context) { precache = precacheImage(provider, context); return Container(); }, ), ); provider.complete(image10x10); await precache; // Should have ended up with only a weak ref, not in cache because cache size is 0 expect(imageCache.liveImageCount, 1); expect(imageCache.containsKey(provider), false); final ImageCacheStatus providerLocation = (await provider.obtainCacheStatus( configuration: ImageConfiguration.empty, ))!; expect(providerLocation, isNotNull); expect(providerLocation.live, true); expect(providerLocation.keepAlive, false); expect(providerLocation.pending, false); // Check that a second resolve of the same image is synchronous. expect(provider._lastResolvedConfiguration, isNotNull); final ImageStream stream = provider.resolve(provider._lastResolvedConfiguration); late bool isSync; final listener = ImageStreamListener((ImageInfo image, bool syncCall) { image.dispose(); isSync = syncCall; }); // Still have live ref because frame has not pumped yet. await tester.pump(); expect(imageCache.liveImageCount, 1); SchedulerBinding.instance.scheduleFrame(); await tester.pump(); // Live ref should be gone - we didn't listen to the stream. expect(imageCache.liveImageCount, 0); expect(imageCache.currentSize, 0); stream.addListener(listener); expect(isSync, true); // because the stream still has the image. expect(imageCache.liveImageCount, 0); expect(imageCache.currentSize, 0); expect(provider.loadCallCount, 1); }); testWidgets( 'precacheImage allows time to take over weak reference', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final provider = _TestImageProvider(); late Future precache; await tester.pumpWidget( Builder( builder: (BuildContext context) { precache = precacheImage(provider, context); return Container(); }, ), ); provider.complete(image10x10); await precache; // Should have ended up in the cache and have a weak reference. expect(imageCache.liveImageCount, 1); expect(imageCache.currentSize, 1); expect(imageCache.containsKey(provider), true); // Check that a second resolve of the same image is synchronous. expect(provider._lastResolvedConfiguration, isNotNull); final ImageStream stream = provider.resolve(provider._lastResolvedConfiguration); late bool isSync; final listener = ImageStreamListener((ImageInfo image, bool syncCall) { isSync = syncCall; }); // Should have ended up in the cache and still have a weak reference. expect(imageCache.liveImageCount, 1); expect(imageCache.currentSize, 1); expect(imageCache.containsKey(provider), true); stream.addListener(listener); expect(isSync, true); expect(imageCache.liveImageCount, 1); expect(imageCache.currentSize, 1); expect(imageCache.containsKey(provider), true); SchedulerBinding.instance.scheduleFrame(); await tester.pump(); expect(imageCache.liveImageCount, 1); expect(imageCache.currentSize, 1); expect(imageCache.containsKey(provider), true); stream.removeListener(listener); expect(imageCache.liveImageCount, 0); expect(imageCache.currentSize, 1); expect(imageCache.containsKey(provider), true); expect(provider.loadCallCount, 1); }, ); testWidgets('evict an image during precache', (WidgetTester tester) async { // This test checks that the live image tracking does not hold on to a // pending image that will never complete because it has been evicted from // the cache. // The scenario may arise in a test harness that is trying to load real // images using `tester.runAsync()`, and wants to make sure that widgets // under test have not also tried to resolve the image in a FakeAsync zone. // The image loaded in the FakeAsync zone will never complete, and the // runAsync call wants to make sure it gets a load attempt from the correct // zone. final bytes = Uint8List.fromList(kTransparentImage); final provider = MemoryImage(bytes); await tester.runAsync(() async { final futures = >[]; await tester.pumpWidget( Builder( builder: (BuildContext context) { futures.add(precacheImage(provider, context)); imageCache.evict(provider); futures.add(precacheImage(provider, context)); return const SizedBox.expand(); }, ), ); await Future.wait(futures); expect(imageCache.statusForKey(provider).keepAlive, true); expect(imageCache.statusForKey(provider).live, true); // Schedule a frame to get precacheImage to stop listening. SchedulerBinding.instance.scheduleFrame(); await tester.pump(); expect(imageCache.statusForKey(provider).keepAlive, true); expect(imageCache.statusForKey(provider).live, false); imageCache.clear(); }); }); testWidgets('errorBuilder - fails on key', (WidgetTester tester) async { final errorKey = UniqueKey(); late Object caughtException; await tester.pumpWidget( Image( image: _FailingImageProvider(failOnObtainKey: true, throws: 'threw', image: image10x10), errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) { caughtException = error; return SizedBox.expand(key: errorKey); }, ), ); await tester.pump(); expect(find.byKey(errorKey), findsOneWidget); expect(caughtException.toString(), 'threw'); expect(tester.takeException(), isNull); }); testWidgets('errorBuilder - fails on load', (WidgetTester tester) async { final errorKey = UniqueKey(); late Object caughtException; await tester.pumpWidget( Image( image: _FailingImageProvider(failOnLoad: true, throws: 'threw', image: image10x10), errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) { caughtException = error; return SizedBox.expand(key: errorKey); }, ), ); await tester.pump(); expect(find.byKey(errorKey), findsOneWidget); expect(caughtException.toString(), 'threw'); expect(tester.takeException(), isNull); }); testWidgets('no errorBuilder - failure reported to FlutterError', (WidgetTester tester) async { await tester.pumpWidget( Image( image: _FailingImageProvider(failOnLoad: true, throws: 'threw', image: image10x10), ), ); await tester.pump(); expect(tester.takeException(), 'threw'); }); Future testRotatedImage(WidgetTester tester, bool isAntiAlias) async { final Key key = UniqueKey(); await tester.pumpWidget( RepaintBoundary( key: key, child: Transform.rotate( angle: math.pi / 180, child: Image.memory(Uint8List.fromList(kBlueRectPng), isAntiAlias: isAntiAlias), ), ), ); // precacheImage is needed, or the image in the golden file will be empty. if (!kIsWeb) { final Finder allImages = find.byType(Image); for (final Element e in allImages.evaluate()) { await tester.runAsync(() async { final image = e.widget as Image; await precacheImage(image.image, e); }); } await tester.pumpAndSettle(); } await expectLater( find.byKey(key), matchesGoldenFile('rotated_image_${isAntiAlias ? 'aa' : 'noaa'}.png'), ); } testWidgets( 'Rotated images', (WidgetTester tester) async { await testRotatedImage(tester, true); await testRotatedImage(tester, false); }, skip: kIsWeb, // https://github.com/flutter/flutter/issues/87933. ); testWidgets( 'Image opacity', (WidgetTester tester) async { final Key key = UniqueKey(); await tester.pumpWidget( RepaintBoundary( key: key, child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, textDirection: TextDirection.ltr, children: [ Image.memory( Uint8List.fromList(kBlueRectPng), opacity: const AlwaysStoppedAnimation(0.25), ), Image.memory( Uint8List.fromList(kBlueRectPng), opacity: const AlwaysStoppedAnimation(0.5), ), Image.memory( Uint8List.fromList(kBlueRectPng), opacity: const AlwaysStoppedAnimation(0.75), ), Image.memory( Uint8List.fromList(kBlueRectPng), opacity: const AlwaysStoppedAnimation(1.0), ), ], ), ), ); // precacheImage is needed, or the image in the golden file will be empty. if (!kIsWeb) { final Finder allImages = find.byType(Image); for (final Element e in allImages.evaluate()) { await tester.runAsync(() async { final image = e.widget as Image; await precacheImage(image.image, e); }); } await tester.pumpAndSettle(); } await expectLater(find.byKey(key), matchesGoldenFile('transparent_image.png')); }, skip: kIsWeb, // https://github.com/flutter/flutter/issues/87933. ); testWidgets( 'Reports image size when painted', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { late ImageSizeInfo imageSizeInfo; var count = 0; debugOnPaintImage = (ImageSizeInfo info) { count += 1; imageSizeInfo = info; }; final ui.Image image = (await tester.runAsync( () => createTestImage(width: 100, height: 100), ))!; final streamCompleter = _TestImageStreamCompleter( ImageInfo(image: image, debugLabel: 'test.png'), ); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); await tester.pumpWidget( Center( child: SizedBox(height: 50, width: 50, child: Image(image: imageProvider)), ), ); expect(count, 1); expect( imageSizeInfo, const ImageSizeInfo( source: 'test.png', imageSize: Size(100, 100), displaySize: Size(150, 150), ), ); debugOnPaintImage = null; }, ); testWidgets('Disposes image handle when disposed', (WidgetTester tester) async { final ui.Image image = (await tester.runAsync(() => createTestImage(cache: false)))!; expect(image.debugGetOpenHandleStackTraces()!.length, 1); final ImageProvider provider = _TestImageProvider( streamCompleter: OneFrameImageStreamCompleter( Future.value(ImageInfo(image: image, debugLabel: '_TestImage')), ), ); // creating the provider should not have changed anything, and the provider // now owns the handle. expect(image.debugGetOpenHandleStackTraces()!.length, 1); await tester.pumpWidget(Image(image: provider)); // Image widget + 1, render object + 1 expect(image.debugGetOpenHandleStackTraces()!.length, 3); await tester.pumpWidget(const SizedBox()); // Image widget and render object go away expect(image.debugGetOpenHandleStackTraces()!.length, 1); await provider.evict(); tester.binding.scheduleFrame(); await tester.pump(); // Image cache listener go away and Image stream listeners go away. // Image is now at zero. expect(image.debugGetOpenHandleStackTraces()!.length, 0); }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/87442 testWidgets('Keeps stream alive when ticker mode is disabled', (WidgetTester tester) async { imageCache.maximumSize = 0; final ui.Image image = (await tester.runAsync(() => createTestImage(cache: false)))!; final provider = _TestImageProvider(); provider.complete(image); await tester.pumpWidget(TickerMode(enabled: true, child: Image(image: provider))); expect(find.byType(Image), findsOneWidget); await tester.pumpWidget(TickerMode(enabled: false, child: Image(image: provider))); expect(find.byType(Image), findsOneWidget); await tester.pumpWidget(TickerMode(enabled: true, child: Image(image: provider))); expect(find.byType(Image), findsOneWidget); }); testWidgets('Keeps stream alive when animations are disabled', (WidgetTester tester) async { imageCache.maximumSize = 0; final ui.Image image = (await tester.runAsync(() => createTestImage(cache: false)))!; final provider = _TestImageProvider(); provider.complete(image); var disableAnimations = false; late StateSetter setState; await tester.pumpWidget( StatefulBuilder( builder: (BuildContext context, StateSetter localSetState) { setState = localSetState; return MediaQuery( data: MediaQueryData(disableAnimations: disableAnimations), child: Image(image: provider), ); }, ), ); expect(find.byType(Image), findsOneWidget); setState(() { disableAnimations = true; }); await tester.pump(); expect(find.byType(Image), findsOneWidget); setState(() { disableAnimations = false; }); await tester.pump(); expect(find.byType(Image), findsOneWidget); }); testWidgets( 'Load a good image after a bad image was loaded should not call errorBuilder', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final errorKey = UniqueKey(); final ui.Image image = (await tester.runAsync(() => createTestImage()))!; final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); await tester.pumpWidget( Center( child: SizedBox( height: 50, width: 50, child: Image( image: imageProvider, excludeFromSemantics: true, errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) { return Container(key: errorKey); }, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { return Padding(padding: const EdgeInsets.all(1), child: child); }, ), ), ), ); // No error widget before loading a invalid image. expect(find.byKey(errorKey), findsNothing); // Loading good image succeed streamCompleter.setData( chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100), ); await tester.pump(); expect(find.byType(Padding), findsOneWidget); // Loading bad image shows the error widget. streamCompleter.setError(exception: 'thrown'); await tester.pump(); expect(find.byKey(errorKey), findsOneWidget); // Loading good image shows the image widget instead of the error widget. streamCompleter.setData(imageInfo: ImageInfo(image: image)); await tester.pump(); expect(find.byType(Padding), findsOneWidget); expect(tester.widget(find.byType(Padding)).child, isA()); expect(find.byKey(errorKey), findsNothing); }, ); testWidgets( 'Failed image loads in debug mode', (WidgetTester tester) async { final Key key = UniqueKey(); await tester.pumpWidget( Center( child: RepaintBoundary( key: key, child: Container( width: 150.0, height: 50.0, decoration: BoxDecoration( border: Border.all(width: 2.0, color: const Color(0xFF00FF99)), ), child: Image.asset('missing-asset'), ), ), ), ); await expectLater(find.byKey(key), matchesGoldenFile('image_test.missing.1.png')); expect( tester.takeException().toString(), equals( 'Unable to load asset: "missing-asset".\n' 'The asset does not exist or has empty data.', ), ); await tester.pump(); await expectLater(find.byKey(key), matchesGoldenFile('image_test.missing.2.png')); }, // https://github.com/flutter/flutter/issues/74935 (broken assets not being reported on web) skip: kIsWeb, ); testWidgets('Image.file throws a non-implemented error on web', (WidgetTester tester) async { const expectedError = 'Image.file is not supported on Flutter Web. ' 'Consider using either Image.asset or Image.network instead.'; final Uri uri = Uri.parse('/home/flutter/dash.png'); final file = File.fromUri(uri); expect( () => Image.file(file), kIsWeb // Web does not support file access, expect AssertionError ? throwsA(predicate((AssertionError e) => e.message == expectedError)) // AOT supports file access, expect constructor to succeed : isNot(throwsA(anything)), ); }); testWidgets( 'Animated GIFs do not require layout for subsequent frames', experimentalLeakTesting: LeakTesting.settings .withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter]. (WidgetTester tester) async { final ui.Codec codec = (await tester.runAsync(() { return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif)); }))!; Future nextFrame() async { final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!; return frameInfo.image; } final streamCompleter = _TestImageStreamCompleter(); final imageProvider = _TestImageProvider(streamCompleter: streamCompleter); int? lastFrame; await tester.pumpWidget( Center( child: Image( image: imageProvider, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { lastFrame = frame; return child; }, ), ), ); expect(tester.getSize(find.byType(Image)), Size.zero); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 0); expect(tester.allRenderObjects.whereType().single.debugNeedsLayout, isFalse); expect(tester.allRenderObjects.whereType().single.debugNeedsPaint, isFalse); expect(tester.getSize(find.byType(Image)), const Size(1, 1)); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); // We only complete the build phase and expect that it does not mark the // RenderImage for layout because the new frame has the same dimensions as // the old one. We only need to repaint. await tester.pump(null, EnginePhase.build); expect(lastFrame, 1); expect(tester.allRenderObjects.whereType().single.debugNeedsLayout, isFalse); expect(tester.allRenderObjects.whereType().single.debugNeedsPaint, isTrue); expect(tester.getSize(find.byType(Image)), const Size(1, 1)); streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame())); await tester.pump(); expect(lastFrame, 2); expect(tester.allRenderObjects.whereType().single.debugNeedsLayout, isFalse); expect(tester.allRenderObjects.whereType().single.debugNeedsPaint, isFalse); expect(tester.getSize(find.byType(Image)), const Size(1, 1)); codec.dispose(); }, ); testWidgets('errorBuilder prevents FlutterError report even if widget is disposed', ( WidgetTester tester, ) async { // This test verifies that if an errorBuilder is provided, FlutterError.reportError // is NOT called, even if the Image widget is removed from the tree before the // image load fails. Regression test for https://github.com/flutter/flutter/issues/97077. // 1. Setup: Capture FlutterError reports final reportedErrors = []; final FlutterExceptionHandler? oldHandler = FlutterError.onError; FlutterError.onError = reportedErrors.add; addTearDown(() { FlutterError.onError = oldHandler; }); // Ensure handler is restored final provider = _TestImageProvider(); final testException = Exception('Network failed'); final StackTrace testStack = StackTrace.current; Widget buildImage() { return Directionality( textDirection: TextDirection.ltr, child: Image( image: provider, errorBuilder: (_, _, _) => const SizedBox(width: 10, height: 10), ), ); } // 2. Pump the widget with the Image. await tester.pumpWidget(buildImage()); expect(find.byType(Image), findsOneWidget); expect(reportedErrors, isEmpty); // No errors yet // 3. Remove the Image widget from the tree. await tester.pumpWidget(const SizedBox.shrink()); expect(find.byType(Image), findsNothing); // 4. Now, make the image provider fail *after* the widget state is disposed. provider.fail(testException, testStack); // 5. Allow asynchronous error propagation to complete robustly. await tester.pumpAndSettle(); // Restore the handler now in case `expect`s in step 6 fail. FlutterError.onError = oldHandler; // 6. CRITICAL ASSERTION: Verify that no FlutterError was reported via the onError handler expect( reportedErrors, isEmpty, reason: 'FlutterError.onError should not be called when an errorBuilder was provided.', ); // Also check takeException as a standard backup. expect(tester.takeException(), isNull); }); testWidgets( 'errorBuilder prevents FlutterError report only if errorBuilder is non-null when widget is disposed', (WidgetTester tester) async { // This test verifies that if an errorBuilder is provided, FlutterError.reportError // is called, only if the errorBuilder stays present when the widget is unmounted. // 1. Setup: Capture FlutterError reports final reportedErrors = []; final FlutterExceptionHandler? oldHandler = FlutterError.onError; FlutterError.onError = reportedErrors.add; addTearDown(() { FlutterError.onError = oldHandler; }); // Ensure handler is restored final provider = _TestImageProvider(); final testException = Exception('Network failed'); final StackTrace testStack = StackTrace.current; // Function to build the widget with the Image Widget buildImage({required bool hasErrorBuilder}) { return Directionality( textDirection: TextDirection.ltr, child: Image( image: provider, errorBuilder: hasErrorBuilder ? (_, _, _) => const SizedBox(width: 10, height: 10) : null, ), ); } // 2. Pump the widget with an errorBuilder await tester.pumpWidget(buildImage(hasErrorBuilder: true)); expect(find.byType(Image), findsOneWidget); expect(reportedErrors, isEmpty); // No errors yet // 3. Update the widget with no errorBuilder await tester.pumpWidget(buildImage(hasErrorBuilder: false)); expect(find.byType(Image), findsOneWidget); expect(reportedErrors, isEmpty); // No errors yet // 4. Remove the Image widget from the tree. await tester.pumpWidget(const SizedBox.shrink()); expect(find.byType(Image), findsNothing); // 5. Now, make the image provider fail *after* the widget state is disposed. provider.fail(testException, testStack); // 5. Allow asynchronous error propagation to complete robustly. await tester.pumpAndSettle(); // Restore the handler now in case `expect`s in step 6 fail. FlutterError.onError = oldHandler; // 6. Verify that a FlutterError was reported via the onError handler expect( reportedErrors, isNotEmpty, reason: 'FlutterError.onError should be called when an errorBuilder was not provided eventually.', ); // Also check takeException as a standard backup. expect(tester.takeException(), isNull); }, ); } @immutable class _ConfigurationAwareKey { const _ConfigurationAwareKey(this.provider, this.configuration); final ImageProvider provider; final ImageConfiguration configuration; @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is _ConfigurationAwareKey && other.provider == provider && other.configuration == configuration; } @override int get hashCode => Object.hash(provider, configuration); } class _ConfigurationKeyedTestImageProvider extends _TestImageProvider { @override Future<_ConfigurationAwareKey> obtainKey(ImageConfiguration configuration) { return SynchronousFuture<_ConfigurationAwareKey>(_ConfigurationAwareKey(this, configuration)); } } class _TestImageProvider extends ImageProvider { _TestImageProvider({ImageStreamCompleter? streamCompleter}) { _streamCompleter = streamCompleter ?? OneFrameImageStreamCompleter(_completer.future); } final Completer _completer = Completer(); late ImageStreamCompleter _streamCompleter; late ImageConfiguration _lastResolvedConfiguration; bool get loadCalled => _loadCallCount > 0; int get loadCallCount => _loadCallCount; int _loadCallCount = 0; @override Future obtainKey(ImageConfiguration configuration) { return SynchronousFuture<_TestImageProvider>(this); } @override void resolveStreamForKey( ImageConfiguration configuration, ImageStream stream, Object key, ImageErrorListener handleError, ) { _lastResolvedConfiguration = configuration; super.resolveStreamForKey(configuration, stream, key, handleError); } @override ImageStreamCompleter loadImage(Object key, ImageDecoderCallback decode) { _loadCallCount += 1; return _streamCompleter; } void complete(ui.Image image) { _completer.complete(ImageInfo(image: image)); } void fail(Object exception, StackTrace? stackTrace) { _completer.completeError(exception, stackTrace); } @override String toString() => '${describeIdentity(this)}()'; } /// An [ImageStreamCompleter] that gives access to the added listeners. /// /// Such an access to listeners is hacky, /// because it breaks encapsulation by allowing to invoke listeners without /// taking care about lifecycle of the created images, that may result in not disposed images. /// /// That's why some tests that use it /// are opted out from leak tracking. class _TestImageStreamCompleter extends ImageStreamCompleter { _TestImageStreamCompleter([this._currentImage]); ImageInfo? _currentImage; final Set listeners = {}; @override void addListener(ImageStreamListener listener) { listeners.add(listener); if (_currentImage != null) { listener.onImage(_currentImage!.clone(), true); } } @override void removeListener(ImageStreamListener listener) { listeners.remove(listener); } void setData({ImageInfo? imageInfo, ImageChunkEvent? chunkEvent}) { if (imageInfo != null) { _currentImage?.dispose(); _currentImage = imageInfo; } final List localListeners = listeners.toList(); for (final listener in localListeners) { if (imageInfo != null) { listener.onImage(imageInfo.clone(), false); } if (chunkEvent != null && listener.onChunk != null) { listener.onChunk!(chunkEvent); } } } void setError({required Object exception, StackTrace? stackTrace}) { final List localListeners = listeners.toList(); for (final listener in localListeners) { listener.onError?.call(exception, stackTrace); } } void dispose() { final List listenersCopy = listeners.toList(); listenersCopy.forEach(removeListener); } } class _DebouncingImageProvider extends ImageProvider { _DebouncingImageProvider(this.imageProvider, this.seenKeys); /// A set of keys that will only get resolved the _first_ time they are seen. /// /// If an ImageProvider produces the same key for two different image /// configurations, it should only actually resolve once using this provider. /// However, if it does care about image configuration, it should make the /// property or properties it cares about part of the key material it /// produces. final Set seenKeys; final ImageProvider imageProvider; @override void resolveStreamForKey( ImageConfiguration configuration, ImageStream stream, Object key, ImageErrorListener handleError, ) { if (seenKeys.add(key)) { imageProvider.resolveStreamForKey(configuration, stream, key, handleError); } } @override Future obtainKey(ImageConfiguration configuration) => imageProvider.obtainKey(configuration); @override ImageStreamCompleter loadImage(Object key, ImageDecoderCallback decode) => imageProvider.loadImage(key, decode); } class _FailingImageProvider extends ImageProvider { const _FailingImageProvider({ this.failOnObtainKey = false, this.failOnLoad = false, required this.throws, required this.image, }) : assert(failOnLoad || failOnObtainKey); final bool failOnObtainKey; final bool failOnLoad; final Object throws; final ui.Image image; @override Future obtainKey(ImageConfiguration configuration) { if (failOnObtainKey) { throw throws; } return SynchronousFuture(hashCode); } @override ImageStreamCompleter loadImage(int key, ImageDecoderCallback decode) { if (failOnLoad) { throw throws; } return OneFrameImageStreamCompleter(Future.value(ImageInfo(image: image, scale: 0))); } } /// The different ways of disabling animations. enum _DisableMethod { /// Use [TickerMode.enabled]. tickerMode, /// Use [MedaiQueryData.disableAnimations]. mediaQuery, }