LLM::Chat::Test::Kit
NAME
LLM::Chat::Test::Kit - fixture-server plumbing shared by the HTTP-level tests
SYNOPSIS
use LLM::Chat::Test::Kit;
my $server = raw-server(-> $request {
given $request.method, $request.path {
when ('POST', '/v1/chat/completions') {
$request.json<stream>
?? %( type => 'text/event-stream', chunks => @sse-frames )
!! %( body => to-json(%reply) );
}
default { %( status => 404, body => '' ) }
}
});
LEAVE $server.stop;
my $backend = LLM::Chat::Backend::OpenAICommon.new(
api_url => $server.url ~ '/v1',
settings => LLM::Chat::Backend::Settings.new,
);
my $response = stream-settled($backend.chat-completion-stream(@messages), 'the stream');
DESCRIPTION
The HTTP-level tests need the same few things: a real HTTP server on a
port nothing else owns, a way to wait for a Promise that gives up loudly
instead of hanging the suite, and a way to drive a
LLM::Chat::Backend::Response to its terminal state. Those live here
so the files that need them stay about the behaviour under test.
The server is raw-server: a small HTTP/1.1 server written directly on
IO::Socket::Async, which finishes writing a response before it closes
anything. Not Cro's own HTTP server, because every client this
distribution makes asks the server to close the connection after its
response (Connection: close), and on that path Cro's server
occasionally loses the end of a response on a machine that is busy
enough: a streamed body cut short at a chunk boundary, or never sent at
all. Nearly every assertion an HTTP-level test makes rests on the whole
response arriving.
This is test support, not library surface. It ships inside the dist's
own namespace (rather than under t/lib) deliberately, so that zef
install's test phase precompiles it as part of lib/ staging before
any test file runs. A t/lib CompUnit repo starts cold on a fresh
zef install, and parallel test files pulling the fixture's
dependency chain into that cold store can race and fail the install
outright; moving test support into lib/ under a ::Test namespace
is the fleet-wide fix for that failure class.
Requests and answers
raw-server listens on a port the OS picks, on 127.0.0.1, and calls
the responder with a RawRequest for each request ā .method,
.path (without the query string), .query, .headers (names
lower-cased), .header($name), .body (a Blob), .text, .json,
.wants-close ā and the responder returns a Hash describing the
answer:
statusā default 200.typeā the Content-Type, written exactly as given; defaultapplication/json. An undefined type sends no Content-Type at all.headersā further headers, a list of Pairs.bodyā a Str (sent as UTF-8) or a Blob, sent with a Content-Length.chunksā instead of a body: a list, or a Supply, of Strs or Blobs, each sent as one HTTP chunk. A Supply is followed for as long as it emits; when the client hangs up first, the Supply's tap is closed, so itsCLOSEphaser is where a test learns that it happened.delayā seconds to wait before the response headers, or a Promise to wait for (a server that answers when the test lets it).
A bare Str or Blob is short for %( body = ... )>. An answer the server
cannot use, or a responder that dies, is answered with a 500 and
recorded in .errors, so a fixture mistake fails the test instead of
hanging it. Connections are kept open between requests unless the
request asks for Connection: close; then the server closes the
connection once the response's last write has completed, as RFC 9112
requires. .requests lists what arrived, .accepted and
.open-connections count connections, .url is the base URL to point
a backend at, and .stop closes everything.
A server that streams forever and notices the client hanging up:
my $hung-up = Promise.new;
my $server = raw-server(-> $ {
%( type => 'text/event-stream', chunks => supply {
whenever Supply.interval(0.01) -> $n { emit "data: tick $n\n\n" }
CLOSE { $hung-up.keep(True) unless $hung-up.status }
} );
});