117 lines
3.6 KiB
Dart
117 lines
3.6 KiB
Dart
|
|
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 <status>`,
|
||
|
|
/// 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<String, String> 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<String, String> headers,
|
||
|
|
List<int> 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<List<int>> 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/<kind>/<protocol>/`, by name.
|
||
|
|
Map<String, Fixture> fixturesOf(String kind, String protocol) {
|
||
|
|
final folder = Directory('test/fixtures/$kind/$protocol');
|
||
|
|
if (!folder.existsSync()) {
|
||
|
|
return const {};
|
||
|
|
}
|
||
|
|
final files =
|
||
|
|
folder
|
||
|
|
.listSync()
|
||
|
|
.whereType<File>()
|
||
|
|
.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<String, Object?> 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<String, Object?>;
|
||
|
|
if (request case http.Abortable(:final abortTrigger?)) {
|
||
|
|
abortTrigger.whenComplete(() => sent?.aborted = true).ignore();
|
||
|
|
}
|
||
|
|
return http.StreamedResponse(
|
||
|
|
fixture.chunked(chunk),
|
||
|
|
fixture.status,
|
||
|
|
headers: fixture.headers,
|
||
|
|
);
|
||
|
|
});
|