diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 0aaf800e8d..9d2387c279 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -92,28 +92,59 @@ void main() { TestZulipBinding.ensureInitialized(); - Future prepareContentBare(WidgetTester tester, String html) async { - await tester.pumpWidget(Builder( - builder: (context) { - return MaterialApp( + Widget plainContent(String html) { + return BlockContentList(nodes: parseContent(html).nodes); + } + + Widget messageContent(String html) { + return MessageContent(message: eg.streamMessage(content: html), + content: parseContent(html)); + } + + // TODO(#488) For content that we need to show outside a per-message context + // or a context without a full PerAccountStore, make sure to include tests + // that don't provide such context. + Future prepareContent(WidgetTester tester, Widget child, { + List navObservers = const [], + bool wrapWithPerAccountStoreWidget = false, + }) async { + Widget widget = child; + + if (wrapWithPerAccountStoreWidget) { + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + widget = PerAccountStoreWidget(accountId: eg.selfAccount.id, child: widget); + } + + widget = GlobalStoreWidget(child: widget); + addTearDown(testBinding.reset); + + prepareBoringImageHttpClient(); + + await tester.pumpWidget( + Builder(builder: (context) => + MaterialApp( theme: ThemeData(typography: zulipTypography(context)), localizationsDelegates: ZulipLocalizations.localizationsDelegates, supportedLocales: ZulipLocalizations.supportedLocales, - home: Scaffold(body: BlockContentList(nodes: parseContent(html).nodes)), - ); - } - )); + navigatorObservers: navObservers, + home: widget))); + await tester.pump(); // global store + if (wrapWithPerAccountStoreWidget) { + await tester.pump(); + } + + debugNetworkImageHttpClientProvider = null; } /// Test that the given content example renders without throwing an exception. /// /// This requires [ContentExample.expectedText] to be non-null in order to /// check that the content has actually rendered. For examples where there's - /// no suitable value for [ContentExample.expectedText], use [prepareContentBare] + /// no suitable value for [ContentExample.expectedText], use [prepareContent] /// and write an appropriate content-has-rendered check directly. void testContentSmoke(ContentExample example) { testWidgets('smoke: ${example.description}', (tester) async { - await prepareContentBare(tester, example.html); + await prepareContent(tester, plainContent(example.html)); assert(example.expectedText != null, 'testContentExample requires expectedText'); tester.widget(find.text(example.expectedText!)); @@ -122,23 +153,23 @@ void main() { group('ThematicBreak', () { testWidgets('smoke ThematicBreak', (tester) async { - await prepareContentBare(tester, ContentExample.thematicBreak.html); + await prepareContent(tester, plainContent(ContentExample.thematicBreak.html)); tester.widget(find.byType(ThematicBreak)); }); }); group('Heading', () { testWidgets('plain h6', (tester) async { - await prepareContentBare(tester, + await prepareContent(tester, // "###### six" - '
six
'); + plainContent('
six
')); tester.widget(find.text('six')); }); testWidgets('smoke test for h1, h2, h3, h4, h5', (tester) async { - await prepareContentBare(tester, + await prepareContent(tester, // "# one\n## two\n### three\n#### four\n##### five" - '

one

\n

two

\n

three

\n

four

\n
five
'); + plainContent('

one

\n

two

\n

three

\n

four

\n
five
')); check(find.byType(Heading).evaluate()).length.equals(5); }); }); @@ -149,28 +180,17 @@ void main() { testContentSmoke(ContentExample.spoilerRichHeaderAndContent); group('interactions: spoiler with tappable content (an image) in the header', () { - Future>> prepareContent(WidgetTester tester, String html) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - prepareBoringImageHttpClient(); - + Future>> prepare(WidgetTester tester, String html) async { final pushedRoutes = >[]; final testNavObserver = TestNavigatorObserver() ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - - await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp( - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - navigatorObservers: [testNavObserver], - home: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: MessageContent( - message: eg.streamMessage(content: html), - content: parseContent(html)))))); - await tester.pump(); // global store - await tester.pump(); // per-account store - debugNetworkImageHttpClientProvider = null; - - // `tester.pumpWidget` introduces an initial route; + await prepareContent(tester, + // Message is needed for the image's lightbox. + messageContent(html), + navObservers: [testNavObserver], + // We try to resolve the image's URL on the self-account's realm. + wrapWithPerAccountStoreWidget: true); + // `tester.pumpWidget` in prepareContent introduces an initial route; // remove it so consumers only have newly pushed routes. assert(pushedRoutes.length == 1); pushedRoutes.removeLast(); @@ -193,7 +213,7 @@ void main() { const example = ContentExample.spoilerHeaderHasImage; testWidgets('tap image', (tester) async { - final pushedRoutes = await prepareContent(tester, example.html); + final pushedRoutes = await prepare(tester, example.html); await tester.tap(find.byType(RealmContentNetworkImage)); check(pushedRoutes).single.isA() @@ -201,7 +221,7 @@ void main() { }); testWidgets('tap header on expand/collapse icon', (tester) async { - final pushedRoutes = await prepareContent(tester, example.html); + final pushedRoutes = await prepare(tester, example.html); checkIsExpanded(tester, false); await tester.tap(find.byIcon(Icons.expand_more)); @@ -216,7 +236,7 @@ void main() { }); testWidgets('tap header away from expand/collapse icon (and image)', (tester) async { - final pushedRoutes = await prepareContent(tester, example.html); + final pushedRoutes = await prepare(tester, example.html); checkIsExpanded(tester, false); await tester.tapAt( @@ -237,24 +257,18 @@ void main() { testContentSmoke(ContentExample.quotation); group('MessageImage, MessageImageList', () { - Future prepareContent(WidgetTester tester, String html) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - prepareBoringImageHttpClient(); - - await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp( - home: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: MessageContent( - message: eg.streamMessage(content: html), - content: parseContent(html)))))); - await tester.pump(); // global store - await tester.pump(); // per-account store - debugNetworkImageHttpClientProvider = null; + Future prepare(WidgetTester tester, String html) async { + await prepareContent(tester, + // Message is needed for an image's lightbox. + messageContent(html), + // We try to resolve image URLs on the self-account's realm. + // For URLs on the self-account's realm, we include the auth credential. + wrapWithPerAccountStoreWidget: true); } testWidgets('single image', (tester) async { const example = ContentExample.imageSingle; - await prepareContent(tester, example.html); + await prepare(tester, example.html); final expectedImages = (example.expectedNodes[0] as ImageNodeList).images; final images = tester.widgetList( find.byType(RealmContentNetworkImage)); @@ -264,7 +278,7 @@ void main() { testWidgets('image with invalid src URL', (tester) async { const example = ContentExample.imageInvalidUrl; - await prepareContent(tester, example.html); + await prepare(tester, example.html); // The image indeed has an invalid URL. final expectedImages = (example.expectedNodes[0] as ImageNodeList).images; check(() => Uri.parse(expectedImages.single.srcUrl)).throws(); @@ -276,7 +290,7 @@ void main() { testWidgets('multiple images', (tester) async { const example = ContentExample.imageCluster; - await prepareContent(tester, example.html); + await prepare(tester, example.html); final expectedImages = (example.expectedNodes[1] as ImageNodeList).images; final images = tester.widgetList( find.byType(RealmContentNetworkImage)); @@ -286,7 +300,7 @@ void main() { testWidgets('content after image cluster', (tester) async { const example = ContentExample.imageClusterThenContent; - await prepareContent(tester, example.html); + await prepare(tester, example.html); final expectedImages = (example.expectedNodes[1] as ImageNodeList).images; final images = tester.widgetList( find.byType(RealmContentNetworkImage)); @@ -296,7 +310,7 @@ void main() { testWidgets('multiple clusters of images', (tester) async { const example = ContentExample.imageMultipleClusters; - await prepareContent(tester, example.html); + await prepare(tester, example.html); final expectedImages = (example.expectedNodes[1] as ImageNodeList).images + (example.expectedNodes[4] as ImageNodeList).images; final images = tester.widgetList( @@ -307,7 +321,7 @@ void main() { testWidgets('image as immediate child in implicit paragraph', (tester) async { const example = ContentExample.imageInImplicitParagraph; - await prepareContent(tester, example.html); + await prepare(tester, example.html); final expectedImages = ((example.expectedNodes[0] as ListNode) .items[0][0] as ImageNodeList).images; final images = tester.widgetList( @@ -318,7 +332,7 @@ void main() { testWidgets('image cluster in implicit paragraph', (tester) async { const example = ContentExample.imageClusterInImplicitParagraph; - await prepareContent(tester, example.html); + await prepare(tester, example.html); final expectedImages = ((example.expectedNodes[0] as ListNode) .items[0][1] as ImageNodeList).images; final images = tester.widgetList( @@ -329,23 +343,22 @@ void main() { }); group("MessageInlineVideo", () { - Future>> prepareContent(WidgetTester tester, String html) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - + Future>> prepare(WidgetTester tester, String html) async { final pushedRoutes = >[]; final testNavObserver = TestNavigatorObserver() ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - - await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp( - navigatorObservers: [testNavObserver], - home: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: MessageContent( - message: eg.streamMessage(content: html), - content: parseContent(html)))))); - await tester.pump(); // global store - await tester.pump(); // per-account store - + await prepareContent(tester, + // Message is needed for a video's lightbox. + messageContent(html), + navObservers: [testNavObserver], + // We try to resolve video URLs on the self-account's realm. + // With #656, we'll show a preview image. We'll try to resolve this + // image's URL on the self-account's realm. If it's on the + // self-account's realm, we'll request it with the auth credential. + // TODO(#656) in above comment, change "we will" to "we do" + wrapWithPerAccountStoreWidget: true); + // `tester.pumpWidget` in prepareContent introduces an initial route; + // remove it so consumers only have newly pushed routes. assert(pushedRoutes.length == 1); pushedRoutes.removeLast(); return pushedRoutes; @@ -353,7 +366,7 @@ void main() { testWidgets('tapping on preview opens lightbox', (tester) async { const example = ContentExample.videoInline; - final pushedRoutes = await prepareContent(tester, example.html); + final pushedRoutes = await prepare(tester, example.html); await tester.tap(find.byIcon(Icons.play_arrow_rounded)); check(pushedRoutes).single.isA() @@ -362,23 +375,16 @@ void main() { }); group("MessageEmbedVideo", () { - Future prepareContent(WidgetTester tester, String html) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - prepareBoringImageHttpClient(); - - await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp( - home: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: MessageContent( - message: eg.streamMessage(content: html), - content: parseContent(html)))))); - await tester.pump(); // global store - await tester.pump(); // per-account store - debugNetworkImageHttpClientProvider = null; + Future prepare(WidgetTester tester, String html) async { + await prepareContent(tester, + // Message is needed for a video's lightbox. + messageContent(html), + // We try to resolve a video preview URL on the self-account's realm. + wrapWithPerAccountStoreWidget: true); } Future checkEmbedVideo(WidgetTester tester, ContentExample example) async { - await prepareContent(tester, example.html); + await prepare(tester, example.html); final expectedTitle = (((example.expectedNodes[0] as ParagraphNode) .nodes.single as LinkNode).nodes.single as TextNode).text; @@ -435,9 +441,9 @@ void main() { required String targetHtml, required double Function(InlineSpan rootSpan) targetFontSizeFinder, }) async { - await prepareContentBare(tester, + await prepareContent(tester, plainContent( '

header-plain $targetHtml

\n' - '

paragraph-plain $targetHtml

'); + '

paragraph-plain $targetHtml

')); final headerRootSpan = tester.renderObject(find.textContaining('header')).text; final headerPlainStyle = mergedStyleOfSubstring(headerRootSpan, 'header-plain '); @@ -513,22 +519,14 @@ void main() { // https://github.com/flutter/flutter/wiki/Flutter-Test-Fonts // We use this to simulate taps on specific glyphs. - Future prepareContent(WidgetTester tester, String html) async { - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - addTearDown(testBinding.reset); - - await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp( - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - home: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: BlockContentList( - nodes: parseContent(html).nodes))))); - await tester.pump(); - await tester.pump(); + Future prepare(WidgetTester tester, String html) async { + await prepareContent(tester, plainContent(html), + // We try to resolve relative links on the self-account's realm. + wrapWithPerAccountStoreWidget: true); } testWidgets('can tap a link to open URL', (tester) async { - await prepareContent(tester, + await prepare(tester, '

hello

'); await tapText(tester, find.text('hello')); @@ -542,7 +540,7 @@ void main() { testWidgets('multiple links in paragraph', (tester) async { final fontSize = Paragraph.textStyle.fontSize!; - await prepareContent(tester, + await prepare(tester, '

foo bar baz

'); final base = tester.getTopLeft(find.text('foo bar baz')) .translate(fontSize/2, fontSize/2); // middle of first letter @@ -560,7 +558,7 @@ void main() { }); testWidgets('link nested in other spans', (tester) async { - await prepareContent(tester, + await prepare(tester, '

word

'); await tapText(tester, find.text('word')); check(testBinding.takeLaunchUrlCalls()) @@ -570,7 +568,7 @@ void main() { testWidgets('link containing other spans', (tester) async { final fontSize = Paragraph.textStyle.fontSize!; - await prepareContent(tester, + await prepare(tester, '

two words

'); final base = tester.getTopLeft(find.text('two words')) .translate(fontSize/2, fontSize/2); // middle of first letter @@ -585,7 +583,7 @@ void main() { }); testWidgets('relative links are resolved', (tester) async { - await prepareContent(tester, + await prepare(tester, '

word

'); await tapText(tester, find.text('word')); check(testBinding.takeLaunchUrlCalls()) @@ -593,7 +591,7 @@ void main() { }); testWidgets('link inside HeadingNode', (tester) async { - await prepareContent(tester, + await prepare(tester, '
word
'); await tapText(tester, find.text('word')); check(testBinding.takeLaunchUrlCalls()) @@ -601,7 +599,7 @@ void main() { }); testWidgets('error dialog if invalid link', (tester) async { - await prepareContent(tester, + await prepare(tester, '

word

'); testBinding.launchUrlResult = false; await tapText(tester, find.text('word')); @@ -613,32 +611,29 @@ void main() { }); group('LinkNode on internal links', () { - Future>> prepareContent(WidgetTester tester, { - required String html, - }) async { - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( - streams: [eg.stream(streamId: 1, name: 'check')], - )); - addTearDown(testBinding.reset); + Future>> prepare(WidgetTester tester, String html) async { final pushedRoutes = >[]; final testNavObserver = TestNavigatorObserver() ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp( - navigatorObservers: [testNavObserver], - home: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: BlockContentList(nodes: parseContent(html).nodes))))); - await tester.pump(); // global store - await tester.pump(); // per-account store - // `tester.pumpWidget` introduces an initial route, remove so - // consumers only have newly pushed routes. + + await prepareContent(tester, plainContent(html), + navObservers: [testNavObserver], + // We try to resolve relative links on the self-account's realm. + wrapWithPerAccountStoreWidget: true); + + // `tester.pumpWidget` in prepareContent introduces an initial route; + // remove it so consumers only have newly pushed routes. assert(pushedRoutes.length == 1); pushedRoutes.removeLast(); + + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store.addStream(eg.stream(name: 'stream')); return pushedRoutes; } testWidgets('valid internal links are navigated to within app', (tester) async { - final pushedRoutes = await prepareContent(tester, - html: '

stream

'); + final pushedRoutes = await prepare(tester, + '

stream

'); await tapText(tester, find.text('stream')); check(testBinding.takeLaunchUrlCalls()).isEmpty(); @@ -648,8 +643,8 @@ void main() { testWidgets('invalid internal links are opened in browser', (tester) async { // Link is invalid due to `topic` operator missing an operand. - final pushedRoutes = await prepareContent(tester, - html: '

invalid

'); + final pushedRoutes = await prepare(tester, + '

invalid

'); await tapText(tester, find.text('invalid')); final expectedUrl = eg.realmUrl.resolve('/#narrow/stream/1-check/topic'); @@ -689,17 +684,14 @@ void main() { final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$'); testWidgets('smoke', (tester) async { - await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: - parseContent('

$timeSpanHtml

').nodes))); + await prepareContent(tester, plainContent('

$timeSpanHtml

')); tester.widget(find.textContaining(renderedTextRegexp)); }); testWidgets('clock icon and text are the same color', (tester) async { - await tester.pumpWidget(MaterialApp(home: DefaultTextStyle( - style: const TextStyle(color: Colors.green), - child: BlockContentList(nodes: - parseContent('

$timeSpanHtml

').nodes), - ))); + await prepareContent(tester, + DefaultTextStyle(style: const TextStyle(color: Colors.green), + child: plainContent('

$timeSpanHtml

'))); final icon = tester.widget( find.descendant(of: find.byType(GlobalTime), @@ -717,8 +709,8 @@ void main() { ..equals(Colors.green); }); - testWidgets('maintains font-size ratio with surrounding text', (tester) async { - Future doCheck(double Function(GlobalTime widget) sizeFromWidget) async { + group('maintains font-size ratio with surrounding text', () { + Future doCheck(WidgetTester tester, double Function(GlobalTime widget) sizeFromWidget) async { await checkFontSizeRatio(tester, targetHtml: '', targetFontSizeFinder: (rootSpan) { @@ -734,53 +726,50 @@ void main() { }); } - // Text is scaled - await doCheck((widget) { - final textSpan = tester.renderObject( - find.descendant(of: find.byWidget(widget), - matching: find.textContaining(renderedTextRegexp) - )).text; - return mergedStyleOfSubstring(textSpan, renderedTextRegexp)!.fontSize!; + testWidgets('text is scaled', (tester) async { + await doCheck(tester, (widget) { + final textSpan = tester.renderObject( + find.descendant(of: find.byWidget(widget), + matching: find.textContaining(renderedTextRegexp) + )).text; + return mergedStyleOfSubstring(textSpan, renderedTextRegexp)!.fontSize!; + }); }); - // Clock icon is scaled - await doCheck((widget) { - final icon = tester.widget( - find.descendant(of: find.byWidget(widget), - matching: find.byIcon(ZulipIcons.clock))); - return icon.size!; + testWidgets('clock icon is scaled', (tester) async { + await doCheck(tester, (widget) { + final icon = tester.widget( + find.descendant(of: find.byWidget(widget), + matching: find.byIcon(ZulipIcons.clock))); + return icon.size!; + }); }); }); }); group('MessageImageEmoji', () { - Future prepareContent(WidgetTester tester, String html) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - prepareBoringImageHttpClient(); - - await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp( - home: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: BlockContentList(nodes: parseContent(html).nodes))))); - await tester.pump(); // global store - await tester.pump(); // per-account store + Future prepare(WidgetTester tester, String html) async { + await prepareContent(tester, plainContent(html), + // We try to resolve image-emoji URLs on the self-account's realm. + // For URLs on the self-account's realm, we include the auth credential. + wrapWithPerAccountStoreWidget: true); } testWidgets('smoke: custom emoji', (tester) async { - await prepareContent(tester, ContentExample.emojiCustom.html); + await prepare(tester, ContentExample.emojiCustom.html); tester.widget(find.byType(MessageImageEmoji)); debugNetworkImageHttpClientProvider = null; }); testWidgets('smoke: custom emoji with invalid URL', (tester) async { - await prepareContent(tester, ContentExample.emojiCustomInvalidUrl.html); + await prepare(tester, ContentExample.emojiCustomInvalidUrl.html); final url = tester.widget(find.byType(MessageImageEmoji)).node.src; check(() => Uri.parse(url)).throws(); debugNetworkImageHttpClientProvider = null; }); testWidgets('smoke: Zulip extra emoji', (tester) async { - await prepareContent(tester, ContentExample.emojiZulipExtra.html); + await prepare(tester, ContentExample.emojiZulipExtra.html); tester.widget(find.byType(MessageImageEmoji)); debugNetworkImageHttpClientProvider = null; });