Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -0,0 +1,719 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod/riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/search_credits/domain/controllers/search_token_issuance_controller.dart';
import 'package:weblibre/features/user/data/providers.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart';
import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart';
void main() {
group('MetaSearchController', () {
test('transitions to needsCredits when no token is available', () async {
final container = ProviderContainer(
overrides: [
metaSearchEnsureTokenAvailableProvider.overrideWithValue(
() async => TokenAvailabilityOutcome.noCredits,
),
riverpodDatabaseStorageProvider.overrideWith(
(ref) => Storage.inMemory(),
),
],
);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.needsCredits);
expect(state.query, 'lensai');
});
test('transitions to error when token issuance fails', () async {
final container = ProviderContainer(
overrides: [
metaSearchEnsureTokenAvailableProvider.overrideWithValue(
() async => TokenAvailabilityOutcome.issuanceFailed,
),
riverpodDatabaseStorageProvider.overrideWith(
(ref) => Storage.inMemory(),
),
],
);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.error);
expect(
state.errorMessage,
'Could not issue search tokens. Please try again.',
);
expect(state.query, 'lensai');
});
test('transitions to error when token availability check fails', () async {
final container = ProviderContainer(
overrides: [
metaSearchEnsureTokenAvailableProvider.overrideWithValue(
() async => throw Exception('balance unavailable'),
),
riverpodDatabaseStorageProvider.overrideWith(
(ref) => Storage.inMemory(),
),
],
);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.error);
expect(
state.errorMessage,
'Could not check search credits. Please try again.',
);
expect(state.query, 'lensai');
});
test('search results populate the ready state', () async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': 'https://example.com/result',
'content': 'Result summary',
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.ready);
expect(state.results, hasLength(1));
expect(state.results.single.title, 'LensAI result');
expect(session.submittedQueries, ['lensai']);
});
test('image frames update cached media maps', () async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.image,
data: {
'url': 'https://example.com/image.png',
'bytes': base64Encode(Uint8List.fromList([4, 5, 6])),
},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.imagesByUrl['https://example.com/image.png'], [4, 5, 6]);
});
test(
'fetchPage marks a result in flight and stores fetched documents',
() async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
await container
.read(metaSearchControllerProvider.notifier)
.fetchPage(Uri.parse(resultUrl));
expect(
container.read(metaSearchControllerProvider).fetchingUrls,
contains(Uri.parse(resultUrl)),
);
expect(session.fetchRequests, [Uri.parse(resultUrl)]);
session.emit(
StreamMessage(
type: MessageTypes.document,
data: {
'url': resultUrl,
'content': '# Preview\n\nFetched content',
'metadata': {
'date': null,
'description': null,
'filedate': null,
'image': null,
'language': null,
'pagetype': null,
'sitename': null,
'title': 'LensAI result',
'author': null,
'license': null,
},
},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.fetchingUrls, isEmpty);
expect(state.documentsByUrl.keys, contains(Uri.parse(resultUrl)));
expect(
state.documentsByUrl[Uri.parse(resultUrl)]?.content,
contains('Fetched content'),
);
},
);
test(
'fetch failures clear in-flight state and preserve search results',
() async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
await container
.read(metaSearchControllerProvider.notifier)
.fetchPage(Uri.parse(resultUrl));
session.emit(
StreamMessage(
type: MessageTypes.error,
data: {'message': 'Document fetch failed'},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.status, WebSearchStatus.ready);
expect(state.results, hasLength(1));
expect(state.fetchingUrls, isEmpty);
expect(state.errorMessage, contains('Document fetch failed'));
},
);
test(
'capturePage dispatches capture request and tracks capturing state',
() async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
final uri = Uri.parse(resultUrl);
await container
.read(metaSearchControllerProvider.notifier)
.capturePage(uri, choice: FetchMethodChoice.singlefileHtml);
expect(
container
.read(metaSearchControllerProvider)
.isCapturing(uri, FetchMethodChoice.singlefileHtml),
isTrue,
);
expect(session.captureRequests, hasLength(1));
expect(session.captureRequests.first.url, uri);
expect(session.captureRequests.first.method, 'singlefile');
expect(session.captureRequests.first.variant, 'balanced');
},
);
test('capture frame clears capturing state', () async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
final uri = Uri.parse(resultUrl);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
await container
.read(metaSearchControllerProvider.notifier)
.capturePage(uri, choice: FetchMethodChoice.singlefileHtml);
expect(
container
.read(metaSearchControllerProvider)
.isCapturing(uri, FetchMethodChoice.singlefileHtml),
isTrue,
);
session.emit(
StreamMessage(
type: MessageTypes.capture,
data: {
'captureId': 'abc123',
'url': resultUrl,
'method': 'singlefile',
'variant': 'balanced',
'contentType': 'text/html; charset=utf-8',
'byteLength': 42,
'downloadToken': 'tok-1',
'filename': null,
'finalUrl': null,
},
),
);
await _flushMicrotasks();
expect(
container.read(metaSearchControllerProvider).capturingByUrl,
isEmpty,
);
});
test('capture messages do not populate documentsByUrl', () async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
final uri = Uri.parse(resultUrl);
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
await container
.read(metaSearchControllerProvider.notifier)
.capturePage(uri, choice: FetchMethodChoice.singlefileHtml);
session.emit(
StreamMessage(
type: MessageTypes.capture,
data: {
'captureId': 'abc123',
'url': resultUrl,
'method': 'singlefile',
'variant': 'balanced',
'contentType': 'text/html; charset=utf-8',
'byteLength': 42,
'downloadToken': 'tok-1',
'filename': null,
'finalUrl': null,
},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.documentsByUrl, isEmpty);
expect(state.capturingByUrl, isEmpty);
final captured = state.capturedPage(
uri,
FetchMethodChoice.singlefileHtml,
);
expect(captured, isNotNull);
expect(captured?.captureId, 'abc123');
});
test(
'URL-scoped error clears specific fetching and capturing URLs',
() async {
final session = _FakeMetaSearchSession();
final container = _createContainer(session);
addTearDown(container.dispose);
final sub = container.listen(
metaSearchControllerProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(sub.close);
const resultUrl = 'https://example.com/result';
await container
.read(metaSearchControllerProvider.notifier)
.submit('lensai');
session.emit(
StreamMessage(
type: MessageTypes.searchResults,
data: _searchResultsPayload(
query: 'lensai',
results: [
{
'title': 'LensAI result',
'url': resultUrl,
'content': null,
'publishedDate': null,
'img_src': null,
'thumbnail': null,
},
],
),
),
);
await _flushMicrotasks();
final uri = Uri.parse(resultUrl);
await container
.read(metaSearchControllerProvider.notifier)
.capturePage(uri, choice: FetchMethodChoice.singlefileHtml);
session.emit(
StreamMessage(
type: MessageTypes.error,
data: {'message': 'Capture failed', 'url': resultUrl},
),
);
await _flushMicrotasks();
final state = container.read(metaSearchControllerProvider);
expect(state.capturingByUrl, isEmpty);
expect(state.fetchingUrls, isEmpty);
expect(state.status, WebSearchStatus.ready);
expect(state.results, hasLength(1));
},
);
});
}
Map<String, dynamic> _searchResultsPayload({
required String query,
required List<Map<String, dynamic>> results,
int page = 0,
int pageSize = 10,
int? totalResults,
bool hasMore = false,
}) {
return {
'query': query,
'infos': const <Object>[],
'results': results,
'page': page,
'pageSize': pageSize,
'totalResults': totalResults ?? results.length,
'hasMore': hasMore,
};
}
ProviderContainer _createContainer(_FakeMetaSearchSession session) {
return ProviderContainer(
overrides: [
metaSearchEnsureTokenAvailableProvider.overrideWithValue(
() async => TokenAvailabilityOutcome.available,
),
metaSearchSessionFactoryProvider.overrideWithValue(() async => session),
captureArtifactDownloaderProvider.overrideWith(
(ref) => _FakeCaptureDownloader(),
),
riverpodDatabaseStorageProvider.overrideWith((ref) => Storage.inMemory()),
],
);
}
class _FakeCaptureDownloader implements CaptureArtifactDownloader {
@override
Future<String> download(CaptureArtifactReceipt receipt) async {
return '/fake/path/${receipt.captureId}.html';
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Future<void> _flushMicrotasks() {
return Future<void>.delayed(Duration.zero);
}
class _FakeMetaSearchSession implements MetaSearchSession {
final _messages = StreamController<StreamMessage>.broadcast();
final List<String> submittedQueries = <String>[];
final List<Uri> fetchRequests = <Uri>[];
final List<_CaptureRequest> captureRequests = <_CaptureRequest>[];
bool _closed = false;
@override
Stream<StreamMessage> get messages => _messages.stream;
@override
bool get isClosed => _closed;
void emit(StreamMessage message) {
_messages.add(message);
}
@override
void fetchPage(Uri url) {
fetchRequests.add(url);
}
@override
void capturePage(
Uri url, {
required String method,
required String variant,
CaptureDimensions? dimensions,
}) {
captureRequests.add(
_CaptureRequest(url: url, method: method, variant: variant),
);
}
@override
Future<void> close() async {
_closed = true;
await _messages.close();
}
@override
void submitQuery(
String query, {
required SearchMode mode,
String? language,
String? region,
SafeSearch? safeSearch,
TimeRange? timeRange,
}) {
submittedQueries.add(query);
}
final List<int> loadPageRequests = <int>[];
@override
void loadPage(int page) {
loadPageRequests.add(page);
}
}
class _CaptureRequest {
final Uri url;
final String method;
final String variant;
_CaptureRequest({
required this.url,
required this.method,
required this.variant,
});
}
@@ -0,0 +1,271 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:weblibre/features/web_search/domain/services/capture_server.dart';
void main() {
late Directory tempDir;
late CaptureServer server;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('capture_server_test_');
server = CaptureServer(storageDir: tempDir);
});
tearDown(() async {
await server.stop();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
Future<File> writeCapture(String captureId, String html) async {
final file = File(p.join(tempDir.path, '$captureId.html'));
await file.writeAsString(html);
return file;
}
group('captures route', () {
test('serves stored HTML with CSP + nosniff', () async {
await writeCapture('abc123', '<html>hi</html>');
final url = await server.publish('abc123');
final res = await _get(url);
expect(res.statusCode, 200);
expect(res.body, contains('hi'));
expect(
res.headers['content-security-policy'],
contains("script-src 'none'"),
);
expect(res.headers['x-content-type-options'], 'nosniff');
expect(res.headers['cache-control'], 'no-store');
});
test('rejects wrong token with 403', () async {
await writeCapture('abc123', 'x');
await server.publish('abc123');
final base = await server.publish('abc123');
final bad = base.replace(queryParameters: {'t': 'invalid'});
final res = await _get(bad);
expect(res.statusCode, 403);
});
test('rejects unknown capture with 403', () async {
final port = await server.ensureStarted();
final url = Uri.parse(
'http://127.0.0.1:$port/captures/unknown.html?t=irrelevant',
);
final res = await _get(url);
expect(res.statusCode, 403);
});
});
group('loader route', () {
test('pending capture returns long-poll shell + locked CSP', () async {
final url = await server.loaderUrl(tabId: 'tab-1', captureId: 'pending1');
final res = await _get(url);
expect(res.statusCode, 200);
// New design: no meta-refresh; the shell ships an inline long-poll
// script that hits /loader/wait. Sanity-check the script is present
// and the pending UI starts in the visible state.
expect(res.body, contains('Capturing'));
expect(res.body, contains('/loader/wait?tab='));
expect(res.body, contains('id="pending"'));
expect(res.body, contains('window.__CAPTURE_ID__="pending1"'));
// Loader CSP must allow inline script (for the long-poll JS) but
// forbid remote subresources — this is the safety boundary that
// makes the inline script acceptable.
expect(
res.headers['content-security-policy'],
contains("script-src 'unsafe-inline'"),
);
expect(
res.headers['content-security-policy'],
contains("default-src 'none'"),
);
});
test('ready capture exposes captureUrl via /loader/wait', () async {
await writeCapture('ready1', '<html>final</html>');
await server.publish('ready1');
final port = await server.ensureStarted();
// The transition to ready is observed via the long-poll endpoint, not
// a Refresh header on /loader. Hit /loader/wait directly and assert
// it returns the loopback capture URL.
final waitUrl = Uri.parse(
'http://127.0.0.1:$port/loader/wait?tab=tab-1&capture=ready1',
);
final res = await _get(waitUrl);
expect(res.statusCode, 200);
expect(res.headers['content-type'], startsWith('application/json'));
final decoded = jsonDecode(res.body) as Map<String, dynamic>;
expect(decoded['status'], 'ready');
expect(decoded['url'], contains('/captures/ready1.html'));
expect(decoded['url'], contains('t='));
});
test('err=1 starts the loader in the error pane with a retry button',
() async {
final url = await server.loaderUrl(
tabId: 'tab-9',
captureId: 'failed9',
error: true,
);
final res = await _get(url);
expect(res.statusCode, 200);
// Retry is now a JS-driven button, not an HTML form — assert the
// shell starts in error mode (error pane visible, pending hidden)
// and that the button + JS retry endpoint string are present.
expect(res.body, contains('id="retry"'));
expect(res.body, contains('id="error" class="error"'));
expect(res.body, contains('id="pending" class="hidden"'));
expect(res.body, contains('/loader/retry?tab='));
});
test('loader with invalid capture id returns 404', () async {
final port = await server.ensureStarted();
final url = Uri.parse(
'http://127.0.0.1:$port/loader?tab=t&capture=not%2Fclean',
);
final res = await _get(url);
expect(res.statusCode, 404);
});
});
group('/loader/wait', () {
test('failed capture returns status=failed immediately', () async {
await server.publish('cap-failed');
server.markFailed('cap-failed');
final port = await server.ensureStarted();
final res = await _get(
Uri.parse(
'http://127.0.0.1:$port/loader/wait?tab=t&capture=cap-failed',
),
);
expect(res.statusCode, 200);
final decoded = jsonDecode(res.body) as Map<String, dynamic>;
expect(decoded['status'], 'failed');
expect(decoded.containsKey('url'), isFalse);
});
test('publish() wakes an in-flight long-poll', () async {
final port = await server.ensureStarted();
// Start the long-poll while nothing has been published yet — it
// should block on the per-id completer, not busy-poll the filesystem.
final pollFuture = _get(
Uri.parse(
'http://127.0.0.1:$port/loader/wait?tab=t&capture=signaled',
),
);
// Race-free: publish only after the request hit the handler. A short
// microtask flush gives the handler time to register its waiter.
await Future<void>.delayed(const Duration(milliseconds: 100));
await writeCapture('signaled', '<html/>');
await server.publish('signaled');
final res = await pollFuture.timeout(const Duration(seconds: 2));
final decoded = jsonDecode(res.body) as Map<String, dynamic>;
expect(decoded['status'], 'ready');
expect(decoded['url'], contains('/captures/signaled.html'));
});
});
group('retry route', () {
test('emits a RetryRequest on retryRequests stream and returns 204',
() async {
final port = await server.ensureStarted();
final retryFuture = server.retryRequests.first;
final res = await _post(
Uri.parse(
'http://127.0.0.1:$port/loader/retry?tab=tab-42&capture=cap42',
),
);
// 204 No Content — the JS loader re-polls; a body would be wasted.
expect(res.statusCode, 204);
final req = await retryFuture.timeout(const Duration(seconds: 2));
expect(req.tabId, 'tab-42');
expect(req.captureId, 'cap42');
});
test('retry rejects GET with 405', () async {
final port = await server.ensureStarted();
final res = await _get(
Uri.parse('http://127.0.0.1:$port/loader/retry?tab=x&capture=y'),
);
expect(res.statusCode, 405);
});
});
group('isReady', () {
test('false until publish + file exist', () async {
expect(server.isReady('x'), isFalse);
await server.publish('x');
expect(server.isReady('x'), isFalse);
await writeCapture('x', '<html/>');
expect(server.isReady('x'), isTrue);
});
});
}
class _Response {
final int statusCode;
final Map<String, String> headers;
final String body;
_Response(this.statusCode, this.headers, this.body);
}
Future<_Response> _get(Uri url) async {
final client = HttpClient();
try {
final req = await client.getUrl(url);
req.followRedirects = false;
final res = await req.close();
return await _readResponse(res);
} finally {
client.close(force: true);
}
}
Future<_Response> _post(Uri url) async {
final client = HttpClient();
try {
final req = await client.postUrl(url);
req.followRedirects = false;
final res = await req.close();
return await _readResponse(res);
} finally {
client.close(force: true);
}
}
Future<_Response> _readResponse(HttpClientResponse response) async {
final bytes = await response.fold<List<int>>(<int>[], (a, c) => a..addAll(c));
final headers = <String, String>{};
response.headers.forEach((name, values) {
headers[name.toLowerCase()] = values.join(',');
});
return _Response(response.statusCode, headers, utf8.decode(bytes));
}