Testing
Rendered Markdown is mostly spans and widgets, not a collection of standalone Text nodes. Test the rendered structure and behavior rather than assuming a plain paragraph.
Find the rendered text
find.text('hello') often fails because the visible text belongs to a span tree insideRichText. Read the text from each rich-text subtree instead. Use a predicate rather thanfind.byType(RichText), because paragraphs needing inline-code or bidirectional handling can be rendered through a RichText subclass.
1String plainText(WidgetTester tester) {
2 final buffer = StringBuffer();
3 for (final richText in tester.widgetList<RichText>(
4 find.byWidgetPredicate((widget) => widget is RichText),
5 )) {
6 buffer.write(richText.text.toPlainText(includePlaceholders: false));
7 }
8 return buffer.toString();
9}
10
11testWidgets('renders Markdown text', (tester) async {
12 await tester.pumpWidget(const MaterialApp(
13 home: Scaffold(body: GptMarkdown('hello **world**')),
14 ));
15
16 expect(plainText(tester), contains('hello world'));
17})find.text still works for content deliberately rendered in a WidgetSpan—such as a custom chip, code block, or table cell—because those contain actual Text widgets. It is span content inside a Markdown paragraph that requires the helper.
Builders, patterns, and remounting
Styles, patterns, and component lists are compared and can update live. Builder closures are intentionally not compared: closures cannot be meaningfully compared without defeating the render cache. Give the widget a changing key when replacing a builder at runtime.
1// A new closure is not a meaningful rendering identity.
2// Changing it at runtime does not regenerate the parsed spans:
3await tester.pumpWidget(app(codeBuilder: builderA));
4await tester.pumpWidget(app(codeBuilder: builderB)); // still builderA
5
6// Remount when the builder identity should change:
7GptMarkdown(
8 text,
9 key: ValueKey(builderId),
10 codeBuilder: builder,
11)Text scale and expected overflow
Long code lines and long headings cannot wrap at raised text scales. Those intentional horizontal overflows are not necessarily a rendering regression. Tables and block math have their own scroll treatment. Drain the expected exception only in the focused test; do not silence exceptions globally or new overflow bugs will be hidden.
1await tester.pumpAndSettle();
2while (tester.takeException() != null) {}Streaming tests
A streaming reveal is an animation, so a normal pumpAndSettle can wait for the whole answer. Pump one frame when you are asserting an intermediate state, or disable animation when the test is about parsing or layout instead.
1// Test one frame of the reveal:
2await tester.pump(const Duration(milliseconds: 16));
3
4// Or let a finite reply finish:
5await tester.pumpAndSettle(const Duration(seconds: 5));
6
7// For a test unrelated to animation, take the static path:
8GptMarkdown(text, animation: GptMarkdownAnimation.none)
9// or:
10GptMarkdown(text, animation: GptMarkdownAnimation.fade, isStreaming: false)1.3 parser and semantics changes
Links are now LinkTextSpan instances rather than LinkButton widgets, so assert on span content and tap behavior instead of exact widget types. During active streaming, semantics intentionally collapse to one live node; after the reply is quiet and the reveal completes, assert the restored headings, links, and list structure. If your code switches over MdNode, add the MdCustomBlock case.
1testWidgets('links remain selectable spans', (tester) async {
2 await tester.pumpWidget(const MaterialApp(
3 home: Scaffold(body: GptMarkdown('[docs](https://example.com)')),
4 ));
5
6 expect(find.byType(LinkButton), findsNothing);
7 expect(
8 tester.widgetList<RichText>(
9 find.byWidgetPredicate((widget) => widget is RichText),
10 ),
11 isNotEmpty,
12 );
13})Test styles at the component boundary
Assert the widget that consumes a resolved style, not a screenshot pixel. Also test precedence: define one field on the app theme and another on the widget-level style sheet, then verify that both survive the merge.
1testWidgets('the quote bar follows the theme', (tester) async {
2 await tester.pumpWidget(
3 MaterialApp(
4 theme: ThemeData(extensions: [
5 GptMarkdownThemeData(
6 brightness: Brightness.light,
7 styleSheet: const GptMarkdownStyleSheet(
8 blockQuote: BlockQuoteStyle(barWidth: 7),
9 ),
10 ),
11 ]),
12 home: const Scaffold(body: GptMarkdown('> quoted')),
13 ),
14 );
15
16 final quote = tester.widget<BlockQuoteWidget>(
17 find.byType(BlockQuoteWidget),
18 );
19 expect(quote.width, 7);
20})Goldens, screenshots, and documentation snippets
Linux goldens
Default-look goldens run on Linux because text rasterization differs by platform. A tolerance was rejected because it hid real changes. Regenerate only after reviewing an intentional visual change.
gh workflow run goldens.yml --ref my-branch
gh run watch
git pullREADME images
Showcase images are documentation assets made through the widget test harness, not goldens. Regenerate them with ./scripts/screenshots.sh, commit the PNGs, and bump every README image's ?v= URL version because GitHub caches proxied images by URL.
Compiled docs
Package documentation snippets live in test/docs/snippets_test.dart and compile in the test suite, so an API rename cannot quietly leave the source guides stale. Add a snippet fixture whenever package documentation gains a new API example.
--update-goldens records the current pixels, including regressions. Inspect the expected, actual, and diff images first; on CI they are in the golden-failures artifact. Use -f commit=false when the workflow should return images as artifacts instead of committing them.
