diff --git a/lib/web_ui/dev/test_platform.dart b/lib/web_ui/dev/test_platform.dart index 17a66d0eff1fc..d4d4e79235da5 100644 --- a/lib/web_ui/dev/test_platform.dart +++ b/lib/web_ui/dev/test_platform.dart @@ -90,6 +90,12 @@ class BrowserPlatform extends PlatformPlugin { // This handler goes last, after all more specific handlers failed to handle the request. .add(_createAbsolutePackageUrlHandler()) .add(_screenshotHandler) + + // Generates and serves a test payload of given length, split into chunks + // of given size. Reponds to requests to /long_test_payload. + .add(_testPayloadGenerator) + + // If none of the handlers above handled the request, return 404. .add(_fileNotFoundCatcher); server.mount(cascade.handler); @@ -320,6 +326,46 @@ class BrowserPlatform extends PlatformPlugin { }; } + Future _testPayloadGenerator(shelf.Request request) async { + if (!request.requestedUri.path.endsWith('/long_test_payload')) { + return shelf.Response.notFound( + 'This request is not handled by the test payload generator'); + } + + final int payloadLength = int.parse(request.requestedUri.queryParameters['length']!); + final int chunkLength = int.parse(request.requestedUri.queryParameters['chunk']!); + + final StreamController> controller = StreamController>(); + + Future fillPayload() async { + int remainingByteCount = payloadLength; + int byteCounter = 0; + while (remainingByteCount > 0) { + final int currentChunkLength = min(chunkLength, remainingByteCount); + final List chunk = List.generate( + currentChunkLength, + (int i) => (byteCounter + i) & 0xFF, + ); + byteCounter = (byteCounter + currentChunkLength) & 0xFF; + remainingByteCount -= currentChunkLength; + controller.add(chunk); + await Future.delayed(const Duration(milliseconds: 100)); + } + await controller.close(); + } + + // Kick off payload filling function but don't block on it. The stream should + // be returned immediately, and the client should receive data in chunks. + unawaited(fillPayload()); + return shelf.Response.ok( + controller.stream, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': '$payloadLength', + }, + ); + } + Future _screenshotHandler(shelf.Request request) async { if (!request.requestedUri.path.endsWith('/screenshot')) { return shelf.Response.notFound( diff --git a/lib/web_ui/lib/src/engine/assets.dart b/lib/web_ui/lib/src/engine/assets.dart index bfc7bba0e46e9..0b39168897d91 100644 --- a/lib/web_ui/lib/src/engine/assets.dart +++ b/lib/web_ui/lib/src/engine/assets.dart @@ -63,52 +63,23 @@ class AssetManager { return Uri.encodeFull('${_baseUrl ?? ''}$assetsDir/$asset'); } + /// Loads an asset and returns the server response. + Future loadAsset(String asset) { + return httpFetch(getAssetUrl(asset)); + } + /// Loads an asset using an [DomXMLHttpRequest] and returns data as [ByteData]. Future load(String asset) async { final String url = getAssetUrl(asset); - try { - final DomXMLHttpRequest request = - await domHttpRequest(url, responseType: 'arraybuffer'); - - final ByteBuffer response = request.response as ByteBuffer; - return response.asByteData(); - } catch (e) { - if (!domInstanceOfString(e, 'ProgressEvent')){ - rethrow; - } - final DomProgressEvent p = e as DomProgressEvent; - final DomEventTarget? target = p.target; - if (domInstanceOfString(target,'XMLHttpRequest')) { - final DomXMLHttpRequest request = target! as DomXMLHttpRequest; - if (request.status == 404 && asset == 'AssetManifest.json') { - printWarning('Asset manifest does not exist at `$url` – ignoring.'); - return Uint8List.fromList(utf8.encode('{}')).buffer.asByteData(); - } - throw AssetManagerException(url, request.status!.toInt()); - } - - final String? constructorName = target == null ? 'null' : - domGetConstructorName(target); - printWarning('Caught ProgressEvent with unknown target: ' - '$constructorName'); - rethrow; - } - } -} + final HttpFetchResponse response = await httpFetch(url); -/// Thrown to indicate http failure during asset loading. -class AssetManagerException implements Exception { - /// Initializes exception with request url and http status. - AssetManagerException(this.url, this.httpStatus); - - /// Http request url for asset. - final String url; - - /// Http status of response. - final int httpStatus; + if (response.status == 404 && asset == 'AssetManifest.json') { + printWarning('Asset manifest does not exist at `$url` - ignoring.'); + return Uint8List.fromList(utf8.encode('{}')).buffer.asByteData(); + } - @override - String toString() => 'Failed to load asset at "$url" ($httpStatus)'; + return (await response.payload.asByteBuffer()).asByteData(); + } } /// An asset manager that gives fake empty responses for assets. @@ -141,6 +112,33 @@ class WebOnlyMockAssetManager implements AssetManager { @override String getAssetUrl(String asset) => asset; + @override + Future loadAsset(String asset) async { + if (asset == getAssetUrl('AssetManifest.json')) { + return MockHttpFetchResponse( + url: asset, + status: 200, + payload: MockHttpFetchPayload( + byteBuffer: _toByteData(utf8.encode(defaultAssetManifest)).buffer, + ) + ); + } + if (asset == getAssetUrl('FontManifest.json')) { + return MockHttpFetchResponse( + url: asset, + status: 200, + payload: MockHttpFetchPayload( + byteBuffer: _toByteData(utf8.encode(defaultFontManifest)).buffer, + ) + ); + } + + return MockHttpFetchResponse( + url: asset, + status: 404, + ); + } + @override Future load(String asset) { if (asset == getAssetUrl('AssetManifest.json')) { @@ -151,7 +149,7 @@ class WebOnlyMockAssetManager implements AssetManager { return Future.value( _toByteData(utf8.encode(defaultFontManifest))); } - throw AssetManagerException(asset, 404); + throw HttpFetchNoPayloadError(asset, status: 404); } ByteData _toByteData(List bytes) { diff --git a/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart b/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart index 3491a43261208..90c2fb31d1213 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart @@ -488,37 +488,33 @@ class NotoDownloader { /// Downloads the [url] and returns it as a [ByteBuffer]. /// /// Override this for testing. - Future downloadAsBytes(String url, {String? debugDescription}) { + Future downloadAsBytes(String url, {String? debugDescription}) async { if (assertionsEnabled) { _debugActiveDownloadCount += 1; } - final Future result = httpFetch(url).then( - (DomResponse fetchResult) => fetchResult - .arrayBuffer() - .then((dynamic x) => x as ByteBuffer)); + final Future data = httpFetchByteBuffer(url); if (assertionsEnabled) { - result.whenComplete(() { + unawaited(data.whenComplete(() { _debugActiveDownloadCount -= 1; - }); + })); } - return result; + return data; } /// Downloads the [url] and returns is as a [String]. /// /// Override this for testing. - Future downloadAsString(String url, {String? debugDescription}) { + Future downloadAsString(String url, {String? debugDescription}) async { if (assertionsEnabled) { _debugActiveDownloadCount += 1; } - final Future result = httpFetch(url).then((DomResponse response) => - response.text().then((dynamic x) => x as String)); + final Future data = httpFetchText(url); if (assertionsEnabled) { - result.whenComplete(() { + unawaited(data.whenComplete(() { _debugActiveDownloadCount -= 1; - }); + })); } - return result; + return data; } } diff --git a/lib/web_ui/lib/src/engine/canvaskit/fonts.dart b/lib/web_ui/lib/src/engine/canvaskit/fonts.dart index 694a015168e87..660f8a136efa3 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/fonts.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/fonts.dart @@ -93,21 +93,15 @@ class SkiaFontCollection implements FontCollection { /// Loads fonts from `FontManifest.json`. @override Future downloadAssetFonts(AssetManager assetManager) async { - ByteData byteData; + final HttpFetchResponse response = await assetManager.loadAsset('FontManifest.json'); - try { - byteData = await assetManager.load('FontManifest.json'); - } on AssetManagerException catch (e) { - if (e.httpStatus == 404) { - printWarning('Font manifest does not exist at `${e.url}` – ignoring.'); - return; - } else { - rethrow; - } + if (!response.hasPayload) { + printWarning('Font manifest does not exist at `${response.url}` - ignoring.'); + return; } - final List? fontManifest = - json.decode(utf8.decode(byteData.buffer.asUint8List())) as List?; + final Uint8List data = await response.asUint8List(); + final List? fontManifest = json.decode(utf8.decode(data)) as List?; if (fontManifest == null) { throw AssertionError( 'There was a problem trying to load FontManifest.json'); @@ -206,10 +200,11 @@ class SkiaFontCollection implements FontCollection { String family ) { Future downloadFont() async { - ByteBuffer buffer; + // Try to get the font leniently. Do not crash the app when failing to + // fetch the font in the spirit of "gradual degradation of functionality". try { - buffer = await httpFetch(url).then(_getArrayBuffer); - return UnregisteredFont(buffer, url, family); + final ByteBuffer data = await httpFetchByteBuffer(url); + return UnregisteredFont(data, url, family); } catch (e) { printWarning('Failed to load font $family at $url'); printWarning(e.toString()); @@ -230,12 +225,6 @@ class SkiaFontCollection implements FontCollection { return actualFamily; } - Future _getArrayBuffer(DomResponse fetchResult) { - return fetchResult - .arrayBuffer() - .then((dynamic x) => x as ByteBuffer); - } - TypefaceFontProvider? fontProvider; @override diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart index 45007a3e4ce85..220bfccfd2c29 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -167,12 +167,6 @@ class ImageCodecException implements Exception { const String _kNetworkImageMessage = 'Failed to load network image.'; -typedef HttpRequestFactory = DomXMLHttpRequest Function(); -HttpRequestFactory httpRequestFactory = () => createDomXMLHttpRequest(); -void debugRestoreHttpRequestFactory() { - httpRequestFactory = () => createDomXMLHttpRequest(); -} - /// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia after /// requesting from URI. Future skiaInstantiateWebImageCodec( @@ -186,49 +180,48 @@ Future skiaInstantiateWebImageCodec( } /// Sends a request to fetch image data. -Future fetchImage( - String url, WebOnlyImageCodecChunkCallback? chunkCallback) { - final Completer completer = Completer(); - - final DomXMLHttpRequest request = httpRequestFactory(); - request.open('GET', url, true); - request.responseType = 'arraybuffer'; - if (chunkCallback != null) { - request.addEventListener('progress', allowInterop((DomEvent event) { - event = event as DomProgressEvent; - chunkCallback.call(event.loaded!.toInt(), event.total!.toInt()); - })); - } - - request.addEventListener('error', allowInterop((DomEvent event) { - completer.completeError(ImageCodecException('$_kNetworkImageMessage\n' +Future fetchImage(String url, WebOnlyImageCodecChunkCallback? chunkCallback) async { + try { + final HttpFetchResponse response = await httpFetch(url); + final int? contentLength = response.contentLength; + + if (!response.hasPayload) { + throw ImageCodecException( + '$_kNetworkImageMessage\n' 'Image URL: $url\n' - 'Trying to load an image from another domain? Find answers at:\n' - 'https://flutter.dev/docs/development/platform-integration/web-images')); - })); - - request.addEventListener('load', allowInterop((DomEvent event) { - final int status = request.status!.toInt(); - final bool accepted = status >= 200 && status < 300; - final bool fileUri = status == 0; // file:// URIs have status of 0. - final bool notModified = status == 304; - final bool unknownRedirect = status > 307 && status < 400; - final bool success = accepted || fileUri || notModified || unknownRedirect; - - if (!success) { - completer.completeError( - ImageCodecException('$_kNetworkImageMessage\n' - 'Image URL: $url\n' - 'Server response code: $status'), + 'Server response code: ${response.status}', ); - return; } - completer.complete(Uint8List.view(request.response as ByteBuffer)); - })); + if (chunkCallback != null && contentLength != null) { + return readChunked(response.payload, contentLength, chunkCallback); + } else { + return await response.asUint8List(); + } + } on HttpFetchError catch (_) { + throw ImageCodecException( + '$_kNetworkImageMessage\n' + 'Image URL: $url\n' + 'Trying to load an image from another domain? Find answers at:\n' + 'https://flutter.dev/docs/development/platform-integration/web-images', + ); + } +} - request.send(); - return completer.future; +/// Reads the [payload] in chunks using the browser's Streams API +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API +Future readChunked(HttpFetchPayload payload, int contentLength, WebOnlyImageCodecChunkCallback chunkCallback) async { + final Uint8List result = Uint8List(contentLength); + int position = 0; + int cumulativeBytesLoaded = 0; + await payload.read((Uint8List chunk) { + cumulativeBytesLoaded += chunk.lengthInBytes; + chunkCallback(cumulativeBytesLoaded, contentLength); + result.setAll(position, chunk); + position += chunk.lengthInBytes; + }); + return result; } /// A [ui.Image] backed by an `SkImage` from Skia. diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index 89456dd375744..90fb36e3a27d4 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -38,8 +38,18 @@ extension DomWindowExtension on DomWindow { external DomNavigator get navigator; external DomVisualViewport? get visualViewport; external DomPerformance get performance; - Future fetch(String url) => - js_util.promiseToFuture(js_util.callMethod(this, 'fetch', [url])); + + @visibleForTesting + Future fetch(String url) { + // To make sure we have a consistent approach for handling and reporting + // network errors, all code related to making HTTP calls is consolidated + // into the `httpFetch` function, and a few convenience wrappers. + throw UnsupportedError( + 'Do not use window.fetch directly. ' + 'Use httpFetch* family of functions instead.', + ); + } + // ignore: non_constant_identifier_names external DomURL get URL; external bool dispatchEvent(DomEvent event); @@ -756,56 +766,400 @@ extension DomCanvasGradientExtension on DomCanvasGradient { @staticInterop class DomXMLHttpRequestEventTarget extends DomEventTarget {} -@JS() -@staticInterop -class DomXMLHttpRequest extends DomXMLHttpRequestEventTarget {} +Future<_DomResponse> _rawHttpGet(String url) { + return js_util.promiseToFuture<_DomResponse>(js_util.callMethod(domWindow, 'fetch', [url])); +} + +typedef MockHttpFetchResponseFactory = Future Function(String url); + +MockHttpFetchResponseFactory? mockHttpFetchResponseFactory; + +/// Makes an HTTP GET request to the given [url] and returns the response. +/// +/// If the request fails, throws [HttpFetchError]. HTTP error statuses, such as +/// 404 and 500 are not treated as request failures. In those cases the HTTP +/// part did succeed and correctly passed the HTTP status down from the server +/// to the client. Those statuses represent application-level errors that need +/// extra interpretation to decide if they are "failures" or not. See +/// [HttpFetchResponse.hasPayload] and [HttpFetchResponse.payload]. +/// +/// This function is designed to handle the most general cases. If the default +/// payload handling, including error checking, is sufficient, consider using +/// convenience functions [httpFetchByteBuffer], [httpFetchJson], or +/// [httpFetchText] instead. +Future httpFetch(String url) async { + if (mockHttpFetchResponseFactory != null) { + return mockHttpFetchResponseFactory!(url); + } + try { + final _DomResponse domResponse = await _rawHttpGet(url); + return HttpFetchResponseImpl._(url, domResponse); + } catch (requestError) { + throw HttpFetchError(url, requestError: requestError); + } +} + +Future<_DomResponse> _rawHttpPost(String url, String data) { + return js_util.promiseToFuture<_DomResponse>(js_util.callMethod( + domWindow, + 'fetch', + [ + url, + js_util.jsify({ + 'method': 'POST', + 'headers': { + 'Content-Type': 'text/plain', + }, + 'body': data, + }), + ], + )); +} + +/// Sends a [data] string as HTTP POST request to [url]. +/// +/// The web engine does not make POST requests in production code because it is +/// designed to be able to run web apps served from plain file servers, so this +/// is meant for tests only. +@visibleForTesting +Future testOnlyHttpPost(String url, String data) async { + try { + final _DomResponse domResponse = await _rawHttpPost(url, data); + return HttpFetchResponseImpl._(url, domResponse); + } catch (requestError) { + throw HttpFetchError(url, requestError: requestError); + } +} + +/// Convenience function for making a fetch request and getting the data as a +/// [ByteBuffer], when the default error handling mechanism is sufficient. +Future httpFetchByteBuffer(String url) async { + final HttpFetchResponse response = await httpFetch(url); + return response.asByteBuffer(); +} + +/// Convenience function for making a fetch request and getting the data as a +/// JSON object, when the default error handling mechanism is sufficient. +Future httpFetchJson(String url) async { + final HttpFetchResponse response = await httpFetch(url); + return response.json(); +} + +/// Convenience function for making a fetch request and getting the data as a +/// [String], when the default error handling mechanism is sufficient. +Future httpFetchText(String url) async { + final HttpFetchResponse response = await httpFetch(url); + return response.text(); +} + +/// Successful result of [httpFetch]. +abstract class HttpFetchResponse { + /// The URL passed to [httpFetch] that returns this response. + String get url; + + /// The HTTP response status, such as 200 or 404. + int get status; + + /// The payload length of this response parsed from the "Content-Length" HTTP + /// header. + /// + /// Returns null if "Content-Length" is missing. + int? get contentLength; + + /// Return true if this response has a [payload]. + /// + /// Returns false if this response does not have a payload and therefore it is + /// unsafe to call the [payload] getter. + bool get hasPayload; + + /// Returns the payload of this response. + /// + /// It is only safe to call this getter if [hasPayload] is true. If + /// [hasPayload] is false, throws [HttpFetchNoPayloadError]. + HttpFetchPayload get payload; +} + +/// Convenience methods for simple cases when the default error checking +/// mechanisms are sufficient. +extension HttpFetchResponseExtension on HttpFetchResponse { + /// Reads the payload a chunk at a time. + /// + /// Combined with [HttpFetchResponse.contentLength], this can be used to + /// implement various "progress bar" functionality. + Future read(HttpFetchReader reader) { + return payload.read(reader); + } -DomXMLHttpRequest createDomXMLHttpRequest() => - domCallConstructorString('XMLHttpRequest', [])! - as DomXMLHttpRequest; + /// Returns the data as a [ByteBuffer]. + Future asByteBuffer() { + return payload.asByteBuffer(); + } + + /// Returns the data as a [Uint8List]. + Future asUint8List() async { + return (await payload.asByteBuffer()).asUint8List(); + } -extension DomXMLHttpRequestExtension on DomXMLHttpRequest { - external dynamic get response; - external String? get responseText; - external String get responseType; - external double? get status; - external set responseType(String value); - void open(String method, String url, [bool? async]) => js_util.callMethod( - this, 'open', [method, url, if (async != null) async]); - void send([Object? bodyOrData]) => js_util - .callMethod(this, 'send', [if (bodyOrData != null) bodyOrData]); + /// Returns the data parsed as JSON. + Future json() { + return payload.json(); + } + + /// Return the data as a string. + Future text() { + return payload.text(); + } } -Future domHttpRequest(String url, - {String? responseType, String method = 'GET', dynamic sendData}) { - final Completer completer = Completer(); - final DomXMLHttpRequest xhr = createDomXMLHttpRequest(); - xhr.open(method, url, /* async */ true); - if (responseType != null) { - xhr.responseType = responseType; +class HttpFetchResponseImpl implements HttpFetchResponse { + HttpFetchResponseImpl._(this.url, this._domResponse); + + @override + final String url; + + final _DomResponse _domResponse; + + @override + int get status => _domResponse.status.toInt(); + + @override + int? get contentLength { + final String? header = _domResponse.headers.get('Content-Length'); + if (header == null) { + return null; + } + return int.tryParse(header); } - xhr.addEventListener('load', allowInterop((DomEvent e) { - final int status = xhr.status!.toInt(); + @override + bool get hasPayload { final bool accepted = status >= 200 && status < 300; final bool fileUri = status == 0; final bool notModified = status == 304; final bool unknownRedirect = status > 307 && status < 400; - if (accepted || fileUri || notModified || unknownRedirect) { - completer.complete(xhr); - } else { - completer.completeError(e); + return accepted || fileUri || notModified || unknownRedirect; + } + + @override + HttpFetchPayload get payload { + if (!hasPayload) { + throw HttpFetchNoPayloadError(url, status: status); } - })); + return HttpFetchPayloadImpl._(_domResponse); + } +} - xhr.addEventListener('error', allowInterop((DomEvent event) => completer.completeError(event))); - xhr.send(sendData); - return completer.future; +/// A fake implementation of [HttpFetchResponse] for testing. +class MockHttpFetchResponse implements HttpFetchResponse { + MockHttpFetchResponse({ + required this.url, + required this.status, + this.contentLength, + HttpFetchPayload? payload, + }) : _payload = payload; + + final HttpFetchPayload? _payload; + + @override + final String url; + + @override + final int status; + + @override + final int? contentLength; + + @override + bool get hasPayload => _payload != null; + + @override + HttpFetchPayload get payload => _payload!; +} + +typedef HttpFetchReader = void Function(T chunk); + +/// Data returned with a [HttpFetchResponse]. +abstract class HttpFetchPayload { + /// Reads the payload a chunk at a time. + /// + /// Combined with [HttpFetchResponse.contentLength], this can be used to + /// implement various "progress bar" functionality. + Future read(HttpFetchReader reader); + + /// Returns the data as a [ByteBuffer]. + Future asByteBuffer(); + + /// Returns the data parsed as JSON. + Future json(); + + /// Return the data as a string. + Future text(); +} + +class HttpFetchPayloadImpl implements HttpFetchPayload { + HttpFetchPayloadImpl._(this._domResponse); + + final _DomResponse _domResponse; + + @override + Future read(HttpFetchReader callback) async { + final _DomReadableStream stream = _domResponse.body; + final _DomStreamReader reader = stream.getReader(); + + while (true) { + final _DomStreamChunk chunk = await reader.read(); + if (chunk.done) { + break; + } + callback(chunk.value as T); + } + } + + /// Returns the data as a [ByteBuffer]. + @override + Future asByteBuffer() async { + return (await _domResponse.arrayBuffer()) as ByteBuffer; + } + + /// Returns the data parsed as JSON. + @override + Future json() => _domResponse.json(); + + /// Return the data as a string. + @override + Future text() => _domResponse.text(); +} + +typedef MockOnRead = Future Function(HttpFetchReader callback); + +class MockHttpFetchPayload implements HttpFetchPayload { + MockHttpFetchPayload({ + ByteBuffer? byteBuffer, + Object? json, + String? text, + MockOnRead? onRead, + }) : _byteBuffer = byteBuffer, _json = json, _text = text, _onRead = onRead; + + final ByteBuffer? _byteBuffer; + final Object? _json; + final String? _text; + final MockOnRead? _onRead; + + @override + Future read(HttpFetchReader callback) => _onRead!(callback); + + @override + Future asByteBuffer() async => _byteBuffer!; + + @override + Future json() async => _json!; + + @override + Future text() async => _text!; +} + +/// Indicates a missing HTTP payload when one was expected, such as when +/// [HttpFetchResponse.payload] was called. +/// +/// Unlike [HttpFetchError], this error happens when the HTTP request/response +/// succeeded, but the response type is not the kind that provides useful +/// payload, such as 404, or 500. +class HttpFetchNoPayloadError implements Exception { + /// Creates an exception from a successful HTTP request, but an unsuccessful + /// HTTP response code, such as 404 or 500. + HttpFetchNoPayloadError(this.url, { required this.status }); + + /// HTTP request URL for asset. + final String url; + + /// If the HTTP request succeeded, the HTTP response status. + /// + /// Null if the HTTP request failed. + final int status; + + @override + String toString() { + return 'Flutter Web engine failed to fetch "$url". HTTP request succeeded, ' + 'but the server responded with HTTP status $status.'; + } +} + +/// Indicates a failure trying to fetch a [url]. +/// +/// Unlike [HttpFetchNoPayloadError] this error indicates that there was no HTTP +/// response and the roundtrip what interrupted by something else, like a loss +/// of network connectivity, or request being interrupted by the OS, a browser +/// CORS policy, etc. In particular, there's not even a HTTP status code to +/// report, such as 200, 404, or 500. +class HttpFetchError implements Exception { + /// Creates an exception from a failed HTTP request. + HttpFetchError(this.url, { required this.requestError }); + + /// HTTP request URL for asset. + final String url; + + /// The underlying network error that prevented [httpFetch] from succeeding. + final Object requestError; + + @override + String toString() { + return 'Flutter Web engine failed to complete HTTP request to fetch ' + '"$url": $requestError'; + } } @JS() @staticInterop -class DomResponse {} +class _DomResponse {} + +extension _DomResponseExtension on _DomResponse { + external double get status; + + external _DomHeaders get headers; + + external _DomReadableStream get body; + + Future arrayBuffer() => js_util + .promiseToFuture(js_util.callMethod(this, 'arrayBuffer', [])); + + Future json() => + js_util.promiseToFuture(js_util.callMethod(this, 'json', [])); + + Future text() => + js_util.promiseToFuture(js_util.callMethod(this, 'text', [])); +} + +@JS() +@staticInterop +class _DomHeaders {} + +extension _DomHeadersExtension on _DomHeaders { + external String? get(String? headerName); +} + +@JS() +@staticInterop +class _DomReadableStream {} +extension _DomReadableStreamExtension on _DomReadableStream { + external _DomStreamReader getReader(); +} + +@JS() +@staticInterop +class _DomStreamReader {} +extension _DomStreamReaderExtension on _DomStreamReader { + Future<_DomStreamChunk> read() { + return js_util.promiseToFuture<_DomStreamChunk>(js_util.callMethod(this, 'read', [])); + } +} + +@JS() +@staticInterop +class _DomStreamChunk {} +extension _DomStreamChunkExtension on _DomStreamChunk { + external Object? get value; + external bool get done; +} @JS() @staticInterop @@ -940,17 +1294,6 @@ extension DomClipboardExtension on DomClipboard { .promiseToFuture(js_util.callMethod(this, 'writeText', [data])); } -extension DomResponseExtension on DomResponse { - Future arrayBuffer() => js_util - .promiseToFuture(js_util.callMethod(this, 'arrayBuffer', [])); - - Future json() => - js_util.promiseToFuture(js_util.callMethod(this, 'json', [])); - - Future text() => - js_util.promiseToFuture(js_util.callMethod(this, 'text', [])); -} - @JS() @staticInterop class DomUIEvent extends DomEvent {} diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart index 06859c6684d2c..85421fb2a15dd 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -463,12 +463,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { case 'flutter/assets': final String url = utf8.decode(data!.buffer.asUint8List()); - ui.webOnlyAssetManager.load(url).then((ByteData assetData) { - replyToPlatformMessage(callback, assetData); - }, onError: (dynamic error) { - printWarning('Error while trying to load an asset: $error'); - replyToPlatformMessage(callback, null); - }); + _handleFlutterAssetsMessage(url, callback); return; case 'flutter/platform': @@ -613,6 +608,17 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { replyToPlatformMessage(callback, null); } + Future _handleFlutterAssetsMessage(String url, ui.PlatformMessageResponseCallback? callback) async { + try { + final HttpFetchResponse response = await ui.webOnlyAssetManager.loadAsset(url); + final ByteBuffer assetData = await response.asByteBuffer(); + replyToPlatformMessage(callback, assetData.asByteData()); + } catch (error) { + printWarning('Error while trying to load an asset: $error'); + replyToPlatformMessage(callback, null); + } + } + int _getHapticFeedbackDuration(String? type) { const int vibrateLongPress = 50; const int vibrateLightImpact = 10; diff --git a/lib/web_ui/lib/src/engine/text/font_collection.dart b/lib/web_ui/lib/src/engine/text/font_collection.dart index 9918326c6bbec..a4abd969e2dd7 100644 --- a/lib/web_ui/lib/src/engine/text/font_collection.dart +++ b/lib/web_ui/lib/src/engine/text/font_collection.dart @@ -27,21 +27,15 @@ class HtmlFontCollection implements FontCollection { /// fonts declared within. @override Future downloadAssetFonts(AssetManager assetManager) async { - ByteData byteData; + final HttpFetchResponse response = await assetManager.loadAsset('FontManifest.json'); - try { - byteData = await assetManager.load('FontManifest.json'); - } on AssetManagerException catch (e) { - if (e.httpStatus == 404) { - printWarning('Font manifest does not exist at `${e.url}` – ignoring.'); - return; - } else { - rethrow; - } + if (!response.hasPayload) { + printWarning('Font manifest does not exist at `${response.url}` - ignoring.'); + return; } - final List? fontManifest = - json.decode(utf8.decode(byteData.buffer.asUint8List())) as List?; + final Uint8List data = await response.asUint8List(); + final List? fontManifest = json.decode(utf8.decode(data)) as List?; if (fontManifest == null) { throw AssertionError( 'There was a problem trying to load FontManifest.json'); diff --git a/lib/web_ui/lib/src/engine/util.dart b/lib/web_ui/lib/src/engine/util.dart index a96bffef73f50..9400936386b7a 100644 --- a/lib/web_ui/lib/src/engine/util.dart +++ b/lib/web_ui/lib/src/engine/util.dart @@ -563,12 +563,6 @@ String blurSigmasToCssString(double sigmaX, double sigmaY) { return 'blur(${(sigmaX + sigmaY) * 0.5}px)'; } -/// A typed variant of [domWindow.fetch]. -Future httpFetch(String url) async { - final Object? result = await domWindow.fetch(url); - return result! as DomResponse; -} - /// Extensions to [Map] that make it easier to treat it as a JSON object. The /// keys are `dynamic` because when JSON is deserialized from method channels /// it arrives as `Map`. diff --git a/lib/web_ui/test/canvaskit/image_golden_test.dart b/lib/web_ui/test/canvaskit/image_golden_test.dart index 343b439ed1e2d..6940776132b8d 100644 --- a/lib/web_ui/test/canvaskit/image_golden_test.dart +++ b/lib/web_ui/test/canvaskit/image_golden_test.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:js/js.dart'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; @@ -25,7 +24,7 @@ void testMain() { setUpCanvasKitTest(); tearDown(() { - debugRestoreHttpRequestFactory(); + mockHttpFetchResponseFactory = null; }); _testCkAnimatedImage(); @@ -196,7 +195,7 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { // This test ensures that toByteData() returns pixels that can be used by decodeImageFromPixels // for the following videoFrame formats: // [BGRX, I422, I420, I444, BGRA] - final DomResponse listingResponse = await httpFetch('/test_images/'); + final HttpFetchResponse listingResponse = await httpFetch('/test_images/'); final List testFiles = (await listingResponse.json() as List).cast(); Future testDecodeFromPixels(Uint8List pixels, int width, int height) async { @@ -224,8 +223,8 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { expect(testFiles, contains(matches(RegExp(r'.*\.bmp')))); for (final String testFile in testFiles) { - final DomResponse imageResponse = await httpFetch('/test_images/$testFile'); - final Uint8List imageData = (await imageResponse.arrayBuffer() as ByteBuffer).asUint8List(); + final HttpFetchResponse imageResponse = await httpFetch('/test_images/$testFile'); + final Uint8List imageData = await imageResponse.asUint8List(); final ui.Codec codec = await skiaInstantiateImageCodec(imageData); expect(codec.frameCount, greaterThan(0)); expect(codec.repetitionCount, isNotNull); @@ -289,15 +288,16 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { test('skiaInstantiateWebImageCodec loads an image from the network', () async { - final TestHttpRequestMock mock = TestHttpRequestMock() - ..status = 200 - ..response = kTransparentImage.buffer; - httpRequestFactory = () => TestHttpRequest(mock); - final Future futureCodec = - skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', - null); - mock.sendEvent('load', DomProgressEvent('test progress event')); - final ui.Codec codec = await futureCodec; + mockHttpFetchResponseFactory = (String url) async { + return MockHttpFetchResponse( + url: url, + status: 200, + payload: MockHttpFetchPayload(byteBuffer: kTransparentImage.buffer), + ); + }; + + final ui.Codec codec = await skiaInstantiateWebImageCodec( + 'http://image-server.com/picture.jpg', null); expect(codec.frameCount, 1); final ui.Image image = (await codec.getNextFrame()).image; expect(image.height, 1); @@ -365,13 +365,12 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { test('skiaInstantiateWebImageCodec throws exception on request error', () async { - final TestHttpRequestMock mock = TestHttpRequestMock(); - httpRequestFactory = () => TestHttpRequest(mock); + mockHttpFetchResponseFactory = (String url) async { + throw HttpFetchError(url, requestError: 'This is a test request error.'); + }; + try { - final Future futureCodec = skiaInstantiateWebImageCodec( - 'url-does-not-matter', null); - mock.sendEvent('error', DomProgressEvent('test error')); - await futureCodec; + await skiaInstantiateWebImageCodec('url-does-not-matter', null); fail('Expected to throw'); } on ImageCodecException catch (exception) { expect( @@ -403,15 +402,16 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { test('skiaInstantiateWebImageCodec includes URL in the error for malformed image', () async { - final TestHttpRequestMock mock = TestHttpRequestMock() - ..status = 200 - ..response = Uint8List(0).buffer; - httpRequestFactory = () => TestHttpRequest(mock); + mockHttpFetchResponseFactory = (String url) async { + return MockHttpFetchResponse( + url: url, + status: 200, + payload: MockHttpFetchPayload(byteBuffer: Uint8List(0).buffer), + ); + }; + try { - final Future futureCodec = skiaInstantiateWebImageCodec( - 'http://image-server.com/picture.jpg', null); - mock.sendEvent('load', DomProgressEvent('test progress event')); - await futureCodec; + await skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', null); fail('Expected to throw'); } on ImageCodecException catch (exception) { if (!browserSupportsImageDecoder) { @@ -620,7 +620,7 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { }); test('Decode test images', () async { - final DomResponse listingResponse = await httpFetch('/test_images/'); + final HttpFetchResponse listingResponse = await httpFetch('/test_images/'); final List testFiles = (await listingResponse.json() as List).cast(); // Sanity-check the test file list. If suddenly test files are moved or @@ -634,8 +634,8 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { expect(testFiles, contains(matches(RegExp(r'.*\.bmp')))); for (final String testFile in testFiles) { - final DomResponse imageResponse = await httpFetch('/test_images/$testFile'); - final Uint8List imageData = (await imageResponse.arrayBuffer() as ByteBuffer).asUint8List(); + final HttpFetchResponse imageResponse = await httpFetch('/test_images/$testFile'); + final Uint8List imageData = await imageResponse.asUint8List(); final ui.Codec codec = await skiaInstantiateImageCodec(imageData); expect(codec.frameCount, greaterThan(0)); expect(codec.repetitionCount, isNotNull); @@ -650,8 +650,8 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { // Reproduces https://skbug.com/12721 test('decoded image can be read back from picture', () async { - final DomResponse imageResponse = await httpFetch('/test_images/mandrill_128.png'); - final Uint8List imageData = (await imageResponse.arrayBuffer() as ByteBuffer).asUint8List(); + final HttpFetchResponse imageResponse = await httpFetch('/test_images/mandrill_128.png'); + final Uint8List imageData = await imageResponse.asUint8List(); final ui.Codec codec = await skiaInstantiateImageCodec(imageData); final ui.FrameInfo frame = await codec.getNextFrame(); final CkImage image = frame.image as CkImage; @@ -743,8 +743,8 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { }); test('toImageSync with texture-backed image', () async { - final DomResponse imageResponse = await httpFetch('/test_images/mandrill_128.png'); - final Uint8List imageData = (await imageResponse.arrayBuffer() as ByteBuffer).asUint8List(); + final HttpFetchResponse imageResponse = await httpFetch('/test_images/mandrill_128.png'); + final Uint8List imageData = await imageResponse.asUint8List(); final ui.Codec codec = await skiaInstantiateImageCodec(imageData); final ui.FrameInfo frame = await codec.getNextFrame(); final CkImage mandrill = frame.image as CkImage; @@ -787,8 +787,8 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { }); test('decoded image can be read back from picture', () async { - final DomResponse imageResponse = await httpFetch('/test_images/mandrill_128.png'); - final Uint8List imageData = (await imageResponse.arrayBuffer() as ByteBuffer).asUint8List(); + final HttpFetchResponse imageResponse = await httpFetch('/test_images/mandrill_128.png'); + final Uint8List imageData = await imageResponse.asUint8List(); final ui.Codec codec = await skiaInstantiateImageCodec(imageData); final ui.FrameInfo frame = await codec.getNextFrame(); final CkImage image = frame.image as CkImage; @@ -959,55 +959,6 @@ void _testCkBrowserImageDecoder() { }, skip: isWasm); } -class TestHttpRequestMock { - String responseType = 'invalid'; - int timeout = 10; - bool withCredentials = false; - dynamic response; - int status = -1; - Map listeners = {}; - - void open(String method, String url, [bool? async]) {} - void send() {} - void addEventListener(String eventType, DomEventListener listener, [bool? - useCapture]) => - listeners[eventType] = listener; - - void sendEvent(String eventType, DomProgressEvent event) => - listeners[eventType]!(event); -} - -@JS() -@anonymous -@staticInterop -class TestHttpRequest implements DomXMLHttpRequest { - factory TestHttpRequest(TestHttpRequestMock mock) { - return TestHttpRequest._( - responseType: mock.responseType, - timeout: mock.timeout.toDouble(), - withCredentials: mock.withCredentials, - response: mock.response, - status: mock.status.toDouble(), - open: allowInterop((String method, String url, [bool? async]) => - mock.open(method, url, async)), - send: allowInterop(() => mock.send()), - addEventListener: allowInterop((String eventType, DomEventListener - listener, [bool? useCapture]) => - mock.addEventListener(eventType, listener, useCapture))); - } - - external factory TestHttpRequest._({ - String responseType, - double timeout, - bool withCredentials, - dynamic response, - double status, - void Function(String method, String url, [bool? async]) open, - void Function() send, - void Function(String eventType, DomEventListener listener) addEventListener - }); -} - Future expectFrameData(ui.FrameInfo frame, List data) async { final ByteData frameData = (await frame.image.toByteData())!; expect(frameData.buffer.asUint8List(), Uint8List.fromList(data)); diff --git a/lib/web_ui/test/canvaskit/image_test.dart b/lib/web_ui/test/canvaskit/image_test.dart index f3b9fd88e07a9..26ad0f889a42f 100644 --- a/lib/web_ui/test/canvaskit/image_test.dart +++ b/lib/web_ui/test/canvaskit/image_test.dart @@ -3,9 +3,11 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:typed_data'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; +import 'package:ui/src/engine/canvaskit/image.dart'; import 'package:ui/ui.dart' as ui; import 'common.dart'; @@ -64,6 +66,38 @@ void testMain() { ui.Image.onDispose = null; }); + + test('fetchImage fetches image in chunks', () async { + final List cumulativeBytesLoadedInvocations = []; + final List expectedTotalBytesInvocations = []; + final Uint8List result = await fetchImage('/long_test_payload?length=100000&chunk=1000', (int cumulativeBytesLoaded, int expectedTotalBytes) { + cumulativeBytesLoadedInvocations.add(cumulativeBytesLoaded); + expectedTotalBytesInvocations.add(expectedTotalBytes); + }); + + // Check that image payload was chunked. + expect(cumulativeBytesLoadedInvocations, hasLength(greaterThan(1))); + + // Check that reported total byte count is the same across all invocations. + for (final int expectedTotalBytes in expectedTotalBytesInvocations) { + expect(expectedTotalBytes, 100000); + } + + // Check that cumulative byte count grows with each invocation. + cumulativeBytesLoadedInvocations.reduce((int previous, int next) { + expect(next, greaterThan(previous)); + return next; + }); + + // Check that the last cumulative byte count matches the total byte count. + expect(cumulativeBytesLoadedInvocations.last, 100000); + + // Check the contents of the returned data. + expect( + result, + List.generate(100000, (int i) => i & 0xFF), + ); + }); } Future _createImage() => _createPicture().toImage(10, 10); diff --git a/lib/web_ui/test/canvaskit/skia_font_collection_test.dart b/lib/web_ui/test/canvaskit/skia_font_collection_test.dart index 9dc43898158e5..2650e586fa864 100644 --- a/lib/web_ui/test/canvaskit/skia_font_collection_test.dart +++ b/lib/web_ui/test/canvaskit/skia_font_collection_test.dart @@ -32,9 +32,14 @@ void testMain() { }); setUp(() { + mockHttpFetchResponseFactory = null; warnings.clear(); }); + tearDown(() { + mockHttpFetchResponseFactory = null; + }); + test('logs no warnings with the default mock asset manager', () async { final SkiaFontCollection fontCollection = SkiaFontCollection(); final WebOnlyMockAssetManager mockAssetManager = @@ -46,6 +51,15 @@ void testMain() { }); test('logs a warning if one of the registered fonts is invalid', () async { + mockHttpFetchResponseFactory = (String url) async { + final ByteBuffer bogusData = Uint8List.fromList('this is not valid font data'.codeUnits).buffer; + return MockHttpFetchResponse( + status: 200, + url: url, + contentLength: bogusData.lengthInBytes, + payload: MockHttpFetchPayload(byteBuffer: bogusData), + ); + }; final SkiaFontCollection fontCollection = SkiaFontCollection(); final WebOnlyMockAssetManager mockAssetManager = WebOnlyMockAssetManager(); @@ -75,6 +89,35 @@ void testMain() { ); }); + test('logs an HTTP warning if one of the registered fonts is missing (404 file not found)', () async { + final SkiaFontCollection fontCollection = SkiaFontCollection(); + final WebOnlyMockAssetManager mockAssetManager = + WebOnlyMockAssetManager(); + mockAssetManager.defaultFontManifest = ''' +[ + { + "family":"Roboto", + "fonts":[{"asset":"/fonts/Roboto-Regular.ttf"}] + }, + { + "family": "ThisFontDoesNotExist", + "fonts":[{"asset":"packages/bogus/ThisFontDoesNotExist.ttf"}] + } + ] + '''; + + // It should complete without error, but emit a warning about ThisFontDoesNotExist. + await fontCollection.downloadAssetFonts(mockAssetManager); + fontCollection.registerDownloadedFonts(); + expect( + warnings, + containsAllInOrder([ + 'Failed to load font ThisFontDoesNotExist at packages/bogus/ThisFontDoesNotExist.ttf', + 'Flutter Web engine failed to fetch "packages/bogus/ThisFontDoesNotExist.ttf". HTTP request succeeded, but the server responded with HTTP status 404.', + ]), + ); + }); + test('prioritizes Ahem loaded via FontManifest.json', () async { final SkiaFontCollection fontCollection = SkiaFontCollection(); final WebOnlyMockAssetManager mockAssetManager = @@ -88,7 +131,7 @@ void testMain() { ] '''.trim(); - final ByteBuffer robotoData = (await (await httpFetch('/assets/fonts/Roboto-Regular.ttf')).arrayBuffer())! as ByteBuffer; + final ByteBuffer robotoData = await httpFetchByteBuffer('/assets/fonts/Roboto-Regular.ttf'); await fontCollection.downloadAssetFonts(mockAssetManager); await fontCollection.debugDownloadTestFonts(); @@ -110,7 +153,7 @@ void testMain() { WebOnlyMockAssetManager(); mockAssetManager.defaultFontManifest = '[]'; - final ByteBuffer ahemData = (await (await httpFetch('/assets/fonts/ahem.ttf')).arrayBuffer())! as ByteBuffer; + final ByteBuffer ahemData = await httpFetchByteBuffer('/assets/fonts/ahem.ttf'); await fontCollection.downloadAssetFonts(mockAssetManager); await fontCollection.debugDownloadTestFonts(); diff --git a/lib/web_ui/test/engine/dom_http_fetch_test.dart b/lib/web_ui/test/engine/dom_http_fetch_test.dart new file mode 100644 index 0000000000000..1d1b8628ccdc5 --- /dev/null +++ b/lib/web_ui/test/engine/dom_http_fetch_test.dart @@ -0,0 +1,233 @@ +// Copyright 2013 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. + +import 'dart:typed_data'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; + +import 'package:ui/ui.dart' as ui; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +Future testMain() async { + await ui.webOnlyInitializePlatform(); + + // Test successful HTTP roundtrips where the server returns a happy status + // code and a payload. + await _testSuccessfulPayloads(); + + // Test successful HTTP roundtrips where the server returned something other + // than a happy result (404 in particular is a common one). + await _testHttpErrorCodes(); + + // Test network errors that prevent the HTTP roundtrip to complete. These + // errors include invalid URLs, CORS issues, lost internet access, etc. + await _testNetworkErrors(); + + test('window.fetch is banned', () async { + expect( + () => domWindow.fetch('/'), + throwsA(isA()), + ); + }); +} + +Future _testSuccessfulPayloads() async { + test('httpFetch fetches a text file', () async { + final HttpFetchResponse response = await httpFetch('/lib/src/engine/alarm_clock.dart'); + expect(response.status, 200); + expect(response.contentLength, greaterThan(0)); + expect(response.hasPayload, isTrue); + expect(response.payload, isNotNull); + expect(response.url, '/lib/src/engine/alarm_clock.dart'); + expect( + await response.text(), + startsWith(''' +// Copyright 2013 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.'''), + ); + }); + + test('httpFetch fetches a binary file as ByteBuffer', () async { + final HttpFetchResponse response = await httpFetch('/test_images/1x1.png'); + expect(response.status, 200); + expect(response.contentLength, greaterThan(0)); + expect(response.hasPayload, isTrue); + expect(response.payload, isNotNull); + expect(response.url, '/test_images/1x1.png'); + expect( + (await response.asByteBuffer()).asUint8List().sublist(0, 8), + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], + ); + }); + + test('httpFetch fetches a binary file as Uint8List', () async { + final HttpFetchResponse response = await httpFetch('/test_images/1x1.png'); + expect(response.status, 200); + expect(response.contentLength, greaterThan(0)); + expect(response.hasPayload, isTrue); + expect(response.payload, isNotNull); + expect(response.url, '/test_images/1x1.png'); + expect( + (await response.asUint8List()).sublist(0, 8), + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], + ); + }); + + test('httpFetch fetches json', () async { + final HttpFetchResponse response = await httpFetch('/test_images/'); + expect(response.status, 200); + expect(response.contentLength, greaterThan(0)); + expect(response.hasPayload, isTrue); + expect(response.payload, isNotNull); + expect(response.url, '/test_images/'); + expect( + await response.json(), + isA>(), + ); + }); + + test('httpFetch reads data in chunks', () async { + // There is no guarantee that the server will actually serve the data in any + // particular chunk sizes, but breaking up the data in _some_ way does cause + // it to chunk it. + const List> lengthAndChunks = >[ + [0, 0], + [10, 10], + [1000, 100], + [10000, 1000], + [100000, 10000], + ]; + for (final List lengthAndChunk in lengthAndChunks) { + final int length = lengthAndChunk.first; + final int chunkSize = lengthAndChunk.last; + final String url = '/long_test_payload?length=$length&chunk=$chunkSize'; + final HttpFetchResponse response = await httpFetch(url); + expect(response.status, 200); + expect(response.contentLength, length); + expect(response.hasPayload, isTrue); + expect(response.payload, isNotNull); + expect(response.url, url); + + final List result = []; + await response.payload.read(result.addAll); + expect(result, hasLength(length)); + expect( + result, + List.generate(length, (int i) => i & 0xFF), + ); + } + }); + + test('httpFetchText fetches a text file', () async { + final String text = await httpFetchText('/lib/src/engine/alarm_clock.dart'); + expect( + text, + startsWith(''' +// Copyright 2013 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.'''), + ); + }); + + test('httpFetchByteBuffer fetches a binary file as ByteBuffer', () async { + final ByteBuffer response = await httpFetchByteBuffer('/test_images/1x1.png'); + expect( + response.asUint8List().sublist(0, 8), + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], + ); + }); + + test('httpFetchJson fetches json', () async { + final Object? json = await httpFetchJson('/test_images/'); + expect(json, isA>()); + }); +} + +Future _testHttpErrorCodes() async { + test('httpFetch throws HttpFetchNoPayloadError on 404', () async { + final HttpFetchResponse response = await httpFetch('/file_not_found'); + expect(response.status, 404); + expect(response.hasPayload, isFalse); + expect(response.url, '/file_not_found'); + + try { + // Attempting to read the payload when there isn't one should result in + // HttpFetchNoPayloadError thrown. + response.payload; + fail('Expected HttpFetchNoPayloadError'); + } on HttpFetchNoPayloadError catch(error) { + expect(error.status, 404); + expect(error.url, '/file_not_found'); + expect( + error.toString(), + 'Flutter Web engine failed to fetch "/file_not_found". ' + 'HTTP request succeeded, but the server responded with HTTP status 404.', + ); + } + }); + + test('httpFetch* functions throw HttpFetchNoPayloadError on 404', () async { + final List testFunctions = [ + () async => httpFetchText('/file_not_found'), + () async => httpFetchByteBuffer('/file_not_found'), + () async => httpFetchJson('/file_not_found'), + ]; + + for (final AsyncCallback testFunction in testFunctions) { + try { + await testFunction(); + fail('Expected HttpFetchNoPayloadError'); + } on HttpFetchNoPayloadError catch(error) { + expect(error.status, 404); + expect(error.url, '/file_not_found'); + expect( + error.toString(), + 'Flutter Web engine failed to fetch "/file_not_found". ' + 'HTTP request succeeded, but the server responded with HTTP status 404.', + ); + } + } + }); +} + +Future _testNetworkErrors() async { + test('httpFetch* functions throw HttpFetchError on network errors', () async { + // Fetch throws the error this test wants on URLs with user credentials. + const String badUrl = 'https://user:password@example.com/'; + + final List testFunctions = [ + () async => httpFetch(badUrl), + () async => httpFetchText(badUrl), + () async => httpFetchByteBuffer(badUrl), + () async => httpFetchJson(badUrl), + ]; + + for (final AsyncCallback testFunction in testFunctions) { + try { + await testFunction(); + fail('Expected HttpFetchError'); + } on HttpFetchError catch(error) { + expect(error.url, badUrl); + expect( + error.toString(), + // Browsers agree on throwing a TypeError, but they disagree on the + // error message. So this only checks for the common error prefix, but + // not the entire error message. + startsWith( + 'Flutter Web engine failed to complete HTTP request to fetch ' + '"https://user:password@example.com/": TypeError: ' + ), + ); + } + } + }); +} + +typedef AsyncCallback = Future Function(); diff --git a/lib/web_ui/test/text/font_loading_test.dart b/lib/web_ui/test/text/font_loading_test.dart index b68aef33d8ee3..46aa755d3b800 100644 --- a/lib/web_ui/test/text/font_loading_test.dart +++ b/lib/web_ui/test/text/font_loading_test.dart @@ -35,11 +35,8 @@ Future testMain() async { test('loads Blehm font from buffer', () async { expect(_containsFontFamily('Blehm'), isFalse); - final DomXMLHttpRequest response = await domHttpRequest( - testFontUrl, - responseType: 'arraybuffer'); - await ui.loadFontFromList(Uint8List.view(response.response as ByteBuffer), - fontFamily: 'Blehm'); + final ByteBuffer response = await httpFetchByteBuffer(testFontUrl); + await ui.loadFontFromList(response.asUint8List(), fontFamily: 'Blehm'); expect(_containsFontFamily('Blehm'), isTrue); }, @@ -59,11 +56,8 @@ Future testMain() async { // Now, loads a new font using loadFontFromList. This should clear the // cache - final DomXMLHttpRequest response = await domHttpRequest( - testFontUrl, - responseType: 'arraybuffer'); - await ui.loadFontFromList(Uint8List.view(response.response as ByteBuffer), - fontFamily: 'Blehm'); + final ByteBuffer response = await httpFetchByteBuffer(testFontUrl); + await ui.loadFontFromList(response.asUint8List(), fontFamily: 'Blehm'); // Verifies the font is loaded, and the cache is cleaned. expect(_containsFontFamily('Blehm'), isTrue); @@ -84,11 +78,8 @@ Future testMain() async { buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); message = utf8.decode(list); }; - final DomXMLHttpRequest response = await domHttpRequest( - testFontUrl, - responseType: 'arraybuffer'); - await ui.loadFontFromList(Uint8List.view(response.response as ByteBuffer), - fontFamily: 'Blehm'); + final ByteBuffer response = await httpFetchByteBuffer(testFontUrl); + await ui.loadFontFromList(response.asUint8List(), fontFamily: 'Blehm'); final Completer completer = Completer(); domWindow.requestAnimationFrame(allowInterop((_) { completer.complete();}) ); await completer.future; diff --git a/web_sdk/web_engine_tester/lib/golden_tester.dart b/web_sdk/web_engine_tester/lib/golden_tester.dart index a03c64b15d209..f27b657df9aaa 100644 --- a/web_sdk/web_engine_tester/lib/golden_tester.dart +++ b/web_sdk/web_engine_tester/lib/golden_tester.dart @@ -13,13 +13,14 @@ import 'package:ui/src/engine/dom.dart'; import 'package:ui/ui.dart'; Future _callScreenshotServer(dynamic requestData) async { - final DomXMLHttpRequest request = await domHttpRequest( + // This is test code, but because the file name doesn't end with "_test.dart" + // the analyzer doesn't know it, so have to ignore the lint explicitly. + // ignore: invalid_use_of_visible_for_testing_member + final HttpFetchResponse response = await testOnlyHttpPost( 'screenshot', - method: 'POST', - sendData: json.encode(requestData), + json.encode(requestData), ); - - return json.decode(request.responseText!); + return json.decode(await response.text()); } /// How to compare pixels within the image.