genui_mock
Test your GenUI widgets against real model output, without the network.
Record a live streaming session once, save it as a small JSON fixture, and replay it in your tests as often as you like.
- No network, no API key. Tests run offline and in CI.
- Deterministic. The same bytes arrive in the same order every run, so you can assert on what renders.
- Real rendering. Fixtures play through GenUI's own
Conversation,SurfaceController, and catalog widgets. Only the source of the bytes is swapped out. - Reviewable. Fixtures are pretty-printed JSON, so re-recording shows up as a readable diff in your pull request.
Quick start
1. Install
dev_dependencies:
genui_mock: ^0.1.3
2. Record a session
You already pipe a Stream<String> of model chunks into GenUI. Wrap that
stream with a GenUiSessionRecorder and use the stream it returns instead:
final recorder = GenUiSessionRecorder(
name: 'greeting',
description: 'user asks for a friendly greeting',
);
// Once per user turn, pass the recorded stream where you passed the raw one:
await for (final chunk in recorder.recordTurn(rawChunksFromYourModel())) {
transportAdapter.addChunk(chunk);
}
// When the session is done:
await File('test/fixtures/greeting.json')
.writeAsString(recorder.toFixture().encode());
Every chunk is forwarded unchanged, so your app behaves exactly as before. The recorder only notes each chunk's text and how long it took to arrive.
3. Replay it in a test
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:genui/genui.dart';
import 'package:genui_mock/genui_mock.dart';
void main() {
testWidgets('renders the recorded greeting', (tester) async {
final fixture = GenUiFixture.decode(
File('test/fixtures/greeting.json').readAsStringSync(),
);
final transport = GenUiMockTransport(fixture);
final controller = SurfaceController(
catalogs: [BasicCatalogItems.asCatalog()],
);
final conversation = Conversation(
transport: transport,
controller: controller,
);
addTearDown(conversation.dispose);
await conversation.sendRequest(ChatMessage.user('say hello'));
await tester.pumpWidget(MaterialApp(
home: Surface(surfaceContext: controller.contextFor('greeting')),
));
expect(find.text('Hello!'), findsOneWidget);
});
}
Use readAsStringSync to load fixtures inside testWidgets. Real async file
I/O can hang there because the test body runs in a fake-async zone.
For a complete runnable app, see example/.
The fixture file
{
"formatVersion": 1,
"name": "greeting",
"description": "user asks for a friendly greeting",
"recordedAt": "2026-07-02T10:15:00.000Z",
"turns": [
{
"label": "asks for a greeting",
"chunks": [
{ "text": "Here you go:\n", "delayMs": 0 },
{ "text": "```json\n{\"version\": \"v0.9\", \"createSurface\": {\"surfaceId\": \"greeting\", \"catalogId\": \"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json\"}}\n```", "delayMs": 300 },
{ "text": "```json\n{\"version\": \"v0.9\", \"updateComponents\": {\"surfaceId\": \"greeting\", \"components\": [{\"id\": \"root\", \"component\": \"Text\", \"text\": \"Hello!\"}]}}\n```", "delayMs": 400 }
]
}
]
}
A turn is the ordered list of raw text chunks that arrived after one
sendRequest. Each chunk records its text and the delay since the previous
chunk.
Chunks are captured before any A2UI parsing, so a fixture holds exactly what the model said. Replay re-emits them verbatim, whitespace included, so concatenating the chunks reproduces the original text byte for byte.
description and label are optional. Label turns
(recordTurn(chunks, label: 'asks for tomorrow')) to keep multi-turn fixtures
readable.
Replaying
Multi-turn conversations
Each sendRequest plays the fixture's next turn, so a multi-turn fixture
drives a multi-turn conversation in the order it was recorded. Sending more
requests than were recorded throws a StateError, so a test fails loudly
instead of hanging or replaying stale data.
Pacing
By default chunks arrive back to back, which keeps tests fast. Use paced mode to replay the original timing:
GenUiMockTransport(fixture); // instant (default)
GenUiMockTransport(fixture, mode: PlaybackMode.paced); // original timing
GenUiMockTransport(fixture, mode: PlaybackMode.paced, speedMultiplier: 5); // 5x faster
Testing loading states
Paced replay runs on timers, so inside testWidgets you can step through a
stream with tester.pump and assert on what is visible at each point:
final transport = GenUiMockTransport(fixture, mode: PlaybackMode.paced);
// ...controller and conversation as above...
final reply = conversation.sendRequest(ChatMessage.user('say hello'));
await tester.pumpWidget(MaterialApp(
home: Surface(surfaceContext: controller.contextFor('greeting')),
));
expect(find.text('Hello!'), findsNothing); // still streaming
await tester.pump(const Duration(milliseconds: 700));
await tester.pump();
expect(find.text('Hello!'), findsOneWidget); // fully rendered
await reply;
Asserting on what you sent
transport.sentMessages holds every ChatMessage passed to sendRequest, in
order. Use it to check that your UI sent the right prompt or action.
Without GenUI
GenUiFixturePlayer replays a fixture as a plain Stream<String> with no
Transport involved. That's useful for testing your own chunk handling.
final player = GenUiFixturePlayer(fixture);
while (player.hasMoreTurns) {
await for (final chunk in player.playNextTurn()) {
// ...
}
}
player.reset(); // back to the first turn
Tips
- Keep fixtures next to tests, in
test/fixtures/, and commit them. Name them after the scenario, not the date. - Re-record when your prompt, model, or catalog changes. A fixture captures one moment of model behavior, and the PR diff shows what changed.
- Edit fixtures by hand to cover cases a model rarely produces, such as a malformed component or an empty surface. The format is plain JSON.
- Build fixtures in code with
GenUiFixture,RecordedTurn, andRecordedChunkfor small, focused tests. The example does this.
API at a glance
| Type | What it does | Needs genui? |
|---|---|---|
GenUiSessionRecorder |
Wraps a live chunk stream and records it into a fixture | No |
GenUiFixture |
The fixture: encode() / decode() to and from JSON |
No |
RecordedTurn, RecordedChunk |
One request/response cycle, and one chunk with its delay | No |
GenUiFixturePlayer, replayTurn |
Replay turns as a plain Stream<String> |
No |
PlaybackMode |
instant or paced |
No |
GenUiMockTransport |
A GenUI Transport that replays a fixture |
Yes |
How it works
GenUI's Transport is a four-method interface (incomingText,
incomingMessages, sendRequest, dispose) that separates rendering from any
particular network client. It has kept the same shape since genui 0.8.0,
which makes it the most stable seam in the API, so it's the only one this
package touches.
GenUiMockTransport is the only file that imports genui. Everything else is
plain Dart and unaffected by GenUI API changes. The transport doesn't parse
anything itself: it feeds recorded chunks to GenUI's own
A2uiTransportAdapter, so this package never has to track the A2UI wire
format.
Compatibility
Built against genui 0.10.4. Requires genui ^0.10.4.
genui_mock |
genui |
Notes |
|---|---|---|
0.1.3 |
^0.10.4 |
0.10.3 renamed the basic catalog id to …/v0_9/catalogs/basic/catalog.json and kept the old …/v0_9/basic_catalog.json as an alias. Older fixtures still render. |
0.1.2 |
^0.10.2 |
0.10.2 stopped trimming every streamed chunk, which is what makes byte-for-byte replay possible. |
0.1.1 |
^0.10.1 |
0.10.0 moved the A2UI message types into package:a2ui_core. |
0.1.0 |
^0.9.2 |
Initial release. |
None of these required re-recording fixtures. See the CHANGELOG for details.
Contributing
Bug reports and pull requests are welcome on GitHub. If GenUI ships a release that breaks replay, an issue with the failing fixture is the most useful thing you can send.
License
MIT. See LICENSE.
Libraries
- genui_mock
- Record, save, and replay GenUI streaming sessions as deterministic, git-diffable JSON fixtures — so you can test how a Catalog widget renders against real recorded model output without burning an API call or tolerating non-deterministic responses.