import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; /// A provider's reply as a fixture file keeps it (`.http`): `HTTP `, /// the headers that matter, one per line, an empty line, then the body as it /// came, byte for byte. `tool/record_llm.dart` writes recorded replies so. final class const Fixture({ required final String name, required final int status, required final Map headers, required final Uint8List body, }) { /// The fixture in [file]. factory read(File file) => Fixture.parse( file.uri.pathSegments.last.replaceAll('.http', ''), file.readAsBytesSync(), ); /// The fixture [bytes] hold. factory parse(String name, Uint8List bytes) { final end = _headEnd(bytes); final head = utf8.decode(bytes.sublist(0, end)).split('\n'); final status = RegExp(r'^HTTP (\d{3})$').firstMatch(head.first); if (status == null) { throw FormatException('No status in $name'); } return Fixture( name: name, status: int.parse(status[1]!), headers: { for (final line in head.skip(1).where((line) => line.isNotEmpty)) line.substring(0, line.indexOf(':')).toLowerCase(): line .substring(line.indexOf(':') + 1) .trim(), }, body: bytes.sublist(end + 2), ); } /// The bytes of a fixture of [status], [headers] and [body]. static Uint8List write( int status, Map headers, List body, ) => Uint8List.fromList([ ...utf8.encode('HTTP $status\n'), for (final MapEntry(:key, :value) in headers.entries) ...utf8.encode('$key: $value\n'), ...utf8.encode('\n'), ...body, ]); static int _headEnd(Uint8List bytes) { for (var i = 0; i + 1 < bytes.length; i++) { if (bytes[i] == 0x0A && bytes[i + 1] == 0x0A) { return i; } } throw const FormatException('No end of the head'); } /// The body in pieces of [size] bytes, as a network may cut it (cutting /// characters and lines in two). Stream> chunked(int size) => Stream.fromIterable([ for (var at = 0; at < body.length; at += size) body.sublist(at, at + size > body.length ? body.length : at + size), ]); } /// The fixtures in `test/fixtures///`, by name. Map fixturesOf(String kind, String protocol) { final folder = Directory('test/fixtures/$kind/$protocol'); if (!folder.existsSync()) { return const {}; } final files = folder .listSync() .whereType() .where( (file) => file.path.endsWith('.http'), ) .toList() ..sort((a, b) => a.path.compareTo(b.path)); return { for (final file in files) Fixture.read(file).name: Fixture.read(file), }; } /// What a [MockClient] was sent. final class Sent() { late http.BaseRequest request; late Map body; bool aborted = false; } /// An HTTP client replying [fixture] in pieces of [chunk] bytes, keeping /// what it was sent in [sent]. http.Client replying(Fixture fixture, {Sent? sent, int chunk = 7}) => MockClient.streaming((request, bodyStream) async { sent?.request = request; sent?.body = jsonDecode( utf8.decode(await bodyStream.toBytes()), ) as Map; if (request case http.Abortable(:final abortTrigger?)) { abortTrigger.whenComplete(() => sent?.aborted = true).ignore(); } return http.StreamedResponse( fixture.chunked(chunk), fixture.status, headers: fixture.headers, ); });