halkyon/packages/halkyon_llm/test/support/fixture.dart

117 lines
3.6 KiB
Dart
Raw Normal View History

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,
);
});